Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# agentchat

a minimal sdk to connect your agents to slack, teams, imessage, whatsapp.
a minimal sdk to connect your agents to slack, teams, imessage, whatsapp, telegram.

use any SDK (Claude Agents, OpenAI Agents, Langchain DeepAgents, Pydantic Agents) and instantly connect it to all supported channels.

Expand Down Expand Up @@ -187,3 +187,4 @@ async def respond(context: MessageContext) -> str:
| iMessage | BlueBubbles on a Mac signed into Messages | `imessage` | [iMessage guide](docs/imessage.md) |
| WhatsApp | Business Cloud API with signed webhooks | `whatsapp` | [WhatsApp guide](docs/whatsapp.md) |
| Discord | Bot gateway | `discord` | [Discord guide](docs/discord.md) |
| Telegram | Bot API long polling or HTTPS webhook | `telegram` | [Telegram guide](docs/telegram.md) |
6 changes: 3 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,9 @@ channels/
Every Slack-specific module lives below `slack/`. There are no sibling Slack
compatibility modules or provider aliases in the shared namespace.

Discord, Teams, iMessage, and WhatsApp use the same package boundary with
Discord, Teams, iMessage, WhatsApp, and Telegram use the same package boundary with
`adapter.py`, normalization in `events.py`, and text validation in `rendering.py`.
Discord and BlueBubbles have small REST transport modules too. The provider-neutral
Discord, BlueBubbles, and Telegram have small REST transport modules too. The provider-neutral
private helper `agentchat/_delivery.py` shares bounded background dispatch and
cleanup. Provider authentication, acknowledgement, and overflow policy stay in
each adapter. See the [provider guides](providers.md) for supported operations.
Expand Down Expand Up @@ -149,5 +149,5 @@ not import Slack, aiohttp or OpenAI Agents. Installation defaults are unchanged:
Slack dependencies remain installed with the base package, while OpenAI Agents
still uses the `openai` extra.

Discord, Teams, and iMessage use separate optional extras. WhatsApp uses aiohttp
Discord, Teams, and iMessage use separate optional extras. WhatsApp and Telegram use aiohttp
from the base install. Requesting one provider does not import the others.
10 changes: 9 additions & 1 deletion docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ a runnable echo example; no agent framework or model key is needed for that chec
| Teams | `pip install -e '.[teams]'` | Authenticated HTTPS endpoint | [Teams](teams.md) |
| iMessage | `pip install -e '.[imessage]'` | BlueBubbles Socket.IO + REST | [iMessage](imessage.md) |
| WhatsApp | `pip install -e '.[whatsapp]'` | Signed HTTPS webhook + Cloud API | [WhatsApp](whatsapp.md) |
| Telegram | `pip install -e '.[telegram]'` | Bot API long polling or HTTPS webhook | [Telegram](telegram.md) |

Use each provider's own package for imports, following the current Slack guide.
The `whatsapp` extra needs
The `whatsapp` and `telegram` extras need
no additional dependency because aiohttp is already installed. Provider packages
and agent-framework integrations load only when imported. Python 3.11+ is required.

Expand Down Expand Up @@ -83,6 +84,10 @@ These four new adapters implement the minimal `Channel` contract: receive,
lifecycle, and confirmed text replies. They normalize sender identity and
incoming attachment references. They do not download those attachments.

Telegram also implements `set_status()` (the typing indicator), `reply_rich()`
(native reply markup such as inline keyboards), `upload_files()`, and
`download_attachment()`. It does not implement native history or mirroring.

History uses the application's local `State`; default `MemoryState` is lost on
restart. Native history, working indicators, rich replies, file transfers, and
cross-surface mirroring remain optional capabilities. On the new adapters,
Expand All @@ -104,6 +109,9 @@ conversation using the configured `State`.
- Teams returns 202 after admission; WhatsApp returns 200. A full HTTP receiver
returns 503 before admission so the provider can apply its retry policy.
WhatsApp batches are admitted atomically.
- Telegram webhooks return 503 the same way. Telegram polling stops reading at
the first update it cannot admit and polls again from it, so Telegram redelivers
it instead of the adapter dropping it.
- Discord and BlueBubbles callbacks cannot ask the gateway to replay an event.
At capacity, the adapter logs a warning and drops the excess event. Reconnects
do not provide durable catch-up. Size the limit/host for the expected workload.
Expand Down
142 changes: 142 additions & 0 deletions docs/telegram.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# Telegram

