Spec: F21 — Telegram Bot
Status: shipped · Audited: 2026-06-10 Technical design: see Appendix A — Setup & Operations below; this spec is the single source for both the product contract and the operational detail.
1. Problem & CEO Job
Meet users where they already are. Vietnamese SMEs run on chat apps; requiring a warehouse staffer to open a web app daily is a retention bet Lumi will lose. Telegram turns the pulse (F07) into bot replies and the Brief (F12) into a chief-of-staff-like morning message: the product’s highest-leverage habit-formation feature.
2. User Stories & Acceptance Criteria
- As a user, I want to connect Telegram in under a minute, so that setup never blocks the habit.
- Given
/api/telegram/connect/, then I get a short pairing code; sending it to the bot links my chat (mark_connected) — no OAuth ceremony. - Given
/api/telegram/status/, the web settings page shows connected/pending; disconnect is one tap and immediate. - Pairing codes are single-purpose, regenerable, and unguessable enough for their short life (8 chars,
secrets-generated).
- Given
- As a member, I want to do my daily pulse in Telegram, so that it takes one chat thread, not an app visit.
- Given the pulse flow starts, then the bot asks the six questions (F07 contract) one at a time, tracking position (
current_question_index); answers accumulate; finishing submits a realPulseEntry(same uniqueness, evidence, and extraction path as web). - Given an interrupted conversation, then state survives (DB-backed,
awaiting_pulse_answers) and can be reset cleanly.
- Given the pulse flow starts, then the bot asks the six questions (F07 contract) one at a time, tracking position (
- As a CEO, I want the morning brief delivered to Telegram at
brief_time, so that Lumi’s core promise arrives without opening anything. - Brief delivery to Telegram:
generate_daily_briefs_for_timenow callssend_brief_telegram(org_id, brief)after push delivery. The function builds an HTML-formatted Telegram message (top 10 items with priority emoji prefixes) and delivers it to all members with activeTelegramConnectionrecords. See Bug/Change Log 2026-06-14. - Webhook security: the webhook path embeds a secret (
/webhook/<secret>/); requests without it are rejected. A chat id can only ever act as its paired user — the bot must never become a cross-tenant side door.
3. Scope Boundaries
- In: pairing, status/disconnect, pulse conversation flow, brief delivery, webhook handling.
- Out: Zalo/Slack (registry Out-of-Scope), arbitrary chat-with-Lumi via Telegram (web chat F14 only, for now), group-chat features, per-event notifications (digest discipline — Lumi messages at most around pulse + brief moments).
4. Data Model
telegram.TelegramConnection— OneToOne toUser(not member: the human pairs once, org context resolves at action time),telegram_chat_id,connection_code,is_active.telegram.TelegramConversation— state machine:idle ⇄ awaiting_pulse_answers;pulse_date,answers_json,current_question_index;reset().
5. API Contract
| Method | Path | Auth | Notes |
|---|---|---|---|
| POST | /api/telegram/webhook/<secret>/ | URL secret | Telegram update ingress → handlers.py |
| POST | /api/telegram/connect/ | JWT | Issue/refresh pairing code |
| GET | /api/telegram/status/ | JWT | Connection state for settings UI |
| POST | /api/telegram/disconnect/ | JWT | Unlink immediately |
6. UI / UX
- Web side: a small card at
/hub/telegram— code display + status + disconnect. Nothing more. - Bot side IS the UX: questions one at a time, plain Vietnamese, confirmation when the pulse lands (“Đã ghi nhận ✓”), graceful handling of off-script replies (offer to restart, never dead-end).
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Pairing lifecycle: connect → code → webhook link → status → disconnect | backend | backend/tests/test_telegram.py |
| 2 | Pulse conversation: full Q&A → real PulseEntry; interruption + reset | backend | same |
| 3 | Webhook: bad secret rejected; unknown chat id safe | backend | same |
| 4 | Brief delivery formatting | backend | same |
8. Current Behavior
Shipped. Telegram is a live notification and capture channel in the Lumi loop, integrated into the existing Celery and brief-time infrastructure rather than as a detached bot experiment.
9. Error States
| # | Error | Response | Notes |
|---|---|---|---|
| ES1 | Webhook forbidden secret — wrong secret in URL | 403 {"error": "forbidden"} | Covered: test_webhook_invalid_secret (test_telegram.py:349) |
| ES2 | Webhook missing HMAC header — X-Telegram-Bot-Api-Secret-Token missing or mismatched | 403 | No test for missing header specifically |
| ES3 | Telegram API unreachable — network partition or DNS failure on send_message | ConnectionError propagates → Celery task retries (3×); on exhaustion, message lost | No test for Telegram API network error |
| ES4 | Connection code generation failure — secrets.token_hex(4) blocks on entropy exhaustion | System-level failure; by design (Python stdlib concern) | No application-level guard |
| ES5 | REST API unauthenticated — connect/status/disconnect without JWT | 401 | Covered by DRF IsAuthenticated |
| ES6 | Follow-up reply target not found — find_telegram_reply_target(membership) returns None | Fallback reply sent: “Lumi chưa hiểu yêu cầu của bạn.” | No test for follow-up-not-found |
| ES7 | Celery task retry exhaustion on message send | Task fails definitively; user misses pulse reminder or brief that day | No test for retry exhaustion |
| ES8 | DIGEST_HOUR misconfiguration — value outside 0–23 | Digest never fires (>23) or fires every hour (0) | No test for invalid DIGEST_HOUR |
| ES9 | Hub digest channel creation failure | All connected members miss the digest | No test for channel creation failure |
10. Diagrams & Docs Touched
DIAGRAMS.md (Telegram ingress) · conversation state machine belongs in backend/DIAGRAMS.md.
11. Implementation Audit (2026-07-14)
The canonical Contract Graph declaration is contracts/features/F21.cue. The current code-vs-doc audit is F21-telegram-audit.md. The API-focused contract attaches executable proof to pairing, webhook security, pulse capture, scheduled delivery, and transport failure handling. Live Telegram credentials/network, browser settings runtime, and multi-organization attribution remain explicit blockers.
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | Pairing lifecycle: connect → code (8-char secrets) → webhook link → status → disconnect | ✅ match | test_telegram.py pairing lifecycle tests; 2026-06-10 feature commit |
| 2 | Pulse conversation: 6-question flow, DB-backed state, real PulseEntry on completion | ✅ match | test_telegram.py full Q&A + interruption + reset tests |
| 3 | Brief delivery: send_brief_telegram called from generate_daily_briefs_for_time (2026-06-14 fix) | ✅ match | apps/notifications/push.py::send_brief_telegram; test_send_brief_telegram_* (4 tests) |
| 4 | Webhook security: secret in URL path; wrong secret → 403 | ✅ match | test_webhook_invalid_secret (test_telegram.py:349) |
| 5 | Chat-id uniqueness prevents cross-tenant: telegram_chat_id unique constraint enforced | ✅ match | test_webhook_chat_id_unique_prevents_cross_tenant (2026-06-12) |
| 6 | Celery beat registered for pulse reminders + hub digest; working_days respected | ✅ match | CELERY_BEAT_SCHEDULE; test_send_daily_pulse_reminders (all 5 pass); weekend fix 2026-06-13 |
| 7 | Bot blocked (Telegram 403 error) not caught — exception propagates, no auto-deactivate of connection | ⚠️ gap | E2; send_message does not inspect Telegram error codes; bot-blocked connection stays is_active=True |
| 8 | Pulse already submitted today: guard present; blocks before conversation starts | ✅ match | handle_pulse_start (telegram/handlers.py lines 73-80) queries for an existing submitted PulseEntry for today and returns an early “already submitted” reply. True remaining risk: a webhook race where two simultaneous /pulse messages both pass the check before either conversation write — not absent guard |
| 9 | Multi-org user: which org’s pulse attributed? No design decision; conversation state picks org arbitrarily | ⚠️ gap | E10; §12 Open Question unresolved |
Summary: 7/9 match; 2 gaps (blocked-bot no auto-deactivate, multi-org attribution unresolved).
Edge Cases Catalog (formerly §12, 2026-06-12; E1–E14): Key items integrated above. Remaining: E1 bot token revoked — all API calls return 401, no handler (integration test would need real revoked token); E3 webhook secret mismatch (✅ tested); E4 duplicate /pulse mid-conversation — OneToOneField race or reset behavior (no test for mid-conversation restart); E5 conversation timeout — stays awaiting_pulse_answers indefinitely (no stale recovery); E7 off-script reply appended as answer (no command routing inside pulse state); E8 long pulse answer (>4096 chars — Telegram limit); E9 very large answers_json (no explicit size limit); E11 connection code collision (negligible probability with 62^8 space; model uniqueness constraint); E12 mention digest cap at 10 mentions (no test); E13 disconnect during delivery race. Full catalog in git history.
12. Open Questions
- Multi-org users: which org’s pulse does the bot collect? Needs an explicit answer (likely: ask once, remember per conversation) before multi-org users adopt Telegram.
13. Bug / Change Log
2026-07-14 — Telegram reminder delivery honors unsuccessful API responses
Type: Bug fix
Summary:
- Telegram network failures continue to return a safe unsuccessful result and are logged as warnings because the scheduled task retries on a later beat.
- Daily pulse reminders now inspect the Telegram response before creating or resetting
TelegramConversationstate, so a handled timeout cannot create a false awaiting state or inflate the sent count. - Added regression coverage for both the handled transport failure and the
ok: falsebatch path.
Tests:
backend/tests/notifications/test_telegram_api.pybackend/tests/notifications/test_telegram_delivery.py
2026-07-14 — Contract Graph ownership and proof alignment
Type: Documentation and proof-traceability change
Summary:
- Added canonical
contracts/features/F21.cueownership for the three OpenAPI endpoints plus the secret-protected webhook boundary. - Attached stable proof IDs across authenticated pairing, chat-id isolation, pulse completion, reminder/brief delivery, and transport failure behavior.
- Preserved live Telegram, browser, and multi-organization attribution gaps instead of inferring external runtime success from mocks.
2026-07-02 — Correct false gap in §11 row 8, fix test name in §9/§11 row 4, and fix §6 route
Type: Documentation truth fix
Summary:
- §11 row 8 (⚠️ “today-pulse guard missing”): upgraded to ✅.
handle_pulse_start(telegram/handlers.pylines 73-80) already queries for an existing submittedPulseEntryfor today and returns early. The true remaining risk is a concurrent-webhook race, not an absent guard. - §9 ES1 and §11 row 4: corrected test name. The actual test is
test_webhook_invalid_secretattest_telegram.py:349, nottest_webhook_bad_secret_returns_403. - §6: corrected the web card route from
/settingsto/hub/telegram(App.tsxline 213, confirmed by the 2026-06-19 changelog entry). - §11 summary updated from “3 gaps” to “2 gaps” (blocked-bot no auto-deactivate, multi-org attribution unresolved).
Tests: No new tests — all behaviors were already present; only the spec was stale.
2026-06-19 — Telegram visual QC: denser setup state and screenshot coverage
Type: UX Fix + Documentation Change
Summary:
- Added the missing Telegram screenshot set at
frontend/screenshots/21-telegram/and made the screenshot flow state-aware so it always captures the live Telegram page plus the code-ready state when a connection code already exists. - Tightened the
/hub/telegramlayout so the connect/code panel no longer leaves a large dead slab of empty space; the page now uses that space for clear “what this unlocks” guidance before and after pairing. - Added a focused web test (
frontend/src/test/TelegramPage.test.tsx) covering the unconnected state and the generated-code state so the new hierarchy is guarded. - Authored the new explanatory copy in both
enandvitelegram namespaces.
Reason:
- The live screenshots showed a page that technically worked but felt visually unfinished: the primary card had too much empty space, so the setup flow looked sparse instead of deliberate.
Tests:
cd frontend && pnpm test -- src/test/TelegramPage.test.tsxcd frontend && pnpm exec tsc --noEmitcd frontend && pnpm exec playwright test tests/e2e/screenshot-flow.spec.ts --project=chromium --reporter=list -g "21-telegram"
2026-06-15 — Consolidate operational Telegram doc into F21
Type: Documentation Change
Summary:
- Moved the remaining useful operational detail from the deleted standalone
docs/telegram.mdinto Appendix A of this spec. - F21 is now the single source for product contract plus setup/ops detail.
- Removed the stale external-doc references from the spec.
2026-06-14 — Restructure to TEMPLATE.md section order; expand §11 audit (SP fleet)
Type: Documentation Change
Summary:
- Numbered “Current Behavior” as §8; §8 Diagrams → §10; §9 Implementation Audit (narrative, 2 struck items) → §11 (9-row table); §10 Open Questions → §12; §11 Bug Log → §13; §12 Edge Cases absorbed into §11 note; §13 Error States → §9.
- §11 audit: 9 rows, 6 ✅, 3 gaps (blocked-bot, today-pulse guard, multi-org attribution).
2026-06-14 — Telegram brief delivery gap closed
Type: Feature
Summary:
- Added
send_brief_telegram(organization_id, brief)toapps/notifications/push.py. Builds HTML-formatted message (📋 header + up to 10 items with 🔴/🟡/• priority prefixes) and sends to all members with activeTelegramConnectionrecords. generate_daily_briefs_for_timenow callssend_brief_telegramimmediately aftersend_brief_push.
Tests:
test_send_brief_telegram_sends_to_connected_member,test_send_brief_telegram_skips_inactive_connection,test_send_brief_telegram_skips_empty_brief,test_generate_daily_briefs_calls_telegram, plus all 5 pulse reminder tests pass.
2026-06-13 — Fix flaky weekend test in TaskTests.setUp
Type: Bug (test)
Summary:
TaskTests.setUphadworking_days=["mon","tue","wed","thu","fri"];test_send_daily_pulse_remindersfailed on weekends. Fixed by adding"sat","sun"toworking_daysin setUp.
2026-06-12 — Close tenant-scoping and webhook-security acceptance criteria
Type: Test Coverage
Summary:
- Added
test_feedback_cross_tenant_isolation(F15) — proves feedback submission is scoped to the request tenant. - Added
test_webhook_chat_id_unique_prevents_cross_tenant(F21) — provestelegram_chat_idunique constraint ensures 1:1 chat↔user mapping.
2026-06-12 — Add Edge Cases and Error States to F21 spec
Type: Documentation Change
Summary:
- Added §12 Edge Cases (E1–E14) and §13 Error States (ES1–ES9).
2026-06-11 — Hub announcement digest delivery
Type: Feature
Summary:
- New delivery type:
apps/hub/tasks.py::send_hub_digest, registered inCELERY_BEAT_SCHEDULE["hub-evening-digest"]. One evening message per connected member bundling all of that org’s announcements published that day.
2026-06-10 — Wire Celery beat scheduler + migrate tests to flat structure
Type: Feature
Summary:
apps/briefs/tasks.py::generate_daily_briefstriggers Telegram brief delivery for connected owners after generation (Celery beat, every 15 min).apps/telegram/tasks.py::send_daily_pulse_remindersregistered inCELERY_BEAT_SCHEDULE.- Migrated all tests from
apps/telegram/tests.pytobackend/tests/test_telegram.pyper ADR-004.
Appendix A — Setup & Operations
A.1 Environment Variables
| Variable | Required | Purpose |
|---|---|---|
TELEGRAM_BOT_TOKEN | yes | Bot token from @BotFather; used by TelegramBotService for sendMessage, webhook registration, and webhook inspection |
TELEGRAM_WEBHOOK_SECRET | yes | Shared secret used in both the webhook path and the X-Telegram-Bot-Api-Secret-Token header |
TELEGRAM_WEBHOOK_BASE_URL | yes | Public backend base URL used by set_telegram_webhook() to register https://<base>/api/telegram/webhook/<secret>/ |
A.2 Bot Commands
| Command | Behavior |
|---|---|
/start | welcome text with connect instructions |
/help | command list |
/connect <code> | links the Telegram chat to a Lumi user |
/disconnect | unlinks the current chat |
/pulse | starts today’s pulse flow manually |
/status | shows current connection state |
Non-command text is interpreted in this order:
- pulse-answer continuation when
TelegramConversation.state=awaiting_pulse_answers - follow-up reply capture via
find_telegram_reply_target - fallback unknown-command response
A.3 Webhook Registration
Register the webhook through the shipped task:
cd backend
./.venv/bin/python manage.py shell -c "from apps.telegram.tasks import set_telegram_webhook; print(set_telegram_webhook())"
This posts:
https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook
with:
{
"url": "https://<TELEGRAM_WEBHOOK_BASE_URL>/api/telegram/webhook/<TELEGRAM_WEBHOOK_SECRET>/",
"secret_token": "<TELEGRAM_WEBHOOK_SECRET>"
}
Verify webhook state with:
curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/getWebhookInfo"
A.4 Scheduled Behavior
The current app has two Telegram-related scheduled behaviors:
apps.telegram.tasks.send_daily_pulse_reminders- registered in
CELERY_BEAT_SCHEDULE - sends the first pulse question to active connected users on org working days
- registered in
apps.briefs.tasks.generate_daily_briefs- generates briefs on schedule
- the brief delivery path then calls
send_brief_telegram(...)for connected users
This is already wired in config/settings/base.py; it is not a separate feature toggle beyond supplying the required environment variables and running worker/beat.
A.5 Test Command
cd backend
./.venv/bin/python -m pytest -q -p no:cacheprovider --override-ini addopts= -m 'not benchmark' \
tests/notifications/test_telegram_api.py \
tests/notifications/test_telegram_handlers.py \
tests/notifications/test_telegram_webhook.py \
tests/notifications/test_telegram_delivery.py
Current focused verification result: 80 passing tests across the Telegram API, handler, webhook, and delivery modules (2026-07-14).
A.6 Troubleshooting
| Symptom | Check |
|---|---|
| Bot does not respond | verify TELEGRAM_BOT_TOKEN, webhook registration, public reachability of the backend, and 403s from secret mismatch |
| Webhook returns 403 | verify both the URL path secret and X-Telegram-Bot-Api-Secret-Token header match TELEGRAM_WEBHOOK_SECRET |
| Daily reminders not sent | verify Celery beat is running, the org’s working_days includes today, and the user has not already submitted a pulse |
/connect <code> rejected | generate a fresh code from the authenticated connect endpoint; codes are regenerated for inactive connections and become unusable once linked |