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.
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): the message carriesreply_markup: {force_reply: true}, and the sentmessage_id → callback_urlmapping is stored in plugin-owned Redis keys (channel:telegram:corr:{message_id}, TTL = the question's remaining budget). Aselectquestion lists its options as guided text.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 |
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.
Metadata
Release files for tai42-channel-telegram 1.2.3
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-1.2.3.tar.gz | 43.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tai42_channel_telegram-1.2.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 72.6 kB
Release files / tai42_channel_telegram-1.2.3.tar.gz
| Download URL | tai42_channel_telegram-1.2.3.tar.gz |
|---|---|
| Size | 43.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
989f41671431307d8c6e1d63a7a6b9cc87ee443ee9d67843ea6ed87d8df451cc
|
|
BLAKE2b-256 checksum How to use checksums |
7a1f7434b4699bc205bf9db0478cd4ceb045a80c0eb4932632b8f346267b4f2b
|
| 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-1.2.3-py3-none-any.whl
| Download URL | tai42_channel_telegram-1.2.3-py3-none-any.whl |
|---|---|
| Size | 28.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
524803f3346f3c9ab366e09e242606179329f5a827e44de7bbf5f109d61e5d16
|
|
BLAKE2b-256 checksum How to use checksums |
862c3a00e05bf3be09d53219f7e22e3c22fd2c5772cf47f603f5f2bc2562731f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|