agent-framework-hosting-telegram 1.0.0a260721__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Microsoft Corporation.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-framework-hosting-telegram
3
+ Version: 1.0.0a260721
4
+ Summary: Telegram Bot API-shaped helpers for agent-framework-hosting.
5
+ Author-email: Microsoft <af-support@microsoft.com>
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Typing :: Typed
18
+ License-File: LICENSE
19
+ Requires-Dist: agent-framework-core>=1.11.0,<2
20
+ Requires-Dist: agent-framework-hosting==1.0.0a260721
21
+ Project-URL: homepage, https://aka.ms/agent-framework
22
+ Project-URL: issues, https://github.com/microsoft/agent-framework/issues
23
+ Project-URL: release_notes, https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true
24
+ Project-URL: source, https://github.com/microsoft/agent-framework/tree/main/python
25
+
26
+ # agent-framework-hosting-telegram
27
+
28
+ Telegram Bot API-shaped helpers for app-owned Agent Framework hosting.
29
+
30
+ This is an alpha, helper-only package: it converts between Telegram's
31
+ `Update` JSON shape and Agent Framework run values in both directions. It
32
+ does **not** provide a bot client, a hosting/channel registry, or a
33
+ long-running service. Your app remains fully responsible for:
34
+
35
+ - **Fetching updates** -- long polling (`getUpdates`) or registering a
36
+ webhook -- and for verifying webhook authenticity (e.g. Telegram's secret
37
+ token header, or an IP allowlist).
38
+ - **The Bot API client** -- issuing the actual HTTP calls (`sendMessage`,
39
+ `sendPhoto`, `editMessageText`, `answerCallbackQuery`, `getFile`, ...) with
40
+ whatever HTTP library you prefer.
41
+ - **Rate limits and retries** -- Telegram enforces per-chat and global rate
42
+ limits; back off and retry on `429`/`5xx` yourself.
43
+ - **Command dispatch** -- `telegram_command(...)` only parses and normalizes
44
+ a leading `/command`; your app decides what each command does.
45
+ - **Sessions/storage** -- pair these helpers with
46
+ [`agent-framework-hosting`](https://pypi.org/project/agent-framework-hosting/)'s
47
+ `AgentState` / `SessionStore` (or your own) to persist `AgentSession`s across turns.
48
+
49
+ ## Helpers
50
+
51
+ - `telegram_chat_id(update)` -- the chat id an update belongs to.
52
+ - `telegram_session_id(update, bot_id=...)` -- a bot-scoped `AgentState`
53
+ session id. Private chats use `telegram:<bot_id>:<user_id>`; other chats use
54
+ `telegram:<bot_id>:<chat_id>`.
55
+ - `telegram_command(update)` -- a leading slash command, with `/name@bot args`
56
+ normalized to `/name args`. Returns `None` if there is none.
57
+ - `telegram_callback_query_id(update)` -- a callback query's id, so you can
58
+ call `answerCallbackQuery` yourself.
59
+ - `telegram_media_file_id(update_or_message)` -- the `(file_id, mime_type)`
60
+ of inbound media (largest photo size, document, voice, audio, or video).
61
+ - `telegram_to_run(update, *, resolve_file_url=None, stream=False)` -- convert
62
+ a `message`, `edited_message`, or `callback_query` update into
63
+ `Agent.run` arguments. Provide `resolve_file_url` (typically backed by
64
+ `getFile`) to turn inbound media into content; without it (or when it
65
+ returns `None`), text/caption is preserved and media is otherwise dropped.
66
+ Media-only input with no resolvable URL raises `ValueError`.
67
+ - `telegram_from_run(result, *, chat_id, parse_mode=None)` -- render a
68
+ finished run as one `TelegramOperation` (`sendPhoto` when the response has
69
+ an image, otherwise `sendMessage`, falling back to `"(no response)"`).
70
+ - `telegram_from_streaming_run(stream, *, chat_id, message_id,
71
+ initial_text=None, parse_mode=None)` -- render a streaming run as
72
+ `editMessageText` operations with the cumulative text so far, followed by any
73
+ images in the final response as `sendPhoto` operations. Pass the
74
+ app-created placeholder text as `initial_text` so an identical first edit is
75
+ omitted. Image-only responses first emit `deleteMessage` for the placeholder.
76
+
77
+ `TelegramOperation` is a minimal `TypedDict` of `{"method": str, "payload": dict}`
78
+ -- your app is responsible for actually calling the Bot API with it.
79
+
80
+ ```python
81
+ from agent_framework_hosting import AgentState
82
+ from agent_framework_hosting_telegram import (
83
+ telegram_chat_id,
84
+ telegram_from_run,
85
+ telegram_session_id,
86
+ telegram_to_run,
87
+ )
88
+
89
+ state = AgentState(agent)
90
+
91
+
92
+ async def handle_update(update: dict) -> None:
93
+ chat_id = telegram_chat_id(update)
94
+ if chat_id is None:
95
+ return # Not a chat update this bot handles.
96
+
97
+ session_id = telegram_session_id(update, bot_id=bot.id)
98
+ session = await state.get_or_create_session(session_id)
99
+ run = await telegram_to_run(update, resolve_file_url=resolve_telegram_file_url)
100
+ result = await (await state.get_target()).run(run["messages"], session=session, options=run["options"])
101
+ await state.set_session(session_id, session) # type: ignore[arg-type]
102
+
103
+ operation = telegram_from_run(result, chat_id=chat_id)
104
+ await call_bot_api(operation["method"], operation["payload"]) # Your HTTP client.
105
+ ```
106
+
107
+ The base execution-state helpers live in
108
+ [`agent-framework-hosting`](https://pypi.org/project/agent-framework-hosting/).
109
+
@@ -0,0 +1,83 @@
1
+ # agent-framework-hosting-telegram
2
+
3
+ Telegram Bot API-shaped helpers for app-owned Agent Framework hosting.
4
+
5
+ This is an alpha, helper-only package: it converts between Telegram's
6
+ `Update` JSON shape and Agent Framework run values in both directions. It
7
+ does **not** provide a bot client, a hosting/channel registry, or a
8
+ long-running service. Your app remains fully responsible for:
9
+
10
+ - **Fetching updates** -- long polling (`getUpdates`) or registering a
11
+ webhook -- and for verifying webhook authenticity (e.g. Telegram's secret
12
+ token header, or an IP allowlist).
13
+ - **The Bot API client** -- issuing the actual HTTP calls (`sendMessage`,
14
+ `sendPhoto`, `editMessageText`, `answerCallbackQuery`, `getFile`, ...) with
15
+ whatever HTTP library you prefer.
16
+ - **Rate limits and retries** -- Telegram enforces per-chat and global rate
17
+ limits; back off and retry on `429`/`5xx` yourself.
18
+ - **Command dispatch** -- `telegram_command(...)` only parses and normalizes
19
+ a leading `/command`; your app decides what each command does.
20
+ - **Sessions/storage** -- pair these helpers with
21
+ [`agent-framework-hosting`](https://pypi.org/project/agent-framework-hosting/)'s
22
+ `AgentState` / `SessionStore` (or your own) to persist `AgentSession`s across turns.
23
+
24
+ ## Helpers
25
+
26
+ - `telegram_chat_id(update)` -- the chat id an update belongs to.
27
+ - `telegram_session_id(update, bot_id=...)` -- a bot-scoped `AgentState`
28
+ session id. Private chats use `telegram:<bot_id>:<user_id>`; other chats use
29
+ `telegram:<bot_id>:<chat_id>`.
30
+ - `telegram_command(update)` -- a leading slash command, with `/name@bot args`
31
+ normalized to `/name args`. Returns `None` if there is none.
32
+ - `telegram_callback_query_id(update)` -- a callback query's id, so you can
33
+ call `answerCallbackQuery` yourself.
34
+ - `telegram_media_file_id(update_or_message)` -- the `(file_id, mime_type)`
35
+ of inbound media (largest photo size, document, voice, audio, or video).
36
+ - `telegram_to_run(update, *, resolve_file_url=None, stream=False)` -- convert
37
+ a `message`, `edited_message`, or `callback_query` update into
38
+ `Agent.run` arguments. Provide `resolve_file_url` (typically backed by
39
+ `getFile`) to turn inbound media into content; without it (or when it
40
+ returns `None`), text/caption is preserved and media is otherwise dropped.
41
+ Media-only input with no resolvable URL raises `ValueError`.
42
+ - `telegram_from_run(result, *, chat_id, parse_mode=None)` -- render a
43
+ finished run as one `TelegramOperation` (`sendPhoto` when the response has
44
+ an image, otherwise `sendMessage`, falling back to `"(no response)"`).
45
+ - `telegram_from_streaming_run(stream, *, chat_id, message_id,
46
+ initial_text=None, parse_mode=None)` -- render a streaming run as
47
+ `editMessageText` operations with the cumulative text so far, followed by any
48
+ images in the final response as `sendPhoto` operations. Pass the
49
+ app-created placeholder text as `initial_text` so an identical first edit is
50
+ omitted. Image-only responses first emit `deleteMessage` for the placeholder.
51
+
52
+ `TelegramOperation` is a minimal `TypedDict` of `{"method": str, "payload": dict}`
53
+ -- your app is responsible for actually calling the Bot API with it.
54
+
55
+ ```python
56
+ from agent_framework_hosting import AgentState
57
+ from agent_framework_hosting_telegram import (
58
+ telegram_chat_id,
59
+ telegram_from_run,
60
+ telegram_session_id,
61
+ telegram_to_run,
62
+ )
63
+
64
+ state = AgentState(agent)
65
+
66
+
67
+ async def handle_update(update: dict) -> None:
68
+ chat_id = telegram_chat_id(update)
69
+ if chat_id is None:
70
+ return # Not a chat update this bot handles.
71
+
72
+ session_id = telegram_session_id(update, bot_id=bot.id)
73
+ session = await state.get_or_create_session(session_id)
74
+ run = await telegram_to_run(update, resolve_file_url=resolve_telegram_file_url)
75
+ result = await (await state.get_target()).run(run["messages"], session=session, options=run["options"])
76
+ await state.set_session(session_id, session) # type: ignore[arg-type]
77
+
78
+ operation = telegram_from_run(result, chat_id=chat_id)
79
+ await call_bot_api(operation["method"], operation["payload"]) # Your HTTP client.
80
+ ```
81
+
82
+ The base execution-state helpers live in
83
+ [`agent-framework-hosting`](https://pypi.org/project/agent-framework-hosting/).
@@ -0,0 +1,43 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ """Telegram Bot API-shaped helpers for app-owned Agent Framework hosting."""
4
+
5
+ import importlib.metadata
6
+
7
+ from ._parsing import (
8
+ ResolveFileUrl,
9
+ telegram_callback_query_id,
10
+ telegram_chat_id,
11
+ telegram_command,
12
+ telegram_media_file_id,
13
+ telegram_session_id,
14
+ telegram_to_run,
15
+ )
16
+ from ._rendering import (
17
+ TELEGRAM_MAX_CAPTION_LENGTH,
18
+ TELEGRAM_MAX_TEXT_LENGTH,
19
+ TelegramOperation,
20
+ telegram_from_run,
21
+ telegram_from_streaming_run,
22
+ )
23
+
24
+ try:
25
+ __version__ = importlib.metadata.version(__name__)
26
+ except importlib.metadata.PackageNotFoundError:
27
+ __version__ = "0.0.0"
28
+
29
+ __all__ = [
30
+ "TELEGRAM_MAX_CAPTION_LENGTH",
31
+ "TELEGRAM_MAX_TEXT_LENGTH",
32
+ "ResolveFileUrl",
33
+ "TelegramOperation",
34
+ "__version__",
35
+ "telegram_callback_query_id",
36
+ "telegram_chat_id",
37
+ "telegram_command",
38
+ "telegram_from_run",
39
+ "telegram_from_streaming_run",
40
+ "telegram_media_file_id",
41
+ "telegram_session_id",
42
+ "telegram_to_run",
43
+ ]
@@ -0,0 +1,338 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ """Parsing helpers for the Telegram Bot API ``Update`` object.
4
+
5
+ Telegram delivers updates as JSON objects shaped like ``{"update_id": ...,
6
+ "message": {...}}`` (or ``edited_message`` / ``callback_query`` instead of
7
+ ``message``). These helpers pull out the handful of fields an app-owned route
8
+ needs -- chat id, a namespaced session id, a leading slash command, a
9
+ callback query id, and inbound media -- and translate an update into Agent
10
+ Framework ``Agent.run`` arguments. They do not poll for updates, register
11
+ webhooks, call the Telegram HTTP API, or dispatch commands; that is app-owned
12
+ route/bot-client code.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
19
+ from typing import Any, cast
20
+
21
+ from agent_framework import ChatOptions, Content, Message
22
+ from agent_framework_hosting import AgentRunArgs
23
+
24
+ # Telegram media fields whose objects carry a `file_id` (and, except photos,
25
+ # a `mime_type`) directly, mapped to the MIME type Telegram uses when the
26
+ # object omits `mime_type` (voice notes are always OGG/Opus, for example).
27
+ _MEDIA_DEFAULT_MIME_TYPES: dict[str, str] = {
28
+ "document": "application/octet-stream",
29
+ "voice": "audio/ogg",
30
+ "audio": "audio/mpeg",
31
+ "video": "video/mp4",
32
+ }
33
+
34
+ # Matches a leading Telegram bot command: `/name`, optionally `@botname`,
35
+ # optionally followed by arguments. Command names are `[A-Za-z0-9_]` per the
36
+ # Bot API's `bot_command` entity rules.
37
+ _COMMAND_PATTERN = re.compile(r"^/(?P<name>[A-Za-z0-9_]+)(?:@(?P<bot>[A-Za-z0-9_]+))?(?P<rest>.*)$", re.DOTALL)
38
+
39
+ ResolveFileUrl = Callable[[str], Awaitable[str | None]]
40
+ """Async callable resolving a Telegram ``file_id`` to a fetchable URL, or ``None``."""
41
+
42
+
43
+ def _inner_message(update: Mapping[str, Any]) -> Mapping[str, Any] | None:
44
+ """Return the ``message`` or ``edited_message`` object from ``update``, if present."""
45
+ for key in ("message", "edited_message"):
46
+ candidate = update.get(key)
47
+ if isinstance(candidate, Mapping):
48
+ return cast("Mapping[str, Any]", candidate)
49
+ return None
50
+
51
+
52
+ def _callback_query(update: Mapping[str, Any]) -> Mapping[str, Any] | None:
53
+ """Return the ``callback_query`` object from ``update``, if present."""
54
+ candidate = update.get("callback_query")
55
+ return cast("Mapping[str, Any]", candidate) if isinstance(candidate, Mapping) else None
56
+
57
+
58
+ def telegram_chat_id(update: Mapping[str, Any]) -> int | None:
59
+ """Return the chat id an update belongs to.
60
+
61
+ Reads ``message.chat.id`` / ``edited_message.chat.id``, falling back to
62
+ ``callback_query.message.chat.id`` for callback-only updates.
63
+
64
+ Args:
65
+ update: A Telegram Bot API ``Update`` object.
66
+
67
+ Returns:
68
+ The chat id, or ``None`` if the update carries none of the supported shapes.
69
+ """
70
+ message = _inner_message(update)
71
+ if message is None:
72
+ callback_query = _callback_query(update)
73
+ candidate = callback_query.get("message") if callback_query is not None else None
74
+ if not isinstance(candidate, Mapping):
75
+ return None
76
+ message = cast("Mapping[str, Any]", candidate)
77
+ chat_candidate = message.get("chat")
78
+ if not isinstance(chat_candidate, Mapping):
79
+ return None
80
+ chat = cast("Mapping[str, Any]", chat_candidate)
81
+ chat_id = chat.get("id")
82
+ return chat_id if isinstance(chat_id, int) else None
83
+
84
+
85
+ def telegram_session_id(update: Mapping[str, Any], *, bot_id: int) -> str | None:
86
+ """Return a session id using Telegram's native bot, user, and chat boundaries.
87
+
88
+ Args:
89
+ update: A Telegram Bot API ``Update`` object.
90
+
91
+ Keyword Args:
92
+ bot_id: The Telegram bot's numeric user id.
93
+
94
+ Returns:
95
+ ``telegram:<bot_id>:<user_id>`` for a private chat,
96
+ ``telegram:<bot_id>:<chat_id>`` for other chats, or ``None`` when the
97
+ required Telegram identity is absent.
98
+ """
99
+ chat_id = telegram_chat_id(update)
100
+ if chat_id is None:
101
+ return None
102
+
103
+ message = _inner_message(update)
104
+ callback_query = _callback_query(update)
105
+ if message is None and callback_query is not None:
106
+ callback_message = callback_query.get("message")
107
+ if isinstance(callback_message, Mapping):
108
+ message = cast("Mapping[str, Any]", callback_message)
109
+
110
+ chat_candidate = message.get("chat") if message is not None else None
111
+ chat = cast("Mapping[str, Any]", chat_candidate) if isinstance(chat_candidate, Mapping) else None
112
+ chat_type = chat.get("type") if chat is not None else None
113
+ if chat_type != "private":
114
+ return f"telegram:{bot_id}:{chat_id}"
115
+
116
+ if callback_query is not None:
117
+ sender_candidate = callback_query.get("from")
118
+ elif message is not None:
119
+ sender_candidate = message.get("from")
120
+ else:
121
+ return None
122
+ if not isinstance(sender_candidate, Mapping):
123
+ return None
124
+ sender = cast("Mapping[str, Any]", sender_candidate)
125
+ sender_id = sender.get("id")
126
+ return f"telegram:{bot_id}:{sender_id}" if isinstance(sender_id, int) else None
127
+
128
+
129
+ def telegram_callback_query_id(update: Mapping[str, Any]) -> str | None:
130
+ """Return the callback query id from an update, if present.
131
+
132
+ Apps need this to answer the callback query (``answerCallbackQuery``) so
133
+ Telegram stops showing the client-side loading spinner; this helper only
134
+ extracts the id, it does not call the Bot API.
135
+
136
+ Args:
137
+ update: A Telegram Bot API ``Update`` object.
138
+
139
+ Returns:
140
+ The callback query id, or ``None`` if the update has no ``callback_query``.
141
+ """
142
+ callback_query = _callback_query(update)
143
+ if callback_query is None:
144
+ return None
145
+ query_id = callback_query.get("id")
146
+ return query_id if isinstance(query_id, str) else None
147
+
148
+
149
+ def _command_source_text(update: Mapping[str, Any]) -> str | None:
150
+ """Return the text a leading command should be parsed from."""
151
+ message = _inner_message(update)
152
+ if message is not None:
153
+ text = message.get("text")
154
+ if isinstance(text, str):
155
+ return text
156
+ callback_query = _callback_query(update)
157
+ if callback_query is not None:
158
+ data = callback_query.get("data")
159
+ if isinstance(data, str):
160
+ return data
161
+ return None
162
+
163
+
164
+ def telegram_command(update: Mapping[str, Any]) -> str | None:
165
+ """Parse a leading slash command out of an update, without dispatching it.
166
+
167
+ Looks at ``message.text`` / ``edited_message.text`` first, then
168
+ ``callback_query.data``. A bot-suffixed command (``/name@bot args``) is
169
+ normalized to ``/name args`` since a single Bot API integration only ever
170
+ serves one bot username. Callers are responsible for matching the
171
+ returned command name and acting on it.
172
+
173
+ Args:
174
+ update: A Telegram Bot API ``Update`` object.
175
+
176
+ Returns:
177
+ The normalized command (e.g. ``"/start"`` or ``"/start hello"``), or
178
+ ``None`` if the source text does not start with a command.
179
+ """
180
+ text = _command_source_text(update)
181
+ if text is None:
182
+ return None
183
+ match = _COMMAND_PATTERN.match(text)
184
+ if not match:
185
+ return None
186
+ return f"/{match.group('name')}{match.group('rest')}".rstrip()
187
+
188
+
189
+ def _resolve_media_message(update_or_message: Mapping[str, Any]) -> Mapping[str, Any] | None:
190
+ """Return the message object to inspect for media, from either an update or a bare message."""
191
+ if "message" in update_or_message or "edited_message" in update_or_message or "callback_query" in update_or_message:
192
+ message = _inner_message(update_or_message)
193
+ if message is not None:
194
+ return message
195
+ callback_query = _callback_query(update_or_message)
196
+ if callback_query is not None:
197
+ candidate = callback_query.get("message")
198
+ if isinstance(candidate, Mapping):
199
+ return cast("Mapping[str, Any]", candidate)
200
+ return None
201
+ return update_or_message
202
+
203
+
204
+ def _largest_photo_file_id(photo_sizes: Sequence[Any]) -> str | None:
205
+ """Return the ``file_id`` of the highest-resolution entry in a Telegram ``photo`` array."""
206
+ candidates = [cast("Mapping[str, Any]", size) for size in photo_sizes if isinstance(size, Mapping)]
207
+ if not candidates:
208
+ return None
209
+
210
+ def _area(size: Mapping[str, Any]) -> int:
211
+ file_size = size.get("file_size")
212
+ if isinstance(file_size, int):
213
+ return file_size
214
+ width, height = size.get("width"), size.get("height")
215
+ return width * height if isinstance(width, int) and isinstance(height, int) else 0
216
+
217
+ largest = max(candidates, key=_area)
218
+ file_id = largest.get("file_id")
219
+ return file_id if isinstance(file_id, str) else None
220
+
221
+
222
+ def telegram_media_file_id(update_or_message: Mapping[str, Any]) -> tuple[str, str] | None:
223
+ """Return the ``(file_id, mime_type)`` of the inbound media attached to an update or message.
224
+
225
+ Accepts either a full ``Update`` object (media is read from ``message`` /
226
+ ``edited_message`` / ``callback_query.message``) or a bare Telegram
227
+ message object. Photos pick the largest size Telegram sent (by
228
+ ``file_size``, falling back to pixel area); documents, voice notes,
229
+ audio, and video use their own ``file_id`` and ``mime_type`` (falling
230
+ back to Telegram's known default MIME type when the field is absent).
231
+
232
+ Args:
233
+ update_or_message: A Telegram ``Update`` or message object.
234
+
235
+ Returns:
236
+ A ``(file_id, mime_type)`` tuple, or ``None`` if there is no supported media.
237
+ """
238
+ message = _resolve_media_message(update_or_message)
239
+ if message is None:
240
+ return None
241
+
242
+ photo = message.get("photo")
243
+ if isinstance(photo, Sequence) and not isinstance(photo, (str, bytes, bytearray)) and photo:
244
+ file_id = _largest_photo_file_id(cast("Sequence[Any]", photo))
245
+ if file_id is not None:
246
+ return file_id, "image/jpeg"
247
+
248
+ for key, default_mime_type in _MEDIA_DEFAULT_MIME_TYPES.items():
249
+ item_candidate = message.get(key)
250
+ if isinstance(item_candidate, Mapping):
251
+ item = cast("Mapping[str, Any]", item_candidate)
252
+ file_id = item.get("file_id")
253
+ if isinstance(file_id, str):
254
+ mime_type = item.get("mime_type")
255
+ return file_id, mime_type if isinstance(mime_type, str) and mime_type else default_mime_type
256
+ return None
257
+
258
+
259
+ async def _contents_from_message(
260
+ message: Mapping[str, Any],
261
+ resolve_file_url: ResolveFileUrl | None,
262
+ ) -> list[Content]:
263
+ """Translate one Telegram message object into Agent Framework content parts.
264
+
265
+ Raises:
266
+ ValueError: If the message has no text, caption, or resolvable media.
267
+ """
268
+ text = message.get("text")
269
+ caption = message.get("caption")
270
+ text_value = text if isinstance(text, str) and text else (caption if isinstance(caption, str) and caption else None)
271
+
272
+ contents: list[Content] = []
273
+ media = telegram_media_file_id(message)
274
+ if media is not None:
275
+ file_id, mime_type = media
276
+ resolved_url = await resolve_file_url(file_id) if resolve_file_url is not None else None
277
+ if resolved_url:
278
+ contents.append(Content.from_uri(uri=resolved_url, media_type=mime_type))
279
+ elif text_value is None:
280
+ raise ValueError(
281
+ f"Cannot resolve Telegram media file_id={file_id!r}: no `resolve_file_url` was given (or it "
282
+ "returned None), and the message has no text/caption to fall back to."
283
+ )
284
+ if text_value is not None:
285
+ contents.append(Content.from_text(text=text_value))
286
+
287
+ if not contents:
288
+ raise ValueError("Telegram message has no text, caption, or resolvable media to convert to a run.")
289
+ return contents
290
+
291
+
292
+ async def telegram_to_run(
293
+ update: Mapping[str, Any],
294
+ *,
295
+ resolve_file_url: ResolveFileUrl | None = None,
296
+ stream: bool = False,
297
+ ) -> AgentRunArgs:
298
+ """Convert a Telegram update into Agent Framework run values.
299
+
300
+ Supports ``message``, ``edited_message``, and ``callback_query`` updates.
301
+ Message/edited-message text or caption becomes text content; inbound
302
+ media becomes uri content when ``resolve_file_url`` resolves it (media
303
+ alone with no resolvable URL and no text/caption raises rather than
304
+ producing an empty run). A callback query's ``data`` becomes the user's
305
+ text.
306
+
307
+ Args:
308
+ update: A Telegram Bot API ``Update`` object.
309
+
310
+ Keyword Args:
311
+ resolve_file_url: Optional async callable that resolves a Telegram
312
+ ``file_id`` (typically via the Bot API's ``getFile``) to a
313
+ fetchable URL, or ``None`` if it cannot. When omitted, media is
314
+ ignored and only text/caption is used.
315
+ stream: Whether the caller intends to run the agent in streaming mode.
316
+
317
+ Returns:
318
+ Arguments corresponding to ``Agent.run``.
319
+
320
+ Raises:
321
+ ValueError: If the update has no actionable message/callback data, or
322
+ a message has no text, caption, or resolvable media.
323
+ """
324
+ message = _inner_message(update)
325
+ if message is not None:
326
+ contents = await _contents_from_message(message, resolve_file_url)
327
+ else:
328
+ callback_query = _callback_query(update)
329
+ data = callback_query.get("data") if callback_query is not None else None
330
+ if not isinstance(data, str) or not data:
331
+ raise ValueError("Telegram update has no actionable `message`, `edited_message`, or `callback_query.data`.")
332
+ contents = [Content.from_text(text=data)]
333
+
334
+ return AgentRunArgs(
335
+ messages=[Message("user", contents)],
336
+ options=cast("ChatOptions[Any]", {}),
337
+ stream=stream,
338
+ )
@@ -0,0 +1,171 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ """Rendering helpers that translate Agent Framework results into native Telegram Bot API calls.
4
+
5
+ Each helper returns (or yields) a small ``TelegramOperation`` -- a Telegram
6
+ Bot API method name plus its JSON payload. App-owned code decides how to
7
+ actually invoke the Bot API (``sendMessage``, ``sendPhoto``,
8
+ ``editMessageText``, ...), including authentication, retries, and rate
9
+ limiting; these helpers make no network calls.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import AsyncIterator
15
+ from typing import Any, TypedDict
16
+
17
+ from agent_framework import AgentResponse, AgentResponseUpdate, ResponseStream
18
+
19
+ # Telegram's documented maximum length, in UTF-16 code units, for message
20
+ # text (`sendMessage` / `editMessageText`) and photo captions (`sendPhoto`).
21
+ TELEGRAM_MAX_TEXT_LENGTH = 4096
22
+ TELEGRAM_MAX_CAPTION_LENGTH = 1024
23
+
24
+ _NO_RESPONSE_TEXT = "(no response)"
25
+
26
+
27
+ class TelegramOperation(TypedDict):
28
+ """A single native Telegram Bot API call produced by a rendering helper.
29
+
30
+ Attributes:
31
+ method: The Telegram Bot API method name (e.g. ``"sendMessage"``).
32
+ payload: The JSON-serializable request body for that method.
33
+ """
34
+
35
+ method: str
36
+ payload: dict[str, Any]
37
+
38
+
39
+ def _truncate(text: str, max_length: int) -> str:
40
+ """Deterministically cap ``text`` at ``max_length`` UTF-16 code units."""
41
+ units = 0
42
+ for index, char in enumerate(text):
43
+ units += 2 if ord(char) > 0xFFFF else 1
44
+ if units > max_length:
45
+ return text[:index]
46
+ return text
47
+
48
+
49
+ def _text_and_image_uris(result: AgentResponse[Any]) -> tuple[str, list[str]]:
50
+ """Return the response's concatenated text and any image uris it carries."""
51
+ image_uris = [
52
+ content.uri
53
+ for message in result.messages
54
+ for content in message.contents
55
+ if content.type == "uri" and content.uri and (content.media_type or "").startswith("image/")
56
+ ]
57
+ return result.text, image_uris
58
+
59
+
60
+ def telegram_from_run(
61
+ result: AgentResponse[Any],
62
+ *,
63
+ chat_id: int,
64
+ parse_mode: str | None = None,
65
+ ) -> TelegramOperation:
66
+ """Render a finished agent run as one native Telegram Bot API call.
67
+
68
+ An image in the response renders as ``sendPhoto`` (using the first image
69
+ found; any accompanying text becomes its caption). Otherwise, response
70
+ text renders as ``sendMessage``, falling back to ``"(no response)"`` when
71
+ the response has neither text nor an image.
72
+
73
+ Args:
74
+ result: The finished agent response to render.
75
+
76
+ Keyword Args:
77
+ chat_id: The Telegram chat id to address the message to.
78
+ parse_mode: Optional Telegram ``parse_mode`` (e.g. ``"MarkdownV2"``,
79
+ ``"HTML"``) to attach to the rendered payload.
80
+
81
+ Returns:
82
+ A ``TelegramOperation`` describing the Bot API call to make.
83
+ """
84
+ text, image_uris = _text_and_image_uris(result)
85
+ if image_uris:
86
+ payload: dict[str, Any] = {"chat_id": chat_id, "photo": image_uris[0]}
87
+ if text:
88
+ payload["caption"] = _truncate(text, TELEGRAM_MAX_CAPTION_LENGTH)
89
+ if parse_mode:
90
+ payload["parse_mode"] = parse_mode
91
+ return TelegramOperation(method="sendPhoto", payload=payload)
92
+
93
+ payload = {"chat_id": chat_id, "text": _truncate(text or _NO_RESPONSE_TEXT, TELEGRAM_MAX_TEXT_LENGTH)}
94
+ if parse_mode:
95
+ payload["parse_mode"] = parse_mode
96
+ return TelegramOperation(method="sendMessage", payload=payload)
97
+
98
+
99
+ async def telegram_from_streaming_run(
100
+ stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]],
101
+ *,
102
+ chat_id: int,
103
+ message_id: int,
104
+ initial_text: str | None = None,
105
+ parse_mode: str | None = None,
106
+ ) -> AsyncIterator[TelegramOperation]:
107
+ """Render a streaming agent run as a sequence of native Telegram Bot API calls.
108
+
109
+ Yields one ``editMessageText`` operation per update carrying new text, each
110
+ with the cumulative text so far (Telegram has no incremental-append edit
111
+ call). Interim edits omit ``parse_mode`` since intermediate text is not
112
+ guaranteed to be valid in the target markup. After the stream ends, yields
113
+ a final ``editMessageText`` reflecting the finalized response text (this
114
+ one includes ``parse_mode`` when given) and then one ``sendPhoto``
115
+ operation per image the finalized response carries. Errors raised while
116
+ iterating the stream or while finalizing it (``stream.get_final_response()``)
117
+ propagate to the caller rather than being swallowed.
118
+
119
+ Args:
120
+ stream: The agent response stream returned by ``agent.run(..., stream=True)``.
121
+
122
+ Keyword Args:
123
+ chat_id: The Telegram chat id the streamed message lives in.
124
+ message_id: The id of the Telegram message to edit as new content arrives.
125
+ initial_text: The current text of the app-created placeholder message.
126
+ Matching edits are omitted because Telegram rejects no-op edits.
127
+ parse_mode: Optional Telegram ``parse_mode`` to attach to the final text edit.
128
+
129
+ Yields:
130
+ ``TelegramOperation`` values describing the Bot API calls to make, in order.
131
+ """
132
+ text = ""
133
+ last_rendered_text = _truncate(initial_text, TELEGRAM_MAX_TEXT_LENGTH) if initial_text is not None else ""
134
+ async for update in stream:
135
+ if update.text:
136
+ text += update.text
137
+ rendered_text = _truncate(text, TELEGRAM_MAX_TEXT_LENGTH)
138
+ if rendered_text == last_rendered_text:
139
+ continue
140
+ last_rendered_text = rendered_text
141
+ yield TelegramOperation(
142
+ method="editMessageText",
143
+ payload={
144
+ "chat_id": chat_id,
145
+ "message_id": message_id,
146
+ "text": rendered_text,
147
+ },
148
+ )
149
+
150
+ final = await stream.get_final_response()
151
+ final_text, image_uris = _text_and_image_uris(final)
152
+
153
+ if final_text or not image_uris:
154
+ rendered_text = _truncate(final_text or _NO_RESPONSE_TEXT, TELEGRAM_MAX_TEXT_LENGTH)
155
+ payload: dict[str, Any] = {
156
+ "chat_id": chat_id,
157
+ "message_id": message_id,
158
+ "text": rendered_text,
159
+ }
160
+ if parse_mode:
161
+ payload["parse_mode"] = parse_mode
162
+ if rendered_text != last_rendered_text or parse_mode:
163
+ yield TelegramOperation(method="editMessageText", payload=payload)
164
+ else:
165
+ yield TelegramOperation(
166
+ method="deleteMessage",
167
+ payload={"chat_id": chat_id, "message_id": message_id},
168
+ )
169
+
170
+ for uri in image_uris:
171
+ yield TelegramOperation(method="sendPhoto", payload={"chat_id": chat_id, "photo": uri})
@@ -0,0 +1,79 @@
1
+ [project]
2
+ name = "agent-framework-hosting-telegram"
3
+ description = "Telegram Bot API-shaped helpers for agent-framework-hosting."
4
+ authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ version = "1.0.0a260721"
8
+ license-files = ["LICENSE"]
9
+ urls.homepage = "https://aka.ms/agent-framework"
10
+ urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
11
+ urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
12
+ urls.issues = "https://github.com/microsoft/agent-framework/issues"
13
+ classifiers = [
14
+ "License :: OSI Approved :: MIT License",
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = [
26
+ "agent-framework-core>=1.11.0,<2",
27
+ "agent-framework-hosting==1.0.0a260721",
28
+ ]
29
+
30
+ [tool.uv]
31
+ prerelease = "if-necessary-or-explicit"
32
+ environments = [
33
+ "sys_platform == 'darwin'",
34
+ "sys_platform == 'linux'",
35
+ "sys_platform == 'win32'"
36
+ ]
37
+
38
+ [tool.uv-dynamic-versioning]
39
+ fallback-version = "0.0.0"
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = 'tests'
43
+ addopts = "-ra -q -r fEX"
44
+ asyncio_mode = "auto"
45
+ asyncio_default_fixture_loop_scope = "function"
46
+ filterwarnings = []
47
+ timeout = 120
48
+ markers = [
49
+ "integration: marks tests as integration tests that require external services",
50
+ ]
51
+
52
+ [tool.ruff]
53
+ extend = "../../pyproject.toml"
54
+
55
+ [tool.coverage.run]
56
+ omit = [
57
+ "**/__init__.py"
58
+ ]
59
+
60
+ [tool.pyright]
61
+ extends = "../../pyproject.toml"
62
+ include = ["agent_framework_hosting_telegram"]
63
+ exclude = ['tests']
64
+
65
+ [tool.bandit]
66
+ targets = ["agent_framework_hosting_telegram"]
67
+ exclude_dirs = ["tests"]
68
+
69
+ [tool.poe]
70
+ executor.type = "uv"
71
+ include = "../../shared_tasks.toml"
72
+
73
+ [tool.poe.tasks.test]
74
+ help = "Run the default unit test suite for this package."
75
+ cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_telegram --cov-report=term-missing:skip-covered tests'
76
+
77
+ [build-system]
78
+ requires = ["flit-core >= 3.11,<4.0"]
79
+ build-backend = "flit_core.buildapi"