feat(tg-fitness-bot): initial Telegram workout tracker bot
Python bot that parses workout messages (Exercise: SetsxRepsxWeight), detects supersets from consecutive lines, extracts machine IDs, stores both raw message text and parsed data in SQLite, and reads original timestamps from forwarded Saved Messages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
817cf8fd95
8 changed files with 590 additions and 0 deletions
2
.env.example
Normal file
2
.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Get your bot token from @BotFather on Telegram
|
||||||
|
BOT_TOKEN=your-token-here
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
.env
|
||||||
|
workouts.db
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
result
|
||||||
211
bot.py
Normal file
211
bot.py
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
"""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(
|
||||||
|
"💪 <b>Fitness Tracker Bot</b>\n\n"
|
||||||
|
"Send me your workout and I'll save it!\n\n"
|
||||||
|
"<b>Format:</b>\n"
|
||||||
|
"<code>Bench press: 4x8x35</code>\n"
|
||||||
|
"<code>Lateral raise: 4x8x4</code>\n\n"
|
||||||
|
"<code>Tri Press rom: 3x10x45</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 — "
|
||||||
|
"I'll use the original timestamp.\n\n"
|
||||||
|
"<b>Commands:</b>\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"📅 <b>{ts.strftime('%a %d %b %Y, %H:%M')}</b>"
|
||||||
|
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\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
|
||||||
|
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"📊 <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>",
|
||||||
|
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"✅ <b>Workout #{workout_id} saved!</b>",
|
||||||
|
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()
|
||||||
148
db.py
Normal file
148
db.py
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
"""Database layer for the fitness bot."""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
DB_PATH = Path(__file__).parent / "workouts.db"
|
||||||
|
|
||||||
|
|
||||||
|
def get_connection() -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_db():
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""Create tables if they don't exist."""
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS workouts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
timestamp TEXT NOT NULL, -- ISO-8601, original workout time
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
note TEXT -- optional free-text note
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS superset_groups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
workout_id INTEGER NOT NULL REFERENCES workouts(id) ON DELETE CASCADE,
|
||||||
|
position INTEGER NOT NULL -- ordering within the workout
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS exercises (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
superset_group_id INTEGER NOT NULL REFERENCES superset_groups(id) ON DELETE CASCADE,
|
||||||
|
position INTEGER NOT NULL, -- ordering within the superset group
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
machine_id TEXT, -- e.g. "500", "620"
|
||||||
|
sets INTEGER NOT NULL,
|
||||||
|
reps INTEGER NOT NULL,
|
||||||
|
weight_kg REAL NOT NULL,
|
||||||
|
raw_line TEXT -- the original line as typed
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workouts_user
|
||||||
|
ON workouts(user_id, timestamp);
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Migration: add raw_text column if it doesn't exist yet
|
||||||
|
cols = {r[1] for r in conn.execute("PRAGMA table_info(workouts)").fetchall()}
|
||||||
|
if "raw_text" not in cols:
|
||||||
|
conn.execute("ALTER TABLE workouts ADD COLUMN raw_text TEXT")
|
||||||
|
|
||||||
|
|
||||||
|
def save_workout(user_id: int, timestamp: datetime, superset_groups: list[list[dict]], raw_text: str | None = None, note: str | None = None) -> int:
|
||||||
|
"""
|
||||||
|
Save a parsed workout.
|
||||||
|
|
||||||
|
superset_groups: list of groups, each group is a list of exercise dicts:
|
||||||
|
{name, machine_id, sets, reps, weight_kg, raw_line}
|
||||||
|
raw_text: the full original message text, stored verbatim.
|
||||||
|
|
||||||
|
Returns the workout id.
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO workouts (user_id, timestamp, note, raw_text) VALUES (?, ?, ?, ?)",
|
||||||
|
(user_id, timestamp.isoformat(), note, raw_text),
|
||||||
|
)
|
||||||
|
workout_id = cur.lastrowid
|
||||||
|
|
||||||
|
for group_pos, group in enumerate(superset_groups):
|
||||||
|
cur2 = conn.execute(
|
||||||
|
"INSERT INTO superset_groups (workout_id, position) VALUES (?, ?)",
|
||||||
|
(workout_id, group_pos),
|
||||||
|
)
|
||||||
|
group_id = cur2.lastrowid
|
||||||
|
|
||||||
|
for ex_pos, ex in enumerate(group):
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO exercises
|
||||||
|
(superset_group_id, position, name, machine_id, sets, reps, weight_kg, raw_line)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(group_id, ex_pos, ex["name"], ex.get("machine_id"),
|
||||||
|
ex["sets"], ex["reps"], ex["weight_kg"], ex.get("raw_line")),
|
||||||
|
)
|
||||||
|
|
||||||
|
return workout_id
|
||||||
|
|
||||||
|
|
||||||
|
def get_workouts(user_id: int, limit: int = 10, offset: int = 0) -> list[dict]:
|
||||||
|
"""Fetch recent workouts for a user, newest first."""
|
||||||
|
with get_db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT id, timestamp, note, raw_text, created_at
|
||||||
|
FROM workouts
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT ? OFFSET ?""",
|
||||||
|
(user_id, limit, offset),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
workouts = []
|
||||||
|
for row in rows:
|
||||||
|
workout = dict(row)
|
||||||
|
groups = conn.execute(
|
||||||
|
"""SELECT sg.id as group_id, sg.position as group_pos,
|
||||||
|
e.name, e.machine_id, e.sets, e.reps, e.weight_kg, e.raw_line, e.position as ex_pos
|
||||||
|
FROM superset_groups sg
|
||||||
|
JOIN exercises e ON e.superset_group_id = sg.id
|
||||||
|
WHERE sg.workout_id = ?
|
||||||
|
ORDER BY sg.position, e.position""",
|
||||||
|
(row["id"],),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
superset_groups = {}
|
||||||
|
for g in groups:
|
||||||
|
gp = g["group_pos"]
|
||||||
|
if gp not in superset_groups:
|
||||||
|
superset_groups[gp] = []
|
||||||
|
superset_groups[gp].append(dict(g))
|
||||||
|
|
||||||
|
workout["superset_groups"] = [superset_groups[k] for k in sorted(superset_groups)]
|
||||||
|
workouts.append(workout)
|
||||||
|
|
||||||
|
return workouts
|
||||||
|
|
||||||
|
|
||||||
|
def get_workout_count(user_id: int) -> int:
|
||||||
|
with get_db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM workouts WHERE user_id = ?", (user_id,)
|
||||||
|
).fetchone()
|
||||||
|
return row["cnt"]
|
||||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1774243609,
|
||||||
|
"narHash": "sha256-6sB2IYqXYwoQS11Ev0u3b0lpAleTpvNv5iv4iqiCCR8=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "4724d5647207377bede08da3212f809cbd94a648",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
40
flake.nix
Normal file
40
flake.nix
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
description = "BigBiggerBiggestBot — Telegram fitness tracker";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, flake-utils }:
|
||||||
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
|
||||||
|
python = pkgs.python3;
|
||||||
|
|
||||||
|
pythonEnv = python.withPackages (ps: with ps; [
|
||||||
|
python-telegram-bot
|
||||||
|
python-dotenv
|
||||||
|
]);
|
||||||
|
in
|
||||||
|
{
|
||||||
|
# `nix develop` — drop into a shell with everything available
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
packages = [ pythonEnv ];
|
||||||
|
shellHook = ''
|
||||||
|
echo "💪 BigBiggerBiggestBot dev shell"
|
||||||
|
echo " Run: python bot.py"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# `nix run` — start the bot from the current directory
|
||||||
|
apps.default = {
|
||||||
|
type = "app";
|
||||||
|
program = toString (pkgs.writeShellScript "run-bot" ''
|
||||||
|
exec ${pythonEnv}/bin/python "$PWD/bot.py"
|
||||||
|
'');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
121
parser.py
Normal file
121
parser.py
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
"""
|
||||||
|
Parse workout messages into structured data.
|
||||||
|
|
||||||
|
Format per line:
|
||||||
|
Exercise Name (optional_machine_id): SETSxREPSxWEIGHT
|
||||||
|
|
||||||
|
Lines with no blank line between them form a superset group.
|
||||||
|
Blank lines separate superset groups.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Exercise:
|
||||||
|
name: str
|
||||||
|
machine_id: str | None
|
||||||
|
sets: int
|
||||||
|
reps: int
|
||||||
|
weight_kg: float
|
||||||
|
raw_line: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"machine_id": self.machine_id,
|
||||||
|
"sets": self.sets,
|
||||||
|
"reps": self.reps,
|
||||||
|
"weight_kg": self.weight_kg,
|
||||||
|
"raw_line": self.raw_line,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Matches lines like:
|
||||||
|
# Bench press: 4x8x35
|
||||||
|
# Lat pulldown (500): 3x5x45
|
||||||
|
# Russian Twists: 3x15x0
|
||||||
|
EXERCISE_RE = re.compile(
|
||||||
|
r"^(?P<name>.+?)" # exercise name (lazy)
|
||||||
|
r"(?:\s*\((?P<machine>\d+)\))?" # optional (machine_id)
|
||||||
|
r"\s*:\s*" # colon separator
|
||||||
|
r"(?P<sets>\d+)\s*x\s*" # sets
|
||||||
|
r"(?P<reps>\d+)\s*x\s*" # reps
|
||||||
|
r"(?P<weight>[\d.]+)" # weight
|
||||||
|
r"\s*$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_exercise_line(line: str) -> Exercise | None:
|
||||||
|
"""Parse a single exercise line. Returns None if it doesn't match."""
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
return None
|
||||||
|
|
||||||
|
m = EXERCISE_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return Exercise(
|
||||||
|
name=m.group("name").strip(),
|
||||||
|
machine_id=m.group("machine"),
|
||||||
|
sets=int(m.group("sets")),
|
||||||
|
reps=int(m.group("reps")),
|
||||||
|
weight_kg=float(m.group("weight")),
|
||||||
|
raw_line=line,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_workout(text: str) -> list[list[Exercise]]:
|
||||||
|
"""
|
||||||
|
Parse a full workout message into superset groups.
|
||||||
|
|
||||||
|
Returns a list of groups, where each group is a list of Exercises.
|
||||||
|
Consecutive non-blank lines form a superset group.
|
||||||
|
Blank lines separate groups.
|
||||||
|
"""
|
||||||
|
lines = text.strip().splitlines()
|
||||||
|
groups: list[list[Exercise]] = []
|
||||||
|
current_group: list[Exercise] = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
if not stripped:
|
||||||
|
# blank line → end current group
|
||||||
|
if current_group:
|
||||||
|
groups.append(current_group)
|
||||||
|
current_group = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
exercise = parse_exercise_line(stripped)
|
||||||
|
if exercise:
|
||||||
|
current_group.append(exercise)
|
||||||
|
# non-matching lines are silently skipped (e.g. notes, headers)
|
||||||
|
|
||||||
|
# flush last group
|
||||||
|
if current_group:
|
||||||
|
groups.append(current_group)
|
||||||
|
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def format_workout(superset_groups: list[list[dict]], include_raw: bool = False) -> str:
|
||||||
|
"""Format structured workout data back into readable text."""
|
||||||
|
parts = []
|
||||||
|
for i, group in enumerate(superset_groups):
|
||||||
|
if i > 0:
|
||||||
|
parts.append("") # blank line between groups
|
||||||
|
|
||||||
|
is_superset = len(group) > 1
|
||||||
|
if is_superset:
|
||||||
|
parts.append("🔗 <b>Superset:</b>")
|
||||||
|
|
||||||
|
for ex in group:
|
||||||
|
machine = f" ({ex['machine_id']})" if ex.get("machine_id") else ""
|
||||||
|
line = f" • {ex['name']}{machine}: {ex['sets']}x{ex['reps']}x{ex['weight_kg']}kg"
|
||||||
|
parts.append(line)
|
||||||
|
|
||||||
|
return "\n".join(parts)
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
python-telegram-bot>=21.0
|
||||||
|
python-dotenv>=1.0
|
||||||
Loading…
Add table
Add a link
Reference in a new issue