refactor: drop the slash-command bot — Mini App is the only interface
bot.py is gone. /start, /history, /stats, /delete, /export, /feedback, and the text-message workout parser are no longer exposed. Everything those commands did is already available in the Mini App (history listing, stats, edit/delete, JSON export via /api/export/json, etc.). Why: prod runs behind a Mini App URL, and shipyard staging is a tenant under the existing shipyard_poc_bot which polls Telegram itself. A second polling process on the same token would 409. By removing polling entirely, prod and staging share one architecture: a pure HTTP server validated against whatever BOT_TOKEN is provided. Changes: - delete bot.py - start.py: stop spawning the bot subprocess; load token, start server, optionally start cloudflared. WEBAPP_URL still skips the tunnel. - flake.nix / requirements.txt: drop python-telegram-bot. - README: rewrite to reflect Mini-App-only architecture. The prod systemd unit doesn't need to change — its ExecStart is `python start.py`, which now boots only the server (+ no tunnel since WEBAPP_URL is set in the unit env). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
aa43e492c3
commit
17248e239b
5 changed files with 53 additions and 406 deletions
52
README.md
52
README.md
|
|
@ -1,14 +1,13 @@
|
||||||
# BigBiggerBiggestBot 💪
|
# BigBiggerBiggestBot 💪
|
||||||
|
|
||||||
A Telegram bot for logging gym workouts, with an embedded Mini App.
|
A Telegram **Mini App** for logging gym workouts. History, stats, notes,
|
||||||
|
|
||||||
Send workouts as plain text, forward them from Saved Messages, or tap
|
|
||||||
through a structured log form inside Telegram. History, stats, notes,
|
|
||||||
edit & delete, JSON/CSV export — all per-user, all in SQLite.
|
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
|
Bench press: 4x8x35
|
||||||
|
|
@ -23,15 +22,6 @@ Pull-ups: 3x10
|
||||||
- Blank line separates superset groups; consecutive lines form a superset
|
- Blank line separates superset groups; consecutive lines form a superset
|
||||||
- Both `,` and `.` work as decimal separators
|
- 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
|
## Run locally
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -39,9 +29,9 @@ nix run
|
||||||
```
|
```
|
||||||
|
|
||||||
This launches:
|
This launches:
|
||||||
- API server (port 8080)
|
- API + Mini App server (port 8080)
|
||||||
- cloudflared tunnel for the Mini App
|
- cloudflared Quick Tunnel for a public HTTPS URL (skipped if `WEBAPP_URL`
|
||||||
- Telegram bot (polling)
|
is already set in the environment, e.g. fronted by a reverse proxy)
|
||||||
|
|
||||||
Put your bot token (from [@BotFather](https://t.me/BotFather)) in
|
Put your bot token (from [@BotFather](https://t.me/BotFather)) in
|
||||||
`~/.secrets/bigbiggerbiggestbot` or a `.env` file:
|
`~/.secrets/bigbiggerbiggestbot` or a `.env` file:
|
||||||
|
|
@ -63,10 +53,12 @@ nix develop --command pytest tests/ -v
|
||||||
Two environments share one host (`sunken-ship`):
|
Two environments share one host (`sunken-ship`):
|
||||||
|
|
||||||
- **Production** — `fitness-bot.service`, working dir `/home/danny/tg_fitness_bot`,
|
- **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
|
- **Shipyard staging** — `fitness-bot-shipyard.service`, working dir
|
||||||
`/home/danny/tg_fitness_bot_shipyard`, watches `origin/staging`, separate
|
`/home/danny/tg_fitness_bot_shipyard`, watches `origin/staging`, served
|
||||||
bot token, ephemeral cloudflared URL each restart.
|
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
|
Each has its own pull timer that fetches every ~15 minutes and restarts
|
||||||
the service when its branch has new commits.
|
the service when its branch has new commits.
|
||||||
|
|
@ -74,7 +66,6 @@ the service when its branch has new commits.
|
||||||
**Workflow:**
|
**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>:staging # → shipyard auto-deploys, test there
|
||||||
git push origin <branch>:main # → production auto-deploys
|
git push origin <branch>:main # → production auto-deploys
|
||||||
```
|
```
|
||||||
|
|
@ -84,13 +75,16 @@ so testing on shipyard never touches production data.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- `bot.py` — Telegram command handlers, polling, message parsing
|
- `server.py` — aiohttp REST API + static file server for the Mini App;
|
||||||
- `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; soft delete)
|
- `db.py` — SQLite data layer (workouts, supersets, exercises, feedback,
|
||||||
- `parser.py` — workout text → structured data
|
events, settings; soft delete).
|
||||||
- `webapp/` — Mini App (HTML/CSS/vanilla JS, Telegram WebApp SDK)
|
- `parser.py` — workout text → structured data (used by the Mini App's
|
||||||
- `start.py` — orchestrator: starts server + tunnel + bot, wires up the Mini App URL
|
"Paste as text" path).
|
||||||
- `tests/` — pytest suite for parser + db
|
- `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
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
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()
|
|
||||||
|
|
@ -14,7 +14,8 @@
|
||||||
python = pkgs.python3;
|
python = pkgs.python3;
|
||||||
|
|
||||||
pythonEnv = python.withPackages (ps: with ps; [
|
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
|
python-dotenv
|
||||||
aiohttp
|
aiohttp
|
||||||
pytest
|
pytest
|
||||||
|
|
@ -27,8 +28,7 @@
|
||||||
packages = [ pythonEnv pkgs.cloudflared ];
|
packages = [ pythonEnv pkgs.cloudflared ];
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
echo "💪 BigBiggerBiggestBot dev shell"
|
echo "💪 BigBiggerBiggestBot dev shell"
|
||||||
echo " Run: python start.py (server + tunnel + bot)"
|
echo " Run: python start.py (server + tunnel)"
|
||||||
echo " Run: python bot.py (bot only, no mini app)"
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
python-telegram-bot>=21.0
|
|
||||||
python-dotenv>=1.0
|
python-dotenv>=1.0
|
||||||
|
aiohttp>=3.9
|
||||||
|
|
|
||||||
63
start.py
63
start.py
|
|
@ -1,11 +1,15 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Orchestrator — single entry point for `nix run`.
|
Orchestrator — single entry point for `nix run`.
|
||||||
1. Loads BOT_TOKEN from ~/.secrets or .env (same as bot.py)
|
1. Loads BOT_TOKEN (env var → ~/.secrets → .env)
|
||||||
2. Starts the API server
|
2. Starts the API + Mini App server
|
||||||
3. Starts localtunnel to get a public HTTPS URL
|
3. Optionally starts a cloudflared Quick Tunnel for a public HTTPS URL
|
||||||
4. Starts the Telegram bot with WEBAPP_URL set
|
(skipped if WEBAPP_URL is already set, e.g. fronted by a reverse proxy)
|
||||||
5. Cleans up everything on Ctrl+C
|
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 os
|
||||||
import re
|
import re
|
||||||
|
|
@ -23,8 +27,8 @@ SECRETS_FILE = pathlib.Path.home() / ".secrets" / "bigbiggerbiggestbot"
|
||||||
def load_token() -> str:
|
def load_token() -> str:
|
||||||
"""Load bot token: BOT_TOKEN env → secrets file → .env in cwd.
|
"""Load bot token: BOT_TOKEN env → secrets file → .env in cwd.
|
||||||
|
|
||||||
Env var wins so multiple instances on the same host (prod + shipyard)
|
Env var wins so multiple instances on the same host (prod + shipyard
|
||||||
can each get a distinct token via systemd EnvironmentFile.
|
staging) can each get a distinct token via systemd EnvironmentFile.
|
||||||
"""
|
"""
|
||||||
# 1. Already in environment (systemd EnvironmentFile sets this for staging)
|
# 1. Already in environment (systemd EnvironmentFile sets this for staging)
|
||||||
token = os.environ.get("BOT_TOKEN", "").strip()
|
token = os.environ.get("BOT_TOKEN", "").strip()
|
||||||
|
|
@ -32,7 +36,7 @@ def load_token() -> str:
|
||||||
print(" Token loaded from BOT_TOKEN env var")
|
print(" Token loaded from BOT_TOKEN env var")
|
||||||
return token
|
return token
|
||||||
|
|
||||||
# 2. Secrets file (same path as bot.py uses)
|
# 2. Default secrets file
|
||||||
if SECRETS_FILE.is_file():
|
if SECRETS_FILE.is_file():
|
||||||
token = SECRETS_FILE.read_text().strip()
|
token = SECRETS_FILE.read_text().strip()
|
||||||
if token:
|
if token:
|
||||||
|
|
@ -102,21 +106,13 @@ def start_tunnel(port: int) -> tuple[subprocess.Popen, str]:
|
||||||
# Keep draining cloudflared output so its pipe buffer doesn't fill up
|
# Keep draining cloudflared output so its pipe buffer doesn't fill up
|
||||||
# (which would block the process and kill the tunnel)
|
# (which would block the process and kill the tunnel)
|
||||||
def _drain():
|
def _drain():
|
||||||
for line in proc.stdout:
|
for _line in proc.stdout:
|
||||||
pass
|
pass
|
||||||
threading.Thread(target=_drain, daemon=True).start()
|
threading.Thread(target=_drain, daemon=True).start()
|
||||||
|
|
||||||
return proc, url
|
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():
|
def main():
|
||||||
port = int(os.environ.get("API_PORT", "8080"))
|
port = int(os.environ.get("API_PORT", "8080"))
|
||||||
procs: list[subprocess.Popen] = []
|
procs: list[subprocess.Popen] = []
|
||||||
|
|
@ -140,11 +136,11 @@ def main():
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print(" ==========================================")
|
print(" ==========================================")
|
||||||
print(" BigBiggerBiggest — Fitness Tracker")
|
print(" BigBiggerBiggest — Mini App backend")
|
||||||
print(" ==========================================")
|
print(" ==========================================")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# 1. Load token
|
# 1. Load token (used by server.py to validate initData HMACs)
|
||||||
bot_token = load_token()
|
bot_token = load_token()
|
||||||
masked = bot_token[:5] + "..." + bot_token[-4:]
|
masked = bot_token[:5] + "..." + bot_token[-4:]
|
||||||
print(f" BOT_TOKEN: {masked}")
|
print(f" BOT_TOKEN: {masked}")
|
||||||
|
|
@ -159,11 +155,10 @@ def main():
|
||||||
print(" Server failed to start!")
|
print(" Server failed to start!")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# 3. Tunnel — skipped if WEBAPP_URL is already provided (e.g. the bot
|
# 3. Tunnel — skipped if WEBAPP_URL is already provided (e.g. fronted
|
||||||
# is fronted by an external reverse proxy that terminates TLS and
|
# by an external reverse proxy that terminates TLS and proxies back to
|
||||||
# proxies back to localhost:$API_PORT over a private network).
|
# localhost:$API_PORT over a private network).
|
||||||
env_webapp_url = os.environ.get("WEBAPP_URL", "").strip()
|
env_webapp_url = os.environ.get("WEBAPP_URL", "").strip()
|
||||||
tunnel = None
|
|
||||||
if env_webapp_url:
|
if env_webapp_url:
|
||||||
webapp_url = env_webapp_url
|
webapp_url = env_webapp_url
|
||||||
print(f" WEBAPP_URL from environment: {webapp_url} (skipping cloudflared)")
|
print(f" WEBAPP_URL from environment: {webapp_url} (skipping cloudflared)")
|
||||||
|
|
@ -171,27 +166,21 @@ def main():
|
||||||
tunnel, webapp_url = start_tunnel(port)
|
tunnel, webapp_url = start_tunnel(port)
|
||||||
procs.append(tunnel)
|
procs.append(tunnel)
|
||||||
|
|
||||||
# 4. Start bot
|
print("\n ==========================================")
|
||||||
print(f"\n WEBAPP_URL: {webapp_url}")
|
print(" All systems go!")
|
||||||
print(" Starting bot...\n")
|
|
||||||
bot = start_bot(bot_token, webapp_url)
|
|
||||||
procs.append(bot)
|
|
||||||
|
|
||||||
print(" ==========================================")
|
|
||||||
print(f" All systems go!")
|
|
||||||
print(f" Mini App: {webapp_url}")
|
print(f" Mini App: {webapp_url}")
|
||||||
print(f" API: http://localhost:{port}")
|
print(f" API: http://localhost:{port}")
|
||||||
print(f" Press Ctrl+C to stop")
|
print(" Press Ctrl+C to stop")
|
||||||
print(" ==========================================")
|
print(" ==========================================\n")
|
||||||
print()
|
|
||||||
|
proc_names = {id(server): "Server"}
|
||||||
|
if procs[-1] is not server:
|
||||||
|
proc_names[id(procs[-1])] = "Tunnel"
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
for p in procs:
|
for p in procs:
|
||||||
ret = p.poll()
|
ret = p.poll()
|
||||||
if ret is not None:
|
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), "?")
|
name = proc_names.get(id(p), "?")
|
||||||
print(f"\n {name} exited with code {ret}")
|
print(f"\n {name} exited with code {ret}")
|
||||||
cleanup()
|
cleanup()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue