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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions python/packages/hosting-telegram/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<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_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)`
Expand Down
Comment thread
dakjdakd marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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()


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions python/samples/04-hosting/af-hosting/local_telegram/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)

Expand All @@ -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)
Expand Down
Loading