Telegram channel
Overview
hango’s first real users are a family: the owner’s wife, sister, sons and parents. None of them will call an HTTP API, so this phase gives them a chat client they already have. A Telegram bot receives private messages, maps each Telegram user to a hango user, turns every message into an ordinary run on that user’s agent, and streams the answer back by editing one Telegram message as tokens arrive. A channel message goes through the same run creation, quota, audit and telemetry paths as POST /v1/sessions/{id}/runs. It is a second front door to the same harness, not a separate feature. A web chat comes later.
The phase teaches what every channel integration in goclaw and OpenClaw has to solve: identity mapping from an untrusted platform, at-least-once delivery from the platform, session continuity, and fitting a token stream into a platform’s rate limits.
Requirements
Functional
- Long polling with
getUpdates(50 s timeout) against the Telegram Bot API. No public URL, TLS certificate or webhook is needed, so hango can run on a home machine behind NAT. Webhook mode is a follow-up. - Polling health, as OpenClaw does it: a
409 ConflictfromgetUpdatesmeans another process polls the same bot, so the poller logs an error naming the cause, backs off for 30 s and counts it, instead of fighting the other poller. A watchdog marks the bot unhealthy when nogetUpdatescall has completed for 120 s, and/readyzreports it. - At startup the bot registers its commands with
setMyCommands, so Telegram shows them in the menu. - Only private chats are handled. Group, channel and edited-message updates are acknowledged and ignored, with a debug log line.
- Identity mapping is static configuration, because the user set is a handful of family members:
HANGO_TELEGRAM_USERSis a JSON object from Telegram user id to{"user_id": "<hango user uuid>", "agent": "<agent slug>"}. At startup each entry is resolved to the user’s tenant and checked: the user exists and is active, and the agent slug exists and is visible to that user under the phase-2 visibility rules. A bad entry fails startup with a message naming the Telegram id. - An unknown sender gets one fixed reply (“This bot is private. Ask the owner to add your Telegram id: N.”) at most once per hour per sender, and no run is created. The sender id is logged and counted, never their message text.
- Session continuity: a Telegram user’s current session is their newest session whose
keystarts withtelegram:<telegram user id>:. If none exists, one is created with keytelegram:<telegram user id>:<unix seconds>on the mapped agent./newstarts a new session the same way. - A reply to an earlier message carries that message’s text into the run input as quoted context (truncated to 1000 characters), so “what did you mean by this?” works.
- Commands:
/new(fresh session),/cancel(cancels the active run in the current session through the phase-6 cancel path),/whoami(shows the mapped hango user name and agent slug),/help. Any other text starting with/gets the help text. - A text message creates a run through the shared run-creation function extracted from the phase-5 handler, with the mapped user’s scope. Over quota (phase 13) replies with the quota message. A session that already has a pending or running run replies “Still working on the last message. Send /cancel to stop it.” and creates nothing.
- Duplicate delivery is harmless: the run’s
inputcarriestelegram_update_id, and run creation for a Telegram message first checks the session for an existing run with that update id. A crash between run creation and the nextgetUpdatesoffset therefore never runs a message twice. - Streaming reply: send a
typingchat action and repeat it every 4 s until the first text arrives, send one placeholder message, subscribe to the run’s events through the phase-6 broadcaster, and edit the placeholder with the accumulated text at most once every 1.5 s per chat. The final edit happens on the terminal event. Text over 4096 characters continues in a new message, split at a paragraph or line boundary. - Formatting: model output is Markdown, so the final edit converts it to Telegram HTML (bold, italic, inline code, code blocks, links, and lists as plain lines) with every other
<,>and&escaped. Intermediate edits stay plain text, because half a Markdown construct cannot be converted safely. If Telegram rejects the HTML (400 can't parse entities), the same text is sent again as plain text and the fallback is counted. - A failed edit (the message was deleted, or it is too old to edit) falls back to sending a new message, as goclaw does, so a reply is never lost.
- A failed run edits the placeholder to a short failure line with the run id. A cancelled run says “Stopped.” Tool calls show as one status line (“Using web_fetch…”) that the next text edit replaces.
- Photos, voice notes, stickers and documents get the reply “Text only for now.” and create no run. Phase 20 adds them.
Non-functional
- One new package,
internal/telegram. Standard library only (net/http,encoding/json); no Telegram SDK, because the five Bot API methods used (getUpdates,sendMessage,editMessageText,sendChatAction,setMyCommands) are small and seeing their JSON is part of the lesson. - The bot token is a secret: never logged, never in span attributes or errors. The Bot API URL contains the token, so the HTTP client’s error wrapping strips the URL before returning.
- The poller runs under
safegoand stops in the phase-6 drain order before the scheduler drains, so no new run is created during shutdown. - Telegram rate limits (about one message per second per chat, 30 per second per bot) are respected by the edit throttle plus a per-bot token bucket. A
429from Telegram honoursretry_afterplus one second on every method, and the edit loop skips edits until then rather than queueing them.
Architecture
// internal/telegram/bot.go
type Bot struct {
api *apiClient // Bot API over net/http, token kept private
users map[int64]Mapping // resolved at startup from HANGO_TELEGRAM_USERS
runs RunCreator // the shared run-creation function, injected by internal/app
events EventSubscriber // phase-6 broadcaster subscription, injected
sessions SessionFinder // newest telegram:<id>: session, or create one
}
type Mapping struct {
TelegramUserID int64
Scope store.Scope // tenant, user and current role, rebuilt per message
AgentID uuid.UUID
}
func (b *Bot) Run(ctx context.Context) error // long-poll loop; returns on ctx cancelgetUpdates (offset) ──▶ for each update:
private text? ──no──▶ ignore or fixed reply
known sender? ──no──▶ "This bot is private…" (once per hour)
command? ──▶ /new /cancel /whoami /help
run already active in session? ──▶ "Still working…"
run with this update_id exists? ──▶ skip (duplicate delivery)
CreateRun(scope, session, input{text, telegram_update_id}) ──▶ quota, audit run.create, enqueue
placeholder message ──▶ subscribe run events ──▶ throttled edits ──▶ final edit
next getUpdates with offset = last update_id + 1internal/telegram imports internal/store for Scope and the session and run lookups, and nothing from internal/server. Run creation moves from internal/server/handlers_runs.go into internal/run/create.go, so the HTTP handler and the bot call one function and cannot drift on quota, dedup or audit behaviour.
Instrumentation
- Each update gets a span
telegram.update(attributestelegram.update_id,telegram.chat_type,hango.telegram.outcome=run_created|duplicate|busy|unknown_sender|ignored|command). A created run’sinvoke_agenttrace is linked to it with a span link, so Tempo shows the hop from the Telegram message to the run. - The run’s audit row is
run.createwithactor_kind=user, the mapped user as actor anddetail.channel = "telegram". No new audit action. - Metrics:
hango.telegram.updatescounter (attributeoutcome),hango.telegram.api_callscounter (attributesmethod,status),hango.telegram.api_durationhistogram,hango.telegram.edit_laghistogram (time from run event to the edit that shows it),hango.telegram.unknown_senderscounter,hango.telegram.format_fallbackscounter (HTML rejected, sent as plain text),hango.telegram.poll_conflictscounter (409 fromgetUpdates). - Logs carry
telegram.update_id,run_idandtrace_id, never message text or the token.
Related Code Files
Create
internal/telegram/api.go,internal/telegram/api_test.go— the Bot API client againsthttptest.Server, token stripped from errors.internal/telegram/bot.go,internal/telegram/bot_test.go— the poll loop, routing, commands and dedup.internal/telegram/stream.go,internal/telegram/stream_test.go— run events to throttled edits, splitting at 4096 characters, tool status lines, edit-failure fallback.internal/telegram/format.go,internal/telegram/format_test.go— Markdown to Telegram HTML with escaping, tested on code blocks, nested emphasis, links with&in the URL and unbalanced input.internal/telegram/mapping.go,internal/telegram/mapping_test.go— parsing and resolvingHANGO_TELEGRAM_USERS.internal/run/create.go,internal/run/create_test.go— run creation extracted from the phase-5 handler, plus the update-id dedup lookup.docs/learning/18-telegram-channel.md.
Modify
plan.md— package cap 18 → 19 plusinternal/tool/builtin, addinginternal/telegram.internal/server/handlers_runs.go— callrun.Create.internal/store/run.go,internal/store/pg/run.go—FindByInputKey(ctx, sessionID, key, value)for the dedup check.internal/config/config.go—HANGO_TELEGRAM_BOT_TOKEN,HANGO_TELEGRAM_USERS,HANGO_TELEGRAM_EDIT_INTERVAL(default 1.5s),HANGO_TELEGRAM_API_URL(defaulthttps://api.telegram.org, overridden by tests and by phase 14’smake obs-verify). The bot starts only when the token is set.internal/app/app.go— build and start the bot, add it to the drain order before the scheduler.internal/telemetry/metrics_names.go— the seven metric names.internal/server/health.go—/readyzincludes the poller watchdog when the bot is enabled..env.example,README.md,docs/learning/README.md,docs/system-architecture.md.
Implementation Steps
- Extract
run.Createfromhandlers_runs.gowith the existing handler tests still green, then addcreate_test.gofor the update-id dedup. - Write
api.goand its tests:getUpdates,sendMessage,editMessageText,sendChatAction,setMyCommands, the429 retry_afterpath, the409back-off, and a test that a failing call’s error string does not contain the token. - Write
mapping.go: parse the JSON, resolve each user and agent at startup, fail with the Telegram id on a bad entry. - Write
bot.go: the poll loop with offset handling and the watchdog, command registration, reply-to context, private-chat filter, unknown-sender reply throttle, commands, busy check, dedup check and run creation. Test with a fake API server that replays a scripted update sequence, including the same update twice. - Write
format.gowith table-driven tests, thenstream.go: repeat the typing action, subscribe, coalesce text deltas, edit at most once per interval, split long text, show and replace tool status lines, finish with the HTML edit and its plain-text fallback, and send a new message when an edit fails. Test with a fake broadcaster and a fake clock. - Wire config, app startup and the drain order. Add the span, the span link to the run trace, metrics and log fields.
- Create a real bot with BotFather, map your own Telegram id, and run a conversation end to end against
docker compose up. Record the Tempo trace fromtelegram.updateto the run and the Langfuse generation in the learning doc. - Map one family member, run their first conversation with them, and note what confused them. That feedback goes into the learning doc and into the web-chat follow-up.
- Update docs and write the learning doc.
Learning Doc Outline
docs/learning/18-telegram-channel.md must contain:
- Concept: a channel is a front door. It turns a platform message into a run in the right tenant, user and session, and turns run events back into platform messages. The harness itself does not change.
- Design choices: long polling instead of a webhook for a home deployment; static identity mapping for a small trusted user set; one session per Telegram user with
/new; dedup onupdate_idbecause Telegram delivers at least once; edit throttling because the platform rate-limits. - Trade-offs: static mapping vs pairing codes (simple and auditable in config vs self-service); edits vs one message per chunk (clean chat vs fewer API calls); plain-text intermediate edits with one HTML final edit vs formatting every edit (never breaks mid-stream vs nicer formatting while streaming).
- goclaw comparison:
internal/channels/telegramand the channel manager, pairing service and per-instance tenant config. - OpenClaw comparison: DM pairing for unknown senders and its trust model, where a channel sender is untrusted input.
- How to debug this layer: start from the
telegram.updatespan (outcome attribute), follow the span link into the run trace, checkhango.telegram.api_callsby status for Telegram errors, andhango.telegram.edit_lagwhen replies feel slow. - What phase 20 adds: photos, documents and voice notes in, files out, pairing codes and approval buttons. Group chats, webhook mode and the web chat stay follow-ups.
- Exercises: send the same message twice quickly and watch the busy reply; kill hango mid-run and watch the run finish after reclaim while the Telegram message completes; lower the edit interval until Telegram returns 429.
- Time budget: 15h implementation + 2h learning doc.
Success Criteria
-
go build ./... && go vet ./... && go test ./...pass, and the package list is exactly the 19 packages plusinternal/tool/builtinnamed inplan.md. - The HTTP run endpoint and the bot both create runs through
run.Create; the existing phase-5 handler tests pass unchanged. - Replaying the same Telegram update twice creates one run (test).
- An unknown sender gets the fixed reply at most once per hour and creates no run (test).
- A message while a run is active gets the busy reply and creates no run (test).
- A run over quota replies with the quota message and creates no run (test).
- Edits happen at most once per interval, and a reply over 4096 characters splits into several messages (test with a fake clock).
- The final message is Telegram HTML converted from Markdown; HTML that Telegram rejects is resent as plain text; a failed edit becomes a new message (tests).
- A
429delays the next call byretry_after, a409backs off without exiting, and/readyzfails when no poll has completed for 120 s (tests). - A reply to an earlier message puts that message’s text in the run input (test).
- No log line, span attribute or returned error contains the bot token (test).
- A run created from Telegram has an audit row
run.createwithdetail.channel = "telegram"and the mapped user as actor. - A real conversation through BotFather’s bot works end to end, and the Tempo trace links
telegram.updateto the run (manual check, recorded in the learning doc). - Shutdown stops the poller before the scheduler drains (test asserting no run is created after drain starts).
Risk Assessment
| Risk | Likelihood x Impact | Signal | Pre-decided response |
|---|---|---|---|
| A family member’s Telegram account is taken over | Low x High | Unusual run volume from one mapped user | Phase-13 quotas bound the cost; remove the entry from HANGO_TELEGRAM_USERS and restart; the audit trail shows every run with its channel |
| Telegram delivers an update twice after a crash | High x Medium | Two runs with the same telegram_update_id | Dedup lookup before run creation, covered by a test |
| The Markdown converter produces HTML Telegram rejects | Medium x Low | hango.telegram.format_fallbacks rises | Plain-text fallback already sends the reply; add the failing input as a test case |
| Edits hit Telegram rate limits | Medium x Low | hango.telegram.api_calls{status="429"} rises | Honour retry_after; raise HANGO_TELEGRAM_EDIT_INTERVAL |
| One tenant quota is shared by the whole family | High x Medium | One person’s usage blocks everyone | Accepted for now; per-user quotas are a follow-up if it happens |
| The bot token leaks through an error that contains the request URL | Medium x High | Token visible in logs | The client strips the URL from errors, covered by a test |
Rollback
Unset HANGO_TELEGRAM_BOT_TOKEN and the bot does not start. Removing internal/telegram and its wiring returns the binary to phase 13 behaviour. run.Create stays, because the HTTP handler uses it.