mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
f0b9ab6733
* feat(hosting-telegram): add Telegram channel package New ``agent-framework-hosting-telegram`` package implementing the Telegram Bot API channel for the Hosting framework. Mounts a webhook endpoint (``POST /telegram/webhook``) and an in-process polling loop onto an ``AgentFrameworkHost`` and translates Telegram ``Update`` payloads to/from the channel-neutral ``ChannelRequest`` / ``HostedRunResult`` plumbing. Surface (re-exported from ``agent_framework_hosting_telegram``): - ``TelegramChannel`` -- concrete ``Channel`` implementation. Owns the webhook route + an optional ``getUpdates`` long-polling lifespan, parses Telegram ``Update``s into ``ChannelRequest`` (text, photo, document, voice, callback_query, …), runs the optional ``ChannelRunHook``, calls back into the ``ChannelContext`` to invoke the agent target, and posts the response back via ``sendMessage`` / ``sendChatAction`` / ``answerCallbackQuery`` on the Telegram Bot API. Honours ``DeliveryReport.include_originating`` so cross-channel pushes can target the originating Telegram chat without double-acking. - Native fields the channel doesn't lift onto ``ChannelRequest`` (e.g. ``chat.type``, ``message.message_id``, ``callback_query.data``) are attached to ``ChannelRequest.attributes`` so a ``ChannelRunHook`` can pick them up via the standard ``protocol_request=`` kwarg. - 13 unit tests covering route wiring, ``Update`` parsing across the common content shapes, hook composition, and originating vs non-originating delivery branches. Registers the package in ``python/pyproject.toml`` ``[tool.uv.sources]`` and adds the matching pyright ``executionEnvironments`` entry. Stacks on PR-2 (Hosting core); independent of PR-3 / PR-4. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(hosting-telegram): preserve in-chat ordering, ack-before-run, drain shutdown - Replace per-update task fan-out with per-chat asyncio.Queue + worker. Telegram only guarantees update ordering up to getUpdates; the previous code spawned one task per update, which broke ordering for adjacent updates in the same chat. Updates are now serialised per chat_id (so /start then "what's the weather" can't race) while different chats still process in parallel. - Webhook handler now acks (200) immediately and runs the agent in the per-chat worker. Telegram redelivers any update the webhook doesn't 200 within ~60 seconds, so a streamed agent reply that runs longer than that previously triggered a retry storm and duplicate replies. - _on_shutdown now drains everything: poll task → per-chat workers → webhook-spawned dispatcher tasks (the new ack-before-run path), then deletes the webhook + closes the HTTP client. Previously webhook tasks were not tracked at all, so an in-flight agent invocation could leak past app shutdown. - _enqueue_update extracts chat_id from message / edited_message / callback_query; updates with no resolvable chat fall back to a one-shot dispatcher task that's still tracked in _update_tasks for shutdown. - Webhook handler now also returns 400 on malformed JSON / non-object payloads instead of crashing the request. 4 new tests cover per-chat serial ordering, parallel-across-chats isolation, ack-before-run latency, and shutdown drain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(hosting): drop redundant @pytest.mark.asyncio decorators asyncio_mode = "auto" is configured in pyproject.toml across the hosting packages, so individual @pytest.mark.asyncio decorators are unnecessary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(hosting-telegram): adapt push tests to hosted run result wrapper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(hosting-telegram): add response hooks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
390 lines
15 KiB
Python
390 lines
15 KiB
Python
# Copyright (c) Microsoft. All rights reserved.
|
|
|
|
"""Unit tests for :mod:`agent_framework_hosting_telegram`.
|
|
|
|
These tests exercise the internal parsing helpers and the webhook entry-point
|
|
without spinning up a real Telegram bot. The polling loop and HTTP-side
|
|
helpers are excluded from coverage because they require a live bot token.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from agent_framework import AgentResponse, Content, Message
|
|
from agent_framework_hosting import (
|
|
AgentFrameworkHost,
|
|
ChannelCommand,
|
|
ChannelCommandContext,
|
|
ChannelRequest,
|
|
HostedRunResult,
|
|
)
|
|
from starlette.testclient import TestClient
|
|
|
|
from agent_framework_hosting_telegram import TelegramChannel, telegram_isolation_key
|
|
from agent_framework_hosting_telegram._channel import (
|
|
_parse_telegram_message,
|
|
_telegram_media_file_id,
|
|
)
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Pure helpers #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_telegram_isolation_key_format() -> None:
|
|
assert telegram_isolation_key(42) == "telegram:42"
|
|
assert telegram_isolation_key("abc") == "telegram:abc"
|
|
|
|
|
|
class TestMediaFileId:
|
|
def test_no_media(self) -> None:
|
|
assert _telegram_media_file_id({"text": "hi"}) is None
|
|
|
|
def test_photo_picks_largest(self) -> None:
|
|
assert _telegram_media_file_id({"photo": [{"file_id": "small"}, {"file_id": "large"}]}) == (
|
|
"large",
|
|
"image/jpeg",
|
|
)
|
|
|
|
def test_photo_empty_list(self) -> None:
|
|
assert _telegram_media_file_id({"photo": []}) is None
|
|
|
|
def test_document_uses_mime_type(self) -> None:
|
|
result = _telegram_media_file_id({"document": {"file_id": "f1", "mime_type": "application/pdf"}})
|
|
assert result == ("f1", "application/pdf")
|
|
|
|
def test_voice_default_mime(self) -> None:
|
|
result = _telegram_media_file_id({"voice": {"file_id": "v1"}})
|
|
assert result == ("v1", "audio/ogg")
|
|
|
|
|
|
class TestParseTelegramMessage:
|
|
async def test_text_only(self) -> None:
|
|
async def resolve(_: str) -> str | None:
|
|
return None
|
|
|
|
msg = await _parse_telegram_message({"text": "hello"}, resolve)
|
|
assert msg.role == "user"
|
|
assert msg.text == "hello"
|
|
|
|
async def test_text_and_photo(self) -> None:
|
|
async def resolve(file_id: str) -> str | None:
|
|
return f"https://files.telegram.org/{file_id}"
|
|
|
|
msg = await _parse_telegram_message({"caption": "look", "photo": [{"file_id": "p1"}]}, resolve)
|
|
assert msg.text == "look"
|
|
# Image content present.
|
|
assert any((getattr(c, "uri", None) or "").endswith("/p1") for c in msg.contents)
|
|
|
|
async def test_unresolvable_media_falls_back_to_text(self) -> None:
|
|
async def resolve(_: str) -> str | None:
|
|
return None
|
|
|
|
msg = await _parse_telegram_message({"text": "x", "voice": {"file_id": "v1"}}, resolve)
|
|
# Resolver returned None — the contents should still include the
|
|
# text without crashing.
|
|
assert msg.text == "x"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Webhook entry point #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@dataclass
|
|
class _FakeAgentResponse:
|
|
text: str
|
|
|
|
|
|
class _FakeAgent:
|
|
def __init__(self, reply: str = "ok") -> None:
|
|
self._reply = reply
|
|
self.runs: list[Any] = []
|
|
|
|
def create_session(self, *, session_id: str | None = None) -> Any:
|
|
return {"session_id": session_id}
|
|
|
|
def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any:
|
|
self.runs.append({"messages": messages, "stream": stream, "kwargs": kwargs})
|
|
|
|
async def _coro() -> _FakeAgentResponse:
|
|
return _FakeAgentResponse(text=self._reply)
|
|
|
|
return _coro()
|
|
|
|
|
|
def _run_result(text: str) -> HostedRunResult[AgentResponse]:
|
|
return HostedRunResult(AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text(text=text)])]))
|
|
|
|
|
|
def _make_telegram(stream_default: bool = False) -> tuple[TelegramChannel, _FakeAgent]:
|
|
agent = _FakeAgent("hi")
|
|
ch = TelegramChannel(
|
|
bot_token="123:abc",
|
|
webhook_url="https://example.com/hook",
|
|
secret_token="s3cr3t",
|
|
stream=stream_default,
|
|
)
|
|
# Replace the internal HTTP client with an AsyncMock so the channel
|
|
# never tries to call the real Telegram API.
|
|
fake_http = MagicMock()
|
|
# post() returns a response object whose raise_for_status() is sync.
|
|
response_mock = MagicMock()
|
|
response_mock.json = MagicMock(return_value={"ok": True, "result": {}})
|
|
fake_http.post = AsyncMock(return_value=response_mock)
|
|
fake_http.get = AsyncMock(return_value=response_mock)
|
|
fake_http.aclose = AsyncMock()
|
|
ch._http = fake_http
|
|
return ch, agent
|
|
|
|
|
|
class TestTelegramWebhook:
|
|
def test_webhook_accepts_text_message_and_dispatches_to_agent(self) -> None:
|
|
ch, agent = _make_telegram()
|
|
host = AgentFrameworkHost(target=agent, channels=[ch])
|
|
# Skip lifespan so polling/setWebhook are not invoked.
|
|
with TestClient(host.app) as client:
|
|
r = client.post(
|
|
"/telegram/webhook",
|
|
json={"update_id": 1, "message": {"chat": {"id": 99}, "text": "hello"}},
|
|
headers={"x-telegram-bot-api-secret-token": "s3cr3t"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert agent.runs, "expected the agent to be invoked"
|
|
|
|
def test_webhook_rejects_bad_secret(self) -> None:
|
|
ch, agent = _make_telegram()
|
|
host = AgentFrameworkHost(target=agent, channels=[ch])
|
|
with TestClient(host.app) as client:
|
|
r = client.post(
|
|
"/telegram/webhook",
|
|
json={"update_id": 1, "message": {"chat": {"id": 99}, "text": "hi"}},
|
|
headers={"x-telegram-bot-api-secret-token": "WRONG"},
|
|
)
|
|
assert r.status_code == 401
|
|
assert not agent.runs
|
|
|
|
async def test_response_hook_can_rewrite_originating_reply(self) -> None:
|
|
contexts: list[Any] = []
|
|
|
|
def hook(result: HostedRunResult, **kwargs: Any) -> HostedRunResult:
|
|
contexts.append(kwargs["context"])
|
|
return HostedRunResult(_FakeAgentResponse(text=result.result.text.upper()), session=result.session)
|
|
|
|
ch, agent = _make_telegram()
|
|
ch.response_hook = hook
|
|
|
|
class _Ctx:
|
|
target: Any = agent
|
|
|
|
async def run(self, _request: ChannelRequest) -> HostedRunResult:
|
|
return HostedRunResult(_FakeAgentResponse(text="hi"))
|
|
|
|
async def deliver_response(self, _request: ChannelRequest, _payload: HostedRunResult) -> bool:
|
|
return True
|
|
|
|
ch._ctx = _Ctx() # type: ignore[assignment] # pyright: ignore[reportPrivateUsage]
|
|
|
|
request = ChannelRequest(channel="telegram", operation="message.create", input="hi", stream=False)
|
|
await ch._dispatch(99, request) # pyright: ignore[reportPrivateUsage]
|
|
|
|
assert ch._http is not None
|
|
args, kwargs = ch._http.post.call_args # type: ignore[attr-defined]
|
|
assert args[0].endswith("/sendMessage")
|
|
assert kwargs["json"]["text"] == "HI"
|
|
assert contexts
|
|
assert contexts[0].channel_name == "telegram"
|
|
assert contexts[0].originating is True
|
|
assert contexts[0].destination_identity is None
|
|
|
|
|
|
class TestPushAndCommand:
|
|
async def test_push_calls_send(self) -> None:
|
|
ch, _agent = _make_telegram()
|
|
from agent_framework_hosting import ChannelIdentity
|
|
|
|
await ch.push(ChannelIdentity(channel="telegram", native_id="42"), _run_result("hi"))
|
|
assert ch._http is not None
|
|
ch._http.post.assert_called() # type: ignore[attr-defined]
|
|
args, kwargs = ch._http.post.call_args # type: ignore[attr-defined]
|
|
assert args[0].endswith("/sendMessage")
|
|
assert kwargs["json"]["chat_id"] in ("42", 42)
|
|
assert kwargs["json"]["text"] == "hi"
|
|
|
|
async def test_command_handler_invoked(self) -> None:
|
|
captured: list[ChannelCommandContext] = []
|
|
|
|
async def handler(ctx: ChannelCommandContext) -> None:
|
|
captured.append(ctx)
|
|
await ctx.reply("pong")
|
|
|
|
ch = TelegramChannel(
|
|
bot_token="123:abc",
|
|
webhook_url="https://example.com/hook",
|
|
commands=[ChannelCommand(name="ping", description="ping", handle=handler)],
|
|
register_native_commands=False,
|
|
)
|
|
fake_http = MagicMock()
|
|
response_mock = MagicMock()
|
|
response_mock.json = MagicMock(return_value={"ok": True, "result": {}})
|
|
fake_http.post = AsyncMock(return_value=response_mock)
|
|
fake_http.get = AsyncMock(return_value=response_mock)
|
|
fake_http.aclose = AsyncMock()
|
|
ch._http = fake_http
|
|
host = AgentFrameworkHost(target=_FakeAgent(), channels=[ch])
|
|
|
|
with TestClient(host.app) as client:
|
|
r = client.post(
|
|
"/telegram/webhook",
|
|
json={"update_id": 2, "message": {"chat": {"id": 7}, "text": "/ping"}},
|
|
)
|
|
assert r.status_code == 200
|
|
assert captured and captured[0].request.operation == "command.invoke"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Per-chat serial ordering #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class TestPerChatOrdering:
|
|
async def test_updates_for_same_chat_run_serially(self) -> None:
|
|
"""Two updates for the same chat must process in arrival order."""
|
|
ch, _ = _make_telegram()
|
|
order: list[int] = []
|
|
slow_event = asyncio.Event()
|
|
|
|
async def fake_process(update: Mapping[str, Any]) -> None:
|
|
uid = update.get("update_id")
|
|
assert isinstance(uid, int)
|
|
if uid == 1:
|
|
# Block the first update so the second is queued behind it.
|
|
await slow_event.wait()
|
|
order.append(uid)
|
|
|
|
ch._process_update = fake_process # type: ignore[method-assign]
|
|
|
|
ch._enqueue_update({"update_id": 1, "message": {"chat": {"id": 100}, "text": "first"}})
|
|
ch._enqueue_update({"update_id": 2, "message": {"chat": {"id": 100}, "text": "second"}})
|
|
|
|
# Let the worker start the first update.
|
|
await asyncio.sleep(0)
|
|
assert order == [] # blocked on slow_event
|
|
slow_event.set()
|
|
# Drain.
|
|
worker = ch._chat_workers[100]
|
|
# Wait for the queue to empty.
|
|
await ch._chat_queues[100].join()
|
|
# Cleanup
|
|
worker.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await worker
|
|
|
|
assert order == [1, 2]
|
|
|
|
async def test_updates_for_different_chats_run_in_parallel(self) -> None:
|
|
"""Different chats get separate workers and can interleave freely."""
|
|
ch, _ = _make_telegram()
|
|
started: list[int] = []
|
|
gate_a = asyncio.Event()
|
|
|
|
async def fake_process(update: Mapping[str, Any]) -> None:
|
|
uid = update.get("update_id")
|
|
assert isinstance(uid, int)
|
|
started.append(uid)
|
|
if uid == 1:
|
|
await gate_a.wait()
|
|
|
|
ch._process_update = fake_process # type: ignore[method-assign]
|
|
|
|
ch._enqueue_update({"update_id": 1, "message": {"chat": {"id": 1}, "text": "a"}})
|
|
ch._enqueue_update({"update_id": 2, "message": {"chat": {"id": 2}, "text": "b"}})
|
|
|
|
# Both should be admitted into their respective workers despite
|
|
# update 1 being blocked.
|
|
await asyncio.sleep(0)
|
|
# Update 2 finishes; update 1 still blocked.
|
|
assert 2 in started
|
|
gate_a.set()
|
|
for cid in (1, 2):
|
|
await ch._chat_queues[cid].join()
|
|
for w in ch._chat_workers.values():
|
|
w.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await w
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Webhook ack-before-run + shutdown drains workers #
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class TestWebhookAckBeforeRun:
|
|
async def test_webhook_returns_200_before_agent_completes(self) -> None:
|
|
"""The webhook must ack before the agent runs, to dodge Telegram's 60s redelivery."""
|
|
ch, _ = _make_telegram()
|
|
from starlette.requests import Request
|
|
|
|
agent_started = asyncio.Event()
|
|
agent_release = asyncio.Event()
|
|
|
|
async def fake_process(update: Mapping[str, Any]) -> None:
|
|
agent_started.set()
|
|
await agent_release.wait()
|
|
|
|
ch._process_update = fake_process # type: ignore[method-assign]
|
|
|
|
async def receive() -> Any:
|
|
payload = b'{"update_id":1,"message":{"chat":{"id":42},"text":"hi"}}'
|
|
return {"type": "http.request", "body": payload, "more_body": False}
|
|
|
|
scope = {
|
|
"type": "http",
|
|
"method": "POST",
|
|
"path": "/telegram/webhook",
|
|
"headers": [(b"x-telegram-bot-api-secret-token", b"s3cr3t")],
|
|
"query_string": b"",
|
|
}
|
|
request = Request(scope, receive=receive)
|
|
|
|
# Drive the webhook handler. Even though the agent won't complete
|
|
# (gate_a still cleared) the webhook must still 200 promptly.
|
|
resp = await ch._handle(request)
|
|
assert resp.status_code == 200
|
|
# The agent task is in flight but not finished — proves ack came first.
|
|
await asyncio.wait_for(agent_started.wait(), timeout=1.0)
|
|
assert not agent_release.is_set()
|
|
|
|
# Cleanup: release the agent and drain.
|
|
agent_release.set()
|
|
await ch._chat_queues[42].join()
|
|
for w in list(ch._chat_workers.values()):
|
|
w.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await w
|
|
|
|
|
|
class TestShutdownDrainsWorkers:
|
|
async def test_shutdown_cancels_in_flight_chat_workers(self) -> None:
|
|
"""`_on_shutdown` must drain per-chat workers, not leak them."""
|
|
ch, _ = _make_telegram()
|
|
forever = asyncio.Event()
|
|
|
|
async def stuck(update: Mapping[str, Any]) -> None:
|
|
await forever.wait()
|
|
|
|
ch._process_update = stuck # type: ignore[method-assign]
|
|
ch._enqueue_update({"update_id": 9, "message": {"chat": {"id": 1}, "text": "a"}})
|
|
await asyncio.sleep(0)
|
|
assert ch._chat_workers and ch._update_tasks
|
|
|
|
await ch._on_shutdown()
|
|
assert not ch._chat_workers
|
|
assert not ch._update_tasks
|