From 9d0bb859d9890c59d711c44877a499687e78bee5 Mon Sep 17 00:00:00 2001 From: dakjdakd <1428823446@qq.com> Date: Mon, 28 Sep 2026 21:29:00 +0800 Subject: [PATCH 1/4] Fix Telegram command routing for other bots --- python/packages/hosting-telegram/README.md | 6 +- .../_parsing.py | 14 ++-- .../tests/hosting_telegram/test_parsing.py | 6 ++ .../af-hosting/local_telegram/README.md | 1 + .../af-hosting/local_telegram/app.py | 8 ++- .../af-hosting/local_telegram/polling_app.py | 8 ++- .../tests/test_command_target.py | 67 +++++++++++++++++++ .../invocations/telegram/README.md | 1 + .../invocations/telegram/main.py | 20 +++++- .../invocations/telegram/tests/test_main.py | 43 +++++++++++- 10 files changed, 159 insertions(+), 15 deletions(-) create mode 100644 python/samples/04-hosting/af-hosting/local_telegram/tests/test_command_target.py diff --git a/python/packages/hosting-telegram/README.md b/python/packages/hosting-telegram/README.md index 6c488f2d7a5..2f36260b098 100644 --- a/python/packages/hosting-telegram/README.md +++ b/python/packages/hosting-telegram/README.md @@ -28,8 +28,10 @@ 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, + 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..72f0eb3e4aa 100644 --- a/python/packages/hosting-telegram/agent_framework_hosting_telegram/_parsing.py +++ b/python/packages/hosting-telegram/agent_framework_hosting_telegram/_parsing.py @@ -164,17 +164,20 @@ def _command_source_text(update: Mapping[str, Any]) -> str | None: 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. + 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 +189,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..675623a5da2 100644 --- a/python/packages/hosting-telegram/tests/hosting_telegram/test_parsing.py +++ b/python/packages/hosting-telegram/tests/hosting_telegram/test_parsing.py @@ -97,6 +97,12 @@ 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" 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..ef7f794f575 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,7 @@ 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`. - **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/af-hosting/local_telegram/tests/test_command_target.py b/python/samples/04-hosting/af-hosting/local_telegram/tests/test_command_target.py new file mode 100644 index 00000000000..0b8f0bd21bf --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_telegram/tests/test_command_target.py @@ -0,0 +1,67 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Command routing checks shared by the polling and webhook samples.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock + +import pytest + + +@pytest.fixture(params=["polling_app", "app"]) +def sample(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> Any: + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "123456:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi") + monkeypatch.setenv("TELEGRAM_WEBHOOK_URL", "https://example.com/telegram") + monkeypatch.setenv("TELEGRAM_WEBHOOK_SECRET", "test-secret") + path = Path(__file__).parents[1] / f"{request.param}.py" + spec = importlib.util.spec_from_file_location(f"telegram_sample_{request.param}", path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Unable to load {path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.mark.parametrize( + ("text", "handled"), + [("/new@otherbot", False), ("/new@MyBot", True), ("/new", True)], +) +async def test_command_target_controls_dispatch( + sample: Any, + monkeypatch: pytest.MonkeyPatch, + text: str, + handled: bool, +) -> None: + bot = SimpleNamespace(id=123456, me=AsyncMock(return_value=SimpleNamespace(username="mybot"))) + monkeypatch.setattr(sample, "bot", bot, raising=False) + handle_command = AsyncMock(return_value=True) + to_run = AsyncMock() + monkeypatch.setattr(sample, "handle_command", handle_command) + monkeypatch.setattr(sample, "telegram_to_run", to_run) + update = { + "update_id": 1, + "message": { + "message_id": 2, + "from": {"id": 42}, + "chat": {"id": -123, "type": "supergroup"}, + "text": text, + }, + } + + if sample.__name__.endswith("polling_app"): + await sample.handle_update(bot, update) + else: + await sample.handle_update(update) + + if handled: + handle_command.assert_awaited_once() + else: + handle_command.assert_not_awaited() + to_run.assert_not_awaited() 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..7ba8ed4c9a9 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,7 @@ 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 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..6ffdcf4d62f 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 @@ -84,6 +84,7 @@ class Runtime: secrets: SecretClient http: httpx.AsyncClient bot_token: str | None = None + bot_username: str | None = None _runtime: Runtime | None = None @@ -146,6 +147,18 @@ 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 + 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 +361,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..1736e6f40e4 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) @@ -185,11 +185,48 @@ async def test_new_clears_durable_history(monkeypatch: pytest.MonkeyPatch) -> No assert "empty history" in operation["payload"]["text"] +@pytest.mark.parametrize("command", ["/new@otherbot", "/help@otherbot", "/unknown@otherbot"]) +async def test_commands_for_another_bot_are_ignored(command: str, monkeypatch: pytest.MonkeyPatch) -> None: + history = SimpleNamespace(clear=AsyncMock()) + agent = SimpleNamespace(run=Mock()) + runtime = cast(Any, SimpleNamespace(history=history, agent=agent, bot_username="mybot")) + execute = AsyncMock(return_value={}) + monkeypatch.setattr(main, "execute_telegram_operation", execute) + monkeypatch.setattr(main, "telegram_to_run", AsyncMock()) + + await main.handle_telegram_update(_message_update(command), "123", runtime) + + history.clear.assert_not_awaited() + execute.assert_not_awaited() + main.telegram_to_run.assert_not_awaited() + agent.run.assert_not_called() + + +async def test_command_addressed_to_this_bot_is_handled(monkeypatch: pytest.MonkeyPatch) -> None: + history = SimpleNamespace(clear=AsyncMock()) + runtime = cast(Any, SimpleNamespace(history=history, bot_username="mybot")) + monkeypatch.setattr(main, "execute_telegram_operation", AsyncMock(return_value={})) + + await main.handle_telegram_update(_message_update("/new@MyBot"), "123", runtime) + + history.clear.assert_awaited_once_with("123") + + +async def test_bot_username_is_cached(monkeypatch: pytest.MonkeyPatch) -> None: + runtime = cast(Any, SimpleNamespace(bot_username=None)) + execute = AsyncMock(return_value={"username": "mybot"}) + monkeypatch.setattr(main, "execute_telegram_operation", execute) + + assert await main.get_bot_username(runtime) == "mybot" + assert await main.get_bot_username(runtime) == "mybot" + execute.assert_awaited_once_with(runtime, {"method": "getMe", "payload": {}}) + + 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 +251,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) From ae20399c0962a2082d0c1d7bc472131ccb979893 Mon Sep 17 00:00:00 2001 From: dakjdakd <1428823446@qq.com> Date: Tue, 29 Sep 2026 00:45:38 +0800 Subject: [PATCH 2/4] Avoid duplicate Telegram getMe lookups on concurrent updates --- .../invocations/telegram/main.py | 18 +++++++++++------- .../invocations/telegram/tests/test_main.py | 12 +++++++++--- 2 files changed, 20 insertions(+), 10 deletions(-) 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 6ffdcf4d62f..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 @@ -85,6 +85,7 @@ class Runtime: 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 @@ -151,12 +152,15 @@ 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 - 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 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: 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 1736e6f40e4..be17ee49130 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 @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import importlib.util import logging import sys @@ -213,11 +214,16 @@ async def test_command_addressed_to_this_bot_is_handled(monkeypatch: pytest.Monk async def test_bot_username_is_cached(monkeypatch: pytest.MonkeyPatch) -> None: - runtime = cast(Any, SimpleNamespace(bot_username=None)) - execute = AsyncMock(return_value={"username": "mybot"}) + runtime = cast(Any, SimpleNamespace(bot_username=None, bot_username_lock=asyncio.Lock())) + + async def execute_get_me(*_: Any) -> dict[str, str]: + await asyncio.sleep(0) + return {"username": "mybot"} + + execute = AsyncMock(side_effect=execute_get_me) monkeypatch.setattr(main, "execute_telegram_operation", execute) - assert await main.get_bot_username(runtime) == "mybot" + assert await asyncio.gather(main.get_bot_username(runtime), main.get_bot_username(runtime)) == ["mybot", "mybot"] assert await main.get_bot_username(runtime) == "mybot" execute.assert_awaited_once_with(runtime, {"method": "getMe", "payload": {}}) From 5a4e589ebf3730377462bf61ae55ad64dad65f3d Mon Sep 17 00:00:00 2001 From: dakjdakd <1428823446@qq.com> Date: Tue, 29 Sep 2026 19:38:35 +0800 Subject: [PATCH 3/4] Handle bot commands in Telegram media captions --- python/packages/hosting-telegram/README.md | 3 ++- .../_parsing.py | 9 +++++-- .../tests/hosting_telegram/test_parsing.py | 13 ++++++++++ .../af-hosting/local_telegram/README.md | 3 ++- .../tests/test_command_target.py | 25 +++++++++++++++++++ .../invocations/telegram/README.md | 3 ++- .../invocations/telegram/tests/test_main.py | 19 ++++++++++++++ 7 files changed, 70 insertions(+), 5 deletions(-) diff --git a/python/packages/hosting-telegram/README.md b/python/packages/hosting-telegram/README.md index 2f36260b098..c55b8481a76 100644 --- a/python/packages/hosting-telegram/README.md +++ b/python/packages/hosting-telegram/README.md @@ -28,7 +28,8 @@ 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, bot_username=None)` -- a leading slash command, +- `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. 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 72f0eb3e4aa..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,6 +161,10 @@ 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 @@ -168,7 +172,8 @@ def telegram_command(update: Mapping[str, Any], *, bot_username: str | None = No """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 + ``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. 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 675623a5da2..35f46281fd0 100644 --- a/python/packages/hosting-telegram/tests/hosting_telegram/test_parsing.py +++ b/python/packages/hosting-telegram/tests/hosting_telegram/test_parsing.py @@ -107,6 +107,19 @@ 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 ef7f794f575..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,7 +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`. + 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/tests/test_command_target.py b/python/samples/04-hosting/af-hosting/local_telegram/tests/test_command_target.py index 0b8f0bd21bf..e3a5965666e 100644 --- a/python/samples/04-hosting/af-hosting/local_telegram/tests/test_command_target.py +++ b/python/samples/04-hosting/af-hosting/local_telegram/tests/test_command_target.py @@ -65,3 +65,28 @@ async def test_command_target_controls_dispatch( else: handle_command.assert_not_awaited() to_run.assert_not_awaited() + + +async def test_media_caption_for_other_bot_is_ignored(sample: Any, monkeypatch: pytest.MonkeyPatch) -> None: + bot = SimpleNamespace(id=123456, me=AsyncMock(return_value=SimpleNamespace(username="mybot"))) + monkeypatch.setattr(sample, "bot", bot, raising=False) + handle_command = AsyncMock() + to_run = AsyncMock() + monkeypatch.setattr(sample, "handle_command", handle_command) + monkeypatch.setattr(sample, "telegram_to_run", to_run) + update = { + "message": { + "from": {"id": 42}, + "chat": {"id": -123, "type": "supergroup"}, + "caption": "/new@otherbot", + "photo": [{"file_id": "photo-1", "file_size": 10}], + } + } + + if sample.__name__.endswith("polling_app"): + await sample.handle_update(bot, update) + else: + await sample.handle_update(update) + + handle_command.assert_not_awaited() + to_run.assert_not_awaited() 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 7ba8ed4c9a9..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,7 +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 are ignored before they can clear history or reach the model. +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/tests/test_main.py b/python/samples/04-hosting/foundry-hosted-agents/invocations/telegram/tests/test_main.py index be17ee49130..1e21d45516e 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 @@ -203,6 +203,25 @@ async def test_commands_for_another_bot_are_ignored(command: str, monkeypatch: p agent.run.assert_not_called() +async def test_media_caption_for_another_bot_is_ignored(monkeypatch: pytest.MonkeyPatch) -> None: + history = SimpleNamespace(clear=AsyncMock()) + agent = SimpleNamespace(run=Mock()) + runtime = cast(Any, SimpleNamespace(history=history, agent=agent, bot_username="mybot")) + execute = AsyncMock(return_value={}) + to_run = AsyncMock() + monkeypatch.setattr(main, "execute_telegram_operation", execute) + monkeypatch.setattr(main, "telegram_to_run", to_run) + update = _message_update(caption="/new@otherbot", photo=[{"file_id": "photo-1", "file_size": 10}]) + del update["message"]["text"] + + await main.handle_telegram_update(update, "123", runtime) + + history.clear.assert_not_awaited() + execute.assert_not_awaited() + to_run.assert_not_awaited() + agent.run.assert_not_called() + + async def test_command_addressed_to_this_bot_is_handled(monkeypatch: pytest.MonkeyPatch) -> None: history = SimpleNamespace(clear=AsyncMock()) runtime = cast(Any, SimpleNamespace(history=history, bot_username="mybot")) From 4318d8e5ca6e1e092774fbca35def4bd83313118 Mon Sep 17 00:00:00 2001 From: dakjdakd <1428823446@qq.com> Date: Wed, 30 Sep 2026 00:13:12 +0800 Subject: [PATCH 4/4] test(telegram): keep command regression coverage in package tests --- .../tests/test_command_target.py | 92 ------------------- .../invocations/telegram/tests/test_main.py | 62 ------------- 2 files changed, 154 deletions(-) delete mode 100644 python/samples/04-hosting/af-hosting/local_telegram/tests/test_command_target.py diff --git a/python/samples/04-hosting/af-hosting/local_telegram/tests/test_command_target.py b/python/samples/04-hosting/af-hosting/local_telegram/tests/test_command_target.py deleted file mode 100644 index e3a5965666e..00000000000 --- a/python/samples/04-hosting/af-hosting/local_telegram/tests/test_command_target.py +++ /dev/null @@ -1,92 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Command routing checks shared by the polling and webhook samples.""" - -from __future__ import annotations - -import importlib.util -import sys -from pathlib import Path -from types import SimpleNamespace -from typing import Any -from unittest.mock import AsyncMock - -import pytest - - -@pytest.fixture(params=["polling_app", "app"]) -def sample(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> Any: - monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "123456:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi") - monkeypatch.setenv("TELEGRAM_WEBHOOK_URL", "https://example.com/telegram") - monkeypatch.setenv("TELEGRAM_WEBHOOK_SECRET", "test-secret") - path = Path(__file__).parents[1] / f"{request.param}.py" - spec = importlib.util.spec_from_file_location(f"telegram_sample_{request.param}", path) - if spec is None or spec.loader is None: - raise RuntimeError(f"Unable to load {path}") - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) - return module - - -@pytest.mark.parametrize( - ("text", "handled"), - [("/new@otherbot", False), ("/new@MyBot", True), ("/new", True)], -) -async def test_command_target_controls_dispatch( - sample: Any, - monkeypatch: pytest.MonkeyPatch, - text: str, - handled: bool, -) -> None: - bot = SimpleNamespace(id=123456, me=AsyncMock(return_value=SimpleNamespace(username="mybot"))) - monkeypatch.setattr(sample, "bot", bot, raising=False) - handle_command = AsyncMock(return_value=True) - to_run = AsyncMock() - monkeypatch.setattr(sample, "handle_command", handle_command) - monkeypatch.setattr(sample, "telegram_to_run", to_run) - update = { - "update_id": 1, - "message": { - "message_id": 2, - "from": {"id": 42}, - "chat": {"id": -123, "type": "supergroup"}, - "text": text, - }, - } - - if sample.__name__.endswith("polling_app"): - await sample.handle_update(bot, update) - else: - await sample.handle_update(update) - - if handled: - handle_command.assert_awaited_once() - else: - handle_command.assert_not_awaited() - to_run.assert_not_awaited() - - -async def test_media_caption_for_other_bot_is_ignored(sample: Any, monkeypatch: pytest.MonkeyPatch) -> None: - bot = SimpleNamespace(id=123456, me=AsyncMock(return_value=SimpleNamespace(username="mybot"))) - monkeypatch.setattr(sample, "bot", bot, raising=False) - handle_command = AsyncMock() - to_run = AsyncMock() - monkeypatch.setattr(sample, "handle_command", handle_command) - monkeypatch.setattr(sample, "telegram_to_run", to_run) - update = { - "message": { - "from": {"id": 42}, - "chat": {"id": -123, "type": "supergroup"}, - "caption": "/new@otherbot", - "photo": [{"file_id": "photo-1", "file_size": 10}], - } - } - - if sample.__name__.endswith("polling_app"): - await sample.handle_update(bot, update) - else: - await sample.handle_update(update) - - handle_command.assert_not_awaited() - to_run.assert_not_awaited() 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 1e21d45516e..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 @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import importlib.util import logging import sys @@ -186,67 +185,6 @@ async def test_new_clears_durable_history(monkeypatch: pytest.MonkeyPatch) -> No assert "empty history" in operation["payload"]["text"] -@pytest.mark.parametrize("command", ["/new@otherbot", "/help@otherbot", "/unknown@otherbot"]) -async def test_commands_for_another_bot_are_ignored(command: str, monkeypatch: pytest.MonkeyPatch) -> None: - history = SimpleNamespace(clear=AsyncMock()) - agent = SimpleNamespace(run=Mock()) - runtime = cast(Any, SimpleNamespace(history=history, agent=agent, bot_username="mybot")) - execute = AsyncMock(return_value={}) - monkeypatch.setattr(main, "execute_telegram_operation", execute) - monkeypatch.setattr(main, "telegram_to_run", AsyncMock()) - - await main.handle_telegram_update(_message_update(command), "123", runtime) - - history.clear.assert_not_awaited() - execute.assert_not_awaited() - main.telegram_to_run.assert_not_awaited() - agent.run.assert_not_called() - - -async def test_media_caption_for_another_bot_is_ignored(monkeypatch: pytest.MonkeyPatch) -> None: - history = SimpleNamespace(clear=AsyncMock()) - agent = SimpleNamespace(run=Mock()) - runtime = cast(Any, SimpleNamespace(history=history, agent=agent, bot_username="mybot")) - execute = AsyncMock(return_value={}) - to_run = AsyncMock() - monkeypatch.setattr(main, "execute_telegram_operation", execute) - monkeypatch.setattr(main, "telegram_to_run", to_run) - update = _message_update(caption="/new@otherbot", photo=[{"file_id": "photo-1", "file_size": 10}]) - del update["message"]["text"] - - await main.handle_telegram_update(update, "123", runtime) - - history.clear.assert_not_awaited() - execute.assert_not_awaited() - to_run.assert_not_awaited() - agent.run.assert_not_called() - - -async def test_command_addressed_to_this_bot_is_handled(monkeypatch: pytest.MonkeyPatch) -> None: - history = SimpleNamespace(clear=AsyncMock()) - runtime = cast(Any, SimpleNamespace(history=history, bot_username="mybot")) - monkeypatch.setattr(main, "execute_telegram_operation", AsyncMock(return_value={})) - - await main.handle_telegram_update(_message_update("/new@MyBot"), "123", runtime) - - history.clear.assert_awaited_once_with("123") - - -async def test_bot_username_is_cached(monkeypatch: pytest.MonkeyPatch) -> None: - runtime = cast(Any, SimpleNamespace(bot_username=None, bot_username_lock=asyncio.Lock())) - - async def execute_get_me(*_: Any) -> dict[str, str]: - await asyncio.sleep(0) - return {"username": "mybot"} - - execute = AsyncMock(side_effect=execute_get_me) - monkeypatch.setattr(main, "execute_telegram_operation", execute) - - assert await asyncio.gather(main.get_bot_username(runtime), main.get_bot_username(runtime)) == ["mybot", "mybot"] - assert await main.get_bot_username(runtime) == "mybot" - execute.assert_awaited_once_with(runtime, {"method": "getMe", "payload": {}}) - - async def test_rejects_mismatched_session_before_telegram_side_effect( monkeypatch: pytest.MonkeyPatch, ) -> None: