shipped

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).
  • 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 real PulseEntry (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.
  • 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_time now calls send_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 active TelegramConnection records. 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 to User (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

MethodPathAuthNotes
POST/api/telegram/webhook/<secret>/URL secretTelegram update ingress → handlers.py
POST/api/telegram/connect/JWTIssue/refresh pairing code
GET/api/telegram/status/JWTConnection state for settings UI
POST/api/telegram/disconnect/JWTUnlink 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

#CaseTypeWhere
1Pairing lifecycle: connect → code → webhook link → status → disconnectbackendbackend/tests/test_telegram.py
2Pulse conversation: full Q&A → real PulseEntry; interruption + resetbackendsame
3Webhook: bad secret rejected; unknown chat id safebackendsame
4Brief delivery formattingbackendsame

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

#ErrorResponseNotes
ES1Webhook forbidden secret — wrong secret in URL403 {"error": "forbidden"}Covered: test_webhook_invalid_secret (test_telegram.py:349)
ES2Webhook missing HMAC header — X-Telegram-Bot-Api-Secret-Token missing or mismatched403No test for missing header specifically
ES3Telegram API unreachable — network partition or DNS failure on send_messageConnectionError propagates → Celery task retries (3×); on exhaustion, message lostNo test for Telegram API network error
ES4Connection code generation failure — secrets.token_hex(4) blocks on entropy exhaustionSystem-level failure; by design (Python stdlib concern)No application-level guard
ES5REST API unauthenticated — connect/status/disconnect without JWT401Covered by DRF IsAuthenticated
ES6Follow-up reply target not found — find_telegram_reply_target(membership) returns NoneFallback reply sent: “Lumi chưa hiểu yêu cầu của bạn.”No test for follow-up-not-found
ES7Celery task retry exhaustion on message sendTask fails definitively; user misses pulse reminder or brief that dayNo test for retry exhaustion
ES8DIGEST_HOUR misconfiguration — value outside 0–23Digest never fires (>23) or fires every hour (0)No test for invalid DIGEST_HOUR
ES9Hub digest channel creation failureAll connected members miss the digestNo 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.

#ClaimStatusEvidence
1Pairing lifecycle: connect → code (8-char secrets) → webhook link → status → disconnect✅ matchtest_telegram.py pairing lifecycle tests; 2026-06-10 feature commit
2Pulse conversation: 6-question flow, DB-backed state, real PulseEntry on completion✅ matchtest_telegram.py full Q&A + interruption + reset tests
3Brief delivery: send_brief_telegram called from generate_daily_briefs_for_time (2026-06-14 fix)✅ matchapps/notifications/push.py::send_brief_telegram; test_send_brief_telegram_* (4 tests)
4Webhook security: secret in URL path; wrong secret → 403✅ matchtest_webhook_invalid_secret (test_telegram.py:349)
5Chat-id uniqueness prevents cross-tenant: telegram_chat_id unique constraint enforced✅ matchtest_webhook_chat_id_unique_prevents_cross_tenant (2026-06-12)
6Celery beat registered for pulse reminders + hub digest; working_days respected✅ matchCELERY_BEAT_SCHEDULE; test_send_daily_pulse_reminders (all 5 pass); weekend fix 2026-06-13
7Bot blocked (Telegram 403 error) not caught — exception propagates, no auto-deactivate of connection⚠️ gapE2; send_message does not inspect Telegram error codes; bot-blocked connection stays is_active=True
8Pulse already submitted today: guard present; blocks before conversation starts✅ matchhandle_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
9Multi-org user: which org’s pulse attributed? No design decision; conversation state picks org arbitrarily⚠️ gapE10; §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 TelegramConversation state, 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: false batch path.

Tests:

  • backend/tests/notifications/test_telegram_api.py
  • backend/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.cue ownership 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.py lines 73-80) already queries for an existing submitted PulseEntry for 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_secret at test_telegram.py:349, not test_webhook_bad_secret_returns_403.
  • §6: corrected the web card route from /settings to /hub/telegram (App.tsx line 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/telegram layout 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 en and vi telegram 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.tsx
  • cd frontend && pnpm exec tsc --noEmit
  • cd 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.md into 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) to apps/notifications/push.py. Builds HTML-formatted message (📋 header + up to 10 items with 🔴/🟡/• priority prefixes) and sends to all members with active TelegramConnection records.
  • generate_daily_briefs_for_time now calls send_brief_telegram immediately after send_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.setUp had working_days=["mon","tue","wed","thu","fri"]; test_send_daily_pulse_reminders failed on weekends. Fixed by adding "sat","sun" to working_days in 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) — proves telegram_chat_id unique 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 in CELERY_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_briefs triggers Telegram brief delivery for connected owners after generation (Celery beat, every 15 min).
  • apps/telegram/tasks.py::send_daily_pulse_reminders registered in CELERY_BEAT_SCHEDULE.
  • Migrated all tests from apps/telegram/tests.py to backend/tests/test_telegram.py per ADR-004.

Appendix A — Setup & Operations

A.1 Environment Variables

VariableRequiredPurpose
TELEGRAM_BOT_TOKENyesBot token from @BotFather; used by TelegramBotService for sendMessage, webhook registration, and webhook inspection
TELEGRAM_WEBHOOK_SECRETyesShared secret used in both the webhook path and the X-Telegram-Bot-Api-Secret-Token header
TELEGRAM_WEBHOOK_BASE_URLyesPublic backend base URL used by set_telegram_webhook() to register https://<base>/api/telegram/webhook/<secret>/

A.2 Bot Commands

CommandBehavior
/startwelcome text with connect instructions
/helpcommand list
/connect <code>links the Telegram chat to a Lumi user
/disconnectunlinks the current chat
/pulsestarts today’s pulse flow manually
/statusshows current connection state

Non-command text is interpreted in this order:

  1. pulse-answer continuation when TelegramConversation.state=awaiting_pulse_answers
  2. follow-up reply capture via find_telegram_reply_target
  3. 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:

  1. 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
  2. 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

SymptomCheck
Bot does not respondverify TELEGRAM_BOT_TOKEN, webhook registration, public reachability of the backend, and 403s from secret mismatch
Webhook returns 403verify both the URL path secret and X-Telegram-Bot-Api-Secret-Token header match TELEGRAM_WEBHOOK_SECRET
Daily reminders not sentverify Celery beat is running, the org’s working_days includes today, and the user has not already submitted a pulse
/connect <code> rejectedgenerate a fresh code from the authenticated connect endpoint; codes are regenerated for inactive connections and become unusable once linked