Skip to content

feat: add Telegram chat ingress (telegrambot)#1081

Draft
murderteeth wants to merge 14 commits into
paradigmxyz:mainfrom
murderteeth:feat/telegrambot
Draft

feat: add Telegram chat ingress (telegrambot)#1081
murderteeth wants to merge 14 commits into
paradigmxyz:mainfrom
murderteeth:feat/telegrambot

Conversation

@murderteeth

Copy link
Copy Markdown

Draft: under internal review on my fork; I'll mark this ready for review when that completes. Direction/architecture feedback welcome in the meantime.

Summary

Adds services/telegrambot, a Telegram chat frontend with the same experience as the Slack/Discord/Teams bots: mention-driven durable agent sessions with streamed progress and answers. The service follows the chat-ingress contract in services/AGENTS.md and mirrors discordbot's pull model — long-polling getUpdates (single replica, Recreate, no public ingress) instead of webhooks.

Key design points:

  • Durable receipt ledger: purpose-built telegram_poll_state / telegram_update_inbox tables (startup migrations, advisory-lock serialized). Each getUpdates batch upserts and advances receive_offset in one fenced transaction; processing never gates receipt. Accepted updates progress idempotently through received → message_appended → execution_accepted → render_obligation_persisted → terminal.
  • Fenced ownership: a lease row (holder, monotonic generation, database-time expiry) guards every cursor update, stage transition, and render claim — a paused old owner can't mutate state after takeover; replicas: 1 is rollout hygiene, not the correctness mechanism.
  • Fail-closed access: empty chat/user allowlists render the bot inert; DMs are per-user allowlisted (Telegram has no workspace boundary); linked-channel identities (sender_chat, auto-forwards) are rejected. Group triggers are replies-to-bot and /ask@botname; plain @botname mentions are deliberately unsupported until the manual acceptance test below records a pass.
  • Rendering: entity-aware Markdown→Telegram-HTML chunking (≤4096 parsed chars, balanced <pre>/<code> across chunks), per-chat rate control honoring 429 retry_after, throttled in-place edits, honest truncation at a max-messages cap, at-least-once terminal delivery via persisted render obligations (recovery may duplicate a final answer, never re-executes the agent).
  • api-rs: telegram:chat:{id} / telegram:private:{id} thread keys derive telegram-chat-* / telegram-user-* principals (topic ids collapse to labels; malformed keys fall to the generic fallback, never another platform's parser).
  • Deploy: Helm Deployment/Service gated on telegrambot.enabled, /live vs /ready probe split (Telegram outages fail readiness only), network policy entries, secrets bootstrap, CI job with a Postgres service container, publish-images matrix entry.

How to review

Suggested order: services/telegrambot/AGENTS.md (invariants) → src/inbox.ts + src/ownership.ts (correctness core) → src/poller.tssrc/index.ts (stage machine, steering, answer delivery) → src/telegram-render.ts / rate-limit.ts / telegram-narrator.ts → api-rs principal.rs diff → chart. src/session-api.ts is a deliberate port of discordbot's client — reviewing the delta against that file is faster than reading it cold. Tests mirror module names under test/.

Test plan

  • Automated: pnpm install --frozen-lockfile, then from services/telegrambot: bun run check:types and TELEGRAMBOT_TEST_DATABASE_URL=postgres://… bun test test (260 tests incl. receipt-cursor atomicity, split-brain fencing, steering recovery, FIFO regressions, render fault injection, 7-scenario e2e against a fake Bot API + fake api-rs + real Postgres; without the env var the DB suites skip cleanly — 216 pass/52 skip). cargo test -p centaur-iron-control (68), cargo fmt --check, clippy -D warnings, helm lint contrib/chart.
  • Manual smoke (needs a throwaway bot + running api-rs):
    1. Create a bot via @Botfather; leave privacy mode on; do not set a webhook.
    2. Start Postgres and api-rs (local stack), then from services/telegrambot: set TELEGRAM_BOT_TOKEN, TELEGRAMBOT_USER_ALLOWLIST=<your telegram user id>, DATABASE_URL, CENTAUR_API_URL, and run bun src/server.ts.
    3. GET :3001/ready → 200 after startup (/live is immediate).
    4. DM the bot → expect 👀 reaction, streamed HTML answer replying to your message, 👍 on settle; a follow-up during a run steers into the same session (no second execution in api-rs logs).
    5. Add the bot to a test group, allowlist the chat id via TELEGRAMBOT_CHAT_ALLOWLIST, and verify /ask@<botname> ping answers while plain chatter is ignored; a non-allowlisted chat gets zero responses.
    6. Kill the process mid-answer and restart → the answer is delivered without re-running the agent (a duplicated final message is acceptable per the at-least-once contract).
  • Manual acceptance (release gate for plain mentions): follow "Manual acceptance: plain-mention delivery" in services/telegrambot/AGENTS.md and record the outcome there.

Risk / impact

Disabled by default (telegrambot.enabled: false); no existing service's behavior changes. The api-rs change adds a principal branch plus a fallback guard (covered by regression tests, incl. that malformed telegram keys can't mint Slack principals). The bot token rides in every Bot API URL path — client code redacts it from all errors/logs; reviewers should keep an eye on that in any follow-ups.

