feat(telegram): add webhook support and ordered message queue
Introduce webhook mode for the Telegram channel and implement a session-based message reordering mechanism.
Key changes:
- Update `python-telegram-bot` dependency to include the `webhooks` extra.
- Add `TelegramConfig` fields for webhook configuration, with validation rules for public HTTPS URLs and Telegram's secret token.
- Implement `_enqueue_ordered_update` and `_drain_ordered_updates` in `TelegramChannel` to stage incoming messages and commands behind a short per-session reorder
window, ensuring sequential delivery based on message and update IDs.
- Configure `start_webhook` in `TelegramChannel.start()` when webhook mode is enabled.
- Add unit tests for webhook config validations, webhook startup, and message reordering.
- Document webhook configuration and reverse proxy details in `docs/chat-apps.md`.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
@@ -36,11 +37,19 @@ class _FakeUpdater:
|
||||
def __init__(self, on_start_polling) -> None:
|
||||
self._on_start_polling = on_start_polling
|
||||
self.start_polling_kwargs = None
|
||||
self.start_webhook_kwargs = None
|
||||
|
||||
async def start_polling(self, **kwargs) -> None:
|
||||
self.start_polling_kwargs = kwargs
|
||||
self._on_start_polling()
|
||||
|
||||
async def start_webhook(self, **kwargs) -> None:
|
||||
self.start_webhook_kwargs = kwargs
|
||||
self._on_start_polling()
|
||||
|
||||
async def stop(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _FakeBot:
|
||||
def __init__(self) -> None:
|
||||
@@ -103,6 +112,12 @@ class _FakeApp:
|
||||
async def start(self) -> None:
|
||||
pass
|
||||
|
||||
async def stop(self) -> None:
|
||||
pass
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _FakeBuilder:
|
||||
def __init__(self, app: _FakeApp) -> None:
|
||||
@@ -232,6 +247,98 @@ async def test_start_respects_custom_pool_config(monkeypatch) -> None:
|
||||
assert poll_req.kwargs["pool_timeout"] == 10.0
|
||||
|
||||
|
||||
def test_webhook_config_requires_https_url_and_secret() -> None:
|
||||
with pytest.raises(ValueError, match="webhook_url is required"):
|
||||
TelegramConfig(enabled=True, token="123:abc", mode="webhook")
|
||||
|
||||
with pytest.raises(ValueError, match="public HTTPS URL"):
|
||||
TelegramConfig(
|
||||
enabled=True,
|
||||
token="123:abc",
|
||||
mode="webhook",
|
||||
webhook_url="http://example.com/telegram",
|
||||
webhook_secret_token="secret",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="webhook_secret_token is required"):
|
||||
TelegramConfig(
|
||||
enabled=True,
|
||||
token="123:abc",
|
||||
mode="webhook",
|
||||
webhook_url="https://example.com/telegram",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_webhook_mode(monkeypatch) -> None:
|
||||
_FakeHTTPXRequest.clear()
|
||||
config = TelegramConfig(
|
||||
enabled=True,
|
||||
token="123:abc",
|
||||
allow_from=["*"],
|
||||
mode="webhook",
|
||||
webhook_url="https://example.com/telegram",
|
||||
webhook_listen_host="127.0.0.1",
|
||||
webhook_listen_port=8081,
|
||||
webhook_path="/telegram",
|
||||
webhook_secret_token="secret-token",
|
||||
webhook_max_connections=1,
|
||||
)
|
||||
bus = MessageBus()
|
||||
channel = TelegramChannel(config, bus)
|
||||
app = _FakeApp(lambda: setattr(channel, "_running", False))
|
||||
builder = _FakeBuilder(app)
|
||||
|
||||
monkeypatch.setattr("nanobot.channels.telegram.HTTPXRequest", _FakeHTTPXRequest)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.telegram.Application",
|
||||
SimpleNamespace(builder=lambda: builder),
|
||||
)
|
||||
|
||||
await channel.start()
|
||||
|
||||
assert app.updater.start_polling_kwargs is None
|
||||
assert app.updater.start_webhook_kwargs == {
|
||||
"listen": "127.0.0.1",
|
||||
"port": 8081,
|
||||
"url_path": "telegram",
|
||||
"webhook_url": "https://example.com/telegram",
|
||||
"allowed_updates": ["message"],
|
||||
"drop_pending_updates": False,
|
||||
"secret_token": "secret-token",
|
||||
"max_connections": 1,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_running_message_handler_reorders_same_session_updates() -> None:
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
seen: list[int] = []
|
||||
|
||||
async def fake_process(update, context) -> None:
|
||||
seen.append(update.message.message_id)
|
||||
|
||||
channel._process_message_update = fake_process
|
||||
channel._running = True
|
||||
|
||||
first = _make_telegram_update(text="first")
|
||||
first.update_id = 100
|
||||
first.message.message_id = 1
|
||||
second = _make_telegram_update(text="second")
|
||||
second.update_id = 101
|
||||
second.message.message_id = 2
|
||||
|
||||
await channel._on_message(second, None)
|
||||
await channel._on_message(first, None)
|
||||
await asyncio.sleep(0.3)
|
||||
channel._running = False
|
||||
|
||||
assert seen == [1, 2]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_text_retries_on_timeout() -> None:
|
||||
"""_send_text retries on TimedOut before succeeding."""
|
||||
|
||||
Reference in New Issue
Block a user