bigbiggerbiggestbot/telegram-fitness-bot/bot.py
Danny ae09ab2eec feat(tg-fitness-bot): add telegram fitness bot with web app
Telegram workout tracker bot with Mini App web UI, SQLite database,
API server, and cloudflared tunnel support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:46:10 +02:00

198 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Telegram Fitness Bot — handles chat commands, reminders, and launches the Mini App.
"""
import logging
from telegram import (
Update,
WebAppInfo,
InlineKeyboardButton,
InlineKeyboardMarkup,
MenuButtonWebApp,
)
from telegram.ext import (
Application,
CommandHandler,
ContextTypes,
MessageHandler,
filters,
)
import database as db
from config import BOT_TOKEN, WEBAPP_URL
logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
level=logging.INFO,
)
logger = logging.getLogger(__name__)
# ── Helpers ──────────────────────────────────────────────────────
def ensure_user(update: Update) -> dict:
"""Create or update the user record from the Telegram message."""
tg_user = update.effective_user
return db.upsert_user(
telegram_id=tg_user.id,
first_name=tg_user.first_name or "",
username=tg_user.username or "",
)
def format_summary(summary: dict) -> str:
"""Format a workout summary dict into a nice chat message."""
if not summary:
return "No workout data found."
lines = [f"*Workout Summary*"]
lines.append(f"Started: {summary['started_at']}")
if summary.get("finished_at"):
lines.append(f"Finished: {summary['finished_at']}")
lines.append("")
for exercise_name, sets in summary["exercises"].items():
lines.append(f"*{exercise_name}*")
for i, s in enumerate(sets, 1):
lines.append(f" Set {i}: {s['reps']} reps × {s['weight']} kg")
lines.append("")
lines.append(f"Total sets: {summary['total_sets']}")
lines.append(f"Total volume: {summary['total_volume']} kg")
return "\n".join(lines)
# ── Command handlers ─────────────────────────────────────────────
async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Greet the user and show the Mini App button."""
ensure_user(update)
webapp_btn = InlineKeyboardButton(
text="Open Workout Tracker",
web_app=WebAppInfo(url=WEBAPP_URL),
)
keyboard = InlineKeyboardMarkup([[webapp_btn]])
await update.message.reply_text(
"Hey! I'm your fitness tracker bot.\n\n"
"Tap the button below to open the workout logger, "
"or use these commands:\n"
"/workout — start a new workout via chat\n"
"/history — see your recent workouts\n"
"/help — list all commands",
reply_markup=keyboard,
)
async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(
"*Commands*\n"
"/start — show the Mini App button\n"
"/workout — quick-start a new workout\n"
"/finish — finish the current workout\n"
"/history — recent workout summaries\n"
"/help — this message",
parse_mode="Markdown",
)
async def cmd_workout(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Start a new workout from chat."""
user = ensure_user(update)
active = db.get_active_workout(user["telegram_id"])
if active:
await update.message.reply_text(
"You already have an active workout! "
"Use /finish to end it, or open the Mini App to keep logging."
)
return
workout = db.start_workout(user["telegram_id"])
webapp_btn = InlineKeyboardButton(
text="Log Sets",
web_app=WebAppInfo(url=WEBAPP_URL),
)
keyboard = InlineKeyboardMarkup([[webapp_btn]])
await update.message.reply_text(
f"Workout #{workout['id']} started! Open the app to log your sets.",
reply_markup=keyboard,
)
async def cmd_finish(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Finish the active workout and send a summary."""
user = ensure_user(update)
active = db.get_active_workout(user["telegram_id"])
if not active:
await update.message.reply_text("No active workout to finish.")
return
db.finish_workout(active["id"], user["telegram_id"])
summary = db.get_workout_summary(active["id"])
text = format_summary(summary)
await update.message.reply_text(text, parse_mode="Markdown")
async def cmd_history(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Show recent workout summaries."""
user = ensure_user(update)
workouts = db.get_recent_workouts(user["telegram_id"], limit=5)
if not workouts:
await update.message.reply_text("No workouts yet! Tap /workout to start one.")
return
for w in workouts:
summary = db.get_workout_summary(w["id"])
text = format_summary(summary)
await update.message.reply_text(text, parse_mode="Markdown")
# ── Web App data handler ────────────────────────────────────────
async def handle_web_app_data(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Handle data sent from the Mini App via Telegram.WebApp.sendData()."""
data = update.effective_message.web_app_data.data
logger.info("Received web app data: %s", data)
await update.message.reply_text("Got it! Your workout has been saved.")
# ── Post-init: set the menu button ──────────────────────────────
async def post_init(app: Application):
"""Set the bot's menu button to open the Mini App."""
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)
# ── Main ─────────────────────────────────────────────────────────
def main():
db.init_db()
app = (
Application.builder()
.token(BOT_TOKEN)
.post_init(post_init)
.build()
)
app.add_handler(CommandHandler("start", cmd_start))
app.add_handler(CommandHandler("help", cmd_help))
app.add_handler(CommandHandler("workout", cmd_workout))
app.add_handler(CommandHandler("finish", cmd_finish))
app.add_handler(CommandHandler("history", cmd_history))
app.add_handler(
MessageHandler(filters.StatusUpdate.WEB_APP_DATA, handle_web_app_data)
)
logger.info("Bot starting...")
app.run_polling(drop_pending_updates=True)
if __name__ == "__main__":
main()