Compare commits
5 commits
aa43e492c3
...
9f146d60fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f146d60fa | ||
|
|
5e3636201f | ||
|
|
d6f8fa41fb | ||
|
|
459c751414 | ||
|
|
17248e239b |
12 changed files with 227 additions and 408 deletions
52
README.md
52
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 <id>` — soft-delete a workout
|
||||
- `/export` — download all data as JSON
|
||||
- `/feedback <text>` — 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 <branch>:staging # → shipyard auto-deploys, test there
|
||||
git push origin <branch>: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
|
||||
|
||||
|
|
|
|||
|
|
@ -15,8 +15,9 @@
|
|||
- [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 <id>` 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 — 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 <branch>:staging` (auto-deploys ~15 min) → `/start` shipyard_poc_bot → test → `git push origin <branch>:main`.
|
||||
- [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 <branch>:staging` (auto-deploys ~15 min) → tap **B3Bot beta** in shipyard_poc_bot → test → `git push origin <branch>: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
|
||||
|
|
|
|||
336
bot.py
336
bot.py
|
|
@ -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 <b>Fitness Tracker Bot</b>\n\n"
|
||||
"Send me your workout and I'll save it!\n\n"
|
||||
"<b>Formats:</b>\n"
|
||||
"<code>Bench press: 4x8x35</code>\n"
|
||||
"<code>Pull-ups: 3x10</code> (bodyweight)\n"
|
||||
"<code>Shoulder press (3032): 8x25, 5x35, 6x40</code>\n\n"
|
||||
"Lines without a blank line between them = superset.\n"
|
||||
"Machine IDs go in parentheses.\n\n"
|
||||
"You can also <b>forward</b> messages from Saved Messages \u2014 "
|
||||
"I'll use the original timestamp.\n\n"
|
||||
"<b>Commands:</b>\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 <b>{ts.strftime('%a %d %b %Y, %H:%M')}</b> (#{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\n<i>Showing latest 5 of {total} workouts.</i>"
|
||||
|
||||
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 <b>Your Stats</b>\n\n"
|
||||
f" \u2022 Workouts logged: <b>{stats['total_workouts']}</b>\n"
|
||||
f" \u2022 Unique exercises: <b>{stats['unique_exercises']}</b>\n"
|
||||
f" \u2022 Total sets: <b>{stats['total_sets']}</b>\n"
|
||||
f" \u2022 Total volume: <b>{stats['total_volume']:,.0f} kg</b>",
|
||||
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 <code>{e.line}</code>" 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"<b>Expected formats:</b>\n"
|
||||
f"<code>Exercise: 4x8x35</code>\n"
|
||||
f"<code>Exercise: 3x10</code> (bodyweight)\n"
|
||||
f"<code>Exercise: 8x25, 5x35, 6x40</code>",
|
||||
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 <b>Workout #{user_number} saved!</b>",
|
||||
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 <code>{e.line}</code>" 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()
|
||||
33
db.py
33
db.py
|
|
@ -263,6 +263,39 @@ 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(
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
'';
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
python-telegram-bot>=21.0
|
||||
python-dotenv>=1.0
|
||||
aiohttp>=3.9
|
||||
|
|
|
|||
13
server.py
13
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
|
||||
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 parser import parse_workout, format_workout
|
||||
|
||||
logging.basicConfig(
|
||||
|
|
@ -283,6 +283,16 @@ 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."""
|
||||
|
|
@ -366,6 +376,7 @@ 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)
|
||||
|
|
|
|||
67
start.py
67
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
|
||||
|
|
@ -16,6 +20,10 @@ 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"
|
||||
|
||||
|
|
@ -23,8 +31,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 +40,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 +110,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 +140,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 +159,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 +170,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()
|
||||
|
|
|
|||
|
|
@ -253,6 +253,48 @@ 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 ───────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ function restoreDraft() {
|
|||
if (Array.isArray(draft.currentSets)) {
|
||||
draft.currentSets.forEach((s) => addSetToDOM(s.reps, s.weight_kg));
|
||||
}
|
||||
loadLastSession(currentExercise.name);
|
||||
}
|
||||
|
||||
// Restore active tab
|
||||
|
|
@ -357,10 +358,72 @@ 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),
|
||||
|
|
@ -592,6 +655,14 @@ 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();
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
</div>
|
||||
<button id="btn-delete-exercise" class="btn-link btn-danger hidden">Remove exercise</button>
|
||||
</div>
|
||||
<div id="last-session-hint" class="last-session-hint hidden"></div>
|
||||
<div id="sets-list"></div>
|
||||
<div class="set-input-row">
|
||||
<input type="text" id="inp-reps" class="input input-small" placeholder="Reps" inputmode="numeric" pattern="[0-9]*" />
|
||||
|
|
|
|||
|
|
@ -241,6 +241,15 @@ 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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue