From bd5b0411497d0d10c27bd28689d64cfb6f6da9e1 Mon Sep 17 00:00:00 2001 From: Andre K Date: Thu, 16 Apr 2026 08:24:16 +0800 Subject: [PATCH] feat: auth allowlist, memoraiz project, git push-to-deploy, group chat support, bot menu commands --- Dockerfile | 1 + compose/.env.example | 1 + compose/docker-compose.yml | 6 +- main.py | 241 +++++++++++++++++++++++++++++------- scripts/deploy-betterbot.sh | 11 +- 5 files changed, 213 insertions(+), 47 deletions(-) diff --git a/Dockerfile b/Dockerfile index aec1306..0e1ec3d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,6 @@ FROM python:3.12-slim WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY main.py . diff --git a/compose/.env.example b/compose/.env.example index d324ec6..228c318 100644 --- a/compose/.env.example +++ b/compose/.env.example @@ -2,3 +2,4 @@ TG_BOT_TOKEN=CHANGE_ME VERCEL_AI_GATEWAY_KEY=CHANGE_ME OPENAI_BASE_URL=https://ai-gateway.vercel.sh/v1 MODEL=anthropic/claude-sonnet-4 +ALLOWED_USERS=876499264,417471802 diff --git a/compose/docker-compose.yml b/compose/docker-compose.yml index 46dbb6e..f6e09e5 100644 --- a/compose/docker-compose.yml +++ b/compose/docker-compose.yml @@ -4,9 +4,11 @@ services: container_name: betterbot restart: unless-stopped volumes: - - /opt/betterlifesg/site:/site:rw + - /opt/src/betterlifesg:/repo/betterlifesg:rw + - /opt/src/hk_memoraiz:/repo/memoraiz:rw env_file: - .env environment: - TZ=${TZ:-Asia/Singapore} - - SITE_DIR=/site + - SITE_DIR=/repo/betterlifesg/site + - MEMORAIZ_DIR=/repo/memoraiz/frontend diff --git a/main.py b/main.py index 9782164..dd75908 100644 --- a/main.py +++ b/main.py @@ -3,12 +3,14 @@ from __future__ import annotations +import json import logging import os import pathlib +import subprocess from openai import OpenAI -from telegram import Update +from telegram import BotCommand, Update from telegram.ext import ( ApplicationBuilder, 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_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1") MODEL = os.environ.get("MODEL", "gpt-4.1") + +# Site directories 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) # --------------------------------------------------------------------------- -# 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 = [ { "type": "function", "function": { "name": "list_files", - "description": "List all files in the website directory.", - "parameters": {"type": "object", "properties": {}, "required": []}, + "description": "List all files in a project directory.", + "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", "function": { "name": "read_file", - "description": "Read the full contents of a website file.", + "description": "Read the full contents of a project file.", "parameters": { "type": "object", "properties": { + "project": { + "type": "string", + "enum": list(PROJECTS.keys()), + "description": "Which project the file belongs to.", + }, "path": { "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", "function": { "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": { "type": "object", "properties": { + "project": { + "type": "string", + "enum": list(PROJECTS.keys()), + "description": "Which project the file belongs to.", + }, "path": { "type": "string", - "description": "Relative path inside the site directory.", + "description": "Relative path inside the project directory.", }, "content": { "type": "string", "description": "The full file content to write.", }, }, - "required": ["path", "content"], + "required": ["project", "path", "content"], }, }, }, ] SYSTEM_PROMPT = """\ -You are BetterBot, a helpful assistant that manages the Better Life SG website. -The website is a static HTML site using Tailwind CSS (loaded via CDN). +You are BetterBot, a helpful assistant that manages two projects: -The site files are in the /site directory. Key files: -- index.html (home page) -- fresh-grads.html, prenatal.html, retirement.html, legacy.html (service pages) -- team.html, contact.html -- images/ folder with photos and backgrounds +1. **Better Life SG website** (project: "betterlifesg") + - Static HTML site using Tailwind CSS (loaded via CDN) + - Key files: index.html, fresh-grads.html, prenatal.html, retirement.html, \ +legacy.html, team.html, contact.html, images/ folder + - Brand color: teal (#00b49a) + - Changes go live immediately after writing -When the user asks you to change something on the website: -1. First read the relevant file(s) to understand the current state -2. Make the requested changes -3. Write the updated file back -4. Confirm what you changed +2. **Memoraiz app** (project: "memoraiz") + - React 19 + Vite 6 + Tailwind CSS 4 frontend + - Source code is under frontend/src/ (pages in frontend/src/pages/, \ +components in frontend/src/components/) + - 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. 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: - """Resolve a relative path inside SITE_DIR, preventing path traversal.""" - resolved = (SITE_DIR / path).resolve() - if not str(resolved).startswith(str(SITE_DIR.resolve())): +def _resolve(base: pathlib.Path, path: str) -> pathlib.Path: + """Resolve a relative path inside a base dir, preventing path traversal.""" + resolved = (base / path).resolve() + if not str(resolved).startswith(str(base.resolve())): raise ValueError(f"Path traversal blocked: {path}") 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: """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": + subdir = args.get("subdirectory", "") + target = _resolve(base, subdir) if subdir else base files = [] - for p in sorted(SITE_DIR.rglob("*")): - if p.is_file(): - files.append(str(p.relative_to(SITE_DIR))) - return "\n".join(files) if files else "(no files found)" + for p in sorted(target.rglob("*")): + if p.is_file() and not any( + part in (".git", "node_modules", "__pycache__") for part in p.parts + ): + files.append(str(p.relative_to(base))) + return "\n".join(files[:200]) if files else "(no files found)" if name == "read_file": - path = _resolve(args["path"]) + path = _resolve(base, args["path"]) if not path.exists(): return f"Error: {args['path']} does not exist." 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") if name == "write_file": - path = _resolve(args["path"]) + path = _resolve(base, args["path"]) path.parent.mkdir(parents=True, exist_ok=True) 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}" @@ -154,32 +264,69 @@ def get_messages(chat_id: int) -> list[dict]: 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 # --------------------------------------------------------------------------- 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( "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" '• "Change the WhatsApp number to 91234567"\n' '• "Update Hendri\'s title to Senior Consultant"\n' - '• "Add a new section about critical illness"\n\n' - "Type /reset to start a fresh conversation." + '• "Update the login page text in Memoraiz"\n\n' + "Type /reset to start a fresh conversation.", + parse_mode="Markdown", ) 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) await update.message.reply_text("Conversation reset! Send me a new request.") 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 user_text = update.message.text if not user_text: 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.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: for tc in msg.tool_calls: - import json - args = ( 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("reset", cmd_reset)) 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__": diff --git a/scripts/deploy-betterbot.sh b/scripts/deploy-betterbot.sh index fd2eb15..5d5c1c4 100644 --- a/scripts/deploy-betterbot.sh +++ b/scripts/deploy-betterbot.sh @@ -5,12 +5,11 @@ set -euo pipefail REPO_DIR="${REPO_DIR:-/opt/src/betterbot}" STACK_DIR="${STACK_DIR:-/opt/betterbot}" -SITE_DIR="${SITE_DIR:-/opt/betterlifesg/site}" cd "$REPO_DIR" 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" @@ -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" 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" docker compose build --pull docker compose up -d