# Telegram Messaging Bridge

## Decision

A Telegram bridge is a `MessagingBridgeRecord` with `kind: "telegram"` and a per-bridge encrypted bot token. The runtime talks to `api.telegram.org` directly over `fetch` — no SDK dependency. Outbound (`POST /api/messaging/:id/send`) calls Telegram's `sendMessage`; inbound is a long-poll `getUpdates` loop owned by the gateway. Bot tokens live in the per-instance secret store under the `messaging.<bridgeId>` namespace and never appear on the bridge record.

The macOS Keychain, public webhooks, and a Telegram SDK are all rejected. They each add either a remote-headless dialog (Keychain), a public-internet surface (webhooks), or a runtime dependency tree (SDK) that the local-first Gini shape does not need.

## Context

Gini's messaging surface already modeled `MessagingBridgeRecord`, `MessagingMessageRecord`, and HTTP endpoints (`POST /api/messaging`, `/:id/send`, `/:id/receive`, `/:id/health`, `/:id/disable`). Until this ADR the telegram code path was a stub: `checkMessagingBridge` only verified that `TELEGRAM_BOT_TOKEN` was set in the runtime env, and `sendMessagingOutput` wrote a state record without performing a Bot API call. A real integration needed three things: secret-bound transport, an outbound call site, and an inbound pump.

Webhook delivery requires Telegram to reach the gateway over the public internet. The gateway binds to `127.0.0.1` per ADR `local-runtime-architecture.md`, so webhook mode would force the user to operate a tunnel (ngrok, cloudflared) and pin a public certificate. Long-poll `getUpdates` works from any network position and keeps the gateway entirely on the loopback interface.

A Telegram SDK (e.g. `node-telegram-bot-api`) is rejected on the same grounds the rest of this codebase prefers — the wire format is a small set of POST endpoints with JSON bodies; introducing a runtime dependency only adds upgrade exposure, a handler abstraction the runtime does not need, and (in the python-side reference systems we surveyed) elaborate workarounds for the SDK's own coupling to a process-wide `Application` lifecycle.

The Telegram Bot API caps the long-poll timeout at 50 seconds and uses an offset-based acknowledgment scheme: the next call's `offset` parameter implicitly acknowledges all updates with `update_id < offset`. The runtime therefore has to persist the next offset so a crash mid-batch doesn't replay messages that already produced tasks.

## Required Now

- `MessagingBridgeRecord` carries optional `secretRefs` (per-bridge encrypted secret pointers) and `metadata` (kind-specific free-form state — for Telegram: `botUsername`, `botId`, `lastOffset`).
- `POST /api/messaging` with `kind: "telegram"` requires a `botToken` field. The token is validated as header-safe printable ASCII (no whitespace, no control characters) before storage; without that gate a control-char token would be rejected at fetch time and the underlying error string would echo the full URL — including `/bot<token>/` — into the persisted `bridge.message`. The token is then written through `writeSecret(instance, "messaging.<bridgeId>", "bot-token", value)` and immediately discarded from memory.
- Three onboarding surfaces ride the same `addMessagingBridge` substrate, all funneling into the encrypted secret store above: (1) the CLI's `gini messaging add <name> telegram --bot-token <token>`; (2) the settings page's Add Telegram dialog (`web/src/app/settings/_components/MessagingCard.tsx`); (3) the in-chat `request_messaging_bridge` agent tool, which mints a `messaging.add_bridge` approval that the chat renders as an inline passthrough card with name + password-masked bot-token inputs. The chat card's Submit posts `{ secrets: { name, botToken } }` to `/api/approvals/<id>/connect`, which calls `resolveApproval(resumeChatTask:false)` BEFORE `addMessagingBridge` (mirrors browser.fill_secret's atomic-resolve-before-side-effect pattern) so a concurrent `/deny` or task cancel cannot leave an orphan bridge + secret on disk. The generic `/approve` endpoint refuses `messaging.add_bridge`; `/deny` still works. The home page and `/permissions` list show only Deny + a "resolve in chat" hint for this action, since they can't display the name/token form. See [chat-block-protocol.md](chat-block-protocol.md) for the wire contract.
- The chat surface also covers the rest of the Telegram bridge lifecycle so an operator can stay in chat without context-switching to settings: (a) `list_messaging_bridges` and `list_messaging_pairings` (always-on read-only tools) let the agent answer "what bots do I have?" and "any pending pairings?" with structured JSON envelopes; (b) `request_messaging_pairing` (approval-gated) surfaces a `messaging.approve_pairing` card showing the sender, chat id, chat type, verification code, and expiry from `recentDeniedChats[chatId]` — Approve calls `allowChat(bridge, chatId, {expectedCode})` reusing the same verification-code handshake the settings dialog enforces; Reject calls `rejectPendingChat`; (c) `request_remove_messaging_bridge` surfaces a `messaging.remove_bridge` card with a destructive confirmation, calling `removeMessagingBridge` on Approve. All three lifecycle actions route through `/api/approvals/<id>/connect` (with delegate modules in `src/execution/messaging-pairing-connect.ts` and `messaging-remove-connect.ts`), share the same atomic-resolve-before-side-effect ordering as `messaging.add_bridge`, and the same `/approve`-refused, hide-Approve-on-home-and-permissions UX. See [chat-block-protocol.md](chat-block-protocol.md) for the per-action wire contracts.
- `POST /api/messaging/:id/health` performs a real `getMe()` round-trip when the bridge is Telegram-kind. Bot username/id land on `bridge.metadata` (merged into live metadata, not overwritten from a pre-await snapshot, so concurrent poller writes to `lastOffset` survive). Failures mark `bridge.status = "error"` with the API description as `bridge.message`. Any `/bot<token>/` residue is scrubbed before persisting via `sanitizeBridgeStatusMessage`. A user-initiated disable racing with the health check is preserved — the final mutate refuses to overwrite `status === "disabled"`.
- `POST /api/messaging/:id/send` dispatches `sendMessage` for Telegram-kind bridges. The `target` field is the `chat_id`. Send failures mark the resulting `MessagingMessageRecord.status = "failed"` with `error` set to the API description.
- Outbound text is rendered as Telegram MarkdownV2 by default. `src/integrations/telegram-format.ts` converts the common Markdown subset agents emit — fenced code blocks, inline code, and `**bold**` — into MarkdownV2 and escapes every other special character so the API cannot reject a payload over a stray `.` or `-`. Callers pass `parseMode: "none"` on the send body to opt out and send a literal string.
- The formatter hides code spans behind sentinel placeholders **before** matching `**bold**`, so bold runs that wrap an inline code span (a common agent pattern: ``**`git rebase`**``) are recognized as a single match instead of two orphan `**` markers. When a bold run wraps a single code token (with optional whitespace), the bold markers are dropped and only the code span renders — Telegram MarkdownV2 does not reliably support inline code nested inside bold, so dropping the bold preserves the agent's intent (highlight a command name) without provoking a malformed-entity error from the API.
- `POST /api/messaging/:id/send` accepts an optional `photo: { url | fileId | path }` field. The bridge dispatches `sendPhoto` instead of `sendMessage`; the caller's `text` becomes the caption, the MarkdownV2 transform applies to the caption, and the resulting `MessagingMessageRecord.media` carries the source descriptor. Local-path uploads use multipart/form-data so the bot can attach agent-produced files without staging them behind a public URL.
- `POST /api/messaging/:id/disable` deletes every encrypted file under `messaging.<bridgeId>` and clears `bridge.secretRefs`. The bridge status flips to `"disabled"` before the file cleanup so a partial failure cannot leave an active token on disk under a configured bridge.
- The gateway runs a Telegram poller supervisor that reconciles per-bridge long-poll loops against state every `GINI_MESSAGING_RECONCILE_MS` milliseconds (5s default; the legacy `GINI_TELEGRAM_RECONCILE_MS` env var is honored for backwards compatibility, as the same cadence drives the Discord supervisor). Loops run `getUpdates(offset, 25, signal)` with `allowed_updates: ["message"]`, route each message through `receiveMessagingInput`, and persist `metadata.lastOffset = update.update_id + 1` after each update. A missing or unreadable secret file flips the bridge to `status: "error"` (via the shared `markBridgeError` helper) so the supervisor drops it on the next reconcile — without that guard the loop would hot-restart on every tick because `shouldRun` only checks the secret-ref entry, not the file's presence on disk. The mark-error path refuses to overwrite an explicit `disabled` status.
- Each Telegram `chat_id` is bound to a persistent `ChatSessionRecord` tagged with `source: { kind: "telegram", bridgeId, chatId, target }`. `receiveMessagingInput` looks the session up (or creates it on first contact) and submits the user turn via `submitChatMessage` — the same path the web chat uses — so the agent sees prior turns and the chat-task loop runs with full session history. Demo / generic bridges keep the standalone-task path for CLI parity. The same key (negative `chat_id` for groups, positive for private DMs) handles both private chats and group/supergroup chats — one session per Telegram chat, shared across every member of the group.
- Group/supergroup chats add two transforms on the inbound path so multi-user threads stay readable: (a) the bot's own `@botusername` mention is stripped from the message text, including the `/cmd@botusername` form that Telegram clients auto-append when commands are issued in groups; (b) the sender's `@username` (falling back to `first_name`) is prefixed onto the task input as `<sender>: <message>` so the agent can address the right person and distinguish concurrent speakers. Other users' mentions in the same message stay intact. Outbound replies pass `reply_to_message_id` so the bot's response threads onto the question it's answering — without this, group conversations become unreadable as soon as more than one speaker is involved. The poller reads the bot's username from `bridge.metadata.botUsername` (populated by the first `getMe` health probe), so `gini messaging health <id>` is now a recommended first step before adding the bot to a group.
- A chat allowlist on `bridge.metadata.allowedChatIds` gates the inbound path. Updates from unknown chats are recorded on `recentDeniedChats` and never routed to a task, so the offset still advances and denied updates can't accumulate on the Telegram side. **There is no trust-on-first-use**: every chat, including the operator's own first DM, is denied unless explicitly enrolled via `gini messaging allow <bridge> <chat-id>` or the per-DM verification-code handshake described next. TOFU was rejected because the bot's `@username` is public the moment it's created with @BotFather — a stranger watching for new bot accounts could otherwise claim ownership before the legitimate operator's first DM.
- A per-chat **verification code** drives the operator-facing approve flow. When an unrecognized chat DMs the bot in a private chat, the poller mints a short code (`F971-8261` format — two uppercase 4-hex-digit groups separated by a single dash, 32 bits of entropy, 10-minute TTL) onto the matching `recentDeniedChats` entry and DMs the same code back to the user via `deliverVerificationCode`. The operator sees the row in their UI (or via `gini messaging chats <bridge>`) with the code rendered alongside the sender info, confirms it matches what the user reports receiving, and clicks Approve — server-side `allowChat` validates the operator-supplied code against the still-live pending entry (rejects mismatch or expired) before enrolling the chat, so a stale UI tab can't be coaxed into approving against a rotated code. Repeated DMs from the same chat inside the window reuse the existing code; a DM after expiry mints a fresh one. Group chats land on the pending list without a code (no safe per-user channel to deliver one) and are approved by chat-id alone.
- After a successful Approve, `allowChat` sends a "Paired. You can chat with the bot now." greeting back to the originating chat so the user knows enrollment closed without having to send another message and wait for an agent reply. The send is best-effort with bounded retries; a final failure is logged but the chat stays enrolled. The verification-code reply and the greeting both skip the active agent's `messagingTargets` filter — the operator's policy can't accidentally block enrollment-flow signals for chats it has never seen.
- To make chat-id discovery painless (operators don't usually know their own Telegram user_id by heart), every denied attempt is recorded on `bridge.metadata.recentDeniedChats` — a bounded ring buffer of `{ chatId, chatType, sender, lastAttemptAt, verificationCode?, verificationCodeExpiresAt? }` entries, deduped by `chatId` so a flood of pings from the same source can't bloat state. The operator DMs the bot once, opens Settings → Messaging bridges (or runs `gini messaging chats <bridge>`), sees their own attempt with the matching verification code, and clicks Approve (or runs `gini messaging allow <bridge> <chat-id>`). Once enrolled, the entry is removed from the denied list so the view stays clean.
- `bridge.metadata.ownerChatId` records the first `chat_id` ever enrolled and stays stable across subsequent `allow`/`deny` calls. Removing the owner chat from the allowlist parks the bot but leaves `ownerChatId` intact as a historical record; future tooling (`gini messaging set-owner`) can rotate it explicitly.
- Allowlist + lifecycle administration surface: `gini messaging allow|deny|reject-pending|chats|disable|remove <bridge> [<chat-id>]` over the CLI, and `POST /api/messaging/:id/{allow,deny,reject-pending,disable,remove}` + `GET /api/messaging/:id/chats` over HTTP. `allow` accepts an optional `expectedCode` field — the UI forwards the verification code it rendered so the server re-checks the handshake before enrolling. `reject-pending` clears a row from `recentDeniedChats` without granting allowlist access — the chat remains denied and its next message re-appears in the pending list — so it backs the operator UI's "Reject" button on a pending pairing request without acting as a durable denylist. `remove` is a hard delete: the bridge record and its encrypted secret files are erased, while existing chat sessions and audit history keep their `bridgeId` reference (the runtime treats them as historical). All surfaces emit audit rows on every decision: `messaging.chat.allowed`, `messaging.chat.denied`, `messaging.pending.rejected`, `messaging.disabled`, and `messaging.removed`. The activity trail captures every enrollment outcome with `chatId` on `evidence`.
- When the chat-task settles, the poller's per-message worker calls `syncChatTaskResult` to land the assistant message in the chat session and dispatches the assistant text back via `sendMessagingOutput`. The MarkdownV2 transform runs over the outbound text on the way through. `[SILENT]` task summaries suppress the mirror (the chat-task contract already honors that marker).
- Inbound photo updates are resolved to local files. The poller picks the largest `PhotoSize`, calls `getFile` + `downloadFile`, writes the bytes to `<instanceRoot>/inbound/<bridgeId>/<update_id>-<file_id>.<ext>` (directory mode `0o700`), and prefixes the task input with a `[photo: <path>]` header so the agent's file toolset can inspect the attachment without changing how task inputs flow through the runtime. The inbound `MessagingMessageRecord.media` records both the local path and the originating `file_id`.
- While an inbound-Telegram task is non-terminal, the poller fires `sendChatAction("typing")` for the originating chat on a ~4-second cadence (Telegram chat actions auto-clear after ~5 seconds). The pulse runs detached so a slow `sendChatAction` does not block the next update batch, and halts on the first error so a revoked chat or network blip cannot keep it looping. The reply mirror still waits for the underlying task to reach terminal state before syncing, so a single typing error does not drop the assistant's response.
- The detached typing+reply worker is tracked in a per-supervisor set; `stopAll` drains the set with a bounded timeout. `sendChatAction` accepts and threads an `AbortSignal` so typing-pulse fetches cancel in-flight when the supervisor aborts; `sendMessage` and `sendPhoto` still don't, so the drain cap is the upper bound on shutdown latency for an in-flight outbound dispatch.
- SIGTERM aborts every active long-poll via `AbortController` so shutdown does not wait out the 25-second timeout.
- The Telegram HTTP client (`src/integrations/telegram.ts`) is mockable via an injected `fetch`; the messaging module exposes `setMessagingDeps` for tests to substitute a stub `TelegramClient`. Production callers leave both unset.

## Security Boundary

- The bot token is a write-only field on the create payload. It is encrypted at rest and never re-emitted — neither on the bridge record, nor in audit evidence, nor on `MessagingMessageRecord`. A bridge owner has no API to read it back; they re-supply it by recreating the bridge.
- The poller calls `receiveMessagingInput` which submits a chat-task. Task-level approval (per ADR `approval-execution-abort.md`) and the active-agent toolset/messaging-target filters (per `agents-replace-profiles.md`) apply unchanged. The bridge does not bypass them. The reply-mirror dispatches the assistant message back via `sendMessagingOutput`, which honors the same messaging-target filter — an agent restricted from a target via its messaging-target filter cannot use the bridge to escape that restriction.
- `allowed_updates` is pinned to `["message"]`. Callback queries, inline events, edits, and other update kinds are not surfaced until the runtime grows explicit routing for them. An attacker controlling a Telegram chat cannot reach internal call sites by sending update kinds the runtime ignores.
- The bridge owner controls which chats can speak to the bot via the chat allowlist on `bridge.metadata.allowedChatIds`. The poller silently drops inbound payloads from chats that are not enrolled (and still advances Telegram's update offset so denied updates can't accumulate). There is NO trust-on-first-use: even the operator's first DM is denied until they either run `gini messaging allow <bridge> <chat-id>` or complete the per-DM verification-code handshake from the operator UI. Task-level approval (per ADR `approval-execution-abort.md`) and the active-agent toolset / messaging-target filters (per `agents-replace-profiles.md`) apply on top of the allowlist for every inbound message that makes it through. The verification-code reply and the post-approve greeting skip the messaging-target filter so an operator policy restricting normal sends to known chats can't lock unknown users out of enrollment itself.

## Open Questions

- Group privacy mode. Telegram bots default to "privacy mode" in groups, meaning Telegram only delivers messages that mention the bot, reply to the bot, or invoke a command. Users who want a fully-listening bot must disable privacy in @BotFather (`/setprivacy` → Disable); we can't toggle this from the code path. The current implementation works in both modes — privacy-on is the safer default since it cuts noise and inference costs in busy chats. Note that even with privacy off, the chat allowlist still gates which chats the bot will respond to; privacy mode is defense-in-depth on top of the allowlist, not a replacement for it.
- Owner rotation. Today `ownerChatId` is implicit (whoever DM'd first) and there's no flow to transfer ownership to a different chat. A future `gini messaging set-owner <bridge> <chat-id>` would let a team rotate the owner without recreating the bridge. The current `allow` / `deny` pair is sufficient for the single-user case the bridge is designed around.
- Other media kinds. Documents, voice notes, videos, and stickers are not surfaced today. Each follows the same pattern as the photo path (extend `IncomingPayload`, add a `getFile` + `downloadFile` call, save under the inbound directory) but each carries its own caption/duration/mime semantics worth handling in a focused follow-up.
- Inline-keyboard buttons and callback queries. Approval prompts would benefit from inline keyboards; both require expanding `allowed_updates` and a dispatch table from `callback_query.data` to runtime actions.
- Vision-aware task input. Inbound photos currently surface as a `[photo: <path>]` header in the task input string. A multimodal provider could consume the file directly via a content block; wiring that requires changes to `Task.input` (which is `string` today) or to the chat-task content shape.
- Network egress fallbacks. Some networks block the public IP `api.telegram.org` resolves to. A hostname-preserving fallback transport (curl `--resolve` equivalent) is a plausible future addition but is not required by the local-first shape.
- Richer markdown fidelity. The formatter only recognizes `**bold**`, inline code, and fenced code blocks today. `_italic_` markers come through as literal underscores (the underscore is ambiguous with `snake_case` identifiers that the agent emits constantly, so naive conversion would break code-adjacent prose). Markdown headers, list bullets, blockquotes, and `[text](url)` links also pass through as escaped literals; Telegram MarkdownV2 has no native list primitive at all, so even an HTML-mode rewrite wouldn't recover bullets. Bare URLs auto-link on Telegram's side, so `https://example.com` works without any markdown sugar.
- Outbound message length. Telegram caps `sendMessage` payloads at 4096 characters; we don't chunk yet. A long assistant summary fails with `Bad Request: message is too long`, the error lands on the outbound `MessagingMessageRecord`, and the user sees nothing in Telegram. Chunking on paragraph or sentence boundaries (and possibly attaching the long form as a document) is a focused follow-up.

## Verification

- `bun test src/integrations/telegram.test.ts` exercises the HTTP client (`getMe`, `sendMessage`, `sendChatAction`, `sendPhoto` for all three source variants, `getFile`, `downloadFile`, `getUpdates`, and error paths) against an injected `fetch`.
- `bun test src/integrations/telegram-format.test.ts` covers the MarkdownV2 formatter: bold conversion, code-span preservation, exhaustive special-character escaping, and the bold-wrapping-code regression case where ``**`cmd`**`` must render as a code span without leaking literal `**`.
- `bun test src/integrations/messaging.test.ts` exercises add/health/send/disable/remove for telegram-kind bridges against an injected `TelegramClient`, including secret-store round-trip, MarkdownV2 dispatch, photo sends, error propagation, allowlist enforcement (every chat denied on a fresh bridge — no TOFU), and the verification-code lifecycle on `recordDeniedChatAttempt` (mint in `F971-8261` format, reuse unexpired codes for repeated DMs from the same chat, mint a fresh code after expiry, and no code for group chats). The `allowChat` / `denyChat` round-trip covers owner-chat persistence across deny, removal from the recent-denied list on allow, and the optional `expectedCode` validation that rejects mismatch or expired codes. The `rejectPendingChat` contract (pending row cleared, allowlist + ownerChatId untouched, idempotent on a chatId that is no longer pending, refuses non-telegram bridges, and `recordDeniedChatAttempt` no-ops when the chat became allowlisted before the deny write) and the `removeMessagingBridge` contract (secret files deleted, bridge dropped from `state.messagingBridges`, `messaging.removed` audit row emitted, chat sessions and messages retained) are also covered.
- `bun test src/integrations/telegram-poller.test.ts` exercises the supervisor's start/stop reconciliation, inbound text routing (incoming text → task submission → offset advance), inbound photo capture (download → save under instance root → `[photo:]` header), the typing-pulse, disable-cleanup, group-chat handling end-to-end (mention stripping, `<sender>: <message>` prefix, and `reply_to_message_id` threading on the mirrored reply), stranger DMs receiving a verification-code reply and landing on the pending list (no `messagingMessageRecord` for the inbound; one outbound carrying the `F971-8261` code; `metadata.lastOffset` still advances), and the verification-code reply text format end-to-end.