Connect a Telegram bot through the Bot API. Long polling is the default, so the
first check needs no public URL, tunnel, or model API key. Webhook mode is
available for deployments that already serve HTTPS.

## Get the first reply

1. In Telegram, open [@BotFather](https://t.me/BotFather), send `/newbot`, and
choose a display name and a username ending in `bot`. BotFather replies with a
token of the form `123456789:AA...`. Store it as `TELEGRAM_BOT_TOKEN`; the part
before the colon is the bot's numeric ID.
2. From this repository, install and run:

```bash
python -m pip install -e '.[telegram]'
export TELEGRAM_BOT_TOKEN='123456789:your-bot-token'
python examples/telegram_echo.py
```

On start the adapter checks the token with `getMe`, removes any webhook
(`deleteWebhook` without dropping pending updates), and long-polls `getUpdates`.
3. Open a private chat with your bot, press **Start**, and send `hello`. Expect
`hello` back as a reply to your message. Stop with Ctrl+C.

The example echoes each accepted input. Replace its handler with your runner
after authorizing the sender. See [shared usage](providers.md) to run several
adapters or bind an existing runner directly.

### Groups and privacy mode

Add the bot to a group, then address it with `@yourbot question`, a command such
as `/status@yourbot`, or a reply to one of its messages. Bots have privacy mode
enabled by default, so Telegram only delivers those addressed messages to them
anyway. The adapter applies the same rule itself: unaddressed group messages never
reach the agent, even if you disable privacy mode with BotFather's `/setprivacy`
or make the bot a group admin. Privacy mode changes apply to groups the bot joins
afterwards; remove and re-add the bot to an existing group.

## Webhook mode

Set both variables to switch from polling to a webhook:

```bash
export TELEGRAM_WEBHOOK_URL='https://YOUR_HOST/webhooks/telegram'
export TELEGRAM_WEBHOOK_SECRET="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
python examples/telegram_echo.py
```

The URL must use HTTPS and end in `/webhooks/telegram`. The secret may use
`A-Z`, `a-z`, `0-9`, `_` and `-`, up to 256 characters. The adapter listens on
`127.0.0.1:3980`, then calls `setWebhook` with the URL and secret. Forward a
public HTTPS tunnel/proxy to this port. Containers can set `TELEGRAM_HOST=0.0.0.0`;
`TELEGRAM_PORT` changes the port. Telegram sends each update with an
`X-Telegram-Bot-Api-Secret-Token` header; requests without the matching secret
get 401.

`close()` leaves the webhook set, so Telegram keeps queueing updates while the bot
is down and redelivers them after a restart. Telegram allows one consumer per
token: starting a poller with the same token removes the webhook.

For an existing aiohttp server, construct `Telegram(bot_token=..., webhook_url=...,
webhook_secret=...)`, host `telegram.create_app()` yourself, and bind the receiver
before serving requests. Register the webhook yourself, for example:

```bash
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
-d "url=$TELEGRAM_WEBHOOK_URL" -d "secret_token=$TELEGRAM_WEBHOOK_SECRET" \
-d 'allowed_updates=["message","callback_query"]'
```

The handler looks up the bot with `getMe` on the first update and returns 503
until that succeeds. Call `await telegram.close()` at shutdown; do not also call
`telegram.run()`.

## Routing and limits

- Each private chat and group is a conversation. In a forum supergroup, each topic
is its own conversation and sets `message.thread_id` to the topic ID. Message
IDs have the form `telegram:<bot_id>:<chat_id>:<message_id>`; button taps use
`telegram:<bot_id>:callback:<callback_query_id>`.
- Messages from bots, channel posts, edits, service messages, and messages without
text, a caption, or a file are ignored. The bot's `@username` is removed from the
text, and from a command suffix (`/status@yourbot` becomes `/status`).
- Photos (largest size), documents, audio, video, voice notes, video notes,
animations, and stickers become attachment references; captions become the
message text. `download_attachment()` fetches the bytes on demand. The Bot API
serves files up to 20 MB.
- Replies quote the original message, post in the same topic, and disable link
previews. If the original was deleted, the reply still posts.
- Agent Markdown is converted to Telegram message entities, never `parse_mode`:
bold (`**`), italic (`*` or `_`), strikethrough (`~~`), spoiler (`||`), inline
code, fenced code blocks with a language, `http(s)` and `tg` links, headings
(bold), bullet and numbered lists, and blockquotes. `__` and intra-word `_`/`*`
stay literal, so `snake_case` and `__init__.py` are safe.
- A reply must fit 4,096 UTF-16 code units after conversion. Oversized replies
raise `ValueError` before sending. Split longer output into separate
`context.reply()` calls.

Replies, uploads, and status updates make one attempt; errors propagate. A Bot API
error raises `TelegramError` (a `RuntimeError`) with `error_code`, `description`,
and, on HTTP 429, `retry_after` seconds for your host's rate-limit scheduling.
Polling itself retries network errors, HTTP 5xx, and 429 with backoff; other
errors, such as 401 or 409, stop `run()`.

Telegram implements the optional capabilities:

- `set_status()` shows the typing indicator. Telegram clears it after about five
seconds and has no custom text, so the adapter re-sends it every four seconds
until you clear it with `""`, two minutes pass without another call, or the
adapter closes. `context.working()` refreshes it for the duration of a handler.
- `reply_rich()` sends `RichReply.text` with one native Telegram reply markup in
`blocks`, such as an inline keyboard:
`RichReply(text="Continue?", blocks=({"inline_keyboard": [[{"text": "Yes", "callback_data": "yes"}]]},))`.
Attachments are not supported.
- Inline keyboard taps arrive as ordinary messages whose text is the tapped
button's label; `metadata["callback_data"]` holds its data. The adapter answers
the tap so the button's spinner stops.
- `upload_files()` sends each file as a document to the source chat or topic, with
`title` as the caption. The Bot API accepts uploads up to 50 MB. If a later file
fails, earlier files were already delivered.

## Troubleshooting

Store the token as a secret. Revoke and regenerate it with BotFather's `/revoke`
if it leaks, then restart.

| Symptom | Check |
| --- | --- |
| `run()` fails with authentication failed | Copy the full token from BotFather, including the numeric prefix. |
| `run()` fails with HTTP 409 Conflict | Another process polls with the same token, or something set a webhook for it. Stop the other consumer. |
| The bot ignores group messages | Mention it, use `/command@yourbot`, or reply to its message. |
| A webhook never receives updates | Check the HTTPS URL and tunnel, and run `getWebhookInfo` for Telegram's last delivery error. |
| Webhook POST returns 401 | `TELEGRAM_WEBHOOK_SECRET` must match the `secret_token` registered with `setWebhook`. |
| HTTP 503 from this receiver | The bot identity is not yet known or the pending-work limit is full; Telegram retries. |
| Replies fail with HTTP 403 | The user blocked the bot, or the bot was removed from the group. |
| HTTP 429 | Pause sends for `TelegramError.retry_after` seconds in your host. |

Local tests exercise polling, webhooks, and sends against a simulated Bot API.
A live echo with your own bot remains the final deployment check.

Reference: [Telegram Bot API](https://core.telegram.org/bots/api).
24 changes: 24 additions & 0 deletions examples/telegram_echo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import asyncio
import logging

from agentchat import AgentChat, MessageContext
from agentchat.channels.telegram import Telegram


async def main() -> None:
app = AgentChat(channels=[Telegram.from_env()])

@app.on_message
async def respond(context: MessageContext) -> str:
# Authorize the sender here before replacing this echo with an agent.
return context.message.text or "Received your attachment."

try:
await app.run()
finally:
await app.close()


if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
asyncio.run(main())
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ teams = [
]
imessage = ["python-socketio[asyncio-client]>=5.13,<6"]
whatsapp = [] # Uses the existing aiohttp dependency.
telegram = []
dev = [
"openai-agents>=0.4,<1",
"discord.py>=2.6,<3",
Expand Down
25 changes: 25 additions & 0 deletions src/agentchat/channels/telegram/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from agentchat.channels.telegram.adapter import Telegram

__all__ = ["Telegram"]


def __getattr__(name: str) -> object:
if name != "Telegram":
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
try:
from agentchat.channels.telegram.adapter import Telegram
except ModuleNotFoundError as exc:
if exc.name and exc.name.split(".")[0] == "aiohttp":
raise ModuleNotFoundError(
"Install Telegram support with: pip install 'agentchat[telegram]'",
name=exc.name,
) from exc
raise
return Telegram


def __dir__() -> list[str]:
return sorted(set(globals()) | set(__all__))
Loading