Telegram channel plugin for the TAI ecosystem — delivers ask_user questions to a Telegram chat and bridges the typed reply back to the interactions callback door.
Project description
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 — 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:
git clone https://github.com/tai42ai/tai-channel-telegram # next to your app checkout
cd /path/to/your/app
uv add --editable ../tai-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 |
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. (formhas no single-reply mapping and is rejected before delivery.)
- 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
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.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tai42_channel_telegram-0.1.1.tar.gz.
File metadata
- Download URL: tai42_channel_telegram-0.1.1.tar.gz
- Upload date:
- Size: 35.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.18 {"installer":{"name":"uv","version":"0.11.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e81f8871c2ae9dbf139ac92290925be9feb6acdcef906310fe75132b139429ab
|
|
| MD5 |
73d884f85592fe24e890ac772bc0e98b
|
|
| BLAKE2b-256 |
a7d4da5582db5180d2785b24292dd93aba94bbc243cfdf1ed93d4d8e9f237e31
|
File details
Details for the file tai42_channel_telegram-0.1.1-py3-none-any.whl.
File metadata
- Download URL: tai42_channel_telegram-0.1.1-py3-none-any.whl
- Upload date:
- Size: 23.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.18 {"installer":{"name":"uv","version":"0.11.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
80e2b897e6992b77c19649cfbedcb264f49b949e0c36f3cef5f1172b7cb727d7
|
|
| MD5 |
345437bf4b670e05b73b6702801a95da
|
|
| BLAKE2b-256 |
f96648eb01000a11829d2920119d7ac19df16c994969ae677a0fa7372cb70649
|