diff --git a/python/packages/hosting-telegram/README.md b/python/packages/hosting-telegram/README.md index 6c488f2d7a5..c55b8481a76 100644 --- a/python/packages/hosting-telegram/README.md +++ b/python/packages/hosting-telegram/README.md @@ -28,8 +28,11 @@ long-running service. Your app remains fully responsible for: - `telegram_session_id(update, bot_id=...)` -- a bot-scoped `AgentState` session id. Private chats use `telegram::`; other chats use `telegram::`. -- `telegram_command(update)` -- a leading slash command, with `/name@bot args` - normalized to `/name args`. Returns `None` if there is none. +- `telegram_command(update, bot_username=None)` -- a leading slash command in + message text, callback data, or a media caption, + with `/name@bot args` normalized to `/name args`. Pass your bot's username to + return `None` for commands addressed to another bot. Without it, parsing + keeps the original behavior. - `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)` diff --git a/python/packages/hosting-telegram/agent_framework_hosting_telegram/_parsing.py b/python/packages/hosting-telegram/agent_framework_hosting_telegram/_parsing.py index e738658dad2..827d3be8ea9 100644 --- a/python/packages/hosting-telegram/agent_framework_hosting_telegram/_parsing.py +++ b/python/packages/hosting-telegram/agent_framework_hosting_telegram/_parsing.py @@ -150,7 +150,7 @@ def telegram_callback_query_id(update: Mapping[str, Any]) -> str | None: def _command_source_text(update: Mapping[str, Any]) -> str | None: - """Return the text a leading command should be parsed from.""" + """Return message text, callback data, or a top-level media caption for command parsing.""" message = _inner_message(update) if message is not None: text = message.get("text") @@ -161,20 +161,28 @@ def _command_source_text(update: Mapping[str, Any]) -> str | None: data = callback_query.get("data") if isinstance(data, str): return data + if message is not None: + caption = message.get("caption") + if isinstance(caption, str): + return caption return None -def telegram_command(update: Mapping[str, Any]) -> str | None: +def telegram_command(update: Mapping[str, Any], *, bot_username: str | None = None) -> 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. + ``callback_query.data``, then ``message.caption`` / ``edited_message.caption``. + A bot-suffixed command (``/name@bot args``) is + normalized to ``/name args``. When ``bot_username`` is provided, commands + addressed to another bot return ``None``. Callers are responsible for + matching the returned command name and acting on it. Args: update: A Telegram Bot API ``Update`` object. + bot_username: This bot's username, without the leading ``@``. Telegram + usernames are matched case-insensitively. Omit to preserve the + original parsing behavior. Returns: The normalized command (e.g. ``"/start"`` or ``"/start hello"``), or @@ -186,6 +194,9 @@ def telegram_command(update: Mapping[str, Any]) -> str | None: match = _COMMAND_PATTERN.match(text) if not match: return None + command_bot = match.group("bot") + if bot_username is not None and command_bot and command_bot.casefold() != bot_username.lstrip("@").casefold(): + return None return f"/{match.group('name')}{match.group('rest')}".rstrip() diff --git a/python/packages/hosting-telegram/tests/hosting_telegram/test_parsing.py b/python/packages/hosting-telegram/tests/hosting_telegram/test_parsing.py index 0cb4af0041b..35f46281fd0 100644 --- a/python/packages/hosting-telegram/tests/hosting_telegram/test_parsing.py +++ b/python/packages/hosting-telegram/tests/hosting_telegram/test_parsing.py @@ -97,10 +97,29 @@ def test_bot_suffixed_command_normalizes(self) -> None: def test_bot_suffixed_command_with_args_normalizes(self) -> None: assert telegram_command(_message_update(text="/echo@mybot hello")) == "/echo hello" + def test_bot_suffixed_command_only_matches_its_target(self) -> None: + update = _message_update(text="/new@OtherBot") + assert telegram_command(update, bot_username="mybot") is None + assert telegram_command(update, bot_username="otherbot") == "/new" + assert telegram_command(_message_update(text="/new"), bot_username="mybot") == "/new" + def test_edited_message_text(self) -> None: update = {"update_id": 1, "edited_message": {"chat": {"id": 1}, "text": "/help"}} assert telegram_command(update) == "/help" + @pytest.mark.parametrize("message_type", ["message", "edited_message"]) + def test_media_caption_command_matches_target(self, message_type: str) -> None: + update = {"update_id": 1, message_type: {"chat": {"id": -1}, "caption": "/new@OtherBot", "photo": []}} + assert telegram_command(update, bot_username="mybot") is None + assert telegram_command(update, bot_username="otherbot") == "/new" + + def test_callback_data_takes_precedence_over_media_caption(self) -> None: + update = { + "message": {"caption": "/new@otherbot"}, + "callback_query": {"data": "/help@mybot"}, + } + assert telegram_command(update, bot_username="mybot") == "/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" diff --git a/python/samples/04-hosting/af-hosting/local_telegram/README.md b/python/samples/04-hosting/af-hosting/local_telegram/README.md index 5434c16d064..4320fdb4a52 100644 --- a/python/samples/04-hosting/af-hosting/local_telegram/README.md +++ b/python/samples/04-hosting/af-hosting/local_telegram/README.md @@ -87,6 +87,8 @@ process just registered. 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. + Commands addressed to another bot in a group are ignored, including `/new` + in a media caption. - **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. diff --git a/python/samples/04-hosting/af-hosting/local_telegram/app.py b/python/samples/04-hosting/af-hosting/local_telegram/app.py index be3db48c1b7..a94642095f7 100644 --- a/python/samples/04-hosting/af-hosting/local_telegram/app.py +++ b/python/samples/04-hosting/af-hosting/local_telegram/app.py @@ -187,8 +187,12 @@ async def handle_update(update: Mapping[str, Any]) -> None: # 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 + if (command := telegram_command(update)) is not None: + username = (await bot.me()).username + if not username or telegram_command(update, bot_username=username) is None: + return + if await handle_command(update, command): + return async def resolve_file_url(file_id: str) -> str | None: file = await bot.get_file(file_id) diff --git a/python/samples/04-hosting/af-hosting/local_telegram/polling_app.py b/python/samples/04-hosting/af-hosting/local_telegram/polling_app.py index b79489920c4..f15e92da73c 100644 --- a/python/samples/04-hosting/af-hosting/local_telegram/polling_app.py +++ b/python/samples/04-hosting/af-hosting/local_telegram/polling_app.py @@ -151,8 +151,12 @@ async def handle_update(bot: Bot, update: Mapping[str, Any]) -> None: 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 + if (command := telegram_command(update)) is not None: + username = (await bot.me()).username + if not username or telegram_command(update, bot_username=username) is None: + return + if await handle_command(bot, update, command): + return chat_id = telegram_chat_id(update) session_id = telegram_session_id(update, bot_id=bot.id) diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/telegram/README.md b/python/samples/04-hosting/foundry-hosted-agents/invocations/telegram/README.md index e6ca0734456..f33634d418b 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/invocations/telegram/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/telegram/README.md @@ -122,6 +122,8 @@ step registers the same new value with Telegram. One bot is deployed per sample environment, so the chat-derived session key is scoped by that environment. `/new` clears that Cosmos history without invoking the model. `/start` and `/help` are also handled in application code. Callback queries are acknowledged before their data is processed. +Commands addressed to another bot, including in media captions, are ignored before they can clear history or reach +the model. For photos, PDF documents, and MP3 or WAV audio, the agent calls Telegram `getFile`, rejects files over 1 MiB, downloads the bytes, and creates an inline data URI. The conservative limit leaves room for base64 and Cosmos DB diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/telegram/main.py b/python/samples/04-hosting/foundry-hosted-agents/invocations/telegram/main.py index 5e46a8a3867..22d75bce427 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/invocations/telegram/main.py +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/telegram/main.py @@ -17,7 +17,7 @@ import os import time from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -84,6 +84,8 @@ class Runtime: secrets: SecretClient http: httpx.AsyncClient bot_token: str | None = None + bot_username: str | None = None + bot_username_lock: asyncio.Lock = field(default_factory=asyncio.Lock) _runtime: Runtime | None = None @@ -146,6 +148,21 @@ async def get_bot_token(runtime: Runtime) -> str: return value +async def get_bot_username(runtime: Runtime) -> str: + """Return this bot's Telegram username, fetching it once from getMe.""" + if runtime.bot_username is not None: + return runtime.bot_username + async with runtime.bot_username_lock: + if runtime.bot_username is not None: + return runtime.bot_username + me = await execute_telegram_operation(runtime, TelegramOperation(method="getMe", payload={})) + username = me.get("username") + if not isinstance(username, str) or not username: + raise RuntimeError("Telegram getMe did not return a bot username") + runtime.bot_username = username + return username + + async def authenticate_ingress(request: Request, runtime: Runtime) -> bool: """Validate the secret stamped by API Management.""" provided_secret = request.headers.get(INGRESS_SECRET_HEADER) @@ -348,8 +365,11 @@ async def handle_telegram_update(update: Mapping[str, Any], session_id: str, run ) command = telegram_command(update) - if command is not None and await _send_command_response(update, command, session_id, runtime): - return + if command is not None: + if telegram_command(update, bot_username=await get_bot_username(runtime)) is None: + return + if await _send_command_response(update, command, session_id, runtime): + return media = telegram_media_file_id(update) model_media_type = MODEL_MEDIA_TYPES.get(media[1].lower()) if media is not None else None diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/telegram/tests/test_main.py b/python/samples/04-hosting/foundry-hosted-agents/invocations/telegram/tests/test_main.py index 3acbf4f503b..012effe9010 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/invocations/telegram/tests/test_main.py +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/telegram/tests/test_main.py @@ -170,7 +170,7 @@ async def test_rejects_missing_or_unsupported_channel(payload: dict[str, Any], m async def test_new_clears_durable_history(monkeypatch: pytest.MonkeyPatch) -> None: history = SimpleNamespace(clear=AsyncMock()) - runtime = cast(Any, SimpleNamespace(history=history)) + runtime = cast(Any, SimpleNamespace(history=history, bot_username="mybot")) execute = AsyncMock(return_value={}) monkeypatch.setattr(main, "execute_telegram_operation", execute) @@ -189,7 +189,7 @@ async def test_rejects_mismatched_session_before_telegram_side_effect( monkeypatch: pytest.MonkeyPatch, ) -> None: history = SimpleNamespace(clear=AsyncMock()) - runtime = cast(Any, SimpleNamespace(history=history)) + runtime = cast(Any, SimpleNamespace(history=history, bot_username="mybot")) execute = AsyncMock(return_value={}) monkeypatch.setattr(main, "execute_telegram_operation", execute) @@ -214,7 +214,7 @@ async def test_application_commands_bypass_model( ) -> None: execute = AsyncMock(return_value={}) agent = SimpleNamespace(run=Mock()) - runtime = cast(Any, SimpleNamespace(agent=agent)) + runtime = cast(Any, SimpleNamespace(agent=agent, bot_username="mybot")) monkeypatch.setattr(main, "execute_telegram_operation", execute) await main.handle_telegram_update(_message_update(command), "123", runtime)