9bd7a37a77
* fix: _extract_retry_after returns None for non-retryable errors
* fix: update _extract_retry_after to handle generic transient errors with default retry delay
* fix: extend base _non_retryable_patterns in channel subclasses
* fix(channels): merge structured SDK error check into non-retryable step
* fix(channels): decouple status code and SDK error code extraction in retry logic
- Independently evaluate HTTP status codes and structured SDK error codes
- Fix misleading doc comments for Feishu and DingTalk patterns
- Remove redundant try-except AttributeError on getattr with default
- Expand test coverage for dual-signal matrix and header parsing
* refactor(channels): simplify status code and SDK error extraction via channel overrides
- Handle httpx and aiohttp exceptions in base Channel class
- Override _extract_status_code and _extract_sdk_error_code in SlackChannel and DiscordChannel
- Replace mock exception types in comprehensive test suite with real httpx and aiohttp errors
- Add dedicated Slack and Discord retry error extraction test suites
* fix(channels): clean up Slack and Discord error code extraction
- Remove defensive string checks and attribute guards in SlackChannel
- Directly access exc.response.status_code and exc.response.get('error') in SlackChannel
- Remove unnecessary _extract_sdk_error_code override in DiscordChannel
- Use real SlackApiError, SlackResponse, and discord.HTTPException in unit tests
* refactor: reorder retry logic to prioritize non-retryable checks, remove aiohttp dependency, and clean up exception handling in base and channel modules.
* test(channels): skip Slack/Discord retry tests when the SDK extra is absent
The retry-extraction tests build real SlackApiError / discord.HTTPException
objects, but slack-sdk and discord.py are optional extras that the dev
dependency group does not install. Under CI's `uv sync --dev` all nine
tests failed with ModuleNotFoundError raised from the channel override.
Gate both test classes with skipif(find_spec(...) is None) so the suite is
green without the extras and the tests still run wherever they are installed.
* refactor(channels): replace retry-delay lookup with _extract_retry_delay
_extract_retry_after still read the server-supplied delay by probing
exc.retry_after and exc.response.headers via getattr/hasattr, the last
remnant of the pattern the extractors moved away from. Replace both steps
with one overridable hook, _extract_retry_delay, implemented against the
real exception types:
- base: httpx.HTTPStatusError -> Retry-After header (httpx.Headers is
case-insensitive; HTTP-date form remains unsupported)
- SlackChannel: SlackApiError -> Retry-After, matched case-insensitively
because SlackResponse.headers is a plain dict whose casing depends on the
HTTP client (same approach as slack_sdk's RateLimitErrorRetryHandler)
- TelegramChannel: telegram.error.RetryAfter.retry_after (int, or timedelta
under PTB_TIMEDELTA)
- DiscordChannel: discord.RateLimited.retry_after, which the old duck-typed
getattr matched and would otherwise have been lost
Drop the isinstance(retry, bool) and val >= 0 guards; no SDK produces those.
Delete the test that asserted the duck-typed attribute; add real-object tests
for each override, guarded like the existing SDK-dependent classes.
* ci: install the all-channels extra so SDK-dependent channel tests run
The Slack, Discord, and Telegram retry tests build real SDK exception
objects and are skipped when the SDK is absent. CI only ran `uv sync --dev`,
so those tests never executed there. Install the existing all-channels
extra alongside the dev group; the skipif guards remain for lean local runs.
* fix(channels): honor HTTP-date Retry-After and tolerate malformed values
RFC 9110 allows Retry-After as either delay-seconds or an HTTP-date. The
httpx path treated a date as unparseable and fell back to the 1.0 s default,
so a 503 asking for a specific wait was retried too early. Add
Channel._parse_retry_after, which returns delay-seconds as-is and converts
an HTTP-date to the non-negative seconds until it (tz-less dates read as
UTC).
SlackChannel used a bare float() on the header. A non-numeric value raised
inside the retry predicate, which escapes retry_async and drops the chunk
instead of retrying. Route Slack through the same helper so a bad header
falls back to _rate_limit_delay.
Addresses CodeRabbit review comments on base.py:866 and slack/channel.py:229.
* fix(channels): treat HTTP 400 and 404 as non-retryable
Both are permanent for a given request, so retrying burns the attempt
budget for nothing. Add them to _non_retryable_status_codes alongside
401/403.
Deliberately not a 4xx range check: 408 and 425 are retryable by
definition and 429 is handled by the rate-limit path. A test pins 408 as
still retryable so the range shortcut is not reintroduced later.
Partially addresses CodeRabbit's outside-diff comment on base.py:749-750.
* fix(channels): guard Slack retry extractors against raw aiohttp responses
slack_sdk attaches the bare aiohttp.ClientResponse to SlackApiError when a
JSON-declared body fails to parse. That object has neither status_code nor
get(), so _extract_status_code raised AttributeError inside should_retry,
replacing the original error and skipping the remaining attempts. Narrow
both extractors to SlackResponse/AsyncSlackResponse so such errors fall
through to the message patterns and retry as before. Add a wire-level
regression test against a local aiohttp server.
---------
Co-authored-by: Dinos Papakostas <dinospk1999@gmail.com>
Co-authored-by: X-iZhang <zacharyzhang2022@gmail.com>
303 lines
10 KiB
Python
303 lines
10 KiB
Python
"""Tests for Telegram channel implementation."""
|
|
|
|
import importlib.util
|
|
import sys
|
|
from datetime import datetime
|
|
from types import ModuleType, SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from EvoScientist.channels.base import ChannelError
|
|
from EvoScientist.channels.telegram.channel import TelegramChannel, TelegramConfig
|
|
|
|
|
|
class TestTelegramConfig:
|
|
def test_default_values(self):
|
|
config = TelegramConfig()
|
|
assert config.bot_token == ""
|
|
assert config.allowed_senders is None
|
|
assert config.text_chunk_limit == 4096
|
|
|
|
def test_custom_values(self):
|
|
config = TelegramConfig(
|
|
bot_token="test-token",
|
|
allowed_senders={"123", "456"},
|
|
text_chunk_limit=2000,
|
|
)
|
|
assert config.bot_token == "test-token"
|
|
assert config.allowed_senders == {"123", "456"}
|
|
assert config.text_chunk_limit == 2000
|
|
|
|
|
|
class TestTelegramChannel:
|
|
def test_init(self):
|
|
config = TelegramConfig(bot_token="test")
|
|
channel = TelegramChannel(config)
|
|
assert channel.config is config
|
|
assert channel._running is False
|
|
|
|
async def test_start_raises_without_token(self):
|
|
config = TelegramConfig(bot_token="")
|
|
channel = TelegramChannel(config)
|
|
with pytest.raises(ChannelError, match="bot token"):
|
|
await channel.start()
|
|
|
|
async def test_stop_when_not_running(self):
|
|
config = TelegramConfig(bot_token="test")
|
|
channel = TelegramChannel(config)
|
|
await channel.stop()
|
|
|
|
async def test_cleanup_is_idempotent(self):
|
|
channel = TelegramChannel(TelegramConfig(bot_token="test"))
|
|
app = SimpleNamespace(
|
|
updater=SimpleNamespace(running=True, stop=AsyncMock()),
|
|
running=True,
|
|
shutdown=AsyncMock(),
|
|
)
|
|
|
|
async def stop_once():
|
|
if not app.running:
|
|
raise RuntimeError("This Application is not running!")
|
|
app.running = False
|
|
|
|
app.stop = AsyncMock(side_effect=stop_once)
|
|
channel._app = app
|
|
|
|
await channel._cleanup()
|
|
await channel._cleanup()
|
|
|
|
app.updater.stop.assert_awaited_once()
|
|
app.stop.assert_awaited_once()
|
|
app.shutdown.assert_awaited_once()
|
|
assert channel._app is None
|
|
|
|
async def test_cleanup_partially_initialized_application(self):
|
|
channel = TelegramChannel(TelegramConfig(bot_token="test"))
|
|
app = SimpleNamespace(
|
|
updater=SimpleNamespace(running=False, stop=AsyncMock()),
|
|
running=False,
|
|
stop=AsyncMock(),
|
|
shutdown=AsyncMock(),
|
|
)
|
|
channel._app = app
|
|
|
|
await channel._cleanup()
|
|
|
|
app.updater.stop.assert_not_awaited()
|
|
app.stop.assert_not_awaited()
|
|
app.shutdown.assert_awaited_once()
|
|
assert channel._app is None
|
|
|
|
async def test_send_returns_false_without_app(self):
|
|
from EvoScientist.channels.base import OutboundMessage
|
|
|
|
config = TelegramConfig(bot_token="test")
|
|
channel = TelegramChannel(config)
|
|
msg = OutboundMessage(
|
|
channel="telegram",
|
|
chat_id="123",
|
|
content="hello",
|
|
metadata={"chat_id": "123"},
|
|
)
|
|
result = await channel.send(msg)
|
|
assert result is False
|
|
|
|
async def test_registered_handler_accepts_bot_commands(self, monkeypatch):
|
|
class FakeFilter:
|
|
def __init__(self, predicate):
|
|
self._predicate = predicate
|
|
|
|
def __or__(self, other):
|
|
return FakeFilter(
|
|
lambda update: (
|
|
self.check_update(update) or other.check_update(update)
|
|
)
|
|
)
|
|
|
|
def __and__(self, other):
|
|
return FakeFilter(
|
|
lambda update: (
|
|
self.check_update(update) and other.check_update(update)
|
|
)
|
|
)
|
|
|
|
def __invert__(self):
|
|
return FakeFilter(lambda update: not self.check_update(update))
|
|
|
|
def check_update(self, update):
|
|
return self._predicate(update)
|
|
|
|
class FakeMessageHandler:
|
|
def __init__(self, message_filter, callback):
|
|
self.filters = message_filter
|
|
self.callback = callback
|
|
|
|
app = SimpleNamespace(
|
|
handlers=[],
|
|
bot=SimpleNamespace(
|
|
get_me=AsyncMock(return_value=SimpleNamespace(username="botname"))
|
|
),
|
|
updater=SimpleNamespace(start_polling=AsyncMock()),
|
|
initialize=AsyncMock(),
|
|
start=AsyncMock(),
|
|
)
|
|
app.add_handler = app.handlers.append
|
|
|
|
class FakeApplicationBuilder:
|
|
def token(self, _token):
|
|
return self
|
|
|
|
def build(self):
|
|
return app
|
|
|
|
text_filter = FakeFilter(lambda update: update.message.text is not None)
|
|
command_filter = FakeFilter(lambda update: update.message.is_command)
|
|
false_filter = FakeFilter(lambda _update: False)
|
|
fake_filters = SimpleNamespace(
|
|
TEXT=text_filter,
|
|
COMMAND=command_filter,
|
|
PHOTO=false_filter,
|
|
VOICE=false_filter,
|
|
AUDIO=false_filter,
|
|
Document=SimpleNamespace(ALL=false_filter),
|
|
VIDEO=false_filter,
|
|
Sticker=SimpleNamespace(ALL=false_filter),
|
|
LOCATION=false_filter,
|
|
)
|
|
telegram_module = ModuleType("telegram")
|
|
ext_module = ModuleType("telegram.ext")
|
|
ext_module.ApplicationBuilder = FakeApplicationBuilder
|
|
ext_module.MessageHandler = FakeMessageHandler
|
|
ext_module.filters = fake_filters
|
|
telegram_module.ext = ext_module
|
|
monkeypatch.setitem(sys.modules, "telegram", telegram_module)
|
|
monkeypatch.setitem(sys.modules, "telegram.ext", ext_module)
|
|
|
|
channel = TelegramChannel(
|
|
TelegramConfig(bot_token="test", include_attachments=False)
|
|
)
|
|
await channel.start()
|
|
|
|
update = SimpleNamespace(message=SimpleNamespace(text="/help", is_command=True))
|
|
assert app.handlers[0].filters.check_update(update) is True
|
|
assert app.handlers[0].callback == channel._on_message
|
|
|
|
async def test_group_command_suffix_is_removed_before_enqueue(self):
|
|
channel = TelegramChannel(
|
|
TelegramConfig(bot_token="test", include_attachments=False)
|
|
)
|
|
channel._bot_username = "botname"
|
|
update = self._text_update("/stop@botname", chat_type="supergroup")
|
|
|
|
await channel._on_message(update, None)
|
|
|
|
message = await channel._queue.get()
|
|
assert message.content == "/stop"
|
|
assert message.is_group is True
|
|
assert message.was_mentioned is True
|
|
|
|
async def test_private_command_suffix_is_removed_before_enqueue(self):
|
|
channel = TelegramChannel(
|
|
TelegramConfig(bot_token="test", include_attachments=False)
|
|
)
|
|
channel._bot_username = "botname"
|
|
update = self._text_update("/stop@botname")
|
|
|
|
await channel._on_message(update, None)
|
|
|
|
message = await channel._queue.get()
|
|
assert message.content == "/stop"
|
|
assert message.is_group is False
|
|
|
|
async def test_bare_group_command_passes_mention_gating(self):
|
|
channel = TelegramChannel(
|
|
TelegramConfig(bot_token="test", include_attachments=False)
|
|
)
|
|
channel._bot_username = "botname"
|
|
update = self._text_update("/stop", chat_type="supergroup")
|
|
|
|
await channel._on_message(update, None)
|
|
|
|
message = await channel._queue.get()
|
|
assert message.content == "/stop"
|
|
assert message.was_mentioned is True
|
|
|
|
async def test_group_command_for_other_bot_is_ignored(self):
|
|
channel = TelegramChannel(
|
|
TelegramConfig(bot_token="test", include_attachments=False)
|
|
)
|
|
channel._bot_username = "botname"
|
|
update = self._text_update("/stop@otherbot", chat_type="supergroup")
|
|
|
|
await channel._on_message(update, None)
|
|
|
|
assert channel._queue.empty()
|
|
|
|
async def test_group_command_bypasses_buffered_history(self):
|
|
channel = TelegramChannel(
|
|
TelegramConfig(bot_token="test", include_attachments=False)
|
|
)
|
|
channel._bot_username = "botname"
|
|
|
|
chatter = self._text_update("background chatter", chat_type="supergroup")
|
|
await channel._on_message(chatter, None)
|
|
assert channel._queue.empty()
|
|
|
|
command = self._text_update("/help@botname", chat_type="supergroup")
|
|
command.message.message_id = 790
|
|
await channel._on_message(command, None)
|
|
|
|
message = await channel._queue.get()
|
|
assert message.content == "/help"
|
|
assert message.is_group is True
|
|
assert message.was_mentioned is True
|
|
|
|
async def test_start_command_flows_to_shared_dispatch(self):
|
|
channel = TelegramChannel(
|
|
TelegramConfig(bot_token="test", include_attachments=False)
|
|
)
|
|
update = self._text_update("/start")
|
|
|
|
await channel._on_message(update, None)
|
|
|
|
message = await channel._queue.get()
|
|
assert message.content == "/start"
|
|
|
|
@staticmethod
|
|
def _text_update(text, *, chat_type="private"):
|
|
message = SimpleNamespace(
|
|
from_user=SimpleNamespace(id=123),
|
|
chat_id=456,
|
|
chat=SimpleNamespace(type=chat_type),
|
|
text=text,
|
|
caption=None,
|
|
date=datetime(2026, 1, 1),
|
|
message_id=789,
|
|
)
|
|
return SimpleNamespace(message=message)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
importlib.util.find_spec("telegram") is None,
|
|
reason="python-telegram-bot not installed",
|
|
)
|
|
class TestTelegramRetryDelay:
|
|
def test_retry_after_honored(self):
|
|
from telegram.error import RetryAfter
|
|
|
|
ch = TelegramChannel(TelegramConfig(bot_token="t"))
|
|
assert ch._extract_retry_delay(RetryAfter(7)) == 7.0
|
|
assert ch._extract_retry_after(RetryAfter(7)) == 7.0
|
|
|
|
def test_other_errors_fall_through(self):
|
|
import httpx
|
|
|
|
ch = TelegramChannel(TelegramConfig(bot_token="t"))
|
|
exc = httpx.HTTPStatusError(
|
|
"429",
|
|
request=httpx.Request("POST", "https://example.invalid"),
|
|
response=httpx.Response(429, headers={"Retry-After": "3"}),
|
|
)
|
|
assert ch._extract_retry_delay(exc) == 3.0
|