feat: auth allowlist, memoraiz project, git push-to-deploy, group chat support, bot menu commands

This commit is contained in:
Andre Kamarudin 2026-04-16 08:24:16 +08:00
parent 92ae862c2d
commit bd5b041149
5 changed files with 213 additions and 47 deletions

View file

@ -1,5 +1,6 @@
FROM python:3.12-slim FROM python:3.12-slim
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY main.py . COPY main.py .

View file

@ -2,3 +2,4 @@ TG_BOT_TOKEN=CHANGE_ME
VERCEL_AI_GATEWAY_KEY=CHANGE_ME VERCEL_AI_GATEWAY_KEY=CHANGE_ME
OPENAI_BASE_URL=https://ai-gateway.vercel.sh/v1 OPENAI_BASE_URL=https://ai-gateway.vercel.sh/v1
MODEL=anthropic/claude-sonnet-4 MODEL=anthropic/claude-sonnet-4
ALLOWED_USERS=876499264,417471802

View file

@ -4,9 +4,11 @@ services:
container_name: betterbot container_name: betterbot
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- /opt/betterlifesg/site:/site:rw - /opt/src/betterlifesg:/repo/betterlifesg:rw
- /opt/src/hk_memoraiz:/repo/memoraiz:rw
env_file: env_file:
- .env - .env
environment: environment:
- TZ=${TZ:-Asia/Singapore} - TZ=${TZ:-Asia/Singapore}
- SITE_DIR=/site - SITE_DIR=/repo/betterlifesg/site
- MEMORAIZ_DIR=/repo/memoraiz/frontend

241
main.py
View file

@ -3,12 +3,14 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import os import os
import pathlib import pathlib
import subprocess
from openai import OpenAI from openai import OpenAI
from telegram import Update from telegram import BotCommand, Update
from telegram.ext import ( from telegram.ext import (
ApplicationBuilder, ApplicationBuilder,
CommandHandler, CommandHandler,
@ -29,36 +31,86 @@ TG_BOT_TOKEN = os.environ["TG_BOT_TOKEN"]
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY") or os.environ["VERCEL_AI_GATEWAY_KEY"] OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY") or os.environ["VERCEL_AI_GATEWAY_KEY"]
OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1") OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
MODEL = os.environ.get("MODEL", "gpt-4.1") MODEL = os.environ.get("MODEL", "gpt-4.1")
# Site directories
SITE_DIR = pathlib.Path(os.environ.get("SITE_DIR", "/site")) SITE_DIR = pathlib.Path(os.environ.get("SITE_DIR", "/site"))
MEMORAIZ_DIR = pathlib.Path(os.environ.get("MEMORAIZ_DIR", "/memoraiz"))
# Authorized users (Telegram user IDs)
ALLOWED_USERS: set[int] = set()
_raw = os.environ.get("ALLOWED_USERS", "876499264,417471802")
for _id in _raw.split(","):
_id = _id.strip()
if _id:
ALLOWED_USERS.add(int(_id))
log.info("Authorized users: %s", ALLOWED_USERS)
client = OpenAI(api_key=OPENAI_API_KEY, base_url=OPENAI_BASE_URL) client = OpenAI(api_key=OPENAI_API_KEY, base_url=OPENAI_BASE_URL)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tools — read / write / list site files # Project definitions
# ---------------------------------------------------------------------------
PROJECTS = {
"betterlifesg": {
"dir": SITE_DIR,
"label": "Better Life SG website",
"git_repo": SITE_DIR.parent, # /repo/betterlifesg/site -> /repo/betterlifesg
"deploy_cmd": None, # static files served directly by Caddy
},
"memoraiz": {
"dir": MEMORAIZ_DIR,
"label": "Memoraiz app (React frontend)",
"git_repo": MEMORAIZ_DIR.parent, # /repo/memoraiz/frontend -> /repo/memoraiz
"deploy_cmd": None, # deploy triggered externally after push
},
}
# ---------------------------------------------------------------------------
# Tools — read / write / list site files + deploy
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
TOOLS = [ TOOLS = [
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "list_files", "name": "list_files",
"description": "List all files in the website directory.", "description": "List all files in a project directory.",
"parameters": {"type": "object", "properties": {}, "required": []}, "parameters": {
"type": "object",
"properties": {
"project": {
"type": "string",
"enum": list(PROJECTS.keys()),
"description": "Which project to list files for.",
},
"subdirectory": {
"type": "string",
"description": "Optional subdirectory to list, e.g. 'src/pages'. Defaults to root.",
"default": "",
},
},
"required": ["project"],
},
}, },
}, },
{ {
"type": "function", "type": "function",
"function": { "function": {
"name": "read_file", "name": "read_file",
"description": "Read the full contents of a website file.", "description": "Read the full contents of a project file.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"project": {
"type": "string",
"enum": list(PROJECTS.keys()),
"description": "Which project the file belongs to.",
},
"path": { "path": {
"type": "string", "type": "string",
"description": "Relative path inside the site directory, e.g. 'index.html' or 'images/logo.png'.", "description": "Relative path inside the project directory.",
} },
}, },
"required": ["path"], "required": ["project", "path"],
}, },
}, },
}, },
@ -66,67 +118,122 @@ TOOLS = [
"type": "function", "type": "function",
"function": { "function": {
"name": "write_file", "name": "write_file",
"description": "Write (create or overwrite) a text file in the website directory. Use for HTML, CSS, JS files.", "description": "Write (create or overwrite) a text file in a project directory.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"project": {
"type": "string",
"enum": list(PROJECTS.keys()),
"description": "Which project the file belongs to.",
},
"path": { "path": {
"type": "string", "type": "string",
"description": "Relative path inside the site directory.", "description": "Relative path inside the project directory.",
}, },
"content": { "content": {
"type": "string", "type": "string",
"description": "The full file content to write.", "description": "The full file content to write.",
}, },
}, },
"required": ["path", "content"], "required": ["project", "path", "content"],
}, },
}, },
}, },
] ]
SYSTEM_PROMPT = """\ SYSTEM_PROMPT = """\
You are BetterBot, a helpful assistant that manages the Better Life SG website. You are BetterBot, a helpful assistant that manages two projects:
The website is a static HTML site using Tailwind CSS (loaded via CDN).
The site files are in the /site directory. Key files: 1. **Better Life SG website** (project: "betterlifesg")
- index.html (home page) - Static HTML site using Tailwind CSS (loaded via CDN)
- fresh-grads.html, prenatal.html, retirement.html, legacy.html (service pages) - Key files: index.html, fresh-grads.html, prenatal.html, retirement.html, \
- team.html, contact.html legacy.html, team.html, contact.html, images/ folder
- images/ folder with photos and backgrounds - Brand color: teal (#00b49a)
- Changes go live immediately after writing
When the user asks you to change something on the website: 2. **Memoraiz app** (project: "memoraiz")
1. First read the relevant file(s) to understand the current state - React 19 + Vite 6 + Tailwind CSS 4 frontend
2. Make the requested changes - Source code is under frontend/src/ (pages in frontend/src/pages/, \
3. Write the updated file back components in frontend/src/components/)
4. Confirm what you changed - Changes require a rebuild to go live (handled automatically after you write)
The brand color is teal/green (#00b49a). The site uses Tailwind CSS classes. When the user asks you to change something:
1. Ask which project if unclear (default to betterlifesg for website questions)
2. First read the relevant file(s) to understand the current state
3. Make the requested changes
4. Write the updated file back
5. Confirm what you changed
When writing a file, always write the COMPLETE file content never partial.
Keep your responses concise and friendly. Always confirm changes after making them. Keep your responses concise and friendly. Always confirm changes after making them.
Do NOT change the overall page structure unless explicitly asked. Do NOT change the overall page structure unless explicitly asked.
When writing a file, always write the COMPLETE file content never partial.
""" """
def _resolve(path: str) -> pathlib.Path: def _resolve(base: pathlib.Path, path: str) -> pathlib.Path:
"""Resolve a relative path inside SITE_DIR, preventing path traversal.""" """Resolve a relative path inside a base dir, preventing path traversal."""
resolved = (SITE_DIR / path).resolve() resolved = (base / path).resolve()
if not str(resolved).startswith(str(SITE_DIR.resolve())): if not str(resolved).startswith(str(base.resolve())):
raise ValueError(f"Path traversal blocked: {path}") raise ValueError(f"Path traversal blocked: {path}")
return resolved return resolved
def _git_push(project_key: str, changed_file: str) -> str:
"""Commit and push changes in the project's git repo."""
proj = PROJECTS[project_key]
repo = proj["git_repo"]
if not (repo / ".git").exists():
return "(no git repo — skipped push)"
try:
subprocess.run(["git", "add", "-A"], cwd=repo, check=True, capture_output=True)
subprocess.run(
["git", "commit", "-m", f"betterbot: update {changed_file}"],
cwd=repo,
check=True,
capture_output=True,
)
result = subprocess.run(
["git", "push", "origin", "HEAD"],
cwd=repo,
check=True,
capture_output=True,
text=True,
)
log.info("Git push for %s: %s", project_key, result.stderr.strip())
# Trigger deploy if needed
deploy_cmd = proj.get("deploy_cmd")
if deploy_cmd:
log.info("Running deploy: %s", deploy_cmd)
subprocess.run(deploy_cmd, shell=True, check=True, capture_output=True)
return f"Pushed and deployed {project_key}"
return f"Pushed {project_key} to git"
except subprocess.CalledProcessError as e:
log.error("Git/deploy error: %s\nstdout: %s\nstderr: %s", e, e.stdout, e.stderr)
return f"Push failed: {e.stderr or e.stdout or str(e)}"
def handle_tool_call(name: str, args: dict) -> str: def handle_tool_call(name: str, args: dict) -> str:
"""Execute a tool call and return the result as a string.""" """Execute a tool call and return the result as a string."""
project_key = args.get("project", "betterlifesg")
if project_key not in PROJECTS:
return f"Unknown project: {project_key}"
base = PROJECTS[project_key]["dir"]
if name == "list_files": if name == "list_files":
subdir = args.get("subdirectory", "")
target = _resolve(base, subdir) if subdir else base
files = [] files = []
for p in sorted(SITE_DIR.rglob("*")): for p in sorted(target.rglob("*")):
if p.is_file(): if p.is_file() and not any(
files.append(str(p.relative_to(SITE_DIR))) part in (".git", "node_modules", "__pycache__") for part in p.parts
return "\n".join(files) if files else "(no files found)" ):
files.append(str(p.relative_to(base)))
return "\n".join(files[:200]) if files else "(no files found)"
if name == "read_file": if name == "read_file":
path = _resolve(args["path"]) path = _resolve(base, args["path"])
if not path.exists(): if not path.exists():
return f"Error: {args['path']} does not exist." return f"Error: {args['path']} does not exist."
if path.suffix in (".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"): if path.suffix in (".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"):
@ -134,10 +241,13 @@ def handle_tool_call(name: str, args: dict) -> str:
return path.read_text(encoding="utf-8") return path.read_text(encoding="utf-8")
if name == "write_file": if name == "write_file":
path = _resolve(args["path"]) path = _resolve(base, args["path"])
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(args["content"], encoding="utf-8") path.write_text(args["content"], encoding="utf-8")
return f"OK — wrote {len(args['content'])} chars to {args['path']}" push_result = _git_push(project_key, args["path"])
return (
f"OK — wrote {len(args['content'])} chars to {args['path']}. {push_result}"
)
return f"Unknown tool: {name}" return f"Unknown tool: {name}"
@ -154,32 +264,69 @@ def get_messages(chat_id: int) -> list[dict]:
return conversations[chat_id] return conversations[chat_id]
# ---------------------------------------------------------------------------
# Auth check
# ---------------------------------------------------------------------------
def _is_authorized(user_id: int | None) -> bool:
if not ALLOWED_USERS:
return True
return user_id in ALLOWED_USERS
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Telegram handlers # Telegram handlers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
if not _is_authorized(update.effective_user.id):
await update.message.reply_text("Sorry, you're not authorized to use this bot.")
return
await update.message.reply_text( await update.message.reply_text(
"Hi! I'm BetterBot 🤖\n\n" "Hi! I'm BetterBot 🤖\n\n"
"I manage the Better Life SG website. Just tell me what you'd like to change!\n\n" "I manage two projects:\n"
"• **Better Life SG** website\n"
"• **Memoraiz** app\n\n"
"Just tell me what you'd like to change!\n\n"
"Examples:\n" "Examples:\n"
'"Change the WhatsApp number to 91234567"\n' '"Change the WhatsApp number to 91234567"\n'
'"Update Hendri\'s title to Senior Consultant"\n' '"Update Hendri\'s title to Senior Consultant"\n'
'"Add a new section about critical illness"\n\n' '"Update the login page text in Memoraiz"\n\n'
"Type /reset to start a fresh conversation." "Type /reset to start a fresh conversation.",
parse_mode="Markdown",
) )
async def cmd_reset(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_reset(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
if not _is_authorized(update.effective_user.id):
return
conversations.pop(update.effective_chat.id, None) conversations.pop(update.effective_chat.id, None)
await update.message.reply_text("Conversation reset! Send me a new request.") await update.message.reply_text("Conversation reset! Send me a new request.")
async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
if not _is_authorized(update.effective_user.id):
return
chat_id = update.effective_chat.id chat_id = update.effective_chat.id
user_text = update.message.text user_text = update.message.text
if not user_text: if not user_text:
return return
# In group chats, only respond if bot is mentioned or replied to
if update.effective_chat.type in ("group", "supergroup"):
bot_username = ctx.bot.username
is_mentioned = bot_username and f"@{bot_username}" in user_text
is_reply = (
update.message.reply_to_message
and update.message.reply_to_message.from_user
and update.message.reply_to_message.from_user.id == ctx.bot.id
)
if not is_mentioned and not is_reply:
return
# Strip the @mention from the text
if bot_username:
user_text = user_text.replace(f"@{bot_username}", "").strip()
if not user_text:
return
messages = get_messages(chat_id) messages = get_messages(chat_id)
messages.append({"role": "user", "content": user_text}) messages.append({"role": "user", "content": user_text})
@ -209,8 +356,6 @@ async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None
if msg.tool_calls: if msg.tool_calls:
for tc in msg.tool_calls: for tc in msg.tool_calls:
import json
args = ( args = (
json.loads(tc.function.arguments) if tc.function.arguments else {} json.loads(tc.function.arguments) if tc.function.arguments else {}
) )
@ -252,8 +397,18 @@ def main() -> None:
app.add_handler(CommandHandler("start", cmd_start)) app.add_handler(CommandHandler("start", cmd_start))
app.add_handler(CommandHandler("reset", cmd_reset)) app.add_handler(CommandHandler("reset", cmd_reset))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message)) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
log.info("BetterBot starting (model=%s, site_dir=%s)", MODEL, SITE_DIR)
app.run_polling(drop_pending_updates=True) # Register bot commands for the / menu
async def post_init(application) -> None:
await application.bot.set_my_commands([
BotCommand("start", "Show welcome message"),
BotCommand("reset", "Reset conversation"),
])
log.info("Bot commands registered")
app.post_init = post_init
log.info("BetterBot starting (model=%s, sites=%s)", MODEL, list(PROJECTS.keys()))
app.run_polling(drop_pending_updates=True, allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -5,12 +5,11 @@ set -euo pipefail
REPO_DIR="${REPO_DIR:-/opt/src/betterbot}" REPO_DIR="${REPO_DIR:-/opt/src/betterbot}"
STACK_DIR="${STACK_DIR:-/opt/betterbot}" STACK_DIR="${STACK_DIR:-/opt/betterbot}"
SITE_DIR="${SITE_DIR:-/opt/betterlifesg/site}"
cd "$REPO_DIR" cd "$REPO_DIR"
git pull --ff-only origin master git pull --ff-only origin master
mkdir -p "$STACK_DIR" "$SITE_DIR" mkdir -p "$STACK_DIR"
cp compose/docker-compose.yml "$STACK_DIR/docker-compose.yml" cp compose/docker-compose.yml "$STACK_DIR/docker-compose.yml"
@ -26,6 +25,14 @@ if [ -f /opt/config/infisical-agent.env ] && [ -f /opt/src/self_hosting/infra/sc
infisical_fetch racknerd-betterbot "$STACK_DIR/.env" infisical_fetch racknerd-betterbot "$STACK_DIR/.env"
fi fi
# Configure git identity for the bot's commits in mounted repos
for repo_dir in /opt/src/betterlifesg /opt/src/hk_memoraiz; do
if [ -d "$repo_dir/.git" ]; then
git -C "$repo_dir" config user.email "betterbot@bytesizeprotip.com"
git -C "$repo_dir" config user.name "BetterBot"
fi
done
cd "$STACK_DIR" cd "$STACK_DIR"
docker compose build --pull docker compose build --pull
docker compose up -d docker compose up -d