🤖 Generated with Claude Code

murderteeth and others added 14 commits July 12, 2026 18:11
Register a new services/telegrambot Bun/TypeScript workspace package
mirroring discordbot's layout (package.json scripts, tsconfig, slim bun
Dockerfile, README, AGENTS.md), plus the shared types contract and
ported utils. Wire the service into pnpm-workspace.yaml, the Justfile
build/tag/deploy targets, and a Telegrambot typecheck-and-tests CI job
with its own change-detection filter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Bot API client embeds the token in every URL path, so errors are
rebuilt from the method name plus the API description and never carry a
request URL. Message acceptance is fail-closed (empty chat/user
allowlists render the bot inert; DMs are per-user allowlisted since
Telegram has no workspace boundary), with replies to the bot and
addressed /ask commands as the privacy-mode group trigger contract.
Thread identity encodes the chat kind in typed durable keys
(telegram:chat:/telegram:private:) with forum-topic preservation and
strict private-chat identity validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port discordbot's session-api module: create/append/execute with stable
client_message_id idempotency keys, SSE replay from after_event_id with
retryable-error classification, steering event detection for
active-execution follow-ups, and attachment ingest via getFile with
conservative count/size caps. Create-session metadata carries
platform/thread routing context and telegram_conversation_name on every
create since api-rs re-upserts the principal each time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The renderer translates harness Markdown into Telegram-supported HTML
and splits it into independently valid chunks whose parsed text stays
within the 4096-char limit, keeping pre/code entities balanced across
chunk boundaries with surrogate-safe cuts, plus an escaped plain-text
fallback for parse rejections. The rate limiter wraps the Bot API
client with per-chat FIFO pacing (~1 msg/s per chat, 20/min group
budget), method-specific 429 retry_after handling, stale-edit
coalescing, and an urgent path so terminal answers outrank progress
edits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port discordbot's append-only narrator: best-effort reactions on the
triggering message (Telegram replaces the reaction set per call, so
settling is a single setMessageReaction), throttled thinking blurbs as
italic HTML posts capped under the message limit, and a typing
keepalive for noticeably slow executions. Reaction and typing failures
are classified and suppressed so cosmetic surfaces never fail delivery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
telegram:chat:{id}[:{topic}] maps to a telegram-chat-{id} principal and
telegram:private:{id}[:{topic}] to telegram-user-{id} — the key's
discriminator, never metadata, decides the kind. Topic sessions
collapse onto the chat/user principal with the topic kept as a label,
ids validate as optionally negative integers (verbatim in the foreign
id, since slugify would collide -N with N), and no team scoping is
applied because Telegram chat ids are globally unique. Adds
telegram_conversation_name to the session principal display-name
fallback chain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deployment + Service gated on telegrambot.enabled: single replica with
Recreate (getUpdates allows one consumer per token; the in-service
fenced lease is the correctness guard), startup/liveness probes on
/live and readiness on /ready so Telegram outages fail readiness
without probe-induced restarts. Adds the telegrambot values block,
network policy entries (api-rs/Postgres plus outbound 443 — FQDN
scoping to api.telegram.org needs an egress proxy or CNI extension),
and TELEGRAM_BOT_TOKEN / TELEGRAMBOT_API_KEY seeding in the secrets
bootstrap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The correctness core of the ingress: purpose-built telegram_poll_state
and telegram_update_inbox tables (never the generic chat-state store),
created by an advisory-lock-serialized startup migration runner with a
version gate readiness can fail closed on. Receipt is separate from
processing — each getUpdates batch upserts and advances receive_offset
in one fenced transaction with a GREATEST guard, so processing can
never gate or regress the cursor. Ownership is a lease row (holder,
monotonic generation, database-time expiry); every cursor update, stage
transition, and prune proves the lease in the same statement, so a
paused old owner cannot mutate state after a takeover. Stage
transitions are compare-and-set and terminal dispositions require a
durable reason. Tests run against real Postgres via
TELEGRAMBOT_TEST_DATABASE_URL and skip cleanly without it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
getUpdates controller mirroring discordbot's gateway crash-to-restart
pattern: startup orders migrations -> getMe -> ownership ->
deleteWebhook(preserve pending) -> poll; only the committed
receive_offset feeds the next poll, with first start polling without an
offset so pending updates are never dropped. Fatal 401/404/409 exits
for a k8s restart; transient failures back off and surface through
/ready (poll freshness, ownership, schema) while /live tracks only a
local loop heartbeat. Ownership loss stops polling immediately.
Includes a reusable scriptable fake Bot API server for tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
createTelegrambot composes the poller, durable dispatcher, and
rendering into the running service: recovery-first dispatch through the
claim scan (restarts resume oldest-first per thread before new work),
fenced stage transitions received -> message_appended ->
execution_accepted -> render_obligation_persisted -> terminal, 409
execute-conflicts resolving as steering with execution-terminal
fallback, and streamed answer delivery that persists every message_id
and delivered chunk before further edits (at-least-once terminal
delivery; recovery never re-executes to redeliver). /live and /ready
map the poller's health surface. server.ts mirrors discordbot's entry
style. Reconciles the client-message-id derivation into
telegram-threading and adds inbox.claimThreadBacklog so live steering
is not starved by the one-row-per-thread claim scan. Includes the
orchestration unit suite and a 7-scenario end-to-end emulate suite
against a fake Bot API server, fake api-rs, and real Postgres.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Confirmed adversarial-review findings: a steering wait registered on an
already-settled execution now resolves as terminal instead of wedging
the follow-up and hanging shutdown; a transiently blocked older row
defers the whole thread to the retry sweep instead of letting newer
siblings execute first (same-thread FIFO); the steering fallback backs
off exponentially and yields its worker slot rather than spinning
execute conflicts once a second; answers collapse honestly at a
max-messages cap (TELEGRAMBOT_ANSWER_MAX_MESSAGES, default 8) with a
truncation notice; answer replies set allow_sending_without_reply so a
deleted trigger message degrades to a reply-less answer instead of an
infinite render retry; settle reactions use the Bot API's whitelisted
emoji set (thumbs up/down — check/cross are rejected with 400); the
typing keepalive refreshes under the indicator's ~5s lifetime;
getFile/downloadFile honor 429 retry_after; group messages with
sender_chat or is_automatic_forward (linked-channel identities) are
rejected fail-closed; per-row in-memory state is dropped on terminal
dispositions. Adds the spec's steering recovery suites, FIFO and
settled-wait regressions, the truncation test, and the
insufficient-DDL-privileges migration test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A telegram:-prefixed thread key that fails validation (e.g.
telegram:chat:C123ABC) previously fell through to the Slack segment
parser, which could mint a real Slack-shaped principal from the
segments. Malformed telegram keys now short-circuit to the generic
thread- fallback with empty labels, with regression coverage pinning
that telegram:chat:D0420 can never collide with the real Slack DM
principal slack-channel-d0420.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The durability core (inbox receipt, fenced ownership, migrations,
poller recovery, end-to-end emulation) is gated on
TELEGRAMBOT_TEST_DATABASE_URL and silently skipped without Postgres, so
the telegrambot job now provisions a postgres service container
(mirroring the rust-api job) and sets the URL. Adds packages/ to the
telegrambot change filter and registers telegrambot in the
publish-images build and manifest matrices so the ghcr image the chart
references actually gets published.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the telegrambot block to contrib/chart/values.dev.yaml (local k3s
dev flow parity with the sibling bots) and lists the service in the
root AGENTS.md ownership-by-tree section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@murderteeth murderteeth changed the title Add Telegram chat ingress (telegrambot) feat: add Telegram chat ingress (telegrambot) Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant