"""Telegram Fitness Bot — track your workouts.""" import logging import os from datetime import datetime, timezone from dotenv import load_dotenv from telegram import Update from telegram.constants import ParseMode from telegram.ext import ( ApplicationBuilder, CommandHandler, ContextTypes, MessageHandler, filters, ) from db import init_db, save_workout, get_workouts, get_workout_count 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: secrets file → .env / environment variable SECRETS_FILE = os.path.expanduser("~/.secrets/bigbiggerbiggestbot") def _load_token() -> str: # 1. Try the secrets file if os.path.isfile(SECRETS_FILE): token = open(SECRETS_FILE).read().strip() if token: return token # 2. Fall back to env var (set via .env or shell) token = os.environ.get("BOT_TOKEN", "").strip() if token: return token raise RuntimeError( f"No bot token found. Put it in {SECRETS_FILE} or set BOT_TOKEN env var." ) BOT_TOKEN = _load_token() # ── 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): await update.message.reply_text( "💪 Fitness Tracker Bot\n\n" "Send me your workout and I'll save it!\n\n" "Format:\n" "Bench press: 4x8x35\n" "Lateral raise: 4x8x4\n\n" "Tri Press rom: 3x10x45\n\n" "Lines without a blank line between them = superset.\n" "Machine IDs go in parentheses: Lat pulldown (500): 3x5x45\n\n" "You can also forward messages from Saved Messages — " "I'll use the original timestamp.\n\n" "Commands:\n" "/history — view recent workouts\n" "/stats — quick summary", parse_mode=ParseMode.HTML, ) async def cmd_history(update: Update, context: ContextTypes.DEFAULT_TYPE): user_id = update.effective_user.id 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"📅 {ts.strftime('%a %d %b %Y, %H:%M')}" body = format_workout(w["superset_groups"]) parts.append(f"{header}\n{body}") text = "\n\n───────────────\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 total = get_workout_count(user_id) if total == 0: await update.message.reply_text("No workouts yet — 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"📊 Your Stats\n\n" f" • Workouts logged: {total}\n" f" • Unique exercises: {len(exercise_names)}\n" f" • Total sets: {total_sets}\n" f" • Total volume: {total_volume:,.0f} kg", parse_mode=ParseMode.HTML, ) # ── 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 = parse_workout(text) if not groups: # Not a workout message — silently ignore so the bot isn't noisy 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) # 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"✅ Workout #{workout_id} saved!", f"📅 {ts_str}" + (" (from forwarded message)" if is_forwarded else ""), f"🏋️ {total_exercises} exercises, {total_sets} total sets", ] if supersets: confirm_parts.append(f"🔗 {supersets} superset(s)") confirm_parts.append(f"\n{format_workout(superset_dicts)}") await update.message.reply_text( "\n".join(confirm_parts), parse_mode=ParseMode.HTML, ) # ── Main ───────────────────────────────────────────────────────────────────── def main(): init_db() app = ApplicationBuilder().token(BOT_TOKEN).build() app.add_handler(CommandHandler("start", cmd_start)) app.add_handler(CommandHandler("history", cmd_history)) app.add_handler(CommandHandler("stats", cmd_stats)) # Handle all text messages (including forwarded ones) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message)) logger.info("Bot started — polling…") app.run_polling(allowed_updates=Update.ALL_TYPES) if __name__ == "__main__": main()