diff --git a/README.md b/README.md index 144c7d6..1faba7a 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # BigBiggerBiggestBot ๐Ÿ’ช -A Telegram **Mini App** for logging gym workouts. History, stats, notes, +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, edit & delete, JSON/CSV export โ€” all per-user, all in SQLite. -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. +## Format -## Workout text format (still supported via "Paste as text" in the Mini App) +Send messages like: ``` Bench press: 4x8x35 @@ -22,6 +23,15 @@ 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 @@ -29,9 +39,9 @@ nix run ``` This launches: -- 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) +- API server (port 8080) +- cloudflared tunnel for the Mini App +- Telegram bot (polling) Put your bot token (from [@BotFather](https://t.me/BotFather)) in `~/.secrets/bigbiggerbiggestbot` or a `.env` file: @@ -53,12 +63,10 @@ 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 at - `https://bbbot.dannydannydanny.me`. + watches `origin/main`, served behind a stable URL via the VPS Caddy. - **Shipyard staging** โ€” `fitness-bot-shipyard.service`, working dir - `/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). + `/home/danny/tg_fitness_bot_shipyard`, watches `origin/staging`, separate + bot token, ephemeral cloudflared URL each restart. Each has its own pull timer that fetches every ~15 minutes and restarts the service when its branch has new commits. @@ -66,6 +74,7 @@ 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 ``` @@ -75,16 +84,13 @@ so testing on shipyard never touches production data. ## Architecture -- `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. +- `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 ## License diff --git a/ROADMAP.md b/ROADMAP.md index cc3c764..c1afd8d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,9 +15,8 @@ - [x] Global exercise name suggestions โ€” autocomplete draws from all users' exercises, ordered by popularity, case-insensitively grouped. - [ ] **#3** Machine-to-muscle mapping โ€” reference dataset + `/machine ` command. Seeded with gym80 IDs. - [x] Interaction / event logging โ€” structured `events` table; bot commands, workout save/update/delete, Mini App opens, and per-set additions all record events. `POST /api/events` endpoint lets the Mini App emit client-side events. Rest-timer prereq done. -- [x] Staging via shipyard โ€” Mini-App-only HTTP tenant under `shipyard_poc_bot` (slash-command bot deleted; phantom-ship's Shipyard owns Telegram polling). `fitness-bot-shipyard.service` on sunken-ship watches `origin/staging` from `/home/danny/tg_fitness_bot_shipyard`, listens on `:8081`, fronted by vps-relay Caddy at **https://b3.dannydannydanny.me**, validates initData against `shipyard_poc_bot`'s token (`EnvironmentFile=/home/danny/.secrets/shipyard_poc_bot.env`). Listed in `~/python-projects/26_shipyard/apps.json` as `b3bot-beta`. Workflow: `git push origin :staging` (auto-deploys ~15 min) โ†’ tap **B3Bot beta** in shipyard_poc_bot โ†’ test โ†’ `git push origin :main`. +- [x] Staging via shipyard โ€” second NixOS-managed systemd instance on sunken-ship (`fitness-bot-shipyard.service`) watches `origin/staging` from `/home/danny/tg_fitness_bot_shipyard`, runs on port 8081, ephemeral cloudflared tunnel, served by the shared `shipyard_poc_bot` Telegram bot. Token via `EnvironmentFile=/home/danny/.secrets/shipyard_poc_bot.env`. Workflow: `git push origin :staging` (auto-deploys ~15 min) โ†’ `/start` shipyard_poc_bot โ†’ test โ†’ `git push origin :main`. - [x] **feedback #9** Negative weight input โ€” `ยฑ` sign-flip button next to the weight input handles iOS numeric keypads that have no minus key; active state indicates a negative value. -- [ ] Last-session recall โ€” when starting an exercise, show the most recent sets/weights logged for it (and pre-fill the weight) so you have a reference for what to beat. ## Later - [ ] **#8** Workout templates โ€” save/load favorite workouts diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..d638082 --- /dev/null +++ b/bot.py @@ -0,0 +1,336 @@ +"""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/db.py b/db.py index 410097b..5b834c4 100644 --- a/db.py +++ b/db.py @@ -263,39 +263,6 @@ def resolve_user_number(user_id: int, user_number: int) -> int | None: return row["id"] if row else None -def get_last_exercise(user_id: int, name: str) -> dict | None: - """Return the most recent logged entry for an exercise (case-insensitive - name match) from this user's non-deleted workouts, or None. - - The returned dict carries the exercise fields plus the parent workout's - `timestamp`, and `sets_detail` parsed back into a list. - """ - with get_db() as conn: - row = conn.execute( - """SELECT e.name, e.machine_id, e.sets, e.reps, e.weight_kg, - e.sets_detail, w.timestamp - FROM workouts w - JOIN superset_groups sg ON sg.workout_id = w.id - JOIN exercises e ON e.superset_group_id = sg.id - WHERE w.user_id = ? AND w.deleted_at IS NULL - AND LOWER(e.name) = LOWER(?) - ORDER BY w.timestamp DESC, e.id DESC - LIMIT 1""", - (user_id, name), - ).fetchone() - if not row: - return None - d = dict(row) - if d.get("sets_detail"): - try: - d["sets_detail"] = json.loads(d["sets_detail"]) - except json.JSONDecodeError: - d["sets_detail"] = [] - else: - d["sets_detail"] = [] - return d - - def get_workout_count(user_id: int) -> int: with get_db() as conn: row = conn.execute( diff --git a/flake.nix b/flake.nix index 1896c3c..881d5c0 100644 --- a/flake.nix +++ b/flake.nix @@ -14,8 +14,7 @@ python = pkgs.python3; pythonEnv = python.withPackages (ps: with ps; [ - # 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-telegram-bot python-dotenv aiohttp pytest @@ -28,7 +27,8 @@ packages = [ pythonEnv pkgs.cloudflared ]; shellHook = '' echo "๐Ÿ’ช BigBiggerBiggestBot dev shell" - echo " Run: python start.py (server + tunnel)" + echo " Run: python start.py (server + tunnel + bot)" + echo " Run: python bot.py (bot only, no mini app)" ''; }; diff --git a/requirements.txt b/requirements.txt index e4d955d..84250d7 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/server.py b/server.py index 71d2f9a..8e0ee77 100644 --- a/server.py +++ b/server.py @@ -17,7 +17,7 @@ from urllib.parse import parse_qs from aiohttp import web -from db import init_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, update_workout, export_workouts, get_user_workout_number, get_all_exercise_names, log_event, get_settings, update_settings, get_last_exercise +from db import init_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, update_workout, export_workouts, get_user_workout_number, get_all_exercise_names, log_event, get_settings, update_settings from parser import parse_workout, format_workout logging.basicConfig( @@ -283,16 +283,6 @@ async def api_get_exercise_names(request: web.Request): return web.json_response({"exercises": get_all_exercise_names()}) -@require_auth -async def api_get_last_exercise(request: web.Request): - """Return the user's most recent logged entry for a given exercise name.""" - name = request.query.get("name", "").strip() - if not name: - return web.json_response({"error": "Missing name"}, status=400) - last = get_last_exercise(request["user_id"], name) - return web.json_response({"last": last}) - - @require_auth async def api_get_stats(request: web.Request): """Return summary stats for the user.""" @@ -376,7 +366,6 @@ def create_app() -> web.Application: app.router.add_put("/api/workouts/{workout_id}", api_update_workout) app.router.add_delete("/api/workouts/{workout_id}", api_delete_workout) app.router.add_get("/api/exercises", api_get_exercise_names) - app.router.add_get("/api/exercises/last", api_get_last_exercise) app.router.add_get("/api/stats", api_get_stats) app.router.add_get("/api/export/json", api_export_json) app.router.add_get("/api/export/csv", api_export_csv) diff --git a/start.py b/start.py index 6471e56..df1537d 100644 --- a/start.py +++ b/start.py @@ -1,15 +1,11 @@ #!/usr/bin/env python3 """ Orchestrator โ€” single entry point for `nix run`. - 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. + 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 """ import os import re @@ -20,10 +16,6 @@ import threading import time import pathlib -# Flush prints line-by-line so the tunnel URL etc. show up promptly in -# systemd journals (stdout is block-buffered when not connected to a TTY). -sys.stdout.reconfigure(line_buffering=True) - SCRIPT_DIR = pathlib.Path(__file__).resolve().parent SECRETS_FILE = pathlib.Path.home() / ".secrets" / "bigbiggerbiggestbot" @@ -31,8 +23,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 - staging) can each get a distinct token via systemd EnvironmentFile. + Env var wins so multiple instances on the same host (prod + shipyard) + 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() @@ -40,7 +32,7 @@ def load_token() -> str: print(" Token loaded from BOT_TOKEN env var") return token - # 2. Default secrets file + # 2. Secrets file (same path as bot.py uses) if SECRETS_FILE.is_file(): token = SECRETS_FILE.read_text().strip() if token: @@ -110,13 +102,21 @@ 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 +140,11 @@ def main(): print() print(" ==========================================") - print(" BigBiggerBiggest โ€” Mini App backend") + print(" BigBiggerBiggest โ€” Fitness Tracker") print(" ==========================================") print() - # 1. Load token (used by server.py to validate initData HMACs) + # 1. Load token bot_token = load_token() masked = bot_token[:5] + "..." + bot_token[-4:] print(f" BOT_TOKEN: {masked}") @@ -159,10 +159,11 @@ def main(): print(" Server failed to start!") sys.exit(1) - # 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). + # 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). 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)") @@ -170,21 +171,27 @@ def main(): tunnel, webapp_url = start_tunnel(port) procs.append(tunnel) - print("\n ==========================================") - print(" All systems go!") + # 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(f" Mini App: {webapp_url}") print(f" API: http://localhost:{port}") - 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" + print(f" Press Ctrl+C to stop") + print(" ==========================================") + print() 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() diff --git a/tests/test_db.py b/tests/test_db.py index d770e8e..82bfd88 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -253,48 +253,6 @@ class TestAllExerciseNames: assert db.get_all_exercise_names() == ["Apple", "Mango", "Zebra"] -# โ”€โ”€ get_last_exercise โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - -class TestGetLastExercise: - def test_none_when_no_history(self, tmp_db): - assert db.get_last_exercise(1, "Bench") is None - - def test_returns_most_recent(self, tmp_db): - t = lambda d: datetime(2024, 1, d, tzinfo=timezone.utc) - db.save_workout(1, t(1), [[_make_exercise(name="Squat", weight=80.0)]]) - db.save_workout(1, t(5), [[_make_exercise(name="Squat", weight=90.0)]]) - last = db.get_last_exercise(1, "Squat") - assert last is not None - assert last["weight_kg"] == 90.0 - assert last["timestamp"].startswith("2024-01-05") - - def test_case_insensitive(self, tmp_db): - _save_simple(name="Bench Press") - assert db.get_last_exercise(1, "bench press") is not None - assert db.get_last_exercise(1, "BENCH PRESS") is not None - - def test_sets_detail_parsed(self, tmp_db): - detail = [{"reps": 8, "weight_kg": 25.0}, {"reps": 5, "weight_kg": 35.0}] - ex = { - "name": "Press", "machine_id": None, - "sets": 2, "reps": 8, "weight_kg": 25.0, - "sets_detail": detail, "raw_line": "Press: 8x25, 5x35", - } - db.save_workout(1, datetime.now(timezone.utc), [[ex]]) - last = db.get_last_exercise(1, "Press") - assert last["sets_detail"] == detail - - def test_scoped_to_user(self, tmp_db): - _save_simple(user_id=1, name="Deadlift") - assert db.get_last_exercise(2, "Deadlift") is None - - def test_ignores_deleted(self, tmp_db): - wid = _save_simple(name="Rows") - db.delete_workout(1, wid) - assert db.get_last_exercise(1, "Rows") is None - - # โ”€โ”€ events / log_event โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/webapp/app.js b/webapp/app.js index 7f8577a..6559fac 100644 --- a/webapp/app.js +++ b/webapp/app.js @@ -125,7 +125,6 @@ function restoreDraft() { if (Array.isArray(draft.currentSets)) { draft.currentSets.forEach((s) => addSetToDOM(s.reps, s.weight_kg)); } - loadLastSession(currentExercise.name); } // Restore active tab @@ -358,72 +357,10 @@ function startExercise(name) { notesSection.classList.remove("hidden"); stopRestTimer(); syncEditorUI(); - loadLastSession(name); tg.HapticFeedback.selectionChanged(); saveDraft(); } -// โ”€โ”€ Last-session recall โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -function _relativeDay(iso) { - const then = new Date(iso); - if (isNaN(then.getTime())) return ""; - const days = Math.floor((Date.now() - then.getTime()) / 86400000); - if (days <= 0) return "today"; - if (days === 1) return "yesterday"; - if (days < 7) return days + " days ago"; - if (days < 14) return "1 week ago"; - if (days < 30) return Math.floor(days / 7) + " weeks ago"; - return then.toLocaleDateString(); -} - -function _setsSummary(last) { - const detail = last.sets_detail || []; - const varied = detail.length > 0 && !detail.every( - (d) => d.reps === detail[0].reps && d.weight_kg === detail[0].weight_kg - ); - if (varied) { - return detail - .map((d) => (d.weight_kg ? `${d.reps}ร—${fmtWeight(d.weight_kg)}kg` : `${d.reps}`)) - .join(", "); - } - return last.weight_kg - ? `${last.sets}ร—${last.reps}ร—${fmtWeight(last.weight_kg)}kg` - : `${last.sets}ร—${last.reps}`; -} - -async function loadLastSession(name) { - const hint = document.getElementById("last-session-hint"); - if (hint) { - hint.classList.add("hidden"); - hint.textContent = ""; - } - try { - const data = await api("GET", "/exercises/last?name=" + encodeURIComponent(name)); - const last = data.last; - // Bail if the user has moved on to a different exercise meanwhile. - if (!last || !currentExercise || currentExercise.name !== name) return; - - if (hint) { - const when = _relativeDay(last.timestamp); - hint.textContent = "Last time: " + _setsSummary(last) + (when ? " ยท " + when : ""); - hint.classList.remove("hidden"); - } - - // Pre-fill the weight input with the last set's weight, but only if the - // user hasn't already started typing or logged a set. - const detail = last.sets_detail || []; - const lastWeight = detail.length - ? detail[detail.length - 1].weight_kg - : last.weight_kg; - if (lastWeight && !weightInput.value.trim() && getCurrentSets().length === 0) { - weightInput.value = String(lastWeight); - syncWeightSignUI(); - } - } catch (e) { - // Silent โ€” recall is a convenience, never block exercise entry. - } -} - function getCurrentSets() { return Array.from(setsList.querySelectorAll(".set-entry")).map((el) => ({ reps: parseInt(el.dataset.reps), @@ -655,14 +592,6 @@ function editExercise(idx) { repsInput.value = ""; repsInput.focus(); - // The set rows in front of you are the reference here โ€” drop any stale - // "last time" hint from an earlier startExercise. - const hint = document.getElementById("last-session-hint"); - if (hint) { - hint.classList.add("hidden"); - hint.textContent = ""; - } - stopRestTimer(); renderWorkout(); tg.HapticFeedback.selectionChanged(); diff --git a/webapp/index.html b/webapp/index.html index 16844cf..dc5f84a 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -37,7 +37,6 @@ -
diff --git a/webapp/style.css b/webapp/style.css index 06d29c2..5e5e144 100644 --- a/webapp/style.css +++ b/webapp/style.css @@ -241,15 +241,6 @@ body { font-variant-numeric: tabular-nums; } -.last-session-hint { - margin-top: 6px; - font-size: 12px; - color: var(--tg-theme-hint-color, #999); - background: var(--tg-theme-bg-color, #fff); - border-radius: 8px; - padding: 6px 10px; -} - .btn-danger { color: #e53935 !important; font-size: 12px !important;