diff --git a/README.md b/README.md index 1faba7a..144c7d6 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,13 @@ # BigBiggerBiggestBot 💪 -A Telegram bot for logging gym workouts, with an embedded Mini App. - -Send workouts as plain text, forward them from Saved Messages, or tap -through a structured log form inside Telegram. History, stats, notes, +A Telegram **Mini App** for logging gym workouts. History, stats, notes, edit & delete, JSON/CSV export — all per-user, all in SQLite. -## Format +The slash-command bot was removed: the Mini App is the only interface. +A Telegram bot identity (token) is still required so the Mini App can +validate user sessions via `initData` HMAC. -Send messages like: +## Workout text format (still supported via "Paste as text" in the Mini App) ``` Bench press: 4x8x35 @@ -23,15 +22,6 @@ Pull-ups: 3x10 - Blank line separates superset groups; consecutive lines form a superset - Both `,` and `.` work as decimal separators -## Commands - -- `/start` — help & open Mini App -- `/history` — recent workouts -- `/stats` — summary (total workouts, sets, volume) -- `/delete ` — soft-delete a workout -- `/export` — download all data as JSON -- `/feedback ` — send feedback to the bot author - ## Run locally ```bash @@ -39,9 +29,9 @@ nix run ``` This launches: -- API server (port 8080) -- cloudflared tunnel for the Mini App -- Telegram bot (polling) +- API + Mini App server (port 8080) +- cloudflared Quick Tunnel for a public HTTPS URL (skipped if `WEBAPP_URL` + is already set in the environment, e.g. fronted by a reverse proxy) Put your bot token (from [@BotFather](https://t.me/BotFather)) in `~/.secrets/bigbiggerbiggestbot` or a `.env` file: @@ -63,10 +53,12 @@ nix develop --command pytest tests/ -v Two environments share one host (`sunken-ship`): - **Production** — `fitness-bot.service`, working dir `/home/danny/tg_fitness_bot`, - watches `origin/main`, served behind a stable URL via the VPS Caddy. + watches `origin/main`, served behind a stable URL via the VPS Caddy at + `https://bbbot.dannydannydanny.me`. - **Shipyard staging** — `fitness-bot-shipyard.service`, working dir - `/home/danny/tg_fitness_bot_shipyard`, watches `origin/staging`, separate - bot token, ephemeral cloudflared URL each restart. + `/home/danny/tg_fitness_bot_shipyard`, watches `origin/staging`, served + by the shared `shipyard_poc_bot` Telegram bot (B3Bot beta is the active + POC tenant). Each has its own pull timer that fetches every ~15 minutes and restarts the service when its branch has new commits. @@ -74,7 +66,6 @@ the service when its branch has new commits. **Workflow:** ``` -# 1. land changes on a working branch (or main locally) git push origin :staging # → shipyard auto-deploys, test there git push origin :main # → production auto-deploys ``` @@ -84,13 +75,16 @@ so testing on shipyard never touches production data. ## Architecture -- `bot.py` — Telegram command handlers, polling, message parsing -- `server.py` — aiohttp REST API + static file server for the Mini App -- `db.py` — SQLite data layer (workouts, supersets, exercises, feedback; soft delete) -- `parser.py` — workout text → structured data -- `webapp/` — Mini App (HTML/CSS/vanilla JS, Telegram WebApp SDK) -- `start.py` — orchestrator: starts server + tunnel + bot, wires up the Mini App URL -- `tests/` — pytest suite for parser + db +- `server.py` — aiohttp REST API + static file server for the Mini App; + validates Telegram `initData` HMACs against the bot token. +- `db.py` — SQLite data layer (workouts, supersets, exercises, feedback, + events, settings; soft delete). +- `parser.py` — workout text → structured data (used by the Mini App's + "Paste as text" path). +- `webapp/` — Mini App (HTML/CSS/vanilla JS, Telegram WebApp SDK). +- `start.py` — orchestrator: loads token, starts server, optionally starts + cloudflared. +- `tests/` — pytest suite for parser + db. ## License diff --git a/bot.py b/bot.py deleted file mode 100644 index d638082..0000000 --- a/bot.py +++ /dev/null @@ -1,336 +0,0 @@ -"""Telegram Fitness Bot — track your workouts.""" - -import logging -import os -from datetime import datetime, timezone - -from dotenv import load_dotenv -from telegram import Update, WebAppInfo, InlineKeyboardButton, InlineKeyboardMarkup, MenuButtonWebApp -from telegram.constants import ParseMode -from telegram.ext import ( - Application, - ApplicationBuilder, - CommandHandler, - ContextTypes, - MessageHandler, - filters, -) - -from db import init_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, save_feedback, get_user_workout_number, resolve_user_number, log_event -from parser import parse_workout, format_workout - -load_dotenv() - -logging.basicConfig( - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - level=logging.INFO, -) -logger = logging.getLogger(__name__) - -# Token resolution: BOT_TOKEN env var → secrets file -# Env var wins so multiple instances on the same host (e.g. prod + shipyard -# staging) can each point to a different token without sharing a secrets file. -SECRETS_FILE = os.path.expanduser("~/.secrets/bigbiggerbiggestbot") - - -def _load_token() -> str: - # 1. Env var (set by systemd EnvironmentFile in multi-instance setups) - token = os.environ.get("BOT_TOKEN", "").strip() - if token: - return token - # 2. Default secrets file - if os.path.isfile(SECRETS_FILE): - token = open(SECRETS_FILE).read().strip() - if token: - return token - raise RuntimeError( - f"No bot token found. Set BOT_TOKEN env var or put it in {SECRETS_FILE}." - ) - - -BOT_TOKEN = _load_token() - -# Mini App URL — set automatically by start.py via tunnel -WEBAPP_URL = os.environ.get("WEBAPP_URL", "") - - -# ── Helpers ────────────────────────────────────────────────────────────────── - - -def extract_timestamp(update: Update) -> tuple[datetime, bool]: - """ - Get the best timestamp for a workout message. - - In python-telegram-bot v21+, forwarded message info lives on - message.forward_origin (a MessageOrigin object) with a .date attribute. - - Returns (timestamp, is_forwarded). - """ - msg = update.effective_message - - # v21+: forward_origin is set when a user forwards a message - origin = getattr(msg, "forward_origin", None) - if origin is not None and hasattr(origin, "date"): - return origin.date.replace(tzinfo=timezone.utc), True - - return msg.date.replace(tzinfo=timezone.utc), False - - -# ── Command handlers ──────────────────────────────────────────────────────── - - -async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE): - log_event(update.effective_user.id, "cmd.start") - text = ( - "\U0001f4aa Fitness Tracker Bot\n\n" - "Send me your workout and I'll save it!\n\n" - "Formats:\n" - "Bench press: 4x8x35\n" - "Pull-ups: 3x10 (bodyweight)\n" - "Shoulder press (3032): 8x25, 5x35, 6x40\n\n" - "Lines without a blank line between them = superset.\n" - "Machine IDs go in parentheses.\n\n" - "You can also forward messages from Saved Messages \u2014 " - "I'll use the original timestamp.\n\n" - "Commands:\n" - "/history \u2014 view recent workouts\n" - "/stats \u2014 quick summary\n" - "/delete <number> \u2014 delete a workout (see /history)\n" - "/export \u2014 export all data as JSON\n" - "/feedback <text> \u2014 send feedback" - ) - - if WEBAPP_URL: - btn = InlineKeyboardButton( - text="Open Workout Tracker", - web_app=WebAppInfo(url=WEBAPP_URL), - ) - await update.message.reply_text( - text, parse_mode=ParseMode.HTML, - reply_markup=InlineKeyboardMarkup([[btn]]), - ) - else: - await update.message.reply_text(text, parse_mode=ParseMode.HTML) - - -async def cmd_history(update: Update, context: ContextTypes.DEFAULT_TYPE): - user_id = update.effective_user.id - log_event(user_id, "cmd.history") - workouts = get_workouts(user_id, limit=5) - - if not workouts: - await update.message.reply_text("No workouts saved yet. Send me one!") - return - - parts = [] - for w in workouts: - ts = datetime.fromisoformat(w["timestamp"]) - header = f"\U0001f4c5 {ts.strftime('%a %d %b %Y, %H:%M')} (#{w['user_number']})" - body = format_workout(w["superset_groups"]) - parts.append(f"{header}\n{body}") - - text = "\n\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n".join(parts) - total = get_workout_count(user_id) - text += f"\n\nShowing latest 5 of {total} workouts." - - await update.message.reply_text(text, parse_mode=ParseMode.HTML) - - -async def cmd_stats(update: Update, context: ContextTypes.DEFAULT_TYPE): - user_id = update.effective_user.id - log_event(user_id, "cmd.stats") - stats = get_stats_sql(user_id) - - if stats["total_workouts"] == 0: - await update.message.reply_text("No workouts yet \u2014 send me your first one!") - return - - await update.message.reply_text( - f"\U0001f4ca Your Stats\n\n" - f" \u2022 Workouts logged: {stats['total_workouts']}\n" - f" \u2022 Unique exercises: {stats['unique_exercises']}\n" - f" \u2022 Total sets: {stats['total_sets']}\n" - f" \u2022 Total volume: {stats['total_volume']:,.0f} kg", - parse_mode=ParseMode.HTML, - ) - - -async def cmd_delete(update: Update, context: ContextTypes.DEFAULT_TYPE): - user_id = update.effective_user.id - log_event(user_id, "cmd.delete", {"args": context.args or []}) - - if not context.args: - await update.message.reply_text( - "Usage: /delete <number>\n" - "Use /history to see workout numbers.", - parse_mode=ParseMode.HTML, - ) - return - - try: - user_number = int(context.args[0]) - except ValueError: - await update.message.reply_text("Workout number must be a number.") - return - - workout_id = resolve_user_number(user_id, user_number) - if workout_id is not None and delete_workout(user_id, workout_id): - log_event(user_id, "workout.delete", {"workout_id": workout_id, "user_number": user_number}) - await update.message.reply_text(f"\U0001f5d1 Workout #{user_number} deleted.") - else: - await update.message.reply_text( - f"Workout #{user_number} not found." - ) - - -async def cmd_export(update: Update, context: ContextTypes.DEFAULT_TYPE): - """Send all workout data as a JSON file.""" - import json - import io - from db import export_workouts - - user_id = update.effective_user.id - log_event(user_id, "cmd.export") - data = export_workouts(user_id) - - if not data: - await update.message.reply_text("No workouts to export.") - return - - content = json.dumps(data, indent=2, ensure_ascii=False) - buf = io.BytesIO(content.encode("utf-8")) - buf.name = "workouts_export.json" - - await update.message.reply_document( - document=buf, - caption=f"\U0001f4e6 Exported {len(data)} exercise records.", - ) - - -async def cmd_feedback(update: Update, context: ContextTypes.DEFAULT_TYPE): - user_id = update.effective_user.id - text = " ".join(context.args) if context.args else "" - - if not text: - await update.message.reply_text( - "Usage: /feedback <your feedback>", - parse_mode=ParseMode.HTML, - ) - return - - save_feedback(user_id, text) - log_event(user_id, "cmd.feedback") - await update.message.reply_text("\U0001f4dd Feedback saved, thanks!") - - -# ── Message handler (workout parsing) ─────────────────────────────────────── - - -async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): - """Parse any text message as a potential workout.""" - text = update.effective_message.text - if not text: - return - - groups, errors = parse_workout(text) - - if not groups and not errors: - # Doesn't look like a workout at all — silently ignore - return - - if not groups and errors: - # Looks like they tried but every line failed - error_lines = "\n".join(f" \u2022 {e.line}" for e in errors) - await update.message.reply_text( - f"\u26a0\ufe0f Could not parse workout. Check your format:\n\n" - f"{error_lines}\n\n" - f"Expected formats:\n" - f"Exercise: 4x8x35\n" - f"Exercise: 3x10 (bodyweight)\n" - f"Exercise: 8x25, 5x35, 6x40", - parse_mode=ParseMode.HTML, - ) - return - - user_id = update.effective_user.id - timestamp, is_forwarded = extract_timestamp(update) - - superset_dicts = [[ex.to_dict() for ex in group] for group in groups] - workout_id = save_workout(user_id, timestamp, superset_dicts, raw_text=text) - user_number = get_user_workout_number(user_id, workout_id) or workout_id - log_event(user_id, "workout.save", { - "source": "text", - "workout_id": workout_id, - "user_number": user_number, - "forwarded": is_forwarded, - }) - - # Count totals for the confirmation - total_exercises = sum(len(g) for g in groups) - total_sets = sum(ex.sets for g in groups for ex in g) - supersets = sum(1 for g in groups if len(g) > 1) - - ts_str = timestamp.strftime("%a %d %b %Y, %H:%M") - - confirm_parts = [ - f"\u2705 Workout #{user_number} saved!", - f"\U0001f4c5 {ts_str}" + (" (from forwarded message)" if is_forwarded else ""), - f"\U0001f3cb\ufe0f {total_exercises} exercises, {total_sets} total sets", - ] - if supersets: - confirm_parts.append(f"\U0001f517 {supersets} superset(s)") - - # Show errors for partially parsed workouts - if errors: - skipped = "\n".join(f" \u2022 {e.line}" for e in errors) - confirm_parts.append(f"\n\u26a0\ufe0f Skipped {len(errors)} unparseable line(s):\n{skipped}") - - confirm_parts.append(f"\n{format_workout(superset_dicts)}") - - await update.message.reply_text( - "\n".join(confirm_parts), - parse_mode=ParseMode.HTML, - ) - - -# ── Main ───────────────────────────────────────────────────────────────────── - - -async def post_init(app: Application): - """Set the bot's menu button to open the Mini App (if URL is available).""" - if WEBAPP_URL: - await app.bot.set_chat_menu_button( - menu_button=MenuButtonWebApp( - text="Workout", - web_app=WebAppInfo(url=WEBAPP_URL), - ) - ) - logger.info("Menu button set to Mini App at %s", WEBAPP_URL) - else: - logger.info("No WEBAPP_URL — menu button not set") - - -def main(): - init_db() - - builder = ApplicationBuilder().token(BOT_TOKEN) - if WEBAPP_URL: - builder = builder.post_init(post_init) - app = builder.build() - - app.add_handler(CommandHandler("start", cmd_start)) - app.add_handler(CommandHandler("history", cmd_history)) - app.add_handler(CommandHandler("stats", cmd_stats)) - app.add_handler(CommandHandler("delete", cmd_delete)) - app.add_handler(CommandHandler("export", cmd_export)) - app.add_handler(CommandHandler("feedback", cmd_feedback)) - - # Handle all text messages (including forwarded ones) - app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message)) - - logger.info("Bot started \u2014 polling\u2026") - app.run_polling(allowed_updates=Update.ALL_TYPES) - - -if __name__ == "__main__": - main() diff --git a/flake.nix b/flake.nix index 881d5c0..1896c3c 100644 --- a/flake.nix +++ b/flake.nix @@ -14,7 +14,8 @@ python = pkgs.python3; pythonEnv = python.withPackages (ps: with ps; [ - python-telegram-bot + # python-telegram-bot was used by the now-deleted bot.py polling + # loop; the Mini App backend doesn't need it. Kept off this list. python-dotenv aiohttp pytest @@ -27,8 +28,7 @@ packages = [ pythonEnv pkgs.cloudflared ]; shellHook = '' echo "💪 BigBiggerBiggestBot dev shell" - echo " Run: python start.py (server + tunnel + bot)" - echo " Run: python bot.py (bot only, no mini app)" + echo " Run: python start.py (server + tunnel)" ''; }; diff --git a/requirements.txt b/requirements.txt index 84250d7..e4d955d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -python-telegram-bot>=21.0 python-dotenv>=1.0 +aiohttp>=3.9 diff --git a/start.py b/start.py index df1537d..2ed0ca4 100644 --- a/start.py +++ b/start.py @@ -1,11 +1,15 @@ #!/usr/bin/env python3 """ Orchestrator — single entry point for `nix run`. - 1. Loads BOT_TOKEN from ~/.secrets or .env (same as bot.py) - 2. Starts the API server - 3. Starts localtunnel to get a public HTTPS URL - 4. Starts the Telegram bot with WEBAPP_URL set - 5. Cleans up everything on Ctrl+C + 1. Loads BOT_TOKEN (env var → ~/.secrets → .env) + 2. Starts the API + Mini App server + 3. Optionally starts a cloudflared Quick Tunnel for a public HTTPS URL + (skipped if WEBAPP_URL is already set, e.g. fronted by a reverse proxy) + 4. Cleans up on Ctrl+C + +The slash-command bot was removed: the Mini App is the only interface. +The bot's identity (token) only matters now for validating Telegram WebApp +initData HMACs and for the menu-button URL — both handled in server.py. """ import os import re @@ -23,8 +27,8 @@ SECRETS_FILE = pathlib.Path.home() / ".secrets" / "bigbiggerbiggestbot" def load_token() -> str: """Load bot token: BOT_TOKEN env → secrets file → .env in cwd. - Env var wins so multiple instances on the same host (prod + shipyard) - can each get a distinct token via systemd EnvironmentFile. + Env var wins so multiple instances on the same host (prod + shipyard + staging) can each get a distinct token via systemd EnvironmentFile. """ # 1. Already in environment (systemd EnvironmentFile sets this for staging) token = os.environ.get("BOT_TOKEN", "").strip() @@ -32,7 +36,7 @@ def load_token() -> str: print(" Token loaded from BOT_TOKEN env var") return token - # 2. Secrets file (same path as bot.py uses) + # 2. Default secrets file if SECRETS_FILE.is_file(): token = SECRETS_FILE.read_text().strip() if token: @@ -102,21 +106,13 @@ def start_tunnel(port: int) -> tuple[subprocess.Popen, str]: # Keep draining cloudflared output so its pipe buffer doesn't fill up # (which would block the process and kill the tunnel) def _drain(): - for line in proc.stdout: + for _line in proc.stdout: pass threading.Thread(target=_drain, daemon=True).start() return proc, url -def start_bot(bot_token: str, webapp_url: str) -> subprocess.Popen: - env = {**os.environ, "BOT_TOKEN": bot_token, "WEBAPP_URL": webapp_url} - return subprocess.Popen( - [sys.executable, str(SCRIPT_DIR / "bot.py")], - env=env, - ) - - def main(): port = int(os.environ.get("API_PORT", "8080")) procs: list[subprocess.Popen] = [] @@ -140,11 +136,11 @@ def main(): print() print(" ==========================================") - print(" BigBiggerBiggest — Fitness Tracker") + print(" BigBiggerBiggest — Mini App backend") print(" ==========================================") print() - # 1. Load token + # 1. Load token (used by server.py to validate initData HMACs) bot_token = load_token() masked = bot_token[:5] + "..." + bot_token[-4:] print(f" BOT_TOKEN: {masked}") @@ -159,11 +155,10 @@ def main(): print(" Server failed to start!") sys.exit(1) - # 3. Tunnel — skipped if WEBAPP_URL is already provided (e.g. the bot - # is fronted by an external reverse proxy that terminates TLS and - # proxies back to localhost:$API_PORT over a private network). + # 3. Tunnel — skipped if WEBAPP_URL is already provided (e.g. fronted + # by an external reverse proxy that terminates TLS and proxies back to + # localhost:$API_PORT over a private network). env_webapp_url = os.environ.get("WEBAPP_URL", "").strip() - tunnel = None if env_webapp_url: webapp_url = env_webapp_url print(f" WEBAPP_URL from environment: {webapp_url} (skipping cloudflared)") @@ -171,27 +166,21 @@ def main(): tunnel, webapp_url = start_tunnel(port) procs.append(tunnel) - # 4. Start bot - print(f"\n WEBAPP_URL: {webapp_url}") - print(" Starting bot...\n") - bot = start_bot(bot_token, webapp_url) - procs.append(bot) - - print(" ==========================================") - print(f" All systems go!") + print("\n ==========================================") + print(" All systems go!") print(f" Mini App: {webapp_url}") print(f" API: http://localhost:{port}") - print(f" Press Ctrl+C to stop") - print(" ==========================================") - print() + print(" Press Ctrl+C to stop") + print(" ==========================================\n") + + proc_names = {id(server): "Server"} + if procs[-1] is not server: + proc_names[id(procs[-1])] = "Tunnel" while True: for p in procs: ret = p.poll() if ret is not None: - proc_names = {id(server): "Server", id(bot): "Bot"} - if tunnel is not None: - proc_names[id(tunnel)] = "Tunnel" name = proc_names.get(id(p), "?") print(f"\n {name} exited with code {ret}") cleanup()