Python: Add Telegram hosting helpers and samples (#7047)

* Python: Add Telegram hosting helpers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 5d6987cd-1b67-4ba1-8b54-3c50da6e7607

* Python: Exclude Telegram samples from aggregate typing

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 5d6987cd-1b67-4ba1-8b54-3c50da6e7607

* Python: Address Telegram helper review feedback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 5d6987cd-1b67-4ba1-8b54-3c50da6e7607

* Python: Serialize Telegram webhook sessions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: d76a9c32-d170-426d-a64f-b70958b08b12
This commit is contained in:
Eduard van Valkenburg
2026-07-14 09:40:12 +02:00
committed by GitHub
parent d93fc2dd74
commit 7ca8bb55b6
22 changed files with 2075 additions and 7 deletions
+2 -2
View File
@@ -181,8 +181,8 @@ The app chooses which helper to call for that route and deployment. For example:
- `responses_session_id(body)` from `agent-framework-hosting-responses`, which can return either a `resp_*` previous
response id or a `conv_*` conversation id when present;
- `telegram_session_id(update)` from `agent-framework-hosting-telegram`, which can choose the chat, user, thread, or
other Telegram-native partitioning logic for that helper;
- `telegram_session_id(update, bot_id=...)` from `agent-framework-hosting-telegram`, which uses the bot and sender for
private chats and the bot and chat for shared group sessions;
- `activity_session_id(activity)`, `discord_session_id(interaction_or_message)`, or
`a2a_session_id(request_context)` from their respective protocol packages;
- `foundry_user_isolation_key()` or `foundry_chat_isolation_key()` from `agent-framework-foundry-hosting`.
+51 -1
View File
@@ -67,7 +67,8 @@ must be aligned with the helper-first model before implementation. Old vocabular
|---|---|---|
| `agent-framework-hosting` | `agent_framework_hosting` | `AgentState`, `WorkflowState`, `SessionStore`, and run-argument `TypedDict`s. |
| `agent-framework-hosting-responses` | `agent_framework_hosting_responses` | Responses helpers: request parsing, session id extraction, response id creation, response rendering, streaming rendering. |
| Future protocol packages | e.g. `agent_framework_hosting_telegram` | Protocol-specific helpers such as `telegram_to_run(...)`, `telegram_from_run(...)`, `telegram_session_id(...)`, and command/media helpers when useful. |
| `agent-framework-hosting-telegram` | `agent_framework_hosting_telegram` | Telegram Bot API helpers: update parsing, chat/session/command/media extraction, final rendering, and streaming edit rendering. |
| Future protocol packages | e.g. `agent_framework_hosting_activity_protocol` | Protocol-specific helpers such as `activity_to_run(...)`, `activity_from_run(...)`, `activity_session_id(...)`, and command/media helpers when useful. |
The core hosting package must not depend on protocol SDKs. Protocol packages may depend on their native protocol SDKs if
needed, but helper functions should stay usable from plain app code and tests.
@@ -244,6 +245,52 @@ text deltas, and a completed event. The final completed payload is produced thro
also preserves the model id observed on streaming updates when the finalized `AgentResponse` no longer carries raw model
metadata.
## `agent-framework-hosting-telegram`
The Telegram package provides side-effect-free helpers around Telegram Bot API
update and method payloads. It does not provide a Bot API client, polling loop,
webhook route, command registry, retry policy, or rate limiter.
### Update helpers
- `telegram_to_run(update, *, resolve_file_url=None, stream=False) -> AgentRunArgs`
- `telegram_chat_id(update) -> int | None`
- `telegram_session_id(update, *, bot_id) -> str | None`
- `telegram_command(update) -> str | None`
- `telegram_callback_query_id(update) -> str | None`
- `telegram_media_file_id(update_or_message) -> tuple[str, str] | None`
`telegram_to_run(...)` handles `message`, `edited_message`, and
`callback_query` updates. Text and captions become AF text content. When the
app supplies an async `resolve_file_url` callback, supported Telegram media
file ids can become AF URI content. The package does not call Telegram's
`getFile` method itself.
`telegram_session_id(..., bot_id=...)` includes the bot identity in every key.
Private chats return `telegram:<bot_id>:<user_id>`; other chats return
`telegram:<bot_id>:<chat_id>`, giving groups a shared session by default. This
matches Telegram's native isolation boundaries while preventing two bots from
sharing state accidentally. Apps that want per-user sessions inside a group
can construct a key that includes both chat and sender ids. The app must
authorize those Telegram identities before loading session state.
`telegram_command(...)` parses Telegram's `/name` and `/name@bot` syntax. It
does not register commands or invoke handlers.
### Response helpers
- `telegram_from_run(result, *, chat_id, parse_mode=None)`
- `telegram_from_streaming_run(stream, *, chat_id, message_id, initial_text=None, parse_mode=None)`
The helpers produce Telegram method/payload values for app-owned Bot API
calls. Final rendering supports text and image URI output and applies
Telegram's text-length boundary. Streaming rendering produces cumulative
`editMessageText` payloads for a placeholder message id supplied by the app,
omitting edits that match an optional `initial_text`, then renders the final
rich output. Image-only responses remove the placeholder with `deleteMessage`
before sending the image. The app owns the initial placeholder send, Bot API
calls, edit throttling, retries, and failure policy.
## Security responsibilities
Protocol helper packages parse and render. They do not authenticate callers, authorize access to state, or decide which
@@ -346,3 +393,6 @@ Implementation validation must cover:
- Responses streaming SSE rendering;
- HTTP round-trip tests showing a native FastAPI route using `AgentState` and Responses helpers;
- sample type checking for the local Responses sample.
- Telegram update parsing, chat/session/command/media extraction, final
rendering, and streaming edit rendering;
- sample type checking for the local Telegram polling and webhook entry points.
+1
View File
@@ -36,6 +36,7 @@ Status is grouped into these buckets:
| `agent-framework-github-copilot` | `python/packages/github_copilot` | `rc` |
| `agent-framework-hosting` | `python/packages/hosting` | `alpha` |
| `agent-framework-hosting-responses` | `python/packages/hosting-responses` | `alpha` |
| `agent-framework-hosting-telegram` | `python/packages/hosting-telegram` | `alpha` |
| `agent-framework-hyperlight` | `python/packages/hyperlight` | `beta` |
| `agent-framework-lab` | `python/packages/lab` | `beta` |
| `agent-framework-mem0` | `python/packages/mem0` | `beta` |
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
@@ -0,0 +1,83 @@
# agent-framework-hosting-telegram
Telegram Bot API-shaped helpers for app-owned Agent Framework hosting.
This is an alpha, helper-only package: it converts between Telegram's
`Update` JSON shape and Agent Framework run values in both directions. It
does **not** provide a bot client, a hosting/channel registry, or a
long-running service. Your app remains fully responsible for:
- **Fetching updates** -- long polling (`getUpdates`) or registering a
webhook -- and for verifying webhook authenticity (e.g. Telegram's secret
token header, or an IP allowlist).
- **The Bot API client** -- issuing the actual HTTP calls (`sendMessage`,
`sendPhoto`, `editMessageText`, `answerCallbackQuery`, `getFile`, ...) with
whatever HTTP library you prefer.
- **Rate limits and retries** -- Telegram enforces per-chat and global rate
limits; back off and retry on `429`/`5xx` yourself.
- **Command dispatch** -- `telegram_command(...)` only parses and normalizes
a leading `/command`; your app decides what each command does.
- **Sessions/storage** -- pair these helpers with
[`agent-framework-hosting`](https://pypi.org/project/agent-framework-hosting/)'s
`AgentState` / `SessionStore` (or your own) to persist `AgentSession`s across turns.
## Helpers
- `telegram_chat_id(update)` -- the chat id an update belongs to.
- `telegram_session_id(update, bot_id=...)` -- a bot-scoped `AgentState`
session id. Private chats use `telegram:<bot_id>:<user_id>`; other chats use
`telegram:<bot_id>:<chat_id>`.
- `telegram_command(update)` -- a leading slash command, with `/name@bot args`
normalized to `/name args`. Returns `None` if there is none.
- `telegram_callback_query_id(update)` -- a callback query's id, so you can
call `answerCallbackQuery` yourself.
- `telegram_media_file_id(update_or_message)` -- the `(file_id, mime_type)`
of inbound media (largest photo size, document, voice, audio, or video).
- `telegram_to_run(update, *, resolve_file_url=None, stream=False)` -- convert
a `message`, `edited_message`, or `callback_query` update into
`Agent.run` arguments. Provide `resolve_file_url` (typically backed by
`getFile`) to turn inbound media into content; without it (or when it
returns `None`), text/caption is preserved and media is otherwise dropped.
Media-only input with no resolvable URL raises `ValueError`.
- `telegram_from_run(result, *, chat_id, parse_mode=None)` -- render a
finished run as one `TelegramOperation` (`sendPhoto` when the response has
an image, otherwise `sendMessage`, falling back to `"(no response)"`).
- `telegram_from_streaming_run(stream, *, chat_id, message_id,
initial_text=None, parse_mode=None)` -- render a streaming run as
`editMessageText` operations with the cumulative text so far, followed by any
images in the final response as `sendPhoto` operations. Pass the
app-created placeholder text as `initial_text` so an identical first edit is
omitted. Image-only responses first emit `deleteMessage` for the placeholder.
`TelegramOperation` is a minimal `TypedDict` of `{"method": str, "payload": dict}`
-- your app is responsible for actually calling the Bot API with it.
```python
from agent_framework_hosting import AgentState
from agent_framework_hosting_telegram import (
telegram_chat_id,
telegram_from_run,
telegram_session_id,
telegram_to_run,
)
state = AgentState(agent)
async def handle_update(update: dict) -> None:
chat_id = telegram_chat_id(update)
if chat_id is None:
return # Not a chat update this bot handles.
session_id = telegram_session_id(update, bot_id=bot.id)
session = await state.get_or_create_session(session_id)
run = await telegram_to_run(update, resolve_file_url=resolve_telegram_file_url)
result = await (await state.get_target()).run(run["messages"], session=session, options=run["options"])
await state.set_session(session_id, session) # type: ignore[arg-type]
operation = telegram_from_run(result, chat_id=chat_id)
await call_bot_api(operation["method"], operation["payload"]) # Your HTTP client.
```
The base execution-state helpers live in
[`agent-framework-hosting`](https://pypi.org/project/agent-framework-hosting/).
@@ -0,0 +1,43 @@
# Copyright (c) Microsoft. All rights reserved.
"""Telegram Bot API-shaped helpers for app-owned Agent Framework hosting."""
import importlib.metadata
from ._parsing import (
ResolveFileUrl,
telegram_callback_query_id,
telegram_chat_id,
telegram_command,
telegram_media_file_id,
telegram_session_id,
telegram_to_run,
)
from ._rendering import (
TELEGRAM_MAX_CAPTION_LENGTH,
TELEGRAM_MAX_TEXT_LENGTH,
TelegramOperation,
telegram_from_run,
telegram_from_streaming_run,
)
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = [
"TELEGRAM_MAX_CAPTION_LENGTH",
"TELEGRAM_MAX_TEXT_LENGTH",
"ResolveFileUrl",
"TelegramOperation",
"__version__",
"telegram_callback_query_id",
"telegram_chat_id",
"telegram_command",
"telegram_from_run",
"telegram_from_streaming_run",
"telegram_media_file_id",
"telegram_session_id",
"telegram_to_run",
]
@@ -0,0 +1,338 @@
# Copyright (c) Microsoft. All rights reserved.
"""Parsing helpers for the Telegram Bot API ``Update`` object.
Telegram delivers updates as JSON objects shaped like ``{"update_id": ...,
"message": {...}}`` (or ``edited_message`` / ``callback_query`` instead of
``message``). These helpers pull out the handful of fields an app-owned route
needs -- chat id, a namespaced session id, a leading slash command, a
callback query id, and inbound media -- and translate an update into Agent
Framework ``Agent.run`` arguments. They do not poll for updates, register
webhooks, call the Telegram HTTP API, or dispatch commands; that is app-owned
route/bot-client code.
"""
from __future__ import annotations
import re
from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import Any, cast
from agent_framework import ChatOptions, Content, Message
from agent_framework_hosting import AgentRunArgs
# Telegram media fields whose objects carry a `file_id` (and, except photos,
# a `mime_type`) directly, mapped to the MIME type Telegram uses when the
# object omits `mime_type` (voice notes are always OGG/Opus, for example).
_MEDIA_DEFAULT_MIME_TYPES: dict[str, str] = {
"document": "application/octet-stream",
"voice": "audio/ogg",
"audio": "audio/mpeg",
"video": "video/mp4",
}
# Matches a leading Telegram bot command: `/name`, optionally `@botname`,
# optionally followed by arguments. Command names are `[A-Za-z0-9_]` per the
# Bot API's `bot_command` entity rules.
_COMMAND_PATTERN = re.compile(r"^/(?P<name>[A-Za-z0-9_]+)(?:@(?P<bot>[A-Za-z0-9_]+))?(?P<rest>.*)$", re.DOTALL)
ResolveFileUrl = Callable[[str], Awaitable[str | None]]
"""Async callable resolving a Telegram ``file_id`` to a fetchable URL, or ``None``."""
def _inner_message(update: Mapping[str, Any]) -> Mapping[str, Any] | None:
"""Return the ``message`` or ``edited_message`` object from ``update``, if present."""
for key in ("message", "edited_message"):
candidate = update.get(key)
if isinstance(candidate, Mapping):
return cast("Mapping[str, Any]", candidate)
return None
def _callback_query(update: Mapping[str, Any]) -> Mapping[str, Any] | None:
"""Return the ``callback_query`` object from ``update``, if present."""
candidate = update.get("callback_query")
return cast("Mapping[str, Any]", candidate) if isinstance(candidate, Mapping) else None
def telegram_chat_id(update: Mapping[str, Any]) -> int | None:
"""Return the chat id an update belongs to.
Reads ``message.chat.id`` / ``edited_message.chat.id``, falling back to
``callback_query.message.chat.id`` for callback-only updates.
Args:
update: A Telegram Bot API ``Update`` object.
Returns:
The chat id, or ``None`` if the update carries none of the supported shapes.
"""
message = _inner_message(update)
if message is None:
callback_query = _callback_query(update)
candidate = callback_query.get("message") if callback_query is not None else None
if not isinstance(candidate, Mapping):
return None
message = cast("Mapping[str, Any]", candidate)
chat_candidate = message.get("chat")
if not isinstance(chat_candidate, Mapping):
return None
chat = cast("Mapping[str, Any]", chat_candidate)
chat_id = chat.get("id")
return chat_id if isinstance(chat_id, int) else None
def telegram_session_id(update: Mapping[str, Any], *, bot_id: int) -> str | None:
"""Return a session id using Telegram's native bot, user, and chat boundaries.
Args:
update: A Telegram Bot API ``Update`` object.
Keyword Args:
bot_id: The Telegram bot's numeric user id.
Returns:
``telegram:<bot_id>:<user_id>`` for a private chat,
``telegram:<bot_id>:<chat_id>`` for other chats, or ``None`` when the
required Telegram identity is absent.
"""
chat_id = telegram_chat_id(update)
if chat_id is None:
return None
message = _inner_message(update)
callback_query = _callback_query(update)
if message is None and callback_query is not None:
callback_message = callback_query.get("message")
if isinstance(callback_message, Mapping):
message = cast("Mapping[str, Any]", callback_message)
chat_candidate = message.get("chat") if message is not None else None
chat = cast("Mapping[str, Any]", chat_candidate) if isinstance(chat_candidate, Mapping) else None
chat_type = chat.get("type") if chat is not None else None
if chat_type != "private":
return f"telegram:{bot_id}:{chat_id}"
if callback_query is not None:
sender_candidate = callback_query.get("from")
elif message is not None:
sender_candidate = message.get("from")
else:
return None
if not isinstance(sender_candidate, Mapping):
return None
sender = cast("Mapping[str, Any]", sender_candidate)
sender_id = sender.get("id")
return f"telegram:{bot_id}:{sender_id}" if isinstance(sender_id, int) else None
def telegram_callback_query_id(update: Mapping[str, Any]) -> str | None:
"""Return the callback query id from an update, if present.
Apps need this to answer the callback query (``answerCallbackQuery``) so
Telegram stops showing the client-side loading spinner; this helper only
extracts the id, it does not call the Bot API.
Args:
update: A Telegram Bot API ``Update`` object.
Returns:
The callback query id, or ``None`` if the update has no ``callback_query``.
"""
callback_query = _callback_query(update)
if callback_query is None:
return None
query_id = callback_query.get("id")
return query_id if isinstance(query_id, str) else None
def _command_source_text(update: Mapping[str, Any]) -> str | None:
"""Return the text a leading command should be parsed from."""
message = _inner_message(update)
if message is not None:
text = message.get("text")
if isinstance(text, str):
return text
callback_query = _callback_query(update)
if callback_query is not None:
data = callback_query.get("data")
if isinstance(data, str):
return data
return None
def telegram_command(update: Mapping[str, Any]) -> str | None:
"""Parse a leading slash command out of an update, without dispatching it.
Looks at ``message.text`` / ``edited_message.text`` first, then
``callback_query.data``. A bot-suffixed command (``/name@bot args``) is
normalized to ``/name args`` since a single Bot API integration only ever
serves one bot username. Callers are responsible for matching the
returned command name and acting on it.
Args:
update: A Telegram Bot API ``Update`` object.
Returns:
The normalized command (e.g. ``"/start"`` or ``"/start hello"``), or
``None`` if the source text does not start with a command.
"""
text = _command_source_text(update)
if text is None:
return None
match = _COMMAND_PATTERN.match(text)
if not match:
return None
return f"/{match.group('name')}{match.group('rest')}".rstrip()
def _resolve_media_message(update_or_message: Mapping[str, Any]) -> Mapping[str, Any] | None:
"""Return the message object to inspect for media, from either an update or a bare message."""
if "message" in update_or_message or "edited_message" in update_or_message or "callback_query" in update_or_message:
message = _inner_message(update_or_message)
if message is not None:
return message
callback_query = _callback_query(update_or_message)
if callback_query is not None:
candidate = callback_query.get("message")
if isinstance(candidate, Mapping):
return cast("Mapping[str, Any]", candidate)
return None
return update_or_message
def _largest_photo_file_id(photo_sizes: Sequence[Any]) -> str | None:
"""Return the ``file_id`` of the highest-resolution entry in a Telegram ``photo`` array."""
candidates = [cast("Mapping[str, Any]", size) for size in photo_sizes if isinstance(size, Mapping)]
if not candidates:
return None
def _area(size: Mapping[str, Any]) -> int:
file_size = size.get("file_size")
if isinstance(file_size, int):
return file_size
width, height = size.get("width"), size.get("height")
return width * height if isinstance(width, int) and isinstance(height, int) else 0
largest = max(candidates, key=_area)
file_id = largest.get("file_id")
return file_id if isinstance(file_id, str) else None
def telegram_media_file_id(update_or_message: Mapping[str, Any]) -> tuple[str, str] | None:
"""Return the ``(file_id, mime_type)`` of the inbound media attached to an update or message.
Accepts either a full ``Update`` object (media is read from ``message`` /
``edited_message`` / ``callback_query.message``) or a bare Telegram
message object. Photos pick the largest size Telegram sent (by
``file_size``, falling back to pixel area); documents, voice notes,
audio, and video use their own ``file_id`` and ``mime_type`` (falling
back to Telegram's known default MIME type when the field is absent).
Args:
update_or_message: A Telegram ``Update`` or message object.
Returns:
A ``(file_id, mime_type)`` tuple, or ``None`` if there is no supported media.
"""
message = _resolve_media_message(update_or_message)
if message is None:
return None
photo = message.get("photo")
if isinstance(photo, Sequence) and not isinstance(photo, (str, bytes, bytearray)) and photo:
file_id = _largest_photo_file_id(cast("Sequence[Any]", photo))
if file_id is not None:
return file_id, "image/jpeg"
for key, default_mime_type in _MEDIA_DEFAULT_MIME_TYPES.items():
item_candidate = message.get(key)
if isinstance(item_candidate, Mapping):
item = cast("Mapping[str, Any]", item_candidate)
file_id = item.get("file_id")
if isinstance(file_id, str):
mime_type = item.get("mime_type")
return file_id, mime_type if isinstance(mime_type, str) and mime_type else default_mime_type
return None
async def _contents_from_message(
message: Mapping[str, Any],
resolve_file_url: ResolveFileUrl | None,
) -> list[Content]:
"""Translate one Telegram message object into Agent Framework content parts.
Raises:
ValueError: If the message has no text, caption, or resolvable media.
"""
text = message.get("text")
caption = message.get("caption")
text_value = text if isinstance(text, str) and text else (caption if isinstance(caption, str) and caption else None)
contents: list[Content] = []
media = telegram_media_file_id(message)
if media is not None:
file_id, mime_type = media
resolved_url = await resolve_file_url(file_id) if resolve_file_url is not None else None
if resolved_url:
contents.append(Content.from_uri(uri=resolved_url, media_type=mime_type))
elif text_value is None:
raise ValueError(
f"Cannot resolve Telegram media file_id={file_id!r}: no `resolve_file_url` was given (or it "
"returned None), and the message has no text/caption to fall back to."
)
if text_value is not None:
contents.append(Content.from_text(text=text_value))
if not contents:
raise ValueError("Telegram message has no text, caption, or resolvable media to convert to a run.")
return contents
async def telegram_to_run(
update: Mapping[str, Any],
*,
resolve_file_url: ResolveFileUrl | None = None,
stream: bool = False,
) -> AgentRunArgs:
"""Convert a Telegram update into Agent Framework run values.
Supports ``message``, ``edited_message``, and ``callback_query`` updates.
Message/edited-message text or caption becomes text content; inbound
media becomes uri content when ``resolve_file_url`` resolves it (media
alone with no resolvable URL and no text/caption raises rather than
producing an empty run). A callback query's ``data`` becomes the user's
text.
Args:
update: A Telegram Bot API ``Update`` object.
Keyword Args:
resolve_file_url: Optional async callable that resolves a Telegram
``file_id`` (typically via the Bot API's ``getFile``) to a
fetchable URL, or ``None`` if it cannot. When omitted, media is
ignored and only text/caption is used.
stream: Whether the caller intends to run the agent in streaming mode.
Returns:
Arguments corresponding to ``Agent.run``.
Raises:
ValueError: If the update has no actionable message/callback data, or
a message has no text, caption, or resolvable media.
"""
message = _inner_message(update)
if message is not None:
contents = await _contents_from_message(message, resolve_file_url)
else:
callback_query = _callback_query(update)
data = callback_query.get("data") if callback_query is not None else None
if not isinstance(data, str) or not data:
raise ValueError("Telegram update has no actionable `message`, `edited_message`, or `callback_query.data`.")
contents = [Content.from_text(text=data)]
return AgentRunArgs(
messages=[Message("user", contents)],
options=cast("ChatOptions[Any]", {}),
stream=stream,
)
@@ -0,0 +1,171 @@
# Copyright (c) Microsoft. All rights reserved.
"""Rendering helpers that translate Agent Framework results into native Telegram Bot API calls.
Each helper returns (or yields) a small ``TelegramOperation`` -- a Telegram
Bot API method name plus its JSON payload. App-owned code decides how to
actually invoke the Bot API (``sendMessage``, ``sendPhoto``,
``editMessageText``, ...), including authentication, retries, and rate
limiting; these helpers make no network calls.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any, TypedDict
from agent_framework import AgentResponse, AgentResponseUpdate, ResponseStream
# Telegram's documented maximum length, in UTF-16 code units, for message
# text (`sendMessage` / `editMessageText`) and photo captions (`sendPhoto`).
TELEGRAM_MAX_TEXT_LENGTH = 4096
TELEGRAM_MAX_CAPTION_LENGTH = 1024
_NO_RESPONSE_TEXT = "(no response)"
class TelegramOperation(TypedDict):
"""A single native Telegram Bot API call produced by a rendering helper.
Attributes:
method: The Telegram Bot API method name (e.g. ``"sendMessage"``).
payload: The JSON-serializable request body for that method.
"""
method: str
payload: dict[str, Any]
def _truncate(text: str, max_length: int) -> str:
"""Deterministically cap ``text`` at ``max_length`` UTF-16 code units."""
units = 0
for index, char in enumerate(text):
units += 2 if ord(char) > 0xFFFF else 1
if units > max_length:
return text[:index]
return text
def _text_and_image_uris(result: AgentResponse[Any]) -> tuple[str, list[str]]:
"""Return the response's concatenated text and any image uris it carries."""
image_uris = [
content.uri
for message in result.messages
for content in message.contents
if content.type == "uri" and content.uri and (content.media_type or "").startswith("image/")
]
return result.text, image_uris
def telegram_from_run(
result: AgentResponse[Any],
*,
chat_id: int,
parse_mode: str | None = None,
) -> TelegramOperation:
"""Render a finished agent run as one native Telegram Bot API call.
An image in the response renders as ``sendPhoto`` (using the first image
found; any accompanying text becomes its caption). Otherwise, response
text renders as ``sendMessage``, falling back to ``"(no response)"`` when
the response has neither text nor an image.
Args:
result: The finished agent response to render.
Keyword Args:
chat_id: The Telegram chat id to address the message to.
parse_mode: Optional Telegram ``parse_mode`` (e.g. ``"MarkdownV2"``,
``"HTML"``) to attach to the rendered payload.
Returns:
A ``TelegramOperation`` describing the Bot API call to make.
"""
text, image_uris = _text_and_image_uris(result)
if image_uris:
payload: dict[str, Any] = {"chat_id": chat_id, "photo": image_uris[0]}
if text:
payload["caption"] = _truncate(text, TELEGRAM_MAX_CAPTION_LENGTH)
if parse_mode:
payload["parse_mode"] = parse_mode
return TelegramOperation(method="sendPhoto", payload=payload)
payload = {"chat_id": chat_id, "text": _truncate(text or _NO_RESPONSE_TEXT, TELEGRAM_MAX_TEXT_LENGTH)}
if parse_mode:
payload["parse_mode"] = parse_mode
return TelegramOperation(method="sendMessage", payload=payload)
async def telegram_from_streaming_run(
stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]],
*,
chat_id: int,
message_id: int,
initial_text: str | None = None,
parse_mode: str | None = None,
) -> AsyncIterator[TelegramOperation]:
"""Render a streaming agent run as a sequence of native Telegram Bot API calls.
Yields one ``editMessageText`` operation per update carrying new text, each
with the cumulative text so far (Telegram has no incremental-append edit
call). Interim edits omit ``parse_mode`` since intermediate text is not
guaranteed to be valid in the target markup. After the stream ends, yields
a final ``editMessageText`` reflecting the finalized response text (this
one includes ``parse_mode`` when given) and then one ``sendPhoto``
operation per image the finalized response carries. Errors raised while
iterating the stream or while finalizing it (``stream.get_final_response()``)
propagate to the caller rather than being swallowed.
Args:
stream: The agent response stream returned by ``agent.run(..., stream=True)``.
Keyword Args:
chat_id: The Telegram chat id the streamed message lives in.
message_id: The id of the Telegram message to edit as new content arrives.
initial_text: The current text of the app-created placeholder message.
Matching edits are omitted because Telegram rejects no-op edits.
parse_mode: Optional Telegram ``parse_mode`` to attach to the final text edit.
Yields:
``TelegramOperation`` values describing the Bot API calls to make, in order.
"""
text = ""
last_rendered_text = _truncate(initial_text, TELEGRAM_MAX_TEXT_LENGTH) if initial_text is not None else ""
async for update in stream:
if update.text:
text += update.text
rendered_text = _truncate(text, TELEGRAM_MAX_TEXT_LENGTH)
if rendered_text == last_rendered_text:
continue
last_rendered_text = rendered_text
yield TelegramOperation(
method="editMessageText",
payload={
"chat_id": chat_id,
"message_id": message_id,
"text": rendered_text,
},
)
final = await stream.get_final_response()
final_text, image_uris = _text_and_image_uris(final)
if final_text or not image_uris:
rendered_text = _truncate(final_text or _NO_RESPONSE_TEXT, TELEGRAM_MAX_TEXT_LENGTH)
payload: dict[str, Any] = {
"chat_id": chat_id,
"message_id": message_id,
"text": rendered_text,
}
if parse_mode:
payload["parse_mode"] = parse_mode
if rendered_text != last_rendered_text or parse_mode:
yield TelegramOperation(method="editMessageText", payload=payload)
else:
yield TelegramOperation(
method="deleteMessage",
payload={"chat_id": chat_id, "message_id": message_id},
)
for uri in image_uris:
yield TelegramOperation(method="sendPhoto", payload={"chat_id": chat_id, "photo": uri})
@@ -0,0 +1,79 @@
[project]
name = "agent-framework-hosting-telegram"
description = "Telegram Bot API-shaped helpers for agent-framework-hosting."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260709"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.11.0,<2",
"agent-framework-hosting==1.0.0a260709",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_hosting_telegram"]
exclude = ['tests']
[tool.bandit]
targets = ["agent_framework_hosting_telegram"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_telegram --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,269 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for the Telegram update parsing/extraction helpers."""
from __future__ import annotations
from typing import Any, cast
import pytest
from agent_framework import Message
from agent_framework_hosting_telegram import (
telegram_callback_query_id,
telegram_chat_id,
telegram_command,
telegram_media_file_id,
telegram_session_id,
telegram_to_run,
)
def _message_update(**message_fields: Any) -> dict[str, Any]:
return {
"update_id": 1,
"message": {
"message_id": 10,
"date": 0,
"chat": {"id": 555, "type": "private"},
"from": {"id": 777, "is_bot": False, "first_name": "Ada"},
**message_fields,
},
}
class TestTelegramChatId:
def test_reads_from_message(self) -> None:
assert telegram_chat_id(_message_update(text="hi")) == 555
def test_reads_from_edited_message(self) -> None:
update = {"update_id": 1, "edited_message": {"chat": {"id": 42}, "text": "hi"}}
assert telegram_chat_id(update) == 42
def test_reads_from_callback_query_message(self) -> None:
update = {
"update_id": 1,
"callback_query": {"id": "cb1", "data": "x", "message": {"chat": {"id": 99}}},
}
assert telegram_chat_id(update) == 99
def test_returns_none_when_absent(self) -> None:
assert telegram_chat_id({"update_id": 1}) is None
def test_returns_none_for_non_int_chat_id(self) -> None:
update = {"update_id": 1, "message": {"chat": {"id": "not-an-int"}}}
assert telegram_chat_id(update) is None
class TestTelegramSessionId:
def test_private_chat_uses_bot_and_user_id(self) -> None:
assert telegram_session_id(_message_update(text="hi"), bot_id=123) == "telegram:123:777"
def test_returns_none_when_no_chat_id(self) -> None:
assert telegram_session_id({"update_id": 1}, bot_id=123) is None
def test_private_chat_returns_none_when_no_user_id(self) -> None:
update = _message_update(text="hi")
del update["message"]["from"]
assert telegram_session_id(update, bot_id=123) is None
def test_group_chat_uses_bot_and_chat_id(self) -> None:
update = _message_update(text="hi")
update["message"]["chat"] = {"id": -555, "type": "supergroup"}
assert telegram_session_id(update, bot_id=123) == "telegram:123:-555"
def test_private_callback_uses_callback_sender(self) -> None:
update = {
"update_id": 1,
"callback_query": {
"id": "cb1",
"from": {"id": 888},
"message": {"chat": {"id": 555, "type": "private"}},
},
}
assert telegram_session_id(update, bot_id=123) == "telegram:123:888"
class TestTelegramCommand:
def test_plain_command(self) -> None:
assert telegram_command(_message_update(text="/start")) == "/start"
def test_command_with_args(self) -> None:
assert telegram_command(_message_update(text="/echo hello world")) == "/echo hello world"
def test_bot_suffixed_command_normalizes(self) -> None:
assert telegram_command(_message_update(text="/start@mybot")) == "/start"
def test_bot_suffixed_command_with_args_normalizes(self) -> None:
assert telegram_command(_message_update(text="/echo@mybot hello")) == "/echo hello"
def test_edited_message_text(self) -> None:
update = {"update_id": 1, "edited_message": {"chat": {"id": 1}, "text": "/help"}}
assert telegram_command(update) == "/help"
def test_callback_query_data(self) -> None:
update = {"update_id": 1, "callback_query": {"id": "cb1", "data": "/confirm@mybot yes"}}
assert telegram_command(update) == "/confirm yes"
def test_non_command_text_returns_none(self) -> None:
assert telegram_command(_message_update(text="hello there")) is None
def test_missing_text_returns_none(self) -> None:
assert telegram_command({"update_id": 1, "message": {"chat": {"id": 1}}}) is None
def test_no_actionable_source_returns_none(self) -> None:
assert telegram_command({"update_id": 1}) is None
class TestTelegramCallbackQueryId:
def test_returns_id(self) -> None:
update = {"update_id": 1, "callback_query": {"id": "cb-123", "data": "x"}}
assert telegram_callback_query_id(update) == "cb-123"
def test_returns_none_without_callback_query(self) -> None:
assert telegram_callback_query_id(_message_update(text="hi")) is None
class TestTelegramMediaFileId:
def test_picks_largest_photo_by_file_size(self) -> None:
update = _message_update(
photo=[
{"file_id": "small", "file_size": 100, "width": 90, "height": 90},
{"file_id": "large", "file_size": 5000, "width": 800, "height": 600},
{"file_id": "medium", "file_size": 1000, "width": 320, "height": 240},
]
)
assert telegram_media_file_id(update) == ("large", "image/jpeg")
def test_picks_largest_photo_by_area_when_no_file_size(self) -> None:
update = _message_update(
photo=[
{"file_id": "small", "width": 90, "height": 90},
{"file_id": "large", "width": 800, "height": 600},
]
)
assert telegram_media_file_id(update) == ("large", "image/jpeg")
def test_document_uses_declared_mime_type(self) -> None:
update = _message_update(document={"file_id": "doc1", "mime_type": "application/pdf"})
assert telegram_media_file_id(update) == ("doc1", "application/pdf")
def test_document_falls_back_to_default_mime_type(self) -> None:
update = _message_update(document={"file_id": "doc1"})
assert telegram_media_file_id(update) == ("doc1", "application/octet-stream")
def test_voice_defaults_to_ogg(self) -> None:
update = _message_update(voice={"file_id": "v1"})
assert telegram_media_file_id(update) == ("v1", "audio/ogg")
def test_audio_defaults_to_mpeg(self) -> None:
update = _message_update(audio={"file_id": "a1"})
assert telegram_media_file_id(update) == ("a1", "audio/mpeg")
def test_video_defaults_to_mp4(self) -> None:
update = _message_update(video={"file_id": "vi1"})
assert telegram_media_file_id(update) == ("vi1", "video/mp4")
def test_accepts_bare_message_object(self) -> None:
message = {"chat": {"id": 1}, "document": {"file_id": "doc1", "mime_type": "text/plain"}}
assert telegram_media_file_id(message) == ("doc1", "text/plain")
def test_returns_none_without_media(self) -> None:
assert telegram_media_file_id(_message_update(text="hi")) is None
def test_reads_media_from_callback_query_message(self) -> None:
update = {
"update_id": 1,
"callback_query": {
"id": "cb1",
"data": "x",
"message": {"chat": {"id": 1}, "document": {"file_id": "doc1", "mime_type": "text/plain"}},
},
}
assert telegram_media_file_id(update) == ("doc1", "text/plain")
class TestTelegramToRun:
async def test_message_text_becomes_user_message(self) -> None:
run = await telegram_to_run(_message_update(text="hello"))
messages = cast("list[Message]", run["messages"])
assert len(messages) == 1
assert messages[0].role == "user"
assert messages[0].text == "hello"
assert run["stream"] is False
async def test_stream_flag_is_forwarded(self) -> None:
run = await telegram_to_run(_message_update(text="hello"), stream=True)
assert run["stream"] is True
async def test_edited_message_becomes_user_message(self) -> None:
update = {"update_id": 1, "edited_message": {"chat": {"id": 1}, "text": "edited"}}
run = await telegram_to_run(update)
messages = cast("list[Message]", run["messages"])
assert messages[0].text == "edited"
async def test_callback_query_data_becomes_user_text(self) -> None:
update = {"update_id": 1, "callback_query": {"id": "cb1", "data": "action:confirm"}}
run = await telegram_to_run(update)
messages = cast("list[Message]", run["messages"])
assert messages[0].text == "action:confirm"
async def test_caption_used_when_no_text(self) -> None:
update = _message_update(caption="a cute cat", photo=[{"file_id": "p1", "file_size": 10}])
run = await telegram_to_run(update)
messages = cast("list[Message]", run["messages"])
assert messages[0].text == "a cute cat"
async def test_media_resolved_via_resolver(self) -> None:
update = _message_update(caption="a cute cat", photo=[{"file_id": "p1", "file_size": 10}])
async def resolve_file_url(file_id: str) -> str | None:
assert file_id == "p1"
return "https://example.com/p1.jpg"
run = await telegram_to_run(update, resolve_file_url=resolve_file_url)
messages = cast("list[Message]", run["messages"])
uris = [c.uri for c in messages[0].contents if c.type == "uri"]
assert uris == ["https://example.com/p1.jpg"]
assert messages[0].text == "a cute cat"
async def test_media_without_resolver_preserves_text(self) -> None:
update = _message_update(caption="a cute cat", photo=[{"file_id": "p1", "file_size": 10}])
run = await telegram_to_run(update)
messages = cast("list[Message]", run["messages"])
assert messages[0].text == "a cute cat"
assert not any(c.type == "uri" for c in messages[0].contents)
async def test_media_resolver_returning_none_preserves_text(self) -> None:
update = _message_update(caption="a cute cat", photo=[{"file_id": "p1", "file_size": 10}])
async def resolve_file_url(file_id: str) -> str | None:
return None
run = await telegram_to_run(update, resolve_file_url=resolve_file_url)
messages = cast("list[Message]", run["messages"])
assert messages[0].text == "a cute cat"
async def test_media_only_unresolved_raises(self) -> None:
update = _message_update(photo=[{"file_id": "p1", "file_size": 10}])
with pytest.raises(ValueError, match="Cannot resolve"):
await telegram_to_run(update)
async def test_media_only_no_resolver_raises(self) -> None:
update = _message_update(document={"file_id": "d1"})
with pytest.raises(ValueError, match="Cannot resolve"):
await telegram_to_run(update)
async def test_empty_message_raises(self) -> None:
update = {"update_id": 1, "message": {"chat": {"id": 1}}}
with pytest.raises(ValueError, match="no text, caption, or resolvable media"):
await telegram_to_run(update)
async def test_callback_query_without_data_raises(self) -> None:
update = {"update_id": 1, "callback_query": {"id": "cb1"}}
with pytest.raises(ValueError, match="no actionable"):
await telegram_to_run(update)
async def test_update_without_actionable_content_raises(self) -> None:
with pytest.raises(ValueError, match="no actionable"):
await telegram_to_run({"update_id": 1, "poll": {"id": "p1"}})
@@ -0,0 +1,255 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for the Telegram Bot API rendering helpers."""
from __future__ import annotations
from collections.abc import AsyncIterator, Sequence
import pytest
from agent_framework import AgentResponse, AgentResponseUpdate, Content, Message, ResponseStream
from agent_framework_hosting_telegram import (
TELEGRAM_MAX_CAPTION_LENGTH,
TELEGRAM_MAX_TEXT_LENGTH,
telegram_from_run,
telegram_from_streaming_run,
)
def _text_response(text: str) -> AgentResponse[None]:
return AgentResponse(messages=Message(role="assistant", contents=[Content.from_text(text=text)]))
class TestTelegramFromRun:
def test_text_response_renders_send_message(self) -> None:
operation = telegram_from_run(_text_response("hello there"), chat_id=555)
assert operation["method"] == "sendMessage"
assert operation["payload"] == {"chat_id": 555, "text": "hello there"}
def test_parse_mode_is_included_when_given(self) -> None:
operation = telegram_from_run(_text_response("hi"), chat_id=555, parse_mode="MarkdownV2")
assert operation["payload"]["parse_mode"] == "MarkdownV2"
def test_no_text_or_image_falls_back_to_no_response(self) -> None:
empty = AgentResponse(messages=Message(role="assistant", contents=[]))
operation = telegram_from_run(empty, chat_id=1)
assert operation["method"] == "sendMessage"
assert operation["payload"]["text"] == "(no response)"
def test_text_is_truncated_to_max_length(self) -> None:
long_text = "x" * (TELEGRAM_MAX_TEXT_LENGTH + 500)
operation = telegram_from_run(_text_response(long_text), chat_id=1)
assert len(operation["payload"]["text"]) == TELEGRAM_MAX_TEXT_LENGTH
assert operation["payload"]["text"] == "x" * TELEGRAM_MAX_TEXT_LENGTH
def test_text_is_truncated_by_utf16_code_units(self) -> None:
operation = telegram_from_run(_text_response("😀" * (TELEGRAM_MAX_TEXT_LENGTH // 2 + 1)), chat_id=1)
assert operation["payload"]["text"] == "😀" * (TELEGRAM_MAX_TEXT_LENGTH // 2)
def test_image_uri_renders_send_photo(self) -> None:
result = AgentResponse(
messages=Message(
role="assistant",
contents=[Content.from_uri(uri="https://example.com/cat.png", media_type="image/png")],
)
)
operation = telegram_from_run(result, chat_id=1)
assert operation["method"] == "sendPhoto"
assert operation["payload"] == {"chat_id": 1, "photo": "https://example.com/cat.png"}
def test_image_with_text_uses_caption(self) -> None:
result = AgentResponse(
messages=Message(
role="assistant",
contents=[
Content.from_text(text="a cat"),
Content.from_uri(uri="https://example.com/cat.png", media_type="image/png"),
],
)
)
operation = telegram_from_run(result, chat_id=1, parse_mode="HTML")
assert operation["method"] == "sendPhoto"
assert operation["payload"]["caption"] == "a cat"
assert operation["payload"]["parse_mode"] == "HTML"
def test_image_caption_is_truncated_by_utf16_code_units(self) -> None:
caption = "😀" * (TELEGRAM_MAX_CAPTION_LENGTH // 2 + 1)
result = AgentResponse(
messages=Message(
role="assistant",
contents=[
Content.from_text(text=caption),
Content.from_uri(uri="https://example.com/cat.png", media_type="image/png"),
],
)
)
operation = telegram_from_run(result, chat_id=1)
assert operation["payload"]["caption"] == "😀" * (TELEGRAM_MAX_CAPTION_LENGTH // 2)
def test_inline_image_data_is_not_rendered_as_photo(self) -> None:
result = AgentResponse(
messages=Message(
role="assistant",
contents=[Content.from_data(data=b"image", media_type="image/png")],
)
)
operation = telegram_from_run(result, chat_id=1)
assert operation == {"method": "sendMessage", "payload": {"chat_id": 1, "text": "(no response)"}}
def test_non_image_uri_is_not_rendered_as_photo(self) -> None:
result = AgentResponse(
messages=Message(
role="assistant",
contents=[
Content.from_text(text="here is a pdf"),
Content.from_uri(uri="https://example.com/report.pdf", media_type="application/pdf"),
],
)
)
operation = telegram_from_run(result, chat_id=1)
assert operation["method"] == "sendMessage"
assert operation["payload"]["text"] == "here is a pdf"
def test_only_first_image_is_rendered(self) -> None:
result = AgentResponse(
messages=Message(
role="assistant",
contents=[
Content.from_uri(uri="https://example.com/first.png", media_type="image/png"),
Content.from_uri(uri="https://example.com/second.png", media_type="image/png"),
],
)
)
operation = telegram_from_run(result, chat_id=1)
assert operation["payload"]["photo"] == "https://example.com/first.png"
class TestTelegramFromStreamingRun:
async def test_accumulates_text_across_updates(self) -> None:
async def updates() -> AsyncIterator[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[Content.from_text(text="hel")], role="assistant")
yield AgentResponseUpdate(contents=[Content.from_text(text="lo")], role="assistant")
stream = ResponseStream(updates(), finalizer=AgentResponse.from_updates)
operations = [op async for op in telegram_from_streaming_run(stream, chat_id=1, message_id=42)]
interim = [op for op in operations if op["payload"].get("text") in ("hel", "hello")]
assert operations[0]["method"] == "editMessageText"
assert operations[0]["payload"] == {"chat_id": 1, "message_id": 42, "text": "hel"}
assert operations[1]["payload"]["text"] == "hello"
assert len(interim) == 2
async def test_final_edit_includes_parse_mode(self) -> None:
async def updates() -> AsyncIterator[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[Content.from_text(text="hi")], role="assistant")
stream = ResponseStream(updates(), finalizer=AgentResponse.from_updates)
operations = [
op async for op in telegram_from_streaming_run(stream, chat_id=1, message_id=42, parse_mode="MarkdownV2")
]
interim_op = operations[0]
final_op = operations[-1]
assert "parse_mode" not in interim_op["payload"]
assert final_op["method"] == "editMessageText"
assert final_op["payload"]["parse_mode"] == "MarkdownV2"
assert final_op["payload"]["text"] == "hi"
async def test_edit_matching_initial_placeholder_is_omitted(self) -> None:
async def updates() -> AsyncIterator[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[Content.from_text(text="...")], role="assistant")
yield AgentResponseUpdate(contents=[Content.from_text(text="done")], role="assistant")
stream = ResponseStream(updates(), finalizer=AgentResponse.from_updates)
operations = [
op
async for op in telegram_from_streaming_run(
stream,
chat_id=1,
message_id=42,
initial_text="...",
)
]
assert len(operations) == 1
assert operations[0]["payload"]["text"] == "...done"
async def test_final_text_is_truncated(self) -> None:
long_text = "y" * (TELEGRAM_MAX_TEXT_LENGTH + 200)
async def updates() -> AsyncIterator[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[Content.from_text(text=long_text)], role="assistant")
stream = ResponseStream(updates(), finalizer=AgentResponse.from_updates)
operations = [op async for op in telegram_from_streaming_run(stream, chat_id=1, message_id=42)]
assert all(len(op["payload"]["text"]) <= TELEGRAM_MAX_TEXT_LENGTH for op in operations)
async def test_images_from_final_response_render_as_send_photo(self) -> None:
async def updates() -> AsyncIterator[AgentResponseUpdate]:
yield AgentResponseUpdate(
contents=[Content.from_uri(uri="https://example.com/cat.png", media_type="image/png")],
role="assistant",
)
stream = ResponseStream(updates(), finalizer=AgentResponse.from_updates)
operations = [op async for op in telegram_from_streaming_run(stream, chat_id=1, message_id=42)]
photo_ops = [op for op in operations if op["method"] == "sendPhoto"]
assert len(photo_ops) == 1
assert photo_ops[0]["payload"] == {"chat_id": 1, "photo": "https://example.com/cat.png"}
async def test_no_text_and_no_image_falls_back_to_no_response(self) -> None:
async def updates() -> AsyncIterator[AgentResponseUpdate]:
return
yield # pragma: no cover - make this an async generator with no items
stream = ResponseStream(updates(), finalizer=AgentResponse.from_updates)
operations = [op async for op in telegram_from_streaming_run(stream, chat_id=1, message_id=42)]
assert len(operations) == 1
assert operations[0]["payload"]["text"] == "(no response)"
async def test_image_only_final_response_skips_text_edit(self) -> None:
async def updates() -> AsyncIterator[AgentResponseUpdate]:
yield AgentResponseUpdate(
contents=[Content.from_uri(uri="https://example.com/cat.png", media_type="image/png")],
role="assistant",
)
stream = ResponseStream(updates(), finalizer=AgentResponse.from_updates)
operations = [op async for op in telegram_from_streaming_run(stream, chat_id=1, message_id=42)]
assert operations[0]["method"] == "deleteMessage"
assert operations[0]["payload"] == {"chat_id": 1, "message_id": 42}
assert operations[1]["method"] == "sendPhoto"
async def test_error_during_iteration_propagates(self) -> None:
async def updates() -> AsyncIterator[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[Content.from_text(text="partial")], role="assistant")
raise RuntimeError("upstream blew up")
stream = ResponseStream(updates(), finalizer=AgentResponse.from_updates)
with pytest.raises(RuntimeError, match="upstream blew up"):
_ = [op async for op in telegram_from_streaming_run(stream, chat_id=1, message_id=42)]
async def test_error_during_finalization_propagates(self) -> None:
async def updates() -> AsyncIterator[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[Content.from_text(text="partial")], role="assistant")
def finalizer(items: Sequence[AgentResponseUpdate]) -> AgentResponse[None]:
raise RuntimeError("finalizer blew up")
stream = ResponseStream(updates(), finalizer=finalizer)
with pytest.raises(RuntimeError, match="finalizer blew up"):
_ = [op async for op in telegram_from_streaming_run(stream, chat_id=1, message_id=42)]
+1
View File
@@ -93,6 +93,7 @@ agent-framework-gemini = { workspace = true }
agent-framework-github-copilot = { workspace = true }
agent-framework-hosting = { workspace = true }
agent-framework-hosting-responses = { workspace = true }
agent-framework-hosting-telegram = { workspace = true }
agent-framework-hyperlight = { workspace = true }
agent-framework-lab = { workspace = true }
agent-framework-mem0 = { workspace = true }
+1
View File
@@ -8,6 +8,7 @@
"**/_to_delete/**",
"**/05-end-to-end/**",
"**/harness/**",
"**/local_telegram/**",
"**/agent_with_foundry_tracing.py",
"**/azure_responses_client_with_foundry.py"
],
+1
View File
@@ -8,6 +8,7 @@
"**/_to_delete/**",
"**/05-end-to-end/**",
"**/harness/**",
"**/local_telegram/**",
"**/agent_with_foundry_tracing.py",
"**/azure_responses_client_with_foundry.py",
"**/github_copilot/**"
@@ -11,9 +11,11 @@ clients, authentication, response construction, and deployment shape.
|---|---|---|
| [`local_responses/`](./local_responses) | One agent + one `@tool` + native FastAPI route + Responses helper functions + `AgentState` / `SessionStore`. | **Local only.** Start here to learn the helper seam. |
| [`local_responses_workflow/`](./local_responses_workflow) | A workflow target behind a native FastAPI route using Responses helper functions, `WorkflowState`, explicit `CheckpointStorage`, and an app-owned checkpoint cursor. | **Local only.** |
| [`local_telegram/`](./local_telegram) | One agent + `aiogram` polling + Telegram conversion helpers + app-owned commands, media policy, and streaming edits. | **Local only.** Requires a Telegram bot token. |
Each sample is self-contained with its own `pyproject.toml`, server `app.py`,
calling script(s), and `storage/` directory. Samples use `[tool.uv.sources]`
Each sample is self-contained with its own `pyproject.toml`, executable
application code, and `storage/` directory. HTTP samples also include calling
scripts. Samples use `[tool.uv.sources]`
to wire unreleased hosting packages to the upstream repo while those packages
are still pre-PyPI. Once those packages publish, drop the `[tool.uv.sources]`
block and let the declared dependencies resolve from PyPI.
@@ -27,7 +29,7 @@ Those samples use the Foundry-managed protocol surface with no
| Aspect | `af-hosting/` (this directory) | `foundry-hosted-agents/` |
|---|---|---|
| Server stack | App-owned FastAPI + hosting protocol helpers | Foundry Hosted Agents runtime |
| Server stack | App-owned framework/native client + hosting protocol helpers | Foundry Hosted Agents runtime |
| Protocol surface | The app exposes the route and calls helpers | The platform exposes Responses + Invocations |
| Run target | Local Hypercorn (`local_responses/`, `local_responses_workflow/`) | Hosted Agents or local container targeting the Hosted Agents contract |
| Run target | Local Hypercorn (`local_responses/`, `local_responses_workflow/`) or native polling (`local_telegram/`) | Hosted Agents or local container targeting the Hosted Agents contract |
| When to pick this | You need custom hosting code or want to learn the helper seam | You want the Foundry-managed hosting surface |
@@ -0,0 +1,147 @@
# local_telegram - Telegram helpers with aiogram polling or webhooks
A local Telegram bot built from the helper-first hosting pieces:
- an actual Foundry-backed Agent Framework `Agent`;
- `AgentState` and `InMemoryHistoryProvider` for process-local per-chat
continuity;
- `telegram_to_run(...)` for Telegram update to AF conversion;
- `telegram_from_streaming_run(...)` for AF stream to Telegram edit payloads;
- `aiogram` for typed updates, polling/webhook dispatch, file download, and Bot
API calls.
There is no Telegram client, polling runtime, webhook router, command registry,
or delivery framework in `agent-framework-hosting-telegram`. This sample uses
the native `aiogram` SDK for those concerns. The helpers are deliberately
agnostic to the Telegram SDK you choose. See the
[Telegram documentation](https://core.telegram.org/bots/samples#python) for
other Python SDK options.
Each entry point is intentionally self-contained so it can be read and copied
without following a shared sample helper module. Both handle text, captions,
supported media, callback-query data, and the commands `/start`, `/help`,
`/new`, and `/weather <city>`.
## Run with polling
Create a Telegram bot with BotFather, then configure:
```bash
export FOUNDRY_PROJECT_ENDPOINT=https://<your-project>.services.ai.azure.com
export FOUNDRY_MODEL=gpt-5-nano
export TELEGRAM_BOT_TOKEN=...
az login
uv run polling_app.py
```
The sample asks `aiogram` to clear any existing webhook before polling because
Telegram does not allow polling while a webhook is registered.
## Run with a webhook
Configure the public HTTPS URL that Telegram should call and a random secret
used to authenticate webhook deliveries:
```bash
export FOUNDRY_PROJECT_ENDPOINT=https://<your-project>.services.ai.azure.com
export FOUNDRY_MODEL=gpt-5-nano
export TELEGRAM_BOT_TOKEN=...
export TELEGRAM_WEBHOOK_URL=https://<your-host>/telegram/webhook
export TELEGRAM_WEBHOOK_SECRET=<random-secret>
az login
uv run app.py
```
Each entry point declares its complete Agent Framework and third-party
dependency set using PEP 723 inline script metadata, so `uv` creates the
appropriate environment directly from the selected script.
`app.py` derives its FastAPI route from the path in
`TELEGRAM_WEBHOOK_URL`, registers that URL with Telegram during application
startup, and validates `X-Telegram-Bot-Api-Secret-Token` with
`TELEGRAM_WEBHOOK_SECRET` before accepting an update.
The app intentionally leaves the webhook registered during shutdown. Deleting
it can race a rolling deployment and remove the webhook that the replacement
process just registered.
## Behavior to notice
- **Session continuity:** `telegram_session_id(..., bot_id=bot.id)` follows
Telegram's native identity boundaries. Private chats use
`telegram:<bot_id>:<user_id>`; groups and supergroups use
`telegram:<bot_id>:<chat_id>`, creating a shared session for that group.
Including `bot.id` prevents two bots from accidentally sharing state.
aiogram derives that numeric bot id from `TELEGRAM_BOT_TOKEN`, so these local
apps do not need a separate `TELEGRAM_BOT_ID` setting.
- **Starting over:** `/new` calls `state.session_store.delete(session_id)`.
The command itself does not run the agent. On the next ordinary message,
`get_or_create_session(...)` finds no stored value and creates a fresh
`AgentSession` with empty `InMemoryHistoryProvider` state. In a group, this
resets the shared group session; an app that wants per-user group sessions
should include both the chat id and sender id in its app-owned key.
- **Process restarts:** history is intentionally process-local in this sample.
Restarting either app also starts fresh. A durable deployment must replace
both the in-memory session store and history provider deliberately.
- **Commands:** recognized commands are handled by application code and bypass
the agent. Unknown slash commands fall through as ordinary agent input.
- **Callback queries:** the app acknowledges callback queries first to clear
Telegram's loading indicator, then treats callback data as user input unless
it matched an app-owned command.
- **Media:** files larger than 5 MiB are not forwarded. Downloaded media is
converted to an inline data URI so a token-bearing Telegram file URL is not
disclosed to the model provider. If media cannot be resolved, a caption can
still be used as text; unresolved media-only updates are ignored.
- **Streaming:** the app sends a placeholder, applies cumulative text with
`editMessageText`, and throttles edits. Telegram can normalize distinct
payloads to the same rendered content; the app treats only its resulting
`message is not modified` edit error as an idempotent success. Other Bot API
errors still propagate. For an image-only result, the helper deletes the
placeholder before emitting `sendPhoto`.
- **Transport ordering:** polling uses `tasks_concurrency_limit=1`, so this
compact sample processes updates serially. The webhook acknowledges first
and processes in a FastAPI background task, but serializes each chat's
updates with an in-process lock so `/new` cannot race an in-flight response.
A multi-process deployment must instead use its storage backend's locking or
transaction mechanisms, or another cross-process ordering strategy.
- **Webhook trust:** `TELEGRAM_WEBHOOK_SECRET` authenticates delivery from
Telegram. It does not authorize the Telegram user or chat to access
application data.
## What the app owns
The helper package only converts protocol values. `aiogram`, `app.py`, and
`polling_app.py` own:
- polling or FastAPI webhook setup and update dispatch;
- bounded media download and inline-data conversion, avoiding disclosure of
token-bearing Telegram file URLs to the model provider;
- slash-command policy and session reset;
- native send, photo, typing, callback acknowledgement, and edit calls;
- edit throttling and error logging.
That code remains visible so an application can replace it with a webhook,
queue, or retry policy without changing the AF conversion helpers.
## Production readiness
These are compact hosting samples, not complete production Telegram
deployments.
Before deploying this pattern:
- use HTTPS and keep webhook secret validation enabled;
- store `TELEGRAM_BOT_TOKEN` in a secret manager and avoid logging Bot API URLs;
- authorize users before mapping a chat id to sensitive/shared state;
- replace process-local history/session state with durable storage partitioned
by tenant/user and define retention;
- make update processing idempotent and preserve per-chat ordering; use
storage-backed locking/transactions or another distributed coordination
mechanism when running more than one process;
- handle Telegram `429` responses and `retry_after` values;
- add bounded retries, delivery telemetry, and dead-letter handling;
- decide how partial streaming edits should recover when a final edit fails.
> This sample is **self-hosted**. The multi-protocol Telegram + Invocations
> Foundry-hosted sample remains part of the separate Invocations work.
@@ -0,0 +1,312 @@
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "agent-framework-core",
# "agent-framework-foundry",
# "agent-framework-hosting",
# "agent-framework-hosting-telegram",
# "aiogram>=3.29.1,<4",
# "azure-identity",
# "fastapi>=0.115.0,<0.138.1",
# "hypercorn>=0.17",
# ]
# ///
# Run with: uv run app.py
# Copyright (c) Microsoft. All rights reserved.
"""Run a self-contained Telegram bot as an authenticated webhook.
This entry point uses native FastAPI routes and aiogram update handling. At
startup it registers ``TELEGRAM_WEBHOOK_URL`` with Telegram and configures
Telegram to send ``TELEGRAM_WEBHOOK_SECRET`` in the
``X-Telegram-Bot-Api-Secret-Token`` header. The route validates that header
before accepting an update.
Production readiness
---
The secret header authenticates Telegram's webhook delivery, but it does not
authorize the end user represented by an update. Production apps must still
authorize chat/user ids before loading sensitive state, use durable state and
idempotent update processing, and add bounded background work with delivery
telemetry. This sample serializes each chat's updates in-process; distributed
deployments need their backend storage's locking or transaction mechanisms, or
another cross-process ordering strategy. See ``README.md#production-readiness``.
Required environment variables: ``FOUNDRY_PROJECT_ENDPOINT``,
``FOUNDRY_MODEL``, ``TELEGRAM_BOT_TOKEN``, ``TELEGRAM_WEBHOOK_URL``, and
``TELEGRAM_WEBHOOK_SECRET``.
Run::
uv run app.py
"""
from __future__ import annotations
import asyncio
import base64
import hmac
import logging
import os
import time
from collections.abc import AsyncIterator, Mapping
from contextlib import asynccontextmanager
from io import BytesIO
from typing import Annotated, Any, cast
from urllib.parse import urlparse
from agent_framework import Agent, InMemoryHistoryProvider, ResponseStream, tool
from agent_framework_foundry import FoundryChatClient
from agent_framework_hosting import AgentState
from agent_framework_hosting_telegram import (
TelegramOperation,
telegram_callback_query_id,
telegram_chat_id,
telegram_command,
telegram_from_streaming_run,
telegram_session_id,
telegram_to_run,
)
from aiogram import Bot, Dispatcher
from aiogram.exceptions import TelegramBadRequest
from aiogram.methods import DeleteMessage, EditMessageText, SendMessage, SendPhoto
from aiogram.types import CallbackQuery, Message, Update
from azure.identity.aio import DefaultAzureCredential
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request, Response
from hypercorn.asyncio import serve
from hypercorn.config import Config
LOGGER = logging.getLogger(__name__)
EDIT_INTERVAL_SECONDS = 0.4
MAX_MEDIA_BYTES = 5 * 1024 * 1024
PLACEHOLDER_TEXT = "..."
ALLOWED_UPDATES = ["message", "edited_message", "callback_query"]
WEBHOOK_URL = os.environ["TELEGRAM_WEBHOOK_URL"]
WEBHOOK_SECRET = os.environ["TELEGRAM_WEBHOOK_SECRET"]
WEBHOOK_PATH = urlparse(WEBHOOK_URL).path or "/"
@tool(approval_mode="never_require")
def lookup_weather(
location: Annotated[str, "The city to look up weather for."],
) -> str:
"""Return a deterministic weather report for a city."""
high_temp = 5 + (sum(location.encode("utf-8")) % 21)
reports = {
"Seattle": f"Seattle is rainy with a high of {high_temp}°C.",
"Amsterdam": f"Amsterdam is cloudy with a high of {high_temp}°C.",
"Tokyo": f"Tokyo is clear with a high of {high_temp}°C.",
}
return reports.get(location, f"{location} is sunny with a high of {high_temp}°C.")
def create_agent() -> Agent:
"""Create the sample weather agent."""
return Agent(
client=FoundryChatClient(credential=DefaultAzureCredential()),
name="WeatherAgent",
instructions=(
"You are a friendly weather assistant. Use the lookup_weather tool "
"for weather questions and answer in one short sentence."
),
tools=[lookup_weather],
context_providers=[InMemoryHistoryProvider()],
default_options={"store": False},
)
state = AgentState(create_agent)
dispatcher = Dispatcher(disable_fsm=True)
bot = Bot(token=os.environ["TELEGRAM_BOT_TOKEN"])
session_locks: dict[str, asyncio.Lock] = {}
def telegram_update(event_name: str, event: Message | CallbackQuery) -> dict[str, Any]:
"""Wrap one aiogram event in the Bot API update shape expected by helpers."""
return {event_name: event.model_dump(mode="json", by_alias=True, exclude_none=True)}
async def execute_operation(operation: TelegramOperation) -> Any:
"""Execute one operation produced by a Telegram rendering helper."""
try:
match operation["method"]:
case "sendMessage":
return await bot(SendMessage.model_validate(operation["payload"]))
case "sendPhoto":
return await bot(SendPhoto.model_validate(operation["payload"]))
case "editMessageText":
return await bot(EditMessageText.model_validate(operation["payload"]))
case "deleteMessage":
return await bot(DeleteMessage.model_validate(operation["payload"]))
case method:
raise ValueError(f"Unsupported Telegram operation: {method}")
except TelegramBadRequest as exc:
if operation["method"] == "editMessageText" and "message is not modified" in exc.message.lower():
LOGGER.debug("Telegram ignored an edit whose rendered content was unchanged")
return None
raise
async def handle_command(update: Mapping[str, Any], command: str) -> bool:
"""Handle sample-owned commands and return whether one matched."""
chat_id = telegram_chat_id(update)
session_id = telegram_session_id(update, bot_id=bot.id)
if chat_id is None or session_id is None:
return False
name, _, argument = command.partition(" ")
if name == "/start":
text = "Hi! I am a weather assistant. Try asking about a city or use /weather <city>."
elif name == "/help":
text = "/new - reset this chat\n/weather <city> - look up weather directly\n/help - show this message"
elif name == "/new":
# SessionStore maps the stable Telegram chat key to its current
# AgentSession. Deleting that entry makes the next message create a
# fresh AgentSession with empty in-memory history.
await state.session_store.delete(session_id)
text = "New session started. Your next message begins with empty history."
elif name == "/weather":
text = lookup_weather(location=argument.strip() or "Seattle")
else:
return False
await bot.send_message(chat_id=chat_id, text=text)
return True
async def handle_update(update: Mapping[str, Any]) -> None:
"""Process one Telegram update through the sample agent."""
callback_query_id = telegram_callback_query_id(update)
if callback_query_id is not None:
await bot.answer_callback_query(callback_query_id=callback_query_id)
chat_id = telegram_chat_id(update)
session_id = telegram_session_id(update, bot_id=bot.id)
if chat_id is None or session_id is None:
return
# Background webhook tasks may overlap. Serialize each chat so /new cannot
# delete a session while an earlier response is still updating it.
async with session_locks.setdefault(session_id, asyncio.Lock()):
if (command := telegram_command(update)) is not None and await handle_command(update, command):
return
async def resolve_file_url(file_id: str) -> str | None:
file = await bot.get_file(file_id)
if file.file_path is None or (file.file_size is not None and file.file_size > MAX_MEDIA_BYTES):
return None
destination = BytesIO()
await bot.download_file(file.file_path, destination=destination)
data = destination.getvalue()
if len(data) > MAX_MEDIA_BYTES:
return None
encoded = base64.b64encode(data).decode("ascii")
return f"data:application/octet-stream;base64,{encoded}"
try:
run = await telegram_to_run(update, resolve_file_url=resolve_file_url, stream=True)
except ValueError:
LOGGER.debug("Ignoring non-actionable Telegram update", exc_info=True)
return
await bot.send_chat_action(chat_id=chat_id, action="typing")
placeholder = await bot.send_message(chat_id=chat_id, text=PLACEHOLDER_TEXT)
target = await state.get_target()
# Reuse one AgentSession per Telegram chat. The /new command removes this
# mapping so get_or_create_session creates a clean session next time.
session = await state.get_or_create_session(session_id)
stream = target.run(
run["messages"],
stream=True,
session=session,
options=run["options"],
)
if not isinstance(stream, ResponseStream):
raise RuntimeError("agent did not return a response stream")
last_edit_at = 0.0
async for operation in telegram_from_streaming_run(
stream,
chat_id=chat_id,
message_id=placeholder.message_id,
initial_text=PLACEHOLDER_TEXT,
):
if operation["method"] == "editMessageText":
delay = EDIT_INTERVAL_SECONDS - (time.monotonic() - last_edit_at)
if delay > 0:
await asyncio.sleep(delay)
last_edit_at = time.monotonic()
await execute_operation(operation)
# Persist the updated AgentSession back under the stable per-chat key after
# streaming has finalized and the history provider has recorded the turn.
await state.set_session(session_id, session)
@dispatcher.message()
async def on_message(message: Message) -> None:
"""Handle a new Telegram message."""
await handle_update(telegram_update("message", message))
@dispatcher.edited_message()
async def on_edited_message(message: Message) -> None:
"""Handle an edited Telegram message."""
await handle_update(telegram_update("edited_message", message))
@dispatcher.callback_query()
async def on_callback_query(callback_query: CallbackQuery) -> None:
"""Handle an inline-button callback query."""
await handle_update(telegram_update("callback_query", callback_query))
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
"""Register the Telegram webhook and close the bot session on shutdown."""
await bot.set_webhook(
url=WEBHOOK_URL,
secret_token=WEBHOOK_SECRET,
allowed_updates=ALLOWED_UPDATES,
)
yield
# Leave the webhook registered. Deleting it during rolling shutdown can
# remove the webhook just registered by the replacement process.
await bot.session.close()
app = FastAPI(lifespan=lifespan)
@app.post(WEBHOOK_PATH, response_model=None)
async def telegram_webhook(request: Request, background_tasks: BackgroundTasks) -> Response:
"""Authenticate and enqueue one Telegram webhook update."""
received_secret = request.headers.get("x-telegram-bot-api-secret-token", "")
if not hmac.compare_digest(received_secret, WEBHOOK_SECRET):
raise HTTPException(status_code=401, detail="invalid Telegram webhook secret")
try:
payload = cast("dict[str, Any]", await request.json())
update = Update.model_validate(payload, context={"bot": bot})
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="invalid Telegram update") from exc
background_tasks.add_task(dispatcher.feed_update, bot, update)
return Response(status_code=200)
async def main() -> None:
"""Run the webhook sample with Hypercorn for local development."""
logging.basicConfig(level=logging.INFO)
config = Config()
config.bind = [f"0.0.0.0:{int(os.environ.get('PORT', '8000'))}"]
await serve(cast(Any, app), config)
if __name__ == "__main__":
asyncio.run(main())
# Sample response:
# HTTP/1.1 200 OK
@@ -0,0 +1,251 @@
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "agent-framework-core",
# "agent-framework-foundry",
# "agent-framework-hosting",
# "agent-framework-hosting-telegram",
# "aiogram>=3.29.1,<4",
# "azure-identity",
# ]
# ///
# Run with: uv run polling_app.py
# Copyright (c) Microsoft. All rights reserved.
"""Run a self-contained Telegram bot with aiogram long polling.
Required environment variables: ``FOUNDRY_PROJECT_ENDPOINT``,
``FOUNDRY_MODEL``, and ``TELEGRAM_BOT_TOKEN``.
Run::
az login
uv run polling_app.py
"""
from __future__ import annotations
import asyncio
import base64
import logging
import os
import time
from collections.abc import Mapping
from io import BytesIO
from typing import Annotated, Any
from agent_framework import Agent, InMemoryHistoryProvider, ResponseStream, tool
from agent_framework_foundry import FoundryChatClient
from agent_framework_hosting import AgentState
from agent_framework_hosting_telegram import (
TelegramOperation,
telegram_callback_query_id,
telegram_chat_id,
telegram_command,
telegram_from_streaming_run,
telegram_session_id,
telegram_to_run,
)
from aiogram import Bot, Dispatcher
from aiogram.exceptions import TelegramBadRequest
from aiogram.methods import DeleteMessage, EditMessageText, SendMessage, SendPhoto
from aiogram.types import CallbackQuery, Message
from azure.identity.aio import DefaultAzureCredential
LOGGER = logging.getLogger(__name__)
EDIT_INTERVAL_SECONDS = 0.4
MAX_MEDIA_BYTES = 5 * 1024 * 1024
PLACEHOLDER_TEXT = "..."
ALLOWED_UPDATES = ["message", "edited_message", "callback_query"]
@tool(approval_mode="never_require")
def lookup_weather(
location: Annotated[str, "The city to look up weather for."],
) -> str:
"""Return a deterministic weather report for a city."""
high_temp = 5 + (sum(location.encode("utf-8")) % 21)
reports = {
"Seattle": f"Seattle is rainy with a high of {high_temp}°C.",
"Amsterdam": f"Amsterdam is cloudy with a high of {high_temp}°C.",
"Tokyo": f"Tokyo is clear with a high of {high_temp}°C.",
}
return reports.get(location, f"{location} is sunny with a high of {high_temp}°C.")
def create_agent() -> Agent:
"""Create the sample weather agent."""
return Agent(
client=FoundryChatClient(credential=DefaultAzureCredential()),
name="WeatherAgent",
instructions=(
"You are a friendly weather assistant. Use the lookup_weather tool "
"for weather questions and answer in one short sentence."
),
tools=[lookup_weather],
context_providers=[InMemoryHistoryProvider()],
default_options={"store": False},
)
state = AgentState(create_agent)
dispatcher = Dispatcher(disable_fsm=True)
def telegram_update(event_name: str, event: Message | CallbackQuery) -> dict[str, Any]:
"""Wrap one aiogram event in the Bot API update shape expected by helpers."""
return {event_name: event.model_dump(mode="json", by_alias=True, exclude_none=True)}
async def execute_operation(bot: Bot, operation: TelegramOperation) -> Any:
"""Execute one operation produced by a Telegram rendering helper."""
try:
match operation["method"]:
case "sendMessage":
return await bot(SendMessage.model_validate(operation["payload"]))
case "sendPhoto":
return await bot(SendPhoto.model_validate(operation["payload"]))
case "editMessageText":
return await bot(EditMessageText.model_validate(operation["payload"]))
case "deleteMessage":
return await bot(DeleteMessage.model_validate(operation["payload"]))
case method:
raise ValueError(f"Unsupported Telegram operation: {method}")
except TelegramBadRequest as exc:
if operation["method"] == "editMessageText" and "message is not modified" in exc.message.lower():
LOGGER.debug("Telegram ignored an edit whose rendered content was unchanged")
return None
raise
async def handle_command(bot: Bot, update: Mapping[str, Any], command: str) -> bool:
"""Handle sample-owned commands and return whether one matched."""
chat_id = telegram_chat_id(update)
session_id = telegram_session_id(update, bot_id=bot.id)
if chat_id is None or session_id is None:
return False
name, _, argument = command.partition(" ")
if name == "/start":
text = "Hi! I am a weather assistant. Try asking about a city or use /weather <city>."
elif name == "/help":
text = "/new - reset this chat\n/weather <city> - look up weather directly\n/help - show this message"
elif name == "/new":
# SessionStore maps the stable Telegram chat key to its current
# AgentSession. Deleting that entry makes the next message create a
# fresh AgentSession with empty in-memory history.
await state.session_store.delete(session_id)
text = "New session started. Your next message begins with empty history."
elif name == "/weather":
text = lookup_weather(location=argument.strip() or "Seattle")
else:
return False
await bot.send_message(chat_id=chat_id, text=text)
return True
async def handle_update(bot: Bot, update: Mapping[str, Any]) -> None:
"""Process one Telegram update through the sample agent."""
callback_query_id = telegram_callback_query_id(update)
if callback_query_id is not None:
await bot.answer_callback_query(callback_query_id=callback_query_id)
if (command := telegram_command(update)) is not None and await handle_command(bot, update, command):
return
chat_id = telegram_chat_id(update)
session_id = telegram_session_id(update, bot_id=bot.id)
if chat_id is None or session_id is None:
return
async def resolve_file_url(file_id: str) -> str | None:
file = await bot.get_file(file_id)
if file.file_path is None or (file.file_size is not None and file.file_size > MAX_MEDIA_BYTES):
return None
destination = BytesIO()
await bot.download_file(file.file_path, destination=destination)
data = destination.getvalue()
if len(data) > MAX_MEDIA_BYTES:
return None
encoded = base64.b64encode(data).decode("ascii")
return f"data:application/octet-stream;base64,{encoded}"
try:
run = await telegram_to_run(update, resolve_file_url=resolve_file_url, stream=True)
except ValueError:
LOGGER.debug("Ignoring non-actionable Telegram update", exc_info=True)
return
await bot.send_chat_action(chat_id=chat_id, action="typing")
placeholder = await bot.send_message(chat_id=chat_id, text=PLACEHOLDER_TEXT)
target = await state.get_target()
# Reuse one AgentSession per Telegram chat. The /new command removes this
# mapping so get_or_create_session creates a clean session next time.
session = await state.get_or_create_session(session_id)
stream = target.run(
run["messages"],
stream=True,
session=session,
options=run["options"],
)
if not isinstance(stream, ResponseStream):
raise RuntimeError("agent did not return a response stream")
last_edit_at = 0.0
async for operation in telegram_from_streaming_run(
stream,
chat_id=chat_id,
message_id=placeholder.message_id,
initial_text=PLACEHOLDER_TEXT,
):
if operation["method"] == "editMessageText":
delay = EDIT_INTERVAL_SECONDS - (time.monotonic() - last_edit_at)
if delay > 0:
await asyncio.sleep(delay)
last_edit_at = time.monotonic()
await execute_operation(bot, operation)
# Persist the updated AgentSession back under the stable per-chat key after
# streaming has finalized and the history provider has recorded the turn.
await state.set_session(session_id, session)
@dispatcher.message()
async def on_message(message: Message, bot: Bot) -> None:
"""Handle a new Telegram message."""
await handle_update(bot, telegram_update("message", message))
@dispatcher.edited_message()
async def on_edited_message(message: Message, bot: Bot) -> None:
"""Handle an edited Telegram message."""
await handle_update(bot, telegram_update("edited_message", message))
@dispatcher.callback_query()
async def on_callback_query(callback_query: CallbackQuery, bot: Bot) -> None:
"""Handle an inline-button callback query."""
await handle_update(bot, telegram_update("callback_query", callback_query))
async def main() -> None:
"""Start aiogram long polling until the process is stopped."""
logging.basicConfig(level=logging.INFO)
bot = Bot(token=os.environ["TELEGRAM_BOT_TOKEN"])
await bot.delete_webhook(drop_pending_updates=False)
await dispatcher.start_polling(
bot,
allowed_updates=ALLOWED_UPDATES,
tasks_concurrency_limit=1,
)
if __name__ == "__main__":
asyncio.run(main())
# Sample output in Telegram:
# User: What is the weather in Tokyo?
# Bot: Tokyo is clear with a high of 18°C.
@@ -0,0 +1,21 @@
[project]
name = "agent-framework-hosting-sample-local-telegram"
version = "0.0.1"
description = "Local Telegram bot using Agent Framework hosting protocol helpers."
requires-python = ">=3.10"
dependencies = [
"agent-framework-foundry",
"agent-framework-hosting",
"agent-framework-hosting-telegram",
"aiogram>=3.29.1,<4",
"azure-identity",
"fastapi>=0.115.0,<0.138.1",
"hypercorn>=0.17",
]
[tool.uv]
package = false
[tool.uv.sources]
agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "main", subdirectory = "python/packages/hosting" }
agent-framework-hosting-telegram = { git = "https://github.com/microsoft/agent-framework.git", branch = "main", subdirectory = "python/packages/hosting-telegram" }
+5
View File
@@ -64,6 +64,11 @@ python/samples/
4. **Single-file for 01-03**: Only 04-hosting and 05-end-to-end use multi-file
projects with their own README.
5. **Self-contained alternatives**: When a sample offers alternative entry
points (for example polling and webhook hosting), keep each entry point
self-contained. Do not extract a shared helper module solely to remove
duplication between sample variants.
## Default provider
All canonical samples (01-get-started) use **Microsoft Foundry project-backed chat** via `FoundryChatClient`
+1
View File
@@ -350,6 +350,7 @@ SAMPLE_TYPING_EXCLUDES = (
"_to_delete",
"05-end-to-end",
"harness",
"local_telegram",
)
+16
View File
@@ -52,6 +52,7 @@ members = [
"agent-framework-github-copilot",
"agent-framework-hosting",
"agent-framework-hosting-responses",
"agent-framework-hosting-telegram",
"agent-framework-hyperlight",
"agent-framework-lab",
"agent-framework-mem0",
@@ -683,6 +684,21 @@ test = [
{ name = "httpx", specifier = ">=0.28.1" },
]
[[package]]
name = "agent-framework-hosting-telegram"
version = "1.0.0a260709"
source = { editable = "packages/hosting-telegram" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-hosting", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
[package.metadata]
requires-dist = [
{ name = "agent-framework-core", editable = "packages/core" },
{ name = "agent-framework-hosting", editable = "packages/hosting" },
]
[[package]]
name = "agent-framework-hyperlight"
version = "1.0.0b260709"