Telegram
Telegram works in both modes:
| Mode | What it does |
|---|---|
| Notification | Single-direction sendMessage from the alert pipeline. |
| IM bridge | Two-way chat with the agent via getUpdates long-poll. |
The same bot token (8…:AA…, from @BotFather) drives both — Telegram authenticates by token-in-path. There's no separate signing scheme.
Designed for users outside Feishu / DingTalk territory
Telegram is the IM channel Ongrid recommends for non-China teams and for hybrid deployments where the operators happen to live on Telegram. The provider was added in ADR-031.
Notification mode
A notify.Sender POSTs {chat_id, text} to https://api.telegram.org/bot<TOKEN>/sendMessage. The credential is the bot token (in the path); the chat_id (numeric) is the destination chat.
// internal/pkg/notify/webhook.go
NewTelegramSender(name, endpoint, chatID, client)Where endpoint is the literal …/bot<TOKEN>/sendMessage URL and chatID is the target chat (a user's numeric ID for a DM, or the negative number Telegram gives you for a group).
Setup:
- DM
@BotFather→/newbot→ pick a username → copy the token. - Add the bot to the target chat (group / channel) or DM it once so it knows about you.
- Grab the chat ID. The quickest way: send any message in the chat, then
curl https://api.telegram.org/bot<TOKEN>/getUpdatesand readresult[0].message.chat.id. - In Ongrid: Settings → Channels → New → Provider =
telegram→ Endpoint =https://api.telegram.org/bot<TOKEN>/sendMessage, chat_id = the number from step 3.
IM bridge mode (two-way)
Inbound is long-polled from the manager's outbound HTTPS connection. Because the call is outbound, Telegram works behind NAT, firewalls, and HTTPS proxies. setWebhook (the alternative) would require Telegram to reach the manager — that's incompatible with most private-cloud deployments and unreliable from mainland China.
stream is the only mode
The validator rejects anything else:
telegram only supports stream mode
Webhook mode is not exposed in the UI for Telegram.
Credential mapping
The im_apps row reuses existing columns — no schema changes:
im_apps column | Telegram meaning |
|---|---|
provider | "telegram" |
mode | "stream" (validator pins this) |
app_id | Bot username (e.g. ongridbot). Display + dedupe. |
app_secret | BotFather token (8…:AA…). Encrypted at rest. |
allow_from | Required comma-separated numeric user IDs. |
verify_token | Unused. |
encrypt_key | Unused. |
allow_from
A non-empty list of numeric Telegram user IDs that may converse with the bot. The validator drops non-numeric tokens (so a typo'd alice lands as a clean required-error, not a silently-empty allowlist) and rejects negative tokens (those are group / supergroup chat IDs and don't belong in a sender allowlist).
telegram requires allow_from — at least one numeric Telegram user ID (the bot is publicly reachable; an empty allowlist would let anyone command the agent)
How to find a numeric user ID:
- DM
@userinfobotand it replies with your numeric ID. - Or send a message in the chat after registering, run
curl https://api.telegram.org/bot<TOKEN>/getUpdates, and readresult[0].message.from.id.
telegram: / tg: prefixes are silently stripped (OpenClaw compatibility), so telegram:123456789 and 123456789 are the same entry.
Silent drop on miss
Non-allowlisted senders are dropped with a WARN log:
telegram inbound from non-allowlisted sender — ignored
user_id=42 user_name=alice chat_id=42There is no reply. There is no placeholder. The bot does not even confirm it exists. This mirrors OpenClaw allowFrom — a reply would leak that an agent-backed bot lives at this username and tempt people to probe it.
Setup
- DM
@BotFather→/newbot→ choose a name and a unique username ending inbot. Copy the token. - (Optional)
@BotFather → /setprivacy → Disableif you want the bot to see messages in groups (default is mention-only). - Find your numeric user ID via
@userinfobot. - In Ongrid: Settings → IM bridge → New → Provider =
telegram→ Mode =stream→ App ID = the bot username → App secret = the BotFather token →allow_from= your numeric ID (and any teammates'). Save and Enable. - DM the bot from an allowlisted account. The bot replies with a placeholder, then edits it in place as the agent reasons.
The proxy story
The Telegram API host (api.telegram.org) is reachable on most networks but blocked from mainland China. The provider uses a zero-value http.Client so it honors HTTPS_PROXY / HTTP_PROXY / NO_PROXY from the manager's environment — the same proxy that carries getUpdates carries sendMessage.
In docker-compose deployments, persist the proxy in docker-compose.override.yml:
services:
manager:
environment:
HTTPS_PROXY: http://your-proxy:8080
NO_PROXY: localhost,127.0.0.1,manager,mysql,prometheus,loki,tempo,grafana,qdrantDon't put the proxy in the main docker-compose.yml
The main docker-compose.yml is shipped with every release. An override file is per-environment and survives make package / install script reruns.
Quirks worth knowing
message is not modified is harmless
Telegram returns HTTP 400 (message is not modified) when an editMessageText payload exactly matches the current message text. Progressive streaming can repeat the same chunk on a throttled tick or on the final flush. The client swallows exactly this error string and returns nil — anything else (400, 403, 5xx) still propagates. See EditMessageText.
Only one poller per bot
Telegram rejects concurrent getUpdates calls. The StreamSupervisor enforces one client per im_app row. If you duplicate a Telegram app row, only one will poll successfully — the other will see 409 Conflict and back off.
Rate-limit retries
429 Too Many Requests is retried up to 3 times. The wait honors Telegram's parameters.retry_after, capped at 60s. 5xx errors retry with 1s / 2s / 4s backoff. Hard 4xx (400/401/403) does not retry — it bubbles to the supervisor and likely indicates a token / chat-id issue. See maxCallRetries.
Long-poll timeout
The server-side long poll waits 25s (pollTimeoutSec) with a 10s buffer on the client side. Slow networks just see longer poll cycles; the supervisor isn't doing busy-wait reconnects.
Text-only
Stickers, photos, voice notes, and files are dropped silently. Only message.text events drive an agent turn. The bridge is intentionally S1 (text in / text out); rich media is a future expansion.