V1 Web PWA · Journey

Journey: Telegram bot — voice/text message → grounded reply

A source-reconciled journey record for V1 Web PWA — shipped behavior, state boundaries, failure modes, and the automation evidence available today.

automated · 3 specs verdict: partial· 2026-05-29
9sections7 minread

On this page
Journey at a glance
ActorsLinked Telegram customerVoice-note customerCommand userCustomer in acute distressOperator/auditor
Automation3 E2E spec(s) · verdict: partial· 2026-05-29

Journey flow#

Generated from the authored steps below — click a node to jump to that section.

flowchart TD s1["1. Link the chat identity"] s2["2. Admit the inbound update"] s3["3. Execute command and callback branches"] s4["4. Interrupt crisis language"] s5["5. Transcribe or abstain on voice"] s6["6. Ground and render the answer"] s7["7. Persist the audit projection"] s8["8. Return the hand-off envelope"] s1 --> s2 --> s3 --> s4 --> s5 --> s6 --> s7 --> s8 click s1 href "#1-link-the-chat-identity" click s2 href "#2-admit-the-inbound-update" click s3 href "#3-execute-command-and-callback-branches" click s4 href "#4-interrupt-crisis-language" click s5 href "#5-transcribe-or-abstain-on-voice" click s6 href "#6-ground-and-render-the-answer" click s7 href "#7-persist-the-audit-projection" click s8 href "#8-return-the-hand-off-envelope" s8 --> v(["verdict: partial"]) click v href "../results/telegram-bot-assistant-delivery.html" style v stroke:#d6a531,color:#d6a531

The BFF accepts authenticated Telegram webhook updates and produces honest, audited BotResponse envelopes for commands, text, voice, inline queries, and crisis interrupts. Grounded answers include disclosure, provenance, and source buttons; save/quiet/stop/voice/unlink commands now perform real state changes. The final channel-delivery join is still absent: the route returns response instructions in JSON, and no code posts them to api.telegram.org.

Personas#

  • Linked Telegram customer — starts linking from /profile/telegram and asks a source-grounded question in the bot chat.
  • Voice-note customer — expects real transcription or an explicit unavailable reply, never a fabricated transcript.
  • Command user — invokes read-only commands or durable /save//quiet//stop//voice//unlink effects.
  • Customer in acute distress — needs a plain-language crisis interrupt before grounding or persona rendering.
  • Operator/auditor — inspects telegram.send and telegram.crisis-suppression events without stored response body text.

Pre-conditions#

  • Telegram is configured with OSHUN_TELEGRAM_WEBHOOK_SECRET; the route fails every webhook closed when the header does not exactly match.
  • The composition root injects createBffSophiaRetriever(app). The fixture grounder is only the fallback when callers register the route without a retriever, such as focused local tests.
  • Real voice transcription additionally needs the bot token and STT API key. Missing configuration installs a provider that throws stt_not_configured.
  • /profile/telegram can mint a 15-minute nonce, and /start link-<nonce> can bind it. Login Widget verification separately binds the deterministic iris:<telegramUserId> identity.
  • Admin durable snapshots, when configured, retain Telegram captures, link state, voice preference, and stop flag; quiet hours live in the shared notification-preference store.

Steps#

An authenticated profile read creates or reuses a one-time deep link. A valid /start link-<nonce> webhook completes that specific profile link, while the Login Widget path HMAC-verifies Telegram data and creates an iris:<telegramUserId> binding. Expired or already-used nonces do not complete.

The ordinary webhook processing path does not look up that stored link before answering. It derives the bot userId directly from Telegram from.id; the effects adapter independently maps that id to iris:<id>.

2. Admit the inbound update#

POST /telegram/webhook validates x-telegram-bot-api-secret-token, reads the optional x-tenant-id header (or uses oshun-default), and classifies message, edited message, callback, inline query, or membership update. A process-local limiter consumes one token for userId:chatId:update-kind; the thirteenth event in a 12-per-60-second bucket receives retry-after copy without running Sophia.

Tenant context is not derived from the completed account binding. The webhook accepts the supplied tenant header/default and the injected retriever must enforce its source partition.

3. Execute command and callback branches#

Read-only /start, /menu, /today, /sources, and /help return fixed copy. Mutating /save, /quiet, /stop, /voice, and /unlink go through createTelegramEffectsPort:

  • save writes an idempotent capture with provenance;
  • quiet enables shared quiet hours;
  • stop stores a durable delivery-stopped flag;
  • voice toggles a durable preference;
  • unlink revokes the stored Telegram link.

save: and quiet: callbacks use the same effects seam. These writes are real, but there is no outbound sender in this route to consume the stop/voice preferences when transmitting a reply.

4. Interrupt crisis language#

Typed text and successful voice transcripts are checked against the configured phrase list before grounding. A match changes the audit persona to plain-operator, returns emergency-resource copy and two trusted links, and skips Sophia. This is a per-update interrupt; it does not set a durable chat-level crisis frame or suppress later responses.

5. Transcribe or abstain on voice#

For a voice update, the provider resolves the Telegram file, downloads it, and calls an OpenAI-compatible transcription endpoint. Provider failure, missing credentials, or an empty transcript produces the exact “could not transcribe” reply. Only a non-empty transcript reaches crisis detection and grounding. Although sendVoice and synthesizeTelegramVoice exist in the type contract, no code emits a TTS response.

6. Ground and render the answer#

The production grounder calls the injected Sophia retriever with text, raw Telegram user id, tenant id, and current time. It requires at least one citation, caps inline source buttons at six, and presents the result as grounded. Retrieval error, zero citations, or renderer rejection becomes the fixed abstention response with no fabricated source buttons.

renderGroundedTelegramAnswer accepts only grounded results with a source. It appends the constant AI disclosure and model/confidence/source-count/time/ provenance footer. This is a Telegram-specific strictness boundary, not the web Sophia UI's partial/ungrounded display behavior.

7. Persist the audit projection#

Each returned envelope is recorded as telegram.send, or telegram.crisis-suppression for crisis copy. The payload includes tenant, update/message/chat identifiers, response method, and the channel audit envelope. It intentionally excludes the response body and raw source text. A write failure is logged but does not fail the webhook response.

8. Return the hand-off envelope#

The HTTP response is { ok: true, responses, link? }. Each response may name sendMessage, sendVoice, answerInlineQuery, or answerCallbackQuery, but the BFF does not execute that method against Telegram. Dispatch, Telegram receipt, retry, and externally observed delivery therefore remain unproven.

Post-conditions#

  • A valid update yields zero or more source-grounded, command, abstention, or crisis response envelopes.
  • Grounded envelopes carry verified disclosure/provenance and at most six citation buttons.
  • Save/quiet/stop/voice/unlink effects mutate their real stores when requested.
  • Audit rows are durable when the admin audit store is wired, without response text leakage.
  • No proof exists that the envelope was sent, received, displayed, or retried by Telegram.
  • The grounder receives a raw Telegram id and caller/default tenant rather than an account-binding-derived authorization context.

Failure modes#

  • Invalid secret — returns 401 invalid-webhook-secret; no response or audit envelope is built.
  • Unresolved account/tenant — a sender can reach grounding without the webhook verifying the stored profile link, and missing tenant headers become oshun-default.
  • Rate-limit restart — buckets are process-local and reset on deploy; they are not a distributed abuse-control boundary.
  • STT unavailable — voice returns honest text fallback and never grounds a fake transcript.
  • Ungrounded retrieval — renderer/retriever failures abstain with HTTP 200, no citations, and no leaked candidate answer.
  • Crisis phrase limits — substring matching covers only four configured English phrases and sets no continuing suppression state.
  • Preference without sender/stop and /voice persist, but no outbound Telegram transport is present to enforce or express them.
  • No external delivery — returning a sendMessage instruction can be mistaken for a delivered message unless operators inspect the boundary.
  • Audit sink failure — the route logs and still returns success, so a response envelope can exist without its audit projection.

E2E coverage#

Per-view files touched#

Cross-references#

Open questions#

  • Which delivery adapter executes each BotResponse.method, honors stop/voice preferences, retries Telegram failures, and records provider receipts?
  • Must webhook grounding reject an unlinked sender, and how should the stored binding determine user, tenant, entitlement, memory, and source-set scope?
  • Should the rate limiter and crisis frame move to shared durable state across BFF instances?
  • Where should TTS synthesis and sendVoice selection run after /voice is enabled?
  • How can operators distinguish “response composed” from “Telegram delivered” in telemetry and audit copy?