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; for the
architecture-level treatment see the companion doc
Channel Abstraction, Routing, and Boundaries.
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):
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:
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
for the broader tier model.
The intent vocabulary the dispatcher routes is:
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 ContentVariants —
{ 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:
{
"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 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.
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 and
Review, Compliance, and Trust & Safety.
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:
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, Veritas,
Nyx, Arete,
Nisaba, and
Isis generation control.
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:
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.
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 anInlineResultcarrying the realcaptureId, aprivate-notelicense badge, and the provenance bundle id.share <artifact-id>— resolves the artifact viaruntime.resolveShareArtifact; if found,buildInlineShareCardproduces an attribution-preserving card whosemessageTextembeds anAI-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 returnsbuildInlineSophiaResult, titledSophia sources (N); on ungrounded results it returnsTELEGRAM_ABSTENTION_COPYwith 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 InlineResults to grammY InlineQueryResultArticles with cache_time: 0
and is_personal: true. Save-to-notebook cross-links to
Customer Curation, Notebooks, Collections, and Sharing.
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:
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.
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.
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:
createTelegramInvoicereturnsweb-checkout-requiredif a crisis is active (payment-suppressed-crisis) or the provider is not configured (provider-unavailable-web-fallback), andinvoice-sentotherwise. This is the planner: it never claims a payment provider call it cannot make.approvePreCheckoutadvancesinvoice-sent → pre-checkout-approved(bound to thepre_checkout_queryid).recordSuccessfulPaymentadvancespre-checkout-approved → paid, attaching the grantedentitlementIdand areceiptProvenanceBundleId(telegram-receipt:<chargeId>) and audit entries (successful-payment:*,grant:*).refundTelegramPaymentadvancespaid → refundedand emitsrefund:*+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
sendInvoiceprovider call requires live credentials. - STT and effects are credential/port-gated, not faked. Without
OSHUN_STT_API_KEYthe voice path throwsstt_not_configuredand the bot says so; without a wiredTelegramEffectsPortthe side-effecting commands say "not connected yet." - Credential seam fails loud.
provider-config-env.tsadds a channel to the liveMessageProviderConfigonly when every credential it needs is present; a channel with missing creds is omitted anddeliverDispatchedMessagereportsmissing-configrather than faking a send. No secret is logged, defaulted, or invented.
For backlog status see the messaging section in ../TODOS.md
(§26 and subsections; deps§26 in ../DEPENDENCIES.md
for build ordering).
Related#
- WhatsApp, Email, Push, SMS, Discord, and Slack — the rest of the channel surfaces and the shared transports (SMTP, Web Push, FCM/APNs/Expo).
- Channel Abstraction, Routing, and Boundaries — the registry, dispatcher, and boundary model in full.
- Sophia Grounding — the grounding the bot and
findinline mode depend on. - Lilith Persona Policy — persona and crisis-break enforcement.
- Crypto Payments — Non-Custodial Entitlement Settlement — the V1-primary settlement rail the Telegram payment fallback layers onto.
- Privacy, Consent, Data Portability, and User Controls — step-up auth and revocation cascades.
- Iris Memory and Identity — durable write gating from chat surfaces.
- Companion architecture: Channel Abstraction, Routing, and Boundaries.
- Hub: ../features.md.