backend / mb3prepbot
MB3PrepBot (MB Runway)
A Python Telegram bot that runs a daily past-question drill, practice and timed papers for one medical-school class, with a Mini App backend, a payments reconciler and a voice-clerking WebSocket service.
2026 · paused · private repository
- [*]LanguagesPython · SQL · JavaScript
- [*]Frameworkspython-telegram-bot · FastAPI · psycopg / psycopg_pool · pydantic settings · Cloudflare Workers runtime
- [*]StoresPostgres (Neon) · SQLite · R2
- [*]Asyncpython-telegram-bot JobQueue (run_daily / run_repeating) · asyncio WebSocket loops
- [*]InfraContabo VPS · systemd · Traefik · Cloudflare Workers · AWS EC2 (until 2026-09-02) · Docker (stale)
- [*]Third partiesTelegram Bot API · Bachs · Polar · Neon HTTP SQL · Deepgram (STT + Aura TTS) · Z.ai GLM · Anthropic (code present, not wired)
MB3PrepBot was the pilot for MBPrep: a Telegram bot for UCH Ibadan's MB BS Part III class (Paediatrics and O&G). One long-polling python-telegram-bot process serves 12 commands, a poll-answer handler, a callback router and a menu-text handler, and its JobQueue runs 7 scheduled jobs: the 06:30 drill, the 21:00 leaderboard close, a weekly report, an evening outreach pass, a 15-second exam clock and a payments reconciler.
The data layer is one set of SQLite-dialect queries that runs unchanged on local SQLite or on Neon Postgres. A 199-line shim translates placeholders and datetime() calls at execute time over a psycopg pool. When a network resets port 5432, the same queries go to Neon's HTTPS SQL endpoint instead, with $n renumbering and batched writes. The schema is 26 tables across 6 hand-applied SQL files: drill polls and answers, a three-table question bank (wq/wstem, wtext, pstation), sessions, attempts, FSRS-style srs rows, timed exams, payments, quota and outreach.
Payments moved from Polar to Bachs (NGN bank transfer). A Cloudflare Worker verifies Polar's Standard Webhooks and Bachs' HMAC signatures and messages Telegram, but it does not grant anything. The bot polls Bachs for every non-terminal checkout and grants the tier itself, so a missed webhook only delays a grant and cannot lose one. A separate stdlib HTTP server verifies Telegram Mini App initData (two-step HMAC) for the full-answer page, and a FastAPI service streams voice clerking: Deepgram STT and Aura TTS, with GLM playing the patient.
Verification is local by choice, with no CI: 23 test scripts (261 check/assert calls), an AST walk that fails the build if core/ imports telegram, and a golden file that freezes the positional scope index behind callback strings. Production ran on a Contabo VPS under systemd, behind Traefik, against Neon in eu-central-1. It moved off AWS EC2 on 2026-09-02.
How it holds up
- Auth
- Students are identified by their Telegram user id from the Bot API; admin commands are gated by an ADMIN_IDS allowlist and scoped command menus; the Mini App and Ward Round verify Telegram initData with the WebAppData two-step HMAC, a 1-hour max age and constant-time compare; the Worker verifies Polar (Standard Webhooks) and Bachs (HMAC over ts.body, 300 s window) signatures.
- Data
- 26 tables across 6 SQL files in schema/ (17 in schema.sql), applied by hand, plus 4 one-off scripts/migrate_*.py; key entities are stems/posts/answers/drills (the group drill), wq+wstem, wtext and pstation (the three-table bank), sessions/attempts/seen/srs (practice and spaced repetition), exams/exam_q, payments/entitlements/quota (supporter tiers).
- Consistency
- Writes are upserts on natural keys: answers on (poll_id, user_id), seen on (user_id, wq_id), drills and posts ON CONFLICT. The drill is guarded once per (day, chat) by a check-then-insert, not a lock. Payment grants are idempotent because grant_tier upserts and the reconciler skips terminal statuses. The Postgres path runs in autocommit, so a marking pass's attempts and seen rows are separate statements, not one transaction.
- Failure
- Port 5432 failure falls back to Neon over HTTPS, and the verdict is cached for the process. The pool borrows and verifies a connection up to 3 times and runs check_connection, because Neon suspends its compute. The webhook is non-load-bearing: a polling reconciler grants paid tiers. Each job isolates failures per chat or per exam, and a drill with no registered chat alerts the admins instead of passing silently.
- Testing
- 23 test scripts in tests/ (126 top-level functions, 261 check()/assert calls) run by tests/test_all.py, with a --fast mode for boundary and config only. Coverage includes Postgres/SQLite parity (test_pg.py), the AST layering rule, callback parity, initData with real empty signed fields, and a 623-row golden scope index. There is no CI; the full suite is meant to run on the server beside the database.
Techniques
- One query dialect, two databases, two transportsSQLite-dialect SQL is rewritten at execute time (? to %s, datetime('now', ?) to now() + interval) so about 165 queries run unchanged on SQLite and Postgres. When 5432 is blocked, the same translator feeds Neon's HTTPS endpoint with $n placeholders and pipelined batches.
src/mbrunway/core/db/pgcompat.py:35src/mbrunway/core/db/__init__.py:164src/mbrunway/core/db/neon_http.py:43src/mbrunway/core/db/neon_http.py:145tests/test_pg.py - Serverless-Postgres-aware connection poolA lazily filled psycopg pool with min 1 and max 10, check_connection, TCP keepalives, max_idle below Neon's suspend window, and a borrow-and-verify loop that discards dead sockets. A suspended Neon compute shows up as a slow first query, not as a handler crash.
src/mbrunway/core/db/pgcompat.py:97src/mbrunway/core/db/pgcompat.py:152 - Webhook-optional payment fulfilmentThe Cloudflare Worker only verifies signatures and notifies. A 'reconcile' job polls Bachs for every non-terminal checkout, persists terminal states and grants the tier through an upsert that never demotes, so a lost or forged-looking webhook cannot leave a payer without the tier.
src/mbrunway/interfaces/telegram/app.py:814src/mbrunway/core/bachs.py:318src/mbrunway/core/access.py:178webhook/src/index.js:114 - Telegram initData verification that survives real clientsTwo-step HMAC keyed by HMAC('WebAppData', token). Empty signed fields are kept, which parse_qsl drops by default. Both 'signature' conventions are accepted, and the unverified Ed25519 field is always stripped from the result so nothing downstream can trust it.
src/mbrunway/interfaces/web/initdata.py:57src/mbrunway/interfaces/web/initdata.py:89src/mbrunway/interfaces/web/initdata.py:119tests/test_initdata.py - Server-side exam clock sweepA 15-second repeating job re-renders every open timed paper, so the progress bar burns down, and marks expired papers out of time. Errors are isolated per paper, and Telegram's 'message not modified' race is ignored.
src/mbrunway/interfaces/telegram/app.py:754src/mbrunway/interfaces/telegram/exam.py:57 - Architecture rule enforced by AST, positional index frozen by golden filetest_layering walks the AST of core/ and fails on any telegram or livekit import, including guarded or unreached ones. Callback strings carry positional indices, so the scope index is snapshotted in a golden file, and a content import that would repoint buttons fails the test.
tests/test_layering.pytests/test_scope_index.pytests/golden/scope_index.txtsrc/mbrunway/interfaces/telegram/quiz.py:175
Counted
- 143Commits
git rev-list --count HEAD - 2026-08-15 / 2026-09-14First / last commit
git log --format=%ad --date=short - 15,915Python LOC, src/
git ls-files 'src/*.py' | xargs wc -l - 9,330Python LOC, scripts/
git ls-files 'scripts/*.py' | xargs wc -l - 4,306Python LOC, tests/
git ls-files 'tests/*.py' | xargs wc -l - 26Tables
unique CREATE TABLE names in schema/*.sql - 6SQL schema files (hand-applied)
ls schema/ - 15 (12 commands)Telegram handlers
grep -c add_handler / CommandHandler( src/mbrunway/interfaces/telegram/app.py - 7Scheduled jobs
grep -c 'run_daily\|run_repeating' app.py - 166callback_data sites
grep -c callback_data src/mbrunway/interfaces/telegram/*.py - 7 (incl. 1 WebSocket)Ward Round FastAPI routes
grep @app. src/mbrunway/interfaces/wardround/server.py - 442 linesWebhook Worker
wc -l webhook/src/index.js - 23 / 261Test scripts / check+assert calls
git ls-files 'tests/test_*.py'; grep -cE 'check\(|ok\(|assert ' - 623Golden scope-index rows
grep -vc '^#' tests/golden/scope_index.txt