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
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 .

View file

@ -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

View file

@ -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

241
main.py
View file

@ -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__":

View file

@ -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