tai42-channel-telegram
A Telegram Channel plugin for the TAI ecosystem. It delivers an ask_user
question to a Telegram chat as a sendMessage — the caller's requested
recipient if it is on the operator allowlist, otherwise the operator-configured
default chat — a
ForceReply for typed text/select answers, a tappable URL button opening the
interaction callback door for confirm/external, a web_app webview button
for form — and bridges the human's
typed reply back to the interactions store through its own verified webhook
route. Outbound is plain HTTPS over a pooled httpx client; there is no
Telegram SDK dependency (the Bot API is flat JSON-over-HTTPS).
The TAI ecosystem
TAI is an open-source runtime for MCP tools, agents, and workflows. A Channel
is a registered deliverer that pushes an interaction question to a human on a
specific medium and bridges the reply back into the interactions store — so
ask_user can reach a person out-of-band instead of only showing the question
in the Studio inbox. This package is one such channel (Telegram); siblings back
the same contract with Slack or Twilio SMS/WhatsApp. The ecosystem is
open-ended: any package can back the same contract, so this repo is this
channel's own full doc home, and the documentation site covers the
platform-level story:
- Interactions concept: https://tai42.ai/concepts/interactions
- Build a channel plugin (author guide): https://tai42.ai/guides/authors/channel
- Ecosystem catalog: https://tai42.ai/reference/catalog
Its only tai-* dependencies are tai42-contract (the Channel protocol,
ChannelDelivery, ChannelDeliveryError, and the tai42_app handle) and
tai42-kit[redis] (HttpxClient, RedisClient, TaiBaseSettings, and the
settings cache). Beyond those it depends on httpx, starlette, and
pydantic / pydantic-settings.
The current release line tracks the 7.x contract (tai42-contract>=7,<8).
Install
Requires Python 3.13+. Install from PyPI into the environment that runs the server:
uv add tai42-channel-telegram
Or from source — clone this repo and add it as an editable dependency; the
tai42-* dependencies resolve in-tree from the workspace.
git clone https://github.com/tai42ai/tai42 # next to your app checkout
cd /path/to/your/app
uv add --editable ../tai42/plugins/channel-telegram
Discovery
The skeleton discovers this channel by importing its modules — the
manifest's channel_modules loader imports every module under the package, and
importing tai42_channel_telegram.register fires the registrations as a
side-effect: the "telegram" channel name on tai42_app.channels, the public
inbound route, and the setWebhook startup hook. Name the package in your
manifest:
channel_modules:
- tai42_channel_telegram
A bare import tai42_channel_telegram (library use) does NOT register anything.
Configuration
Settings are read from the CHANNEL_TELEGRAM_ environment group (see
TelegramSettings). Every credential is bound to env by the operator — never a
tool parameter, never visible to the LLM:
| Env var | Default | Purpose |
|---|---|---|
CHANNEL_TELEGRAM_BOT_TOKEN |
— | Bot credential from BotFather (required) |
CHANNEL_TELEGRAM_ALLOWED_RECIPIENTS |
[] |
Whitelist of chats a caller-supplied recipient may name (numeric id — negative for groups — or @username), as a comma-separated string or a JSON list. A group or channel recipient cannot receive form questions — Telegram allows the web_app button in private chats only |
CHANNEL_TELEGRAM_DEFAULT_RECIPIENT |
— | The chat questions go to when the caller names no recipient (trusted; not checked against the allowlist) |
CHANNEL_TELEGRAM_WEBHOOK_SECRET |
— | setWebhook secret_token; verified on every inbound update (required) |
CHANNEL_TELEGRAM_PUBLIC_BASE_URL |
— | This deployment's public base URL (required) |
CHANNEL_TELEGRAM_REDIS_URL |
— | Redis the correlation store lives in (required) |
CHANNEL_TELEGRAM_API_BASE_URL |
https://api.telegram.org |
Bot API origin (stub servers/e2e only) |
CHANNEL_TELEGRAM_HTTP_TIMEOUT_SECONDS |
30 |
Budget per outbound HTTP call |
Optional Redis connection tuning (see TelegramCorrelationSettings):
| Env var | Default | Purpose |
|---|---|---|
CHANNEL_TELEGRAM_REDIS_MAX_CONNECTIONS |
— | Pool size cap |
CHANNEL_TELEGRAM_SOCKET_TIMEOUT |
— | Per-command socket timeout (seconds) |
CHANNEL_TELEGRAM_SOCKET_CONNECT_TIMEOUT |
— | Connect-phase timeout (seconds) |
CHANNEL_TELEGRAM_RETRY_ON_TIMEOUT |
false |
Retry commands on timeout |
CHANNEL_TELEGRAM_RETRY_ATTEMPTS |
0 |
Exponential-backoff retries on connection errors |
How an answer travels
- A tool calls
ask_user(question, channel="telegram", ...). The runtime persists the interaction, mints a public callback ticket, and calls this plugin'sdeliverwith the question and itscallback_url. deliverresolves the recipient chat: a caller-supplied recipient must be onCHANNEL_TELEGRAM_ALLOWED_RECIPIENTSor the delivery is refused (fail closed, nothing sent); no caller recipient meansCHANNEL_TELEGRAM_DEFAULT_RECIPIENT. It then sends ONEsendMessageto that chat — the Bot API has no idempotency key, so a failed send raisesChannelDeliveryErrorinstead of retrying (a blind retry could double-send).text/select(typed reply): atextask with no suggested replies carriesreply_markup: {force_reply: true}; the sentmessage_id → callback_urlmapping is stored in a plugin-owned Redis key (channel:telegram:corr:{chat_id}:{message_id}, TTL = the question's remaining budget — chat-scoped because a Telegrammessage_idis unique only per chat). Aselectask (and atextask carrying suggested replies) renders its options as a native inline keyboard, one callback button per option — a tap resolves through the correlation ladder, and a typed reply to the same message still anchors the same correlation.confirm/external(tap): the message carries a tappable URL button opening the callback door directly — no correlation state, no inbound involvement.form(webview): the message carries aweb_appbutton opening the schema-rendered callback page as an in-chat webview; the page POSTs the answer straight to the callback door, so like the tap forms there is no correlation state and no inbound involvement. Advertised by thesupports_form_deliverycapability flag. Telegram accepts aweb_appbutton in a PRIVATE chat only, so aformquestion to a group or channel recipient fails the send where every other format succeeds.
- The human replies in Telegram. Telegram POSTs the update to this plugin's
own public route
POST /api/channels/telegram/inbound, registered bysetWebhookat startup with a sharedsecret_token. - The inbound door verifies
X-Telegram-Bot-Api-Secret-Tokenagainst the configured secret — constant-time over sha256 digests of both sides, and FAIL CLOSED: an unset or empty secret answers 500, never "skip verification". The body is read through a streaming bounded reader (413 the moment it crosses 1 MiB). The reply is matched onmessage.reply_to_message.message_idand the configured recipient chats (the default recipient plus the allowlist; an entry matches the update's numeric chat id or its@username), the callback_url is looked up in the correlation store, and the answer is forwarded as the JSON object{"answer": "<typed text>"}— the callback door validates it against the question's storedanswer_formatand records it. - The blocked
ask_usercall returns the recorded answer.
Telegram redelivers an update until it gets a 2xx, so the inbound status code is the retry contract:
| Condition | Response |
|---|---|
| Configured secret unset/empty, or no recipient chat configured | 500 (fail closed, logged) |
| Header missing or mismatched | 401 (one constant deny body) |
| Body over the cap / not a JSON object | 413 / 400 |
| Update out of scope (no message / chat not a configured recipient / not a reply / no text) | 200 "ignored" (reason logged) |
| No pending correlation for the replied-to message | 200 "ignored" (logged warning) |
| Callback door 200 | 200 "forwarded", mapping cleared |
| Callback door 404 (ticket terminally gone) | 200 "stale", mapping cleared |
| Callback door 400 (answer rejected) | 200 "rejected", mapping KEPT — the human can reply again |
| Callback door other status / unreachable | exception → 500, Telegram's redelivery is the recovery |
Notification vocabulary → Bot API
notify_user (and the conversation delivery machine) may hand this channel a rich
ChannelNotification. Each shape maps onto a native Bot API affordance; the channel
advertises the matching capability flags (supports_media_notifications,
supports_location_notifications, supports_interactive_notifications,
supports_form_delivery) and honestly declines the rest.
| Shape | Bot API mapping |
|---|---|
message (+ link media) |
sendMessage (each link item appended as a labelled text line) |
media — image / document / video / audio |
one sendPhoto / sendDocument / sendVideo / sendAudio per item, the item's caption carried along. A document's filename is not separately settable on a remote-url send — Telegram derives it from the url |
options — ReplyOption |
one inline-keyboard callback button per option; a tap submits the option's text |
options — LinkOption |
a native inline url button; a tap opens the url, no message is submitted |
sections |
Telegram has no native sectioned list: the rows across every section render as callback buttons grouped in section order, and each section title renders as a text header line above the keyboard (a clean, documented degradation) |
header (media) |
the standard composition — the media is sent with the body as its caption and the keyboard attached (sendPhoto/sendDocument/sendVideo/sendAudio carrying reply_markup) when the body fits Telegram's 1024-UTF-16-unit caption cap (emoji count double); a longer body degrades to a separate media message followed by the text-plus-keyboard message |
footer |
a trailing muted italic line under the body (parse_mode=HTML is set only when a footer is present, so a plain interactive send is byte-for-byte unchanged) |
location |
sendLocation for a bare pin, or sendVenue when the location carries both a name and an address (Telegram's venue send requires a title and an address) |
template |
not supported — Telegram has no vendor-template concept, so supports_template_notifications is absent and notify_user refuses a template send up front |
schema (ask-less form notification) |
not supported — an ask-less form has no callback sink for an in-chat webview to POST to, so supports_form_notifications is absent and notify_user refuses it up front |
Callback-data id strategy
A reply-option button's callback_data is the option's author-set id sent verbatim
when it is set and fits Telegram's 64-byte cap (so the tap echoes it back on the wire),
otherwise a channel-minted token — the option's index, or a short deterministic hash
only when that index would collide with an author-set id on another button in the same
keyboard. Either token is resolved back to the exact option through the per-anchor side
record (channel:telegram:opts:{chat_id}:{message_id}), which also keeps the author-set
id and any row description independently of the wire token — so a bridged tap can surface
them as params (below) even when the id was too long to ride the wire.
Inbound entry parameters
When a tapped option is bridged into the conversation as a fresh turn (a tap that is
not, or is no longer, an answer to a pending ask — a notify option, or an expired ask),
the channel forwards opaque entry parameters alongside the turn text. They ride verbatim
to a tool target's payload under its own params key; the platform attaches no meaning
and no trust. This is the channel's public inbound contract:
params key |
Set when | Value |
|---|---|---|
reply_id |
A reply option / list row carrying an author-set id is tapped and bridged | that author-set id, verbatim |
reply_description |
The tapped sectioned-list row carried a secondary description line | that description |
Params ride only on the bridge path. A tap that answers a pending ask forwards
{"answer": …} to the callback door alongside the same params (the tap's token is already
consumed there to select the option). Values are transport-bounded (per the platform's
entry-param limits); an over-cap value is dropped (never truncated), and if the aggregate
still overflows the whole set is dropped and the turn bridges without it — a guest message
is never lost to a params bound. An option with no author-set id (a select/suggested-reply
ask, a plain notify option) carries no params.
Setup
- Create a bot with @BotFather (
/newbot) and put its token inCHANNEL_TELEGRAM_BOT_TOKEN. - Find each target chat id (message the bot, then read
https://api.telegram.org/bot<token>/getUpdates; group ids are negative). Put the fallback chat inCHANNEL_TELEGRAM_DEFAULT_RECIPIENTand any chats callers may address per ask inCHANNEL_TELEGRAM_ALLOWED_RECIPIENTS(comma-separated or a JSON list). - Generate a webhook secret (1-256 chars of
[A-Za-z0-9_-]) forCHANNEL_TELEGRAM_WEBHOOK_SECRET. - Set
CHANNEL_TELEGRAM_PUBLIC_BASE_URLto the deployment's public origin. Telegram only delivers webhooks over HTTPS (TLS ≥ 1.2) to ports 443, 80, 88, or 8443. The startup hook points the bot's webhook at{public_base_url}/api/channels/telegram/inboundand aborts startup loudly ifsetWebhookfails.
Development
uv venv --python 3.13
uv pip install --no-sources --group dev --editable .
uv run --no-sync pytest --cov --cov-report=term-missing
uv run --no-sync ruff check .
uv run --no-sync ruff format --check .
uv run --no-sync pyright
The offline suite is fully self-contained (fake transport, in-memory Redis).
The live suite (uv run pytest -m integration) sends real messages through the
Bot API when CHANNEL_TELEGRAM_BOT_TOKEN / CHANNEL_TELEGRAM_DEFAULT_RECIPIENT
are set
in the ambient environment, and skips cleanly otherwise; it never calls
setWebhook.
License
Apache-2.0. See LICENSE and NOTICE.
Correlation surface (2.0)
Since 2.0 inbound answers resolve through the platform's shared inbound-answer
ladder: the plugin exposes its correlation store over the contract's
CorrelationStore port (reserve / peek / release) plus a transport ack, and the
skeleton owns the forward / retry-in-place / bridge ladder. The plugin-local
pop/restore correlation helpers from 1.x are gone.
Metadata
Release files for tai42-channel-telegram 3.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| tai42_channel_telegram-3.3.0.tar.gz | 71.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tai42_channel_telegram-3.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 115.6 kB
Release files / tai42_channel_telegram-3.3.0.tar.gz
| Download URL | tai42_channel_telegram-3.3.0.tar.gz |
|---|---|
| Size | 71.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e6aa8a880b32f9d711dd17e1fcc78a46239b0404f2335b85a5fb0ce2e74d1c3b
|
|
BLAKE2b-256 checksum How to use checksums |
4ffd0e30836bf60b5485c67d5d7fae990b295760fc9951bcd9398cbd06ac434c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / tai42_channel_telegram-3.3.0-py3-none-any.whl
| Download URL | tai42_channel_telegram-3.3.0-py3-none-any.whl |
|---|---|
| Size | 43.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
325a574f33bdb3ef925c9deb63b793e4ec7a13448dd3437d1a57ac92d6e5f5c6
|
|
BLAKE2b-256 checksum How to use checksums |
dd6ab1b6729c5d848be210d9a2757c82b96818b0f89400aa16346e6f273250db
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|