tai42-channel-telegram
A Telegram Channel plugin for the TAI ecosystem. It delivers an ask
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 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.
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(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). The reply input's placeholder reads the caller'swording.answer.reply_hintwhen set, else Reply to answer (1-64 characters — a longer hint is refused before the send). 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. The keyboard replaces the reply prompt, so atextask carrying options draws no placeholder, and a select ask'swording.choice.label(the list-opening button on channels with a native list) names no control here.confirm/external(tap): the message carries a tappable URL button opening the callback door directly — no correlation state, no inbound involvement. The button's text reads the caller'swording.answer.openwhen set, else Answer. The affirmative control (wording.answer.confirm) is drawn by the callback confirm page, not by this channel.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. The button's text reads the caller'swording.form.openwhen set, else Fill form. 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 form'swording.form.cancel/backlabel the webview's own chrome and its heading (wording.form.title) is the callback page's — this channel draws none of them.- Deadline line (every format): every question's text ends with a
deadline line reading the caller's
wording.lines.deadlinewhen set, else (Answer before<deadline>.) — the{deadline}token is filled by a plain substitution with this channel's own formatted instant.wording.lines.answer_link/reply/optionsname no control here (Telegram draws a url button, a force_reply or an inline keyboard, not those sentences) and are drawn nowhere.
- 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
askcall returns the recorded answer.
The parked correlation record also carries the ask's on_mismatch policy and its
per-send wording, both read by the shared answer ladder when a reply lands
off-format: under the retry policy the human is told what's expected in the
caller's wording.answer.rejected sentence (wording.answer.rejected_final when a
refusal also closes the question), each in the end-user's language with an optional
{reason} token filled by the door's reason; under the bridge policy the reply is
bridged as a fresh turn and no notice is sent.
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 participant 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.
The bot token stays out of logs
The Bot API carries the token in every request path (/bot<id>:<secret>/…), and
httpx logs each request line at INFO. Loading the channel registers a token mask
with the kit's transport-log redaction (tai42_kit.logging.register_transport_redaction),
so every httpx/httpcore record shows /bot<redacted>/… before any handler
formats it.
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 6.0.6
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-6.0.6.tar.gz | 93.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tai42_channel_telegram-6.0.6-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 150.5 kB
Release files / tai42_channel_telegram-6.0.6.tar.gz
| Download URL | tai42_channel_telegram-6.0.6.tar.gz |
|---|---|
| Size | 93.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1ae5f9e5791f47f1e8ee8a8fae2f03abe5d8380da28ce133a2646574a5f4bf69
|
|
BLAKE2b-256 checksum How to use checksums |
38b4be2172822cd811cab87bca0d31123c90f2dd8418777731a1480c8149f815
|
| 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-6.0.6-py3-none-any.whl
| Download URL | tai42_channel_telegram-6.0.6-py3-none-any.whl |
|---|---|
| Size | 57.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
04d9f15b10d194b40ba3a6ca1b99a37ea23a681afeedbd1e72a682e0221e7da9
|
|
BLAKE2b-256 checksum How to use checksums |
7734e37f4714e5cb9b8aa323254a6baec1ee7bf022ef50d7e145fdd7b9779286
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|