feat(tg-fitness-bot): multi-set format, delete, export, SQL stats
Parser now supports per-set notation (8x25, 5x35, 6x40), bodyweight exercises (3x10), and asterisk separators. Failed parse lines get user-facing error feedback instead of being silently ignored. Added /delete <id> and /export commands. Stats computed in SQL instead of loading all workouts into memory. API gains DELETE, CSV and JSON export endpoints. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ae09ab2eec
commit
a934c46746
4 changed files with 356 additions and 111 deletions
141
bot.py
141
bot.py
|
|
@ -16,7 +16,7 @@ from telegram.ext import (
|
|||
filters,
|
||||
)
|
||||
|
||||
from db import init_db, save_workout, get_workouts, get_workout_count
|
||||
from db import init_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout
|
||||
from parser import parse_workout, format_workout
|
||||
|
||||
load_dotenv()
|
||||
|
|
@ -48,7 +48,7 @@ def _load_token() -> str:
|
|||
|
||||
BOT_TOKEN = _load_token()
|
||||
|
||||
# Mini App URL — set automatically by start.py via localtunnel
|
||||
# Mini App URL — set automatically by start.py via tunnel
|
||||
WEBAPP_URL = os.environ.get("WEBAPP_URL", "")
|
||||
|
||||
|
||||
|
|
@ -79,19 +79,21 @@ def extract_timestamp(update: Update) -> tuple[datetime, bool]:
|
|||
|
||||
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
text = (
|
||||
"💪 <b>Fitness Tracker Bot</b>\n\n"
|
||||
"\U0001f4aa <b>Fitness Tracker Bot</b>\n\n"
|
||||
"Send me your workout and I'll save it!\n\n"
|
||||
"<b>Format:</b>\n"
|
||||
"<b>Formats:</b>\n"
|
||||
"<code>Bench press: 4x8x35</code>\n"
|
||||
"<code>Lateral raise: 4x8x4</code>\n\n"
|
||||
"<code>Tri Press rom: 3x10x45</code>\n\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: <code>Lat pulldown (500): 3x5x45</code>\n\n"
|
||||
"You can also <b>forward</b> messages from Saved Messages — "
|
||||
"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 — view recent workouts\n"
|
||||
"/stats — quick summary"
|
||||
"/history \u2014 view recent workouts\n"
|
||||
"/stats \u2014 quick summary\n"
|
||||
"/delete <id> \u2014 delete a workout\n"
|
||||
"/export \u2014 export all data as JSON"
|
||||
)
|
||||
|
||||
if WEBAPP_URL:
|
||||
|
|
@ -118,11 +120,11 @@ async def cmd_history(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
parts = []
|
||||
for w in workouts:
|
||||
ts = datetime.fromisoformat(w["timestamp"])
|
||||
header = f"📅 <b>{ts.strftime('%a %d %b %Y, %H:%M')}</b>"
|
||||
header = f"\U0001f4c5 <b>{ts.strftime('%a %d %b %Y, %H:%M')}</b> (#{w['id']})"
|
||||
body = format_workout(w["superset_groups"])
|
||||
parts.append(f"{header}\n{body}")
|
||||
|
||||
text = "\n\n───────────────\n\n".join(parts)
|
||||
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>"
|
||||
|
||||
|
|
@ -131,35 +133,70 @@ async def cmd_history(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
|
||||
async def cmd_stats(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
user_id = update.effective_user.id
|
||||
total = get_workout_count(user_id)
|
||||
stats = get_stats_sql(user_id)
|
||||
|
||||
if total == 0:
|
||||
await update.message.reply_text("No workouts yet — send me your first one!")
|
||||
if stats["total_workouts"] == 0:
|
||||
await update.message.reply_text("No workouts yet \u2014 send me your first one!")
|
||||
return
|
||||
|
||||
workouts = get_workouts(user_id, limit=1000)
|
||||
|
||||
# Collect all unique exercise names
|
||||
exercise_names = set()
|
||||
total_sets = 0
|
||||
total_volume = 0.0
|
||||
for w in workouts:
|
||||
for group in w["superset_groups"]:
|
||||
for ex in group:
|
||||
exercise_names.add(ex["name"].lower())
|
||||
total_sets += ex["sets"]
|
||||
total_volume += ex["sets"] * ex["reps"] * ex["weight_kg"]
|
||||
|
||||
await update.message.reply_text(
|
||||
f"📊 <b>Your Stats</b>\n\n"
|
||||
f" • Workouts logged: <b>{total}</b>\n"
|
||||
f" • Unique exercises: <b>{len(exercise_names)}</b>\n"
|
||||
f" • Total sets: <b>{total_sets}</b>\n"
|
||||
f" • Total volume: <b>{total_volume:,.0f} kg</b>",
|
||||
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
|
||||
|
||||
if not context.args:
|
||||
await update.message.reply_text(
|
||||
"Usage: /delete <workout_id>\n"
|
||||
"Use /history to see workout IDs.",
|
||||
parse_mode=ParseMode.HTML,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
workout_id = int(context.args[0])
|
||||
except ValueError:
|
||||
await update.message.reply_text("Workout ID must be a number.")
|
||||
return
|
||||
|
||||
if delete_workout(user_id, workout_id):
|
||||
await update.message.reply_text(f"\U0001f5d1 Workout #{workout_id} deleted.")
|
||||
else:
|
||||
await update.message.reply_text(
|
||||
f"Workout #{workout_id} not found (or not yours)."
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
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.",
|
||||
)
|
||||
|
||||
|
||||
# ── Message handler (workout parsing) ───────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -169,9 +206,24 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
if not text:
|
||||
return
|
||||
|
||||
groups = parse_workout(text)
|
||||
if not groups:
|
||||
# Not a workout message — silently ignore so the bot isn't noisy
|
||||
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
|
||||
|
|
@ -188,12 +240,17 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||
ts_str = timestamp.strftime("%a %d %b %Y, %H:%M")
|
||||
|
||||
confirm_parts = [
|
||||
f"✅ <b>Workout #{workout_id} saved!</b>",
|
||||
f"📅 {ts_str}" + (" (from forwarded message)" if is_forwarded else ""),
|
||||
f"🏋️ {total_exercises} exercises, {total_sets} total sets",
|
||||
f"\u2705 <b>Workout #{workout_id} 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"🔗 {supersets} superset(s)")
|
||||
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)}")
|
||||
|
||||
|
|
@ -231,11 +288,13 @@ def main():
|
|||
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))
|
||||
|
||||
# Handle all text messages (including forwarded ones)
|
||||
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
|
||||
|
||||
logger.info("Bot started — polling…")
|
||||
logger.info("Bot started \u2014 polling\u2026")
|
||||
app.run_polling(allowed_updates=Update.ALL_TYPES)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue