# Telegram Surfaces — Bot, Mini Apps, Channels, Inline, Auth, Payments

Telegram is the first non-web surface Oshun ships, and it is the most fully
realized one. It is not a thin proxy that re-renders the web app: it is a set of
purpose-built surfaces — a conversational bot, embedded Mini Apps, operator
publishing channels, inline-query cards, a binding/auth flow, and an optional
fiat payment rail — each sitting on the same channel-policy core that governs
every outbound message in V1. This page covers those surfaces, the real symbols
that implement them in `libs/oshun/messaging-channels` and `apps/oshun/`, and
the one honest gap: the live BFF outbound _send loop_ is still unimplemented
even though the policy, grounding, auth, and rendering layers underneath it are
real. For the cross-channel policy machinery shared with WhatsApp, email, push,
SMS, Discord, and Slack, see
[WhatsApp, Email, Push, SMS, Discord, and Slack](./other-channels.md); for the
architecture-level treatment see the companion doc
[Channel Abstraction, Routing, and Boundaries](../architecture/messaging-channels.md).

> **Honesty note (carried from the completeness audit).** End-to-end **outbound
> Telegram delivery in the BFF e2e is `unimplemented`** — the policy core,
> grounding, initData verification, side-effect seam, and message rendering are
> all real and tested, but the loop that actually pushes a scheduled Oshun
> message _out_ to a chat (as opposed to replying to an inbound webhook) has not
> been wired. WhatsApp/SMS/Telegram-payment transports are **pure planners**
> whose real provider HTTP calls require live credentials. Nothing in this area
> is a result-faking stub: where an integration is absent, the code fails loud
> (`missing-config`, `stt_not_configured`, "not connected yet") rather than
> pretending a send happened.

## Where Telegram sits in the V1 channel model

Every outbound intent in V1 flows through one dispatcher and one boundary layer
before it can touch any channel. Telegram occupies three of the twelve channel
ids in the registry (`libs/oshun/messaging-channels/src/registry.ts`):

```text
CHANNEL_IDS = [
  'telegram-bot', 'telegram-channel', 'telegram-miniapp',
  'whatsapp-business', 'discord-bot', 'slack-app', 'sms-twilio',
  'push-fcm', 'push-apns', 'push-expo', 'push-webpush', 'email-ses',
]
```

The registry does not merely list channels; it encodes a per-channel **policy
matrix** that the dispatcher reads on every routing decision. Each
`ChannelEntry` carries a capability set (`CAPABILITY_KINDS`), a `costClass`
(`COST_CLASSES`), a `residency` (`RESIDENCY_PROFILES`), a `consent` profile
(`CONSENT_PROFILES`), a `retention` profile (`RETENTION_PROFILES`), a
`disclosureRenderable` flag, and a `crisisCapable` flag. The three Telegram
entries:

| Channel id         | Capabilities                                                                    | Cost  | Residency | Consent                    | Retention         | Disclosure renderable | Crisis-capable |
| ------------------ | ------------------------------------------------------------------------------- | ----- | --------- | -------------------------- | ----------------- | --------------------- | -------------- |
| `telegram-bot`     | `text`, `rich-cards`, `inline-buttons`, `voice`, `files`, `payments`, `miniapp` | `low` | `global`  | `verified-opt-in-required` | `channel-default` | yes                   | yes            |
| `telegram-channel` | `text`, `rich-cards`, `files`                                                   | `low` | `global`  | `verified-opt-in-required` | `channel-default` | yes                   | **no**         |
| `telegram-miniapp` | `text`, `rich-cards`, `inline-buttons`, `voice`, `files`, `payments`, `miniapp` | `low` | `global`  | `verified-opt-in-required` | `channel-default` | yes                   | yes            |

The full vocabularies the matrix draws from:

| Set                  | Values                                                                          |
| -------------------- | ------------------------------------------------------------------------------- |
| `CAPABILITY_KINDS`   | `text`, `rich-cards`, `inline-buttons`, `voice`, `files`, `payments`, `miniapp` |
| `COST_CLASSES`       | `free`, `low`, `metered`, `high`                                                |
| `RESIDENCY_PROFILES` | `global`, `eu-only`, `us-only`, `apac-only`, `self-hosted`                      |
| `CONSENT_PROFILES`   | `platform-tos-implied`, `verified-opt-in-required`, `double-opt-in-required`    |
| `RETENTION_PROFILES` | `channel-default`, `short-30d`, `standard-365d`, `long-3y`, `indefinite`        |

Note that the broadcast `telegram-channel` is deliberately **not**
crisis-capable — a public editorial channel must never be the place a crisis
hotline is "delivered" to a specific person — while the conversational
`telegram-bot` and the embedded `telegram-miniapp` are.

### The tier guard: why AAA and operator traffic never reaches a chat

The dispatcher (`dispatcher.ts`) is a **pure function**: a `DispatchInput` tuple
in, a `DispatchDecision` out, with the actual provider call left entirely to the
runtime. Its first guard is the audience tier:

```text
TIERS = ['contemplative', 'curated-creator', 'aaa-creator', 'operator-admin']
MESSAGING_ALLOWED_TIERS = { 'contemplative', 'curated-creator' }
```

If the input tier is not in `MESSAGING_ALLOWED_TIERS`, `dispatchMessage` returns
`{ kind: 'suppress', reason: 'tier-not-allowed-on-messaging' }` before any
channel is even considered. This is the concrete enforcement of "tier-aware
routing": AAA-creator and operator-admin intents are structurally incapable of
reaching Telegram (or any messaging channel) — see
[Generation Audience Tiers and Surface Boundaries](./generation-tiers-and-surfaces.md)
for the broader tier model.

The intent vocabulary the dispatcher routes is:

```text
INTENT_KINDS = ['transactional-receipt', 'crisis-hotline', 'reminder',
                'session-notification', 'newsletter', 'engagement-recap']
```

Each intent maps to a `ContentClass` (`INTENT_CONTENT_CLASSES`), and the
dispatcher carries `ContentVariant`s —
`{ requiredCapability, bodyTextPlain, disclosureCopy, provenanceFooter }` — so
it can pick the richest variant a channel's capability set supports and degrade
to plain `text` otherwise (`chooseVariantForChannel`). The suppression reasons
it can return are exhaustive and named: `tier-not-allowed-on-messaging`,
`crisis-suppression`, `quiet-hours`, `frequency-cap`, `tenant-allowlist-empty`,
`no-opt-in`, `no-channel-meets-residency`, `content-class-not-allowed`, and
`no-channel-meets-capability`. Only `crisis-hotline` bypasses crisis
suppression, quiet hours, and frequency caps (`CRISIS_SUPPRESSION_BYPASS`), and
even then it routes **only** to crisis-capable channels.

### The boundary layer and the audit envelope

`boundary.ts` defines the `ContentClass` union — `transactional`,
`grounded-answer`, `ritual-reminder`, `editorial-briefing`,
`identity-verification`, `billing`, `crisis` — and a `CHANNEL_BOUNDARIES` table
that pins, per channel, exactly which content classes are allowed, the residency
and retention, the consent text, whether provenance is preserved, the
`disclosureMechanism` (`body-copy`, `body-copy-and-link`, or
`payload-metadata`), and the audit event names. For example `telegram-bot`
allows only `grounded-answer`, `ritual-reminder`, `identity-verification`, and
`crisis`, preserves provenance, uses `body-copy-and-link` disclosure, and emits
`telegram.receive` / `telegram.send` / `telegram.crisis-suppression` audit
events; `telegram-channel` instead allows `editorial-briefing`,
`grounded-answer`, `ritual-reminder` and emits the `telegram-channel.schedule` /
`.send` / `.takedown` triplet.

Every Telegram response the bot produces carries a frozen `ChannelAuditEnvelope`
built by `buildChannelAuditEnvelope`, with these fields:

```json
{
  "channelId": "telegram-bot",
  "recipientId": "<telegram user id>",
  "intentClass": "grounded-answer",
  "persona": "lilith-oshun-assistant",
  "policyHash": "lilith-v1-prod",
  "provenanceBundleId": "telegram-grounded-answer",
  "residencyTag": "global",
  "retentionClass": "short-30d",
  "disclosureVerificationResult": "passed"
}
```

`decideMemoryIngestion` is the gate on durable Iris writes from chat: it returns
`durableWriteAllowed: true` only when the user has opted in **and** the channel
is in `DURABLE_CHAT_INGESTION_CHANNELS` (`telegram-bot`, `telegram-miniapp`,
`whatsapp-business`, `discord-bot`, `slack-app` — note `telegram-channel`, a
public broadcast surface, is excluded). It also attaches channel-specific
redaction rules — for `telegram-bot`, `strip-telegram-usernames` and
`strip-inline-callback-data`; for `telegram-miniapp`, `strip-initdata` and
`strip-miniapp-session-token` — on top of the always-on `strip-channel-handles`
/ `strip-phone-numbers` / `strip-payment-tokens`. See
[Iris Memory and Identity](./iris-memory-identity.md) for what happens once a
write is allowed.

## Telegram Bot — delivery and light assistant

The bot is a TypeScript service. The deployable app is `apps/oshun/telegram-bot`
(package `@oshun/telegram-bot`, `main = ./src/index.ts`), built on the real
`grammy` framework (`grammy ^1.42.0`). The bot **logic** lives in the policy
core at `libs/oshun/messaging-channels/src/telegram/bot.ts` so it can be
unit-tested without a live Bot API connection; the app's `createOshunGrammyBot`
wires that logic into grammY handlers for the five update kinds.

### Update handling and the pure handler

`handleTelegramUpdate(update, runtime)` is the heart of the bot — a pure async
function returning `readonly BotResponse[]`. `TelegramUpdateKind` is one of
`message`, `callback_query`, `inline_query`, `edited_message`, `my_chat_member`.
A `BotResponse` names a Telegram method (`sendMessage`, `sendVoice`,
`answerInlineQuery`, `answerCallbackQuery`), the chat/query ids, the text, any
inline buttons or inline results, and the `auditEvent` envelope. The grammY app
turns each `BotResponse` into the corresponding `ctx.api.*` call
(`deliverGrammyResponse`).

The first thing every update hits is the rate limiter.

### Rate limiting

`TelegramRateLimiter` (`telegram/rate-limit.ts`) is a fixed-window token bucket
keyed on `userId:chatId:workflowClass`, where `workflowClass` is the update
kind. A single limiter instance lives per process — both the grammY app
(`grammyWebhookRateLimiter`) and the BFF webhook (`webhookRateLimiter`)
configure it as `{ capacity: 12, refillEverySeconds: 60 }`. The comment in the
source explains the choice: a per-request limiter "starts every window empty and
never rejects," so the windowed buckets must survive across updates. When a
bucket is exhausted the bot replies with a friendly degradation — "This chat is
moving quickly. Please try again in N seconds." — carrying a `transactional`
audit envelope, not a hard error.

### The nine commands

All nine documented commands are implemented. They split into read-only commands
handled by `handleTelegramCommand` and side-effecting commands routed through
the effects port (below):

| Command    | Behavior                                                                                               |
| ---------- | ------------------------------------------------------------------------------------------------------ |
| `/start`   | Greeting + the command menu.                                                                           |
| `/menu`    | Per-domain entry list for the four V1.0 rooms (Tara sit, Arete check-in, Nyx tonight, Nisaba passage). |
| `/today`   | What is actually on across the four rooms, read from the rooms port; audited as `ritual-reminder`.     |
| `/sources` | Points the user to the Sophia evidence pack behind citation buttons.                                   |
| `/help`    | Lists available actions.                                                                               |
| `/save`    | Side-effecting: captures text/link to the notebook.                                                    |
| `/quiet`   | Side-effecting: enables quiet hours.                                                                   |
| `/stop`    | Side-effecting: stops delivery and cancels scheduled sends.                                            |
| `/voice`   | Side-effecting: toggles voice replies.                                                                 |

(`/unlink` also exists as a fifth side-effecting command, revoking the Telegram
binding.) The bot string for `/help` and `/start` enumerates the live set, so
the "nine documented commands" claim is verifiable directly in
`handleTelegramCommand`.

### The effects seam — never fabricate a write

The bot's read-only commands can answer from static copy, but `/save`, `/quiet`,
`/stop`, `/voice`, `/unlink` mutate real user state. Earlier these returned
canned "Saved to your notebook" strings that _claimed a write that never
happened_. That is now structurally impossible: side-effecting commands route
through `applyTelegramSideEffect`, which calls the injected
`TelegramEffectsPort` (`telegram/effects.ts`). The port's methods —
`saveToNotebook`, `enableQuietHours`, `stopDelivery`, `toggleVoiceReplies`,
`unlinkTelegramBinding` — each return either a success outcome (`saved` with
`captureId` + `provenanceBundleId`, or `applied`) or
`{ status: 'unavailable' }`. When **no port is wired**, the bot replies with
explicit honesty rather than a fake success, e.g.
`SIDE_EFFECT_NOT_CONNECTED['/save']` = "Saving from Telegram is not connected
yet, so I will not pretend it saved." The BFF supplies the concrete port
(`createTelegramEffectsPort`) that writes to the real capture,
notification-preference, and delivery-suppression stores. The library itself
stays free of any BFF/store dependency — a clean fail-loud seam.

### Sophia-grounded Q&A and abstention

Any inbound text that is not a command and not a crisis trigger is routed to the
Sophia grounder. `groundOrAbstain` calls
`runtime.grounder.answer({ text, userId, tenantId })`; if grounding succeeds,
`renderGroundedTelegramAnswer` (`telegram/rendering.ts`) builds the reply. That
renderer **throws** unless the grounding `state === 'grounded'` and
`sourceCount >= 1`, so the bot can never ship an ungrounded answer. The rendered
message body appends the fixed `TELEGRAM_DISCLOSURE_COPY` ("AI-assisted Oshun
response. Verify important decisions in the linked sources.") and a provenance
footer (`renderProvenanceFooter` — model, confidence band, source count,
generated timestamp, provenance bundle URL), and exposes the citations as an
inline keyboard. `disclosureVerified` is computed by re-checking that both the
disclosure copy and `Provenance:` survived into the body — so the disclosure
cannot be silently stripped.

When grounding is unavailable (no sources, retriever down, or the grounder
throws on an ungrounded result), the bot returns `TELEGRAM_ABSTENTION_COPY`: "I
can't ground that in sources right now, so I won't guess. Try rephrasing, or ask
about a topic in the Oshun library." This is audited as a `grounded-answer`
intent with disclosure passed — an honest abstention, not a fabricated reply.

The two grounder adapters live in `telegram/sophia-grounder.ts`:
`createTelegramSophiaGrounder({ retriever, nowUnixSeconds, minimumSources })`
delegates to a real Sophia retrieval callable and truncates citations to
Telegram's inline-keyboard cap (`TELEGRAM_INLINE_KEYBOARD_LIMIT = 6`), throwing
when fewer than `minimumSources` citations come back; and
`createFixtureSophiaGrounder`, a deterministic fixture the BFF falls back to
when no real retriever is wired (dev/local/e2e), which always emits a grounded
state and two synthetic citations so the rendering path can be exercised without
a Sophia deployment. See [Sophia Grounding](./sophia-grounding.md).

### Crisis-aware behavior

`detectCrisisTrigger(text, phrases)` does a case-insensitive substring scan; the
deployed phrase set is
`['kill myself', 'hurt myself', 'suicide', 'end my life']`. On a match, the bot
breaks persona to plain operator voice (the audit envelope's `persona` becomes
`plain-operator`, `intentClass` becomes `crisis`), surfaces verified hotlines
with inline buttons — `988 Lifeline` (`https://988lifeline.org/`) and
`Find local help` (`https://findahelpline.com/`) — and routes around the
grounding path entirely. At the dispatcher level, crisis suppression then
prevents non-crisis intents from reaching the conversation. See
[Lilith Persona Policy](./lilith-persona-policy.md) and
[Review, Compliance, and Trust & Safety](./review-trust-safety.md).

### Voice notes — real fail-closed STT, not a stub

A voice message is transcribed before grounding. The runtime injects a
`VoiceProvider`; the production resolver
`resolveSttVoiceProvider({ telegramBotToken, env })` returns a real provider
(Telegram `getFile` + file download → an OpenAI-compatible transcription POST)
**only** when both a bot token and `OSHUN_STT_API_KEY` are present. Otherwise it
returns the `unconfiguredVoiceProvider`, whose `transcribeTelegramVoice`
**throws** `stt_not_configured` — it never fabricates a transcript. The bot
handler catches the throw (and an empty transcript) and replies honestly: "I
could not transcribe that voice note. Please type your question and I will
answer with grounded sources." A successful transcript flows through the same
`groundOrAbstain` path as typed text, and the runtime can optionally synthesize
a TTS reply (`synthesizeTelegramVoice`). This corrects any older doc that called
Telegram voice STT a stub — it is a real, fail-closed seam.

### Callback queries

`handleCallbackQuery` handles inline-button taps. `sources:` callbacks only open
an evidence pack the user already received (no write). `save:` and `quiet:`
callbacks route through the same effects port and return the same honest "not
connected" copy when no port is wired.

## Telegram Mini Apps — curated studio surfaces in chat

The Mini App host is a Next app, `apps/oshun/telegram-miniapp`. The seven
curated surfaces are enumerated in `src/app/surface-data.ts` as `SURFACE_SLUGS`:

```text
ALL_SURFACE_SLUGS = ['today', 'sophia', 'veritas', 'nyx', 'arete', 'nisaba', 'illustration', 'library']
// SURFACE_SLUGS is the release-scoped view: Veritas is deferred to V1.2, so
// the app does not route it and `/veritas/` 404s.
```

Five of them — `today`, `arete`, `nyx`, `nisaba`, `library` — read the member's
REAL state from `GET /telegram/miniapp/surface/:slug`, over the same
`TelegramRoomsPort` the bot's commands answer from, authenticated by Telegram's
`initData`. Where that read cannot happen (opened outside Telegram, no BFF
configured, a room with nothing recorded) the surface says which of those it is
and labels what it shows as an example. `sophia` and `illustration` have no port
behind them and are always labelled examples.

Each slug has a typed `SurfaceContent` with a discriminated `payload.kind`:

| Slug           | Domain  | Surface                                                         | `payload.kind`       |
| -------------- | ------- | --------------------------------------------------------------- | -------------------- |
| `today`        | Tara    | Ritual player (timer, breath cues, narration, completion event) | `tara-ritual`        |
| `sophia`       | Sophia  | Grounded Q&A with inspectable evidence pack                     | `sophia-qa`          |
| `veritas`      | Veritas | Claim card with sources **and counterclaims**                   | `veritas-claim`      |
| `nyx`          | Nyx     | Sky viewer (event, observation window, layers, calendar add)    | `nyx-sky`            |
| `arete`        | Arete   | Daily check-in (habit tick, mood, humane streak)                | `arete-check-in`     |
| `nisaba`       | Nisaba  | Passage reader (edition switch, lexicon, annotation)            | `nisaba-reader`      |
| `illustration` | Isis    | Curated illustration card (request, lineage, policy state)      | `illustration-card`  |
| `library`      | Lilith  | Continuity: what is open, and the one thing `/continue` resumes | `library-continuity` |

Each `SurfaceContent` carries `provenance`, `evidence` (an array of
`EvidenceItem` with source + excerpt + href), and a `disclosure` string, so the
Mini App renders provenance and Lilith disclosure **identical** to the web
equivalent — there is no degraded in-chat surface. The Tara payload, for
instance, carries `templateId: 'tara-dawn-ritual-v1'`, a
`completionEventName: 'tara.ritual.completed'`, breath cues with second counts,
and step resources; the Veritas payload carries a `claim`, `confidenceBand`,
`sources`, and explicit `counterclaims`. These map to the corresponding domains:
[Tara](./domain-tara.md), [Veritas](./domain-veritas.md),
[Nyx](./domain-nyx.md), [Arete](./domain-arete.md),
[Nisaba](./domain-nisaba.md), and
[Isis generation control](./isis-generation-control.md).

### Native bridge and theme sync

`src/lib/telegram-webapp.ts` bridges `Telegram.WebApp`. The
`TelegramWebAppBridge` interface exposes `themeParams`, `MainButton`,
`BackButton`, `BiometricManager`, `requestWriteAccess`, and `shareToStory`;
`themeVarsFromTelegram` maps Telegram's `themeParams` to CSS variables (with an
in-browser preview fallback), and `configureTelegramNativeButtons` drives the
MainButton/BackButton. `miniAppRequestHeaders` builds the `initData`-bearing
headers every BFF call carries.

### initData verification and scoped sessions

This is the security spine. `verifyTelegramMiniAppInitData`
(`telegram/security.ts`) performs the real Telegram WebApp check: it parses the
query, builds the data-check string by dropping `hash`, sorting keys, and
joining `key=value` with newlines (`buildDataCheckString`), derives the secret
as `secret = HMAC-SHA256(key='WebAppData', message=botToken)`, then compares
`HMAC-SHA256-hex(secret, dataCheckString)` against the supplied hash in constant
time (`timingSafeHexEqual` over `constantTimeBytesEqual`). It rejects missing
hashes (`missing-hash`), stale/future-skewed `auth_date` (`expired` — default
`maxFutureSkewSeconds = 300`), replayed `query_id`/`id` (`replay`), tampered
hashes (`tampered`), and missing subject ids (`missing-subject`). The crypto is
real: `@noble/hashes` `hmac` + `sha256`.

`verifyTelegramLoginWidgetPayload` is the Login Widget variant — same flow but
the secret is `secret = SHA-256(botToken)` (the Login Widget derivation), not
the `WebAppData` HMAC derivation. Both return a typed
`TelegramVerificationResult` with `ok` and a `reason`.

`issueTelegramMiniAppSession` mints a scoped HS256 JWT (`signHs256Jwt`) with
claims
`{ iss: 'oshun-telegram', aud: 'oshun-telegram-miniapp', sub, telegram_user_id, tenant_id, scopes, iat, exp }`.
Requested scopes are filtered down to `TELEGRAM_ALLOWED_SCOPES`:

```text
TELEGRAM_ALLOWED_SCOPES = ['content:read', 'notebook:save', 'ritual:play',
                           'sky:view', 'qa:ask']
```

### BFF hardening for Mini App callers

The BFF never trusts a client-claimed identity. Mini App requests carry the
`initData` header, the server re-verifies it
(`verifyTelegramInitDataAndIssueSession` in
`apps/oshun/bff/src/telegram/verify-initdata.ts`), and resolves tenant +
entitlement server-side. The bot token used as the verification key fails closed
in production: `botToken()` returns the configured `OSHUN_TELEGRAM_BOT_TOKEN`,
and in production returns `null` (rather than the publicly known dev fallback
`'oshun-v1-local-bot-token'`) when unconfigured — otherwise anyone could mint
valid session JWTs.

## Telegram Channels — editorial publishing target

`telegram/publishing.ts` synthesizes operator channel posts. An
`EditorialDomain` is one of `tara | arete | veritas | nyx | nisaba`. Given a
`TelegramEditorialChannel` (id, domain, locale, owner role, crisis-suppression
switch) and a `TelegramEditorialPost`, `synthesizeTelegramChannelPost` produces
a `ScheduledTelegramPost`: it honors embargo by taking the later of
`embargoUntilIso` and `scheduledAtIso` as `publishAtIso`, appends
`TELEGRAM_DISCLOSURE_COPY` to the body when the post is `synthetic`, adds
`Open in Oshun` / `View evidence` inline buttons, and attaches attribution tags
(`domain=…`, `locale=…`, `utm_source=telegram-channel`) for engagement
attribution. `takedownTelegramChannelPost` returns `edit-existing-post` when the
edit-message API is allowed and falls back to `publish-correction-notice`
otherwise, recording an audit rationale. This integrates with the editorial
calendar in
[Editorial Calendar and Asset & Media Library](./editorial-and-asset-library.md).
Recall that `telegram-channel` is **not** crisis-capable and its boundary allows
only `editorial-briefing`, `grounded-answer`, `ritual-reminder` content classes.

## Telegram Inline Mode — save, share, attribution

`handleInlineQuery` (in `bot.ts`, with helpers in `telegram/inline.ts`) supports
three inline verbs:

- **`save <text or link>`** — captures to the notebook through the effects port.
  With no port wired it returns "Saving from Telegram is not connected." (no
  fabricated card); on success it returns an `InlineResult` carrying the real
  `captureId`, a `private-note` license badge, and the provenance bundle id.
- **`share <artifact-id>`** — resolves the artifact via
  `runtime.resolveShareArtifact`; if found, `buildInlineShareCard` produces an
  attribution-preserving card whose `messageText` embeds an
  `AI-assisted artifact.` disclosure (when synthetic), `Sources: N`, the license
  badge, and the provenance bundle id — all surviving forwarding because they
  live in the message body, not a UI affordance. Unknown artifacts return an
  honest "not available to share."
- **`find <query>`** — grounds via Sophia and returns `buildInlineSophiaResult`,
  titled `Sophia sources (N)`; on ungrounded results it returns
  `TELEGRAM_ABSTENTION_COPY` with no fabricated card.

Abuse detection is `detectInlineAbuse`: queries over 512 characters or more than
20 recent queries are rejected ("Inline query rate limited."). The grammY app
maps `InlineResult`s to grammY `InlineQueryResultArticle`s with `cache_time: 0`
and `is_personal: true`. Save-to-notebook cross-links to
[Customer Curation, Notebooks, Collections, and Sharing](./customer-curation-notebooks.md).

## Telegram Authentication — Login Widget and Mini App initData

The auth surface uses the two verifiers above
(`verifyTelegramLoginWidgetPayload`, `verifyTelegramMiniAppInitData`), both
returning typed results with explicit rejection reasons. The scope contract is
deliberately narrow: `TELEGRAM_ALLOWED_SCOPES` grants only non-sensitive
reads/saves (`content:read`, `notebook:save`, `ritual:play`, `sky:view`,
`qa:ask`).

Sensitive actions require step-up to a primary credential.
`TELEGRAM_SENSITIVE_ACTIONS` enumerates them:

```text
TELEGRAM_SENSITIVE_ACTIONS = ['admin:access', 'privacy:manage', 'consent:change',
                              'billing:manage', 'voice:clone',
                              'generation:promote-aaa']
```

`requiresPrimaryCredentialChallenge(action)` returns `true` for any of these,
and the BFF exposes it directly at `GET /telegram/step-up/:action`, returning
`{ action, stepUpRequired }`. Any such action initiated from a Telegram-bound
identity redirects to web with a primary-credential challenge — see
[Privacy, Consent, Data Portability, and User Controls](./privacy-consent-portability.md).

The binding lifecycle runs through the BFF: a deep link
`https://t.me/<bot>?start=link-<nonce>` (built in
`apps/oshun/bff/src/routes/telegram.ts`, TTL 15 minutes) is completed when the
`/start link-<nonce>` payload arrives at the webhook
(`telegramUserStateStore.completeTelegramLinkByNonce`). Revocation cascades via
`consentRevocationCascade` (`boundary.ts`), which emits ordered actions:
`revoke-binding`, `terminate-miniapp-session:*` for every active Mini App
session, `cancel-scheduled-send:*` for every scheduled send,
`redact-chat-cache`, and `post-revocation-notice`.

### Webhook security and grounding wiring (BFF)

The BFF webhook at `POST /telegram/webhook`
(`apps/oshun/bff/src/routes/telegram.ts`) guards every inbound update with
`requireWebhookSecret`, which compares the `x-telegram-bot-api-secret-token`
header to `OSHUN_TELEGRAM_WEBHOOK_SECRET` and returns
`401 { ok: false, reason: 'invalid-webhook-secret' }` on mismatch
(`handleBffTelegramWebhook` re-checks the secret as a second line). Grounding is
wired with a minimum-sources floor: when a `sophiaRetriever` is supplied the BFF
builds
`createTelegramSophiaGrounder({ retriever, nowUnixSeconds, minimumSources: 1 })`
(`apps/oshun/bff/src/telegram/webhook.ts`), falling back to the fixture grounder
otherwise. The grammY app applies the same `minimumSources: 1` floor.

The deployable bot app's environment table `TELEGRAM_BOT_ENVIRONMENTS` defines
`dev`/`staging`/`prod` instances with env-var tokens and webhook secrets
(`OSHUN_TELEGRAM_BOT_TOKEN_{DEV,STAGING,PROD}` etc.). Production fails fast:
when `OSHUN_TELEGRAM_BOT_TOKEN_PROD` is unset the table stores the sentinel
`UNCONFIGURED_PROD_BOT_TOKEN = 'prod-token'`, and `createOshunGrammyBot`
**throws** at startup rather than authenticate with an unconfigured token — a
loud misconfiguration error instead of a silent half-working bot.

## Telegram Payments — entitlement upgrade flows (V1.x optional)

Telegram Payments is a **fiat-rail fallback** layered on the same entitlement,
receipt, and audit machinery used by the V1-primary crypto path — see
[Crypto Payments — Non-Custodial Entitlement Settlement](./crypto-payments.md).
**In V1.0 the native (Stars/fiat) rail is CUT**, and the cut is enforced rather
than documented: `middleware/release-scope.ts` 404s
`/telegram/payments/{invoice,pre-checkout,success}` by path prefix, leaving
`/refund` reachable so a pre-cut charge is not trapped. The state machine below
describes what returns when V1.1 opens it — and whoever opens it must add
authentication and server-side state first, because those three handlers carry
no auth pre-handler and `success` mints a `paid` receipt from the request body.

`/upgrade` is a routed command behind `TelegramUpgradePort`, not an intercept.
It used to be an intercept inside `apps/oshun/telegram-bot`, which nothing
deploys, so `/upgrade` reached nothing on the live path. The long-poll kit
renders the crypto paywall in-chat through `handleUpgradeCryptoCommand` when a
deployer wires a real `issueInvoice`; the BFF sends the member to the
authenticated billing surface instead, because a chat message carries no session
and an invoice binds a purchaser and a plan. Neither holds an address template
of its own.

The fiat flow is a deterministic state machine in `telegram/payments.ts` —
`TelegramPaymentState` is
`invoice-sent | pre-checkout-approved | paid | refunded | web-checkout-required`:

- `createTelegramInvoice` returns `web-checkout-required` if a crisis is active
  (`payment-suppressed-crisis`) **or** the provider is not configured
  (`provider-unavailable-web-fallback`), and `invoice-sent` otherwise. This is
  the planner: it never claims a payment provider call it cannot make.
- `approvePreCheckout` advances `invoice-sent → pre-checkout-approved` (bound to
  the `pre_checkout_query` id).
- `recordSuccessfulPayment` advances `pre-checkout-approved → paid`, attaching
  the granted `entitlementId` and a `receiptProvenanceBundleId`
  (`telegram-receipt:<chargeId>`) and audit entries (`successful-payment:*`,
  `grant:*`).
- `refundTelegramPayment` advances `paid → refunded` and emits `refund:*` +
  `revoke:*` for chargeback/refund handling.

Each transition appends to an `auditTrail`, and every state is frozen.
Crisis-state suppression is structural: no upsell or payment surfacing happens
during a crisis-flagged conversation. Receipts include the txid (and, for
Monero, the payment proof) on the crypto rail, or the processor receipt id on
the fiat rail. The honest edge: the actual Telegram `sendInvoice` provider HTTP
call is gated on real credentials — the library is a planner that produces the
state and audit trail, not a live charge.

## Cross-domain integration: V3 deep links and session reminders

Telegram is wired into V3 programming through `v3-deep-links.ts` and
`v3-session-reminders.ts`. Deep links use a canonical host
`V3_SHARE_LINK_CANONICAL_HOST = 'app.oshun.com'` (origin
`https://app.oshun.com`) and a custom scheme `oshun://v3`
(`V3_SHARE_LINK_CUSTOM_SCHEME = 'oshun'`, `V3_SHARE_LINK_CUSTOM_HOST = 'v3'`,
path prefix `/v3`).
`V3_MESSAGING_SHARE_CHANNELS = ['telegram', 'whatsapp', 'push', 'email', 'sms']`
names Telegram as a first-class share target across surfaces like
`tara-booking`, `arete-continuation`, `ticket`, `follow`, and
`saraswati-edition`. Session reminders respect a strict pre-session window:
`V3_SESSION_REMINDER_MAX_LEAD_TIME_MINUTES = 30`, so a session reminder is
suppressed (`outside-thirty-minute-window`) unless it falls inside the 30-minute
lead-time window — reusing the same channel dispatcher and suppression
vocabulary.

## Where the honest gaps are

To keep the candor explicit:

- **Outbound BFF send loop is unimplemented.** The bot _replies_ to inbound
  webhooks fully (rate limit → command/grounding/crisis → audited
  `BotResponse`), but the loop that pushes a _scheduled_ Oshun message out to a
  chat is the noted gap. The policy core, grounding, auth, rendering, and
  side-effect seams underneath it are real and tested.
- **Telegram payments are a planner.** The state machine and audit trail are
  real; the `sendInvoice` provider call requires live credentials.
- **STT and effects are credential/port-gated, not faked.** Without
  `OSHUN_STT_API_KEY` the voice path throws `stt_not_configured` and the bot
  says so; without a wired `TelegramEffectsPort` the side-effecting commands say
  "not connected yet."
- **Credential seam fails loud.** `provider-config-env.ts` adds a channel to the
  live `MessageProviderConfig` only when _every_ credential it needs is present;
  a channel with missing creds is omitted and `deliverDispatchedMessage` reports
  `missing-config` rather than faking a send. No secret is logged, defaulted, or
  invented.

For backlog status see the messaging section in [../TODOS.md](../TODOS.md)
(`§26` and subsections; `deps§26` in [../DEPENDENCIES.md](../DEPENDENCIES.md)
for build ordering).

## Related

- [WhatsApp, Email, Push, SMS, Discord, and Slack](./other-channels.md) — the
  rest of the channel surfaces and the shared transports (SMTP, Web Push,
  FCM/APNs/Expo).
- [Channel Abstraction, Routing, and Boundaries](./channel-abstraction-and-boundary.md)
  — the registry, dispatcher, and boundary model in full.
- [Sophia Grounding](./sophia-grounding.md) — the grounding the bot and `find`
  inline mode depend on.
- [Lilith Persona Policy](./lilith-persona-policy.md) — persona and crisis-break
  enforcement.
- [Crypto Payments — Non-Custodial Entitlement Settlement](./crypto-payments.md)
  — the V1-primary settlement rail the Telegram payment fallback layers onto.
- [Privacy, Consent, Data Portability, and User Controls](./privacy-consent-portability.md)
  — step-up auth and revocation cascades.
- [Iris Memory and Identity](./iris-memory-identity.md) — durable write gating
  from chat surfaces.
- Companion architecture:
  [Channel Abstraction, Routing, and Boundaries](../architecture/messaging-channels.md).
- Hub: [../features.md](../features.md).
