Skip to content

Telegram

Telegram works in both modes:

ModeWhat it does
NotificationSingle-direction sendMessage from the alert pipeline.
IM bridgeTwo-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.

go
// 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:

  1. DM @BotFather/newbot → pick a username → copy the token.
  2. Add the bot to the target chat (group / channel) or DM it once so it knows about you.
  3. Grab the chat ID. The quickest way: send any message in the chat, then curl https://api.telegram.org/bot<TOKEN>/getUpdates and read result[0].message.chat.id.
  4. 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 columnTelegram meaning
provider"telegram"
mode"stream" (validator pins this)
app_idBot username (e.g. ongridbot). Display + dedupe.
app_secretBotFather token (8…:AA…). Encrypted at rest.
allow_fromRequired comma-separated numeric user IDs.
verify_tokenUnused.
encrypt_keyUnused.

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 @userinfobot and 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 read result[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:

text
telegram inbound from non-allowlisted sender — ignored
  user_id=42  user_name=alice  chat_id=42

There 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

  1. DM @BotFather/newbot → choose a name and a unique username ending in bot. Copy the token.
  2. (Optional) @BotFather → /setprivacy → Disable if you want the bot to see messages in groups (default is mention-only).
  3. Find your numeric user ID via @userinfobot.
  4. 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.
  5. 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:

yaml
services:
  manager:
    environment:
      HTTPS_PROXY: http://your-proxy:8080
      NO_PROXY: localhost,127.0.0.1,manager,mysql,prometheus,loki,tempo,grafana,qdrant

Don'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.