from __future__ import annotations import time from unittest.mock import AsyncMock, MagicMock import pytest from telegram import ( Bot, CallbackQuery, Chat, ForceReply, InlineKeyboardMarkup, Message, MessageEntity, ReplyKeyboardMarkup, Update, User, ) from telegram.error import ( BadRequest, Conflict, Forbidden, InvalidToken, NetworkError, RetryAfter, TelegramError, TimedOut, ) from yuxi.channels.adapters.telegram.adapter import TelegramAdapter, _build_command_scope from yuxi.channels.adapters.telegram.chunking import ( chunk_mode_from_config, chunk_text, ) from yuxi.channels.adapters.telegram.debounce import ( ConnectTimeoutConfig, MessageDebouncer, ) from yuxi.channels.adapters.telegram.format import ( _convert_bold, _convert_italic, _convert_links, _convert_spoiler, _convert_strikethrough, _convert_underline, _sanitize_html, markdown_to_html, strip_html_tags, ) from yuxi.channels.adapters.telegram.inline_buttons import ( build_exec_approval_buttons, build_inline_keyboard, build_model_selector_buttons, build_paginated_command_keyboard, build_provider_browse_keyboard, ) from yuxi.channels.adapters.telegram.network_errors import ( classify_telegram_error, get_retry_delay, is_recoverable_network_error, is_safe_to_retry_send, ) from yuxi.channels.adapters.telegram.reactions.reaction_level import ( ReactionLevelController, get_reaction_level, ) from yuxi.channels.adapters.telegram.reactions.reaction_notifications import ( ReactionNotifications, ) from yuxi.channels.adapters.telegram.security.security_audit import ( audit_dm_access, audit_security_policy, ) from yuxi.channels.adapters.telegram.send import ( build_quote_payload, build_reply_parameters, send_stream_edit, send_with_format_fallback, send_with_retry, ) from yuxi.channels.adapters.telegram.targets.normalize import ( format_chat_identifier, normalize_chat_id, normalize_group_id, normalize_user_id, strip_internal_prefixes, ) from yuxi.channels.adapters.telegram.targets.target_writeback import ( handle_target_writeback, ) from yuxi.channels.adapters.telegram.targets.targets import ( normalize_telegram_chat_id, normalize_telegram_target, parse_messaging_target, parse_telegram_target, ) from yuxi.channels.adapters.telegram.tools.request_timeouts import ( DEFAULT_GLOBAL_TIMEOUT_SECONDS, DEFAULT_TIMEOUTS, get_timeout, get_timeout_config, resolve_global_timeout, ) from yuxi.channels.adapters.telegram.tools.sendchataction_401_backoff import ( SendChatAction401Backoff, ) from yuxi.channels.adapters.telegram.tools.sequential_key import ( generate_debounce_key, generate_group_sequential_key, generate_sequential_key, generate_topic_sequential_key, ) from yuxi.channels.adapters.telegram.topic_routing import TopicRouter from yuxi.channels.models import ( Attachment, ChannelIdentity, ChannelResponse, ChannelStatus, ChannelType, ChatType, EventType, MessageType, ) from datetime import datetime, timezone # --------------------------------------------------------------------------- # helpers # --------------------------------------------------------------------------- _NOW = datetime.now(timezone.utc) def make_user(id=12345, username="testuser", first_name="Test", last_name="User"): return User(id=id, is_bot=False, first_name=first_name, username=username, last_name=last_name) def make_chat(id=-10012345, chat_type="private", username=None, title=None): return Chat(id=id, type=chat_type, username=username, title=title) def make_message( message_id=1, text="hello", from_user=None, chat=None, date=None, entities=(), is_topic_message=False, message_thread_id=None, ): from datetime import datetime return Message( message_id=message_id, date=date or datetime.now(), chat=chat or make_chat(), from_user=from_user or make_user(), text=text, entities=entities, is_topic_message=is_topic_message, message_thread_id=message_thread_id, ) def make_update(message=None): return Update(update_id=1, message=message) # ============================================================================ # 1. network_errors # ============================================================================ class TestNetworkErrors: def test_is_recoverable_timed_out(self): assert is_recoverable_network_error(TimedOut("timeout")) is True def test_is_recoverable_network_error(self): assert is_recoverable_network_error(NetworkError("network")) is True def test_is_recoverable_os_error(self): assert is_recoverable_network_error(OSError("os")) is True def test_is_recoverable_retry_after(self): assert is_recoverable_network_error(RetryAfter(5)) is True def test_is_not_recoverable_invalid_token(self): assert is_recoverable_network_error(InvalidToken("bad")) is False def test_is_not_recoverable_forbidden(self): assert is_recoverable_network_error(Forbidden("forbidden")) is False def test_is_safe_to_retry_retry_after(self): assert is_safe_to_retry_send(RetryAfter(5)) is True def test_is_safe_to_retry_timed_out(self): assert is_safe_to_retry_send(TimedOut("timeout")) is True def test_is_not_safe_to_retry_invalid_token(self): assert is_safe_to_retry_send(InvalidToken("bad")) is False def test_is_not_safe_to_retry_forbidden(self): assert is_safe_to_retry_send(Forbidden("blocked")) is False def test_is_not_safe_to_retry_bad_request(self): assert is_safe_to_retry_send(BadRequest("chat not found")) is False def test_is_not_safe_to_retry_conflict(self): assert is_safe_to_retry_send(Conflict("conflict")) is False def test_is_safe_telegram_error_flood(self): err = TelegramError("Too many requests: retry later") assert is_safe_to_retry_send(err) is True def test_is_not_safe_telegram_error_chat_not_found(self): err = TelegramError("chat not found") assert is_safe_to_retry_send(err) is False def test_is_not_safe_telegram_error_kicked(self): err = TelegramError("bot was kicked from the group") assert is_safe_to_retry_send(err) is False def test_is_not_safe_plain_exception(self): assert is_safe_to_retry_send(ValueError("random")) is False def test_classify_rate_limited(self): result = classify_telegram_error(RetryAfter(5)) assert result["category"] == "rate_limited" assert result["recoverable"] is True assert result["retry_after"] == 5 def test_classify_auth_error(self): result = classify_telegram_error(InvalidToken("bad")) assert result["category"] == "auth_error" assert result["recoverable"] is False def test_classify_blocked_by_user(self): result = classify_telegram_error(Forbidden("bot was blocked by the user")) assert result["category"] == "blocked_by_user" def test_classify_forbidden(self): result = classify_telegram_error(Forbidden("access denied")) assert result["category"] == "forbidden" def test_classify_chat_not_found(self): result = classify_telegram_error(BadRequest("chat not found")) assert result["category"] == "chat_not_found" def test_classify_message_not_found(self): result = classify_telegram_error(BadRequest("message not found")) assert result["category"] == "message_not_found" def test_classify_bad_request(self): result = classify_telegram_error(BadRequest("invalid")) assert result["category"] == "bad_request" def test_classify_network_error(self): result = classify_telegram_error(TimedOut("timeout")) assert result["category"] == "network_error" assert result["recoverable"] is True def test_classify_unknown(self): result = classify_telegram_error(ValueError("unknown")) assert result["category"] == "unknown" assert result["recoverable"] is False def test_get_retry_delay_retry_after(self): delay = get_retry_delay(RetryAfter(7), 1) assert delay == 7 def test_get_retry_delay_flood(self): err = TelegramError("Too many requests: flood control") delay = get_retry_delay(err, 2) expected = min(60.0, 1.0 * (2 ** (2 + 1))) assert delay == expected def test_get_retry_delay_regular(self): delay = get_retry_delay(TimedOut("timeout"), 0) assert delay == 1.0 def test_get_retry_delay_with_exponential(self): delay = get_retry_delay(TimedOut("timeout"), 2) assert delay == 4.0 def test_get_retry_delay_max_cap(self): delay = get_retry_delay(TimedOut("timeout"), 100) assert delay == 60.0 # ============================================================================ # 2. send # ============================================================================ class TestSend: @pytest.fixture def bot(self): return AsyncMock(spec=Bot) @pytest.mark.asyncio async def test_send_with_retry_success(self, bot): payload = {"chat_id": "123", "text": "hello"} bot.send_message = AsyncMock(return_value=MagicMock(message_id=1)) result = await send_with_retry(bot, "123", payload) assert result.message_id == 1 bot.send_message.assert_called_once() @pytest.mark.asyncio async def test_send_with_retry_retry_after(self, bot): payload = {"chat_id": "123", "text": "hello"} bot.send_message = AsyncMock( side_effect=[RetryAfter(0.01), MagicMock(message_id=2)] ) result = await send_with_retry(bot, "123", payload, {"retry": {"attempts": 3}}) assert result.message_id == 2 @pytest.mark.asyncio async def test_send_with_retry_bad_request_parse(self, bot): payload = {"chat_id": "123", "text": "bad", "parse_mode": "HTML"} bot.send_message = AsyncMock( side_effect=[BadRequest("can't parse entities"), MagicMock(message_id=3)] ) result = await send_with_retry(bot, "123", payload, {"retry": {"attempts": 2}}) assert result.message_id == 3 @pytest.mark.asyncio async def test_send_with_retry_reply_not_found(self, bot): payload = {"chat_id": "123", "text": "hi", "reply_to_message_id": 999} bot.send_message = AsyncMock( side_effect=[BadRequest("reply message not found"), MagicMock(message_id=4)] ) result = await send_with_retry(bot, "123", payload, {"retry": {"attempts": 2}}) assert result.message_id == 4 @pytest.mark.asyncio async def test_send_with_retry_all_retries_exhausted(self, bot): payload = {"chat_id": "123", "text": "hello"} bot.send_message = AsyncMock(side_effect=TimedOut("timeout")) with pytest.raises(TimedOut): await send_with_retry(bot, "123", payload, {"retry": {"attempts": 2, "min_delay_ms": 10}}) @pytest.mark.asyncio async def test_send_stream_edit_success(self): app = MagicMock() app.bot = AsyncMock() app.bot.edit_message_text = AsyncMock() await send_stream_edit(app, "123", "1", "hello") app.bot.edit_message_text.assert_called_once() @pytest.mark.asyncio async def test_send_stream_edit_bad_request(self): app = MagicMock() app.bot = AsyncMock() app.bot.edit_message_text = AsyncMock(side_effect=BadRequest("can't parse entities")) await send_stream_edit(app, "123", "1", "hello") @pytest.mark.asyncio async def test_send_stream_edit_retry_after(self): app = MagicMock() app.bot = AsyncMock() app.bot.edit_message_text = AsyncMock(side_effect=RetryAfter(0.01)) await send_stream_edit(app, "123", "1", "hello") @pytest.mark.asyncio async def test_send_with_format_fallback_success(self, bot): bot.send_message = AsyncMock(return_value=MagicMock(message_id=1)) result = await send_with_format_fallback(bot, "123", "hello") assert result.message_id == 1 @pytest.mark.asyncio async def test_send_with_format_fallback_parse_error(self, bot): bot.send_message = AsyncMock( side_effect=[BadRequest("can't parse entities"), MagicMock(message_id=2)] ) result = await send_with_format_fallback(bot, "123", "bad") assert result.message_id == 2 def test_build_reply_parameters_off(self): response = MagicMock() result = build_reply_parameters(response, "off") assert result is None def test_build_reply_parameters_first_with_id(self): response = MagicMock() response.reply_to_message_id = 42 response.metadata = {"thread_id": "5"} result = build_reply_parameters(response, "first") assert result is not None assert result["reply_to_message_id"] == 42 def test_build_reply_parameters_first_without_id(self): response = MagicMock() response.reply_to_message_id = None result = build_reply_parameters(response, "first") assert result is None def test_build_reply_parameters_unknown_mode(self): response = MagicMock() response.reply_to_message_id = 42 result = build_reply_parameters(response, "unknown") assert result is None def test_build_quote_payload_with_quote(self): response = MagicMock() response.reply_to_message_id = 42 response.quote_text = "hello world" result = build_quote_payload(response) assert result is not None assert result["reply_parameters"].message_id == 42 def test_build_quote_payload_no_quote(self): response = MagicMock() response.reply_to_message_id = 42 response.quote_text = None result = build_quote_payload(response) assert result is None def test_build_quote_payload_dict(self): response = {"reply_to_message_id": 10, "quote_text": "test"} result = build_quote_payload(response) assert result is not None # ============================================================================ # 3. inline_buttons # ============================================================================ class TestInlineButtons: def test_build_inline_keyboard_empty(self): assert build_inline_keyboard([]) is None assert build_inline_keyboard(None) is None def test_build_inline_keyboard_simple(self): buttons = [[{"text": "Click", "callback_data": "click1"}]] result = build_inline_keyboard(buttons) assert isinstance(result, InlineKeyboardMarkup) assert len(result.inline_keyboard) == 1 def test_build_inline_keyboard_with_url(self): buttons = [[{"text": "Go", "url": "https://example.com"}]] result = build_inline_keyboard(buttons) assert isinstance(result, InlineKeyboardMarkup) def test_build_inline_keyboard_scope_off(self): buttons = [[{"text": "Click", "callback_data": "c"}]] result = build_inline_keyboard(buttons, scope="off") assert result is None def test_build_inline_keyboard_scope_dm_direct(self): buttons = [[{"text": "Click", "callback_data": "c"}]] result = build_inline_keyboard(buttons, scope="dm", chat_type="direct") assert isinstance(result, InlineKeyboardMarkup) def test_build_inline_keyboard_scope_dm_group(self): buttons = [[{"text": "Click", "callback_data": "c"}]] result = build_inline_keyboard(buttons, scope="dm", chat_type="group") assert result is None def test_build_inline_keyboard_scope_group_direct(self): buttons = [[{"text": "Click", "callback_data": "c"}]] result = build_inline_keyboard(buttons, scope="group", chat_type="direct") assert result is None def test_build_inline_keyboard_scope_allowlist_match(self): buttons = [[{"text": "Click", "callback_data": "c"}]] result = build_inline_keyboard(buttons, scope="allowlist", allowlist_chats={"123"}, chat_id="123") assert isinstance(result, InlineKeyboardMarkup) def test_build_inline_keyboard_scope_allowlist_no_match(self): buttons = [[{"text": "Click", "callback_data": "c"}]] result = build_inline_keyboard(buttons, scope="allowlist", allowlist_chats={"456"}, chat_id="123") assert result is None def test_build_inline_keyboard_single_dict(self): buttons = [{"text": "Click", "callback_data": "c"}] result = build_inline_keyboard(buttons) assert isinstance(result, InlineKeyboardMarkup) def test_build_model_selector_buttons(self): result = build_model_selector_buttons(["gpt-4", "gpt-3.5"], "gpt-4") assert len(result) == 2 assert "\u2705" in result[0]["text"] def test_build_exec_approval_buttons(self): result = build_exec_approval_buttons("approval123") assert len(result) == 1 assert len(result[0]) == 2 assert result[0][0]["callback_data"] == "exec:approve:approval123" assert result[0][1]["callback_data"] == "exec:reject:approval123" def test_build_provider_browse_keyboard(self): result = build_provider_browse_keyboard(["openai", "anthropic"], "openai") assert len(result) == 2 assert "\u2705" in result[0][0]["text"] def test_build_paginated_command_keyboard_single_page(self): commands = [{"command": "help", "label": "Help"}] result = build_paginated_command_keyboard(commands) assert len(result) >= 1 def test_build_paginated_command_keyboard_multi_page(self): commands = [{"command": f"cmd{i}", "label": f"C{i}"} for i in range(20)] result = build_paginated_command_keyboard(commands, page=0, page_size=8) assert len(result) > 0 nav_row = result[-1] assert any("下一页" in btn["text"] for btn in nav_row) # ============================================================================ # 4. targets # ============================================================================ class TestTargets: def test_parse_telegram_target_chat_id(self): result = parse_telegram_target("12345") assert result == {"chat_id": "12345", "type": "chat_id"} def test_parse_telegram_target_tg_prefix(self): result = parse_telegram_target("tg://12345") assert result == {"chat_id": "12345", "type": "chat_id"} def test_parse_telegram_target_username(self): result = parse_telegram_target("@testbot") assert result == {"username": "testbot", "type": "username"} def test_parse_telegram_target_telegram_prefix(self): result = parse_telegram_target("telegram://@user") assert result == {"username": "user", "type": "username"} def test_parse_telegram_target_invalid(self): assert parse_telegram_target("not valid") is None def test_parse_telegram_target_short_tg_prefix(self): result = parse_telegram_target("tg:12345") assert result == {"chat_id": "12345", "type": "chat_id"} def test_parse_telegram_target_telegram_colon(self): result = parse_telegram_target("telegram:@user") assert result == {"username": "user", "type": "username"} def test_parse_messaging_target_with_thread(self): result = parse_messaging_target("123/456") assert result == {"chat_id": "123", "type": "chat_id", "thread_id": "456"} def test_parse_messaging_target_without_thread(self): result = parse_messaging_target("123") assert result == {"chat_id": "123", "type": "chat_id"} def test_normalize_telegram_chat_id(self): assert normalize_telegram_chat_id("-10012345") == "12345" assert normalize_telegram_chat_id("12345") == "12345" def test_normalize_telegram_target(self): assert normalize_telegram_target("tg://12345") == "tg:12345" assert normalize_telegram_target("@test") == "tg:@test" assert normalize_telegram_target("invalid") == "invalid" # ============================================================================ # 5. targets.normalize # ============================================================================ class TestTargetsNormalize: def test_strip_internal_prefixes_tg(self): assert strip_internal_prefixes("tg:12345") == "12345" def test_strip_internal_prefixes_tg_slash(self): assert strip_internal_prefixes("tg:12345") == "12345" def test_strip_internal_prefixes_telegram(self): assert strip_internal_prefixes("telegram:12345") == "12345" def test_strip_internal_prefixes_telegram_slash(self): assert strip_internal_prefixes("telegram:12345") == "12345" def test_strip_internal_prefixes_plain(self): assert strip_internal_prefixes("12345") == "12345" def test_strip_internal_prefixes_non_digit(self): assert strip_internal_prefixes("tg:@user") == "@user" def test_normalize_chat_id(self): assert normalize_chat_id("-10012345") == "12345" assert normalize_chat_id("12345") == "12345" assert normalize_chat_id(-10067890) == "67890" def test_normalize_user_id(self): assert normalize_user_id("12345") == "12345" assert normalize_user_id(67890) == "67890" def test_normalize_group_id_negative_100(self): assert normalize_group_id("-10012345") == "12345" def test_normalize_group_id_negative(self): assert normalize_group_id("-12345") == "12345" def test_normalize_group_id_positive(self): assert normalize_group_id("12345") == "12345" def test_format_chat_identifier_with_username(self): assert format_chat_identifier("12345", "group", "testuser") == "@testuser" def test_format_chat_identifier_without_username(self): assert format_chat_identifier("-10012345", "group") == "12345" # ============================================================================ # 6. target_writeback # ============================================================================ class TestTargetWriteback: @pytest.mark.asyncio async def test_writeback_updates_monitored_chats(self): adapter = MagicMock() adapter.config = {"monitored_chats": ["old123", "other456"]} result = await handle_target_writeback(adapter, "old123", "new789") assert result["updated"] is True assert adapter.config["monitored_chats"] == ["new789", "other456"] @pytest.mark.asyncio async def test_writeback_updates_groups(self): adapter = MagicMock() adapter.config = {"groups": {"old123": {"name": "test"}}} await handle_target_writeback(adapter, "old123", "new789") assert "new789" in adapter.config["groups"] @pytest.mark.asyncio async def test_writeback_no_match(self): adapter = MagicMock() adapter.config = {"monitored_chats": [], "groups": {}} result = await handle_target_writeback(adapter, "old123", "new789") assert result["updated"] is True # ============================================================================ # 7. debounce # ============================================================================ class TestMessageDebouncer: @pytest.fixture def debouncer(self): return MessageDebouncer(max_entries=5, ttl_seconds=60) def test_build_debounce_key_with_message(self, debouncer): update = {"message": {"chat": {"id": 123}, "message_id": 1, "text": "hello"}} key = debouncer.build_debounce_key(update) assert key is not None assert len(key) == 16 def test_build_debounce_key_without_message(self, debouncer): update = {"other": "data"} key = debouncer.build_debounce_key(update) assert key is None def test_build_debounce_key_with_edited_message(self, debouncer): update = {"edited_message": {"chat": {"id": 123}, "message_id": 1, "text": "hello"}} key = debouncer.build_debounce_key(update) assert key is not None def test_build_debounce_key_with_channel_post(self, debouncer): update = {"channel_post": {"chat": {"id": 123}, "message_id": 1, "text": "hello"}} key = debouncer.build_debounce_key(update) assert key is not None def test_is_duplicate_first_time(self, debouncer): key = "test_key_123" assert debouncer.is_duplicate(key) is False def test_is_duplicate_second_time(self, debouncer): key = "test_key_456" debouncer.is_duplicate(key) assert debouncer.is_duplicate(key) is True def test_cache_size(self, debouncer): debouncer.is_duplicate("a") debouncer.is_duplicate("b") assert debouncer.cache_size() == 2 def test_cache_cleanup_by_ttl(self): debouncer = MessageDebouncer(max_entries=5, ttl_seconds=60) debouncer.is_duplicate("old_key") assert debouncer.cache_size() == 1 assert debouncer.is_duplicate("old_key") is True def test_cache_max_entries(self): debouncer = MessageDebouncer(max_entries=3, ttl_seconds=3600) for i in range(5): debouncer.is_duplicate(f"key_{i}") assert debouncer.cache_size() == 3 def test_clear_entry(self, debouncer): debouncer.is_duplicate("test_key") debouncer.clear_entry("test_key") assert debouncer.cache_size() == 0 class TestConnectTimeoutConfig: def test_default_values(self): cfg = ConnectTimeoutConfig() assert cfg.connect_timeout == 30 assert cfg.read_timeout == 60 assert cfg.write_timeout == 30 assert cfg.pool_timeout == 30 def test_custom_values(self): cfg = ConnectTimeoutConfig({"connect_timeout": 5, "read_timeout": 10}) assert cfg.connect_timeout == 5 assert cfg.read_timeout == 10 def test_get_request_kwargs(self): cfg = ConnectTimeoutConfig({"connect_timeout": 5, "read_timeout": 10}) kwargs = cfg.get_request_kwargs() assert "connect_timeout" in kwargs assert kwargs["connect_timeout"] == 5 # ============================================================================ # 8. tools # ============================================================================ class TestSequentialKeys: def test_generate_sequential_key(self): assert generate_sequential_key("telegram", "123") == "telegram:chat:123" def test_generate_debounce_key(self): assert generate_debounce_key("telegram", "123", "456") == "telegram:debounce:123:456" def test_generate_group_sequential_key(self): assert generate_group_sequential_key("telegram", "789") == "telegram:group:789" def test_generate_topic_sequential_key(self): assert generate_topic_sequential_key("telegram", "123", "456") == "telegram:topic:123:456" class TestRequestTimeouts: def test_default_timeouts(self): assert DEFAULT_TIMEOUTS["send_message"] == 30.0 assert DEFAULT_TIMEOUTS["send_media"] == 60.0 assert DEFAULT_TIMEOUTS["get_me"] == 10.0 def test_get_timeout_default(self): assert get_timeout("send_message") == 30.0 def test_get_timeout_custom(self): assert get_timeout("send_message", {"request_timeouts": {"send_message": 45}}) == 45.0 def test_get_timeout_unknown(self): assert get_timeout("unknown_op") == 30.0 def test_get_timeout_config(self): cfg = get_timeout_config() assert "send_message" in cfg assert cfg["send_message"] == 30.0 def test_resolve_global_timeout_default(self): assert resolve_global_timeout() == 500 def test_resolve_global_timeout_custom(self): assert resolve_global_timeout({"timeout_seconds": 30}) == 30 def test_dotsg_timeout_constant(self): assert DEFAULT_GLOBAL_TIMEOUT_SECONDS == 500 class TestSendChatAction401Backoff: @pytest.fixture def backoff(self): return SendChatAction401Backoff() def test_initial_state(self, backoff): assert backoff.can_send() is True @pytest.mark.asyncio async def test_successful_execute(self, backoff): bot = AsyncMock() bot.send_chat_action = AsyncMock() result = await backoff.execute(bot, "123", "typing") assert result is True @pytest.mark.asyncio async def test_401_backoff(self, backoff): bot = AsyncMock() bot.send_chat_action = AsyncMock( side_effect=Exception("401 Unauthorized") ) result = await backoff.execute(bot, "123", "typing") assert result is False @pytest.mark.asyncio async def test_block_during_backoff(self, backoff): bot = AsyncMock() bot.send_chat_action = AsyncMock( side_effect=Exception("401 Unauthorized") ) await backoff.execute(bot, "123", "typing") assert backoff.can_send() is False def test_reset(self, backoff): backoff._backoff_until = time.monotonic() + 100 backoff._consecutive_failures = 5 backoff.reset() assert backoff.can_send() is True assert backoff._consecutive_failures == 0 # ============================================================================ # 9. reaction_level & reaction_notifications # ============================================================================ class TestReactionLevelController: def test_default_level(self): ctrl = ReactionLevelController() assert ctrl._level == "minimal" def test_custom_level(self): ctrl = ReactionLevelController({"reaction_level": "ack"}) assert ctrl._level == "ack" def test_should_react_off(self): ctrl = ReactionLevelController({"reaction_level": "off"}) assert ctrl.should_react("message.received") is False def test_should_react_ack_message_received(self): ctrl = ReactionLevelController({"reaction_level": "ack"}) assert ctrl.should_react("message.received") is True def test_should_react_ack_other(self): ctrl = ReactionLevelController({"reaction_level": "ack"}) assert ctrl.should_react("other.event") is False def test_should_react_minimal(self): ctrl = ReactionLevelController({"reaction_level": "minimal"}) assert ctrl.should_react("anything") is True def test_get_emojis(self): ctrl = ReactionLevelController() assert ctrl.get_ack_emoji() == "\u2705" assert ctrl.get_error_emoji() == "\u274c" assert ctrl.get_processing_emoji() == "\u23f3" def test_get_reaction_level_func(self): assert get_reaction_level({}) == "minimal" assert get_reaction_level({"reaction_level": "off"}) == "off" class TestReactionNotifications: def test_default_mode(self): rn = ReactionNotifications() assert rn.get_mode() == "all" def test_mode_off(self): rn = ReactionNotifications({"reaction_notifications": "off"}) assert rn.should_notify("bot", "other") is False def test_mode_own_match(self): rn = ReactionNotifications({"reaction_notifications": "own"}) assert rn.should_notify("bot", "bot") is True def test_mode_own_no_match(self): rn = ReactionNotifications({"reaction_notifications": "own"}) assert rn.should_notify("bot", "other") is False def test_mode_all(self): rn = ReactionNotifications({"reaction_notifications": "all"}) assert rn.should_notify("bot", "anyone") is True # ============================================================================ # 10. security_audit # ============================================================================ class TestSecurityAudit: def test_audit_security_policy_open_dm(self): result = audit_security_policy({"dm_policy": "open", "group_policy": "allowlist"}) assert result["dm_policy"] == "open" assert any(i["type"] == "dm_policy_open" for i in result["issues"]) def test_audit_security_policy_open_group(self): result = audit_security_policy({"dm_policy": "pairing", "group_policy": "open"}) assert any(i["type"] == "group_policy_open" for i in result["issues"]) def test_audit_security_policy_all_disabled(self): result = audit_security_policy({"dm_policy": "disabled", "group_policy": "disabled"}) assert any(i["type"] == "all_disabled" for i in result["issues"]) def test_audit_security_policy_empty_allowlist(self): result = audit_security_policy({"dm_policy": "allowlist", "allow_from": []}) assert any(i["type"] == "empty_allowlist" for i in result["issues"]) def test_audit_security_policy_no_approvers(self): config = {"exec_approvals": {"enabled": True, "approvers": []}} result = audit_security_policy(config) assert any(i["type"] == "no_approvers" for i in result["issues"]) def test_audit_security_policy_no_issues(self): result = audit_security_policy({"dm_policy": "pairing", "group_policy": "allowlist", "allow_from": ["tg:123"]}) assert result["issues_count"] == 0 def test_audit_dm_access(self): result = audit_dm_access({"dm_policy": "allowlist", "allow_from": ["tg:111"]}, ["111", "999"]) assert result["allowed_users"] == 1 assert len(result["unknown_users"]) == 1 assert "999" in result["unknown_users"] # ============================================================================ # 11. topic_routing # ============================================================================ class TestTopicRouter: @pytest.fixture def router(self): return TopicRouter({ "groups": { "-100xxx": { "agent_id": "group_agent", "topics": { "42": {"agent_id": "topic_agent"}, }, }, }, "default_agent_id": "default_agent", }) def test_resolve_topic_agent_specific(self, router): assert router.resolve_topic_agent("-100xxx", "42") == "topic_agent" def test_resolve_topic_agent_fallback_group(self, router): assert router.resolve_topic_agent("-100xxx", "99") == "group_agent" def test_resolve_topic_agent_fallback_default(self, router): assert router.resolve_topic_agent("-999", "42") == "default_agent" def test_resolve_group_agent(self, router): assert router.resolve_group_agent("-100xxx") == "group_agent" def test_resolve_group_agent_default(self, router): assert router.resolve_group_agent("-999") == "default_agent" def test_resolve_route_direct(self, router): route = router.resolve_route("123", "private") assert route.startswith("agent:default_agent:telegram:direct:123") def test_resolve_route_group(self, router): route = router.resolve_route("-100xxx", "group") assert "group_agent" in route def test_resolve_route_topic(self, router): route = router.resolve_route("-100xxx", "group", "42") assert "topic_agent" in route assert "topic:42" in route # ============================================================================ # 12. format (supplement) # ============================================================================ class TestFormatSupplement: def test_convert_underline(self): assert _convert_underline("__hello__") == "hello" def test_convert_spoiler(self): assert _convert_spoiler("||secret||") == "secret" def test_convert_strikethrough(self): assert _convert_strikethrough("~~deleted~~") == "deleted" def test_convert_bold_direct(self): assert _convert_bold("**hello**") == "hello" def test_convert_italic_direct(self): assert "hello" in _convert_italic("*hello*") def test_convert_links_direct(self): result = _convert_links("[text](https://example.com)") assert 'text' in result def test_sanitize_html_entity_escape(self): result = _sanitize_html("a > b && c") assert "&" in result def test_strip_html_tags_alias(self): result = strip_html_tags("hello") assert "hello" in result def test_markdown_to_html_underline(self): result = markdown_to_html("__test__") assert "test" in result def test_markdown_to_html_mixed(self): result = markdown_to_html("**bold** and *italic* and ~~strike~~ and __underline__") assert "bold" in result assert "italic" in result assert "strike" in result assert "underline" in result # ============================================================================ # 13. chunking (supplement) # ============================================================================ class TestChunkingSupplement: def test_chunk_by_newline_mode(self): line = "line" * 100 big_text = f"{line}\n{line}\n{line}" result = chunk_text(big_text, limit=100, mode="newline") assert len(result) >= 1 def test_chunk_by_length_mode(self): result = chunk_text("hello world" * 1000, limit=100, mode="length") assert len(result) > 1 def test_chunk_mode_from_config_default(self): assert chunk_mode_from_config() == "paragraph" def test_chunk_mode_from_config_streaming(self): cfg = {"streaming": {"chunk_mode": "newline"}} assert chunk_mode_from_config(cfg) == "newline" def test_chunk_mode_from_config_direct(self): cfg = {"chunking_mode": "length"} assert chunk_mode_from_config(cfg) in ("paragraph", "length") # ============================================================================ # 14. adapter (supplement) # ============================================================================ class TestAdapterSupplement: @pytest.fixture def adapter(self): return TelegramAdapter(config={"bot_token": "123:abc", "dm_policy": "open", "reply_to_mode": "first"}) def test_build_command_scope_default_str(self): assert _build_command_scope("default") is None def test_build_command_scope_all_private(self): scope = _build_command_scope("all_private_chats") assert scope is not None assert scope.type == "all_private_chats" def test_build_command_scope_all_group(self): scope = _build_command_scope("all_group_chats") assert scope is not None assert scope.type == "all_group_chats" def test_build_command_scope_all_admin(self): scope = _build_command_scope("all_chat_administrators") assert scope is not None assert scope.type == "all_chat_administrators" def test_build_command_scope_dict(self): scope = _build_command_scope({"type": "all_private_chats"}) assert scope is not None assert scope.type == "all_private_chats" def test_build_command_scope_dict_default(self): assert _build_command_scope({"type": "default"}) is None def test_build_command_scope_unknown(self): assert _build_command_scope("unknown_type") is None def test_normalize_channel_id(self, adapter): assert adapter._normalize_channel_id("-10012345") == "12345" assert adapter._normalize_channel_id("12345") == "12345" def test_make_skip_event(self, adapter): msg = make_message(text="duplicate") update = make_update(msg) result = adapter._make_skip_event(update) assert result.metadata["skip"] is True def test_is_bot_command_with_entities(self, adapter): entities = (MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=6),) msg = make_message(text="/start", entities=entities) assert adapter._is_bot_command(msg) is True def test_is_bot_command_without_entities(self, adapter): msg = make_message(text="/start") assert adapter._is_bot_command(msg) is True def test_is_bot_command_not_command(self, adapter): msg = make_message(text="hello") assert adapter._is_bot_command(msg) is False def test_is_local_file_path_trusted_no_roots(self, adapter): assert adapter.is_local_file_path_trusted("/some/path") is False def test_is_local_file_path_trusted_with_roots(self): import tempfile import os root = tempfile.gettempdir() adapter = TelegramAdapter(config={"bot_token": "123:abc", "trusted_local_file_roots": [root]}) test_path = os.path.join(root, "test.txt") assert adapter.is_local_file_path_trusted(test_path) is True @pytest.mark.asyncio async def test_verify_webhook_signature_no_secret(self, adapter): assert await adapter.verify_webhook_signature({}, b"test") is True @pytest.mark.asyncio async def test_verify_webhook_signature_match(self): adapter = TelegramAdapter(config={"bot_token": "123:abc", "webhook_secret": "mysecret"}) assert await adapter.verify_webhook_signature({"X-Telegram-Bot-Api-Secret-Token": "mysecret"}, b"test") is True @pytest.mark.asyncio async def test_verify_webhook_signature_mismatch(self): adapter = TelegramAdapter(config={"bot_token": "123:abc", "webhook_secret": "mysecret"}) assert await adapter.verify_webhook_signature({"X-Telegram-Bot-Api-Secret-Token": "wrong"}, b"test") is False def test_properties(self, adapter): assert adapter.history_limit == 50 assert adapter.enabled is True assert adapter.silent_error_replies is False assert adapter.trusted_local_file_roots == [] assert adapter.config_writes is True def test_commands_native_default(self, adapter): assert adapter._get_commands_native() == "auto" def test_commands_native_custom(self): adapter = TelegramAdapter(config={"bot_token": "123:abc", "commands": {"native": "never"}}) assert adapter._get_commands_native() == "never" def test_max_media_size_from_config(self): adapter = TelegramAdapter(config={"bot_token": "123:abc", "max_media_size_mb": 50}) assert adapter.max_media_size_mb == 50 # ------------------------------------------------------------------ # format_outbound supplement # ------------------------------------------------------------------ def test_format_outbound_silent(self, adapter): identity = ChannelIdentity( channel_id="telegram", channel_type=ChannelType.TELEGRAM, channel_user_id="12345", channel_chat_id="-10012345", ) adapter.config["silent"] = True response = ChannelResponse(identity=identity, content="silent msg") payload = adapter.format_outbound(response) assert payload["disable_notification"] is True def test_format_outbound_reply_mode_all(self, adapter): identity = ChannelIdentity( channel_id="telegram", channel_type=ChannelType.TELEGRAM, channel_user_id="12345", channel_chat_id="-10012345", channel_message_id="5", ) adapter.config["reply_to_mode"] = "all" response = ChannelResponse(identity=identity, content="reply all") payload = adapter.format_outbound(response) assert payload["reply_to_message_id"] == 5 def test_format_outbound_image_attachment(self, adapter): identity = ChannelIdentity( channel_id="telegram", channel_type=ChannelType.TELEGRAM, channel_user_id="12345", channel_chat_id="-10012345", ) response = ChannelResponse( identity=identity, content="image caption", message_type=MessageType.IMAGE, attachments=[Attachment(type="image", file_id="file_123")], ) payload = adapter.format_outbound(response) assert "photo" in payload assert "caption" in payload def test_format_outbound_file_attachment(self, adapter): identity = ChannelIdentity( channel_id="telegram", channel_type=ChannelType.TELEGRAM, channel_user_id="12345", channel_chat_id="-10012345", ) response = ChannelResponse( identity=identity, content="file caption", message_type=MessageType.FILE, attachments=[Attachment(type="file", file_id="file_456")], ) payload = adapter.format_outbound(response) assert "document" in payload assert "caption" in payload def test_build_reply_markup_none(self, adapter): response = ChannelResponse( identity=ChannelIdentity( channel_id="telegram", channel_type=ChannelType.TELEGRAM, channel_user_id="123", channel_chat_id="-100", ), content="test", ) assert adapter._build_reply_markup(response) is None def test_build_reply_markup_force_reply(self, adapter): response = ChannelResponse( identity=ChannelIdentity( channel_id="telegram", channel_type=ChannelType.TELEGRAM, channel_user_id="123", channel_chat_id="-100", ), content="test", metadata={"reply_markup": {"type": "force_reply"}}, ) result = adapter._build_reply_markup(response) assert isinstance(result, ForceReply) def test_build_reply_markup_keyboard(self, adapter): response = ChannelResponse( identity=ChannelIdentity( channel_id="telegram", channel_type=ChannelType.TELEGRAM, channel_user_id="123", channel_chat_id="-100", ), content="test", metadata={ "reply_markup": { "type": "keyboard", "keyboard": [["Button1", "Button2"]], } }, ) result = adapter._build_reply_markup(response) assert isinstance(result, ReplyKeyboardMarkup) # ------------------------------------------------------------------ # normalize_inbound supplement # ------------------------------------------------------------------ def test_normalize_member_joined(self, adapter): msg = MagicMock(spec=Message) msg.text = "joined" msg.new_chat_members = [make_user(id=999)] msg.chat = make_chat(id=-100999, chat_type="supergroup") msg.message_id = 99 msg.from_user = make_user() msg.date = _NOW msg.photo = None msg.audio = None msg.voice = None msg.video = None msg.document = None msg.sticker = None msg.animation = None msg.video_note = None msg.entities = None msg.pinned_message = None msg.reply_to_message = None update = make_update(msg) result = adapter.normalize_inbound(update) assert result.event_type == EventType.MEMBER_JOINED def test_normalize_member_left(self, adapter): msg = MagicMock(spec=Message) msg.text = "left" msg.new_chat_members = None msg.left_chat_member = make_user(id=999) msg.chat = make_chat(id=-100999, chat_type="supergroup") msg.message_id = 99 msg.from_user = make_user() msg.date = _NOW msg.photo = None msg.audio = None msg.voice = None msg.video = None msg.document = None msg.sticker = None msg.animation = None msg.video_note = None msg.entities = None msg.pinned_message = None msg.reply_to_message = None update = make_update(msg) result = adapter.normalize_inbound(update) assert result.event_type == EventType.MEMBER_LEFT def test_normalize_pinned_message(self, adapter): pinned_msg = make_message(text="pinned", message_id=99) msg = MagicMock(spec=Message) msg.text = "pinned" msg.new_chat_members = None msg.left_chat_member = None msg.pinned_message = pinned_msg msg.chat = make_chat(id=-100999, chat_type="supergroup") msg.message_id = 99 msg.from_user = make_user() msg.date = _NOW msg.photo = None msg.audio = None msg.voice = None msg.video = None msg.document = None msg.sticker = None msg.animation = None msg.video_note = None msg.entities = None msg.reply_to_message = None update = make_update(msg) result = adapter.normalize_inbound(update) assert result.event_type == EventType.SYSTEM_EVENT def test_normalize_channel_type_chat(self, adapter): chat = make_chat(id=-100111, chat_type="channel") msg = make_message(text="channel msg", chat=chat) update = make_update(msg) result = adapter.normalize_inbound(update) assert result.chat_type == ChatType.GUILD_CHANNEL def test_normalize_with_username_metadata(self, adapter): chat = Chat(id=-100999, type="supergroup", username="testgroup", title="Test Group") msg = make_message(text="hi", chat=chat) update = make_update(msg) result = adapter.normalize_inbound(update) assert result.metadata["chat_username"] == "testgroup" assert result.metadata["chat_title"] == "Test Group" def test_normalize_reply_to_metadata(self, adapter): replied = make_message(text="original", message_id=50) msg = MagicMock(spec=Message) msg.text = "reply" msg.reply_to_message = replied msg.chat = make_chat(id=-100999, chat_type="supergroup") msg.message_id = 99 msg.from_user = make_user() msg.date = _NOW msg.photo = None msg.audio = None msg.voice = None msg.video = None msg.document = None msg.sticker = None msg.animation = None msg.video_note = None msg.entities = None msg.pinned_message = None update = make_update(msg) result = adapter.normalize_inbound(update) assert result.metadata["reply_to_message_id"] == "50" assert result.reply_to_message_id == "50" def test_normalize_caption_as_content(self, adapter): msg = MagicMock(spec=Message) msg.text = None msg.caption = "image caption" msg.chat = make_chat(id=-10012345) msg.message_id = 99 msg.from_user = make_user() msg.date = _NOW msg.photo = None msg.audio = None msg.voice = None msg.video = None msg.document = None msg.sticker = None msg.animation = None msg.video_note = None msg.entities = None msg.pinned_message = None msg.reply_to_message = None update = make_update(msg) result = adapter.normalize_inbound(update) assert result.content == "image caption" def test_normalize_callback_query_passthrough(self, adapter): update = MagicMock(spec=Update) update.update_id = 1 update.message = None update.edited_message = None update.my_chat_member = None update.chat_member = None update.poll = None update.poll_answer = None update.message_reaction = None update.message_reaction_count = None update.chat_join_request = None cb = MagicMock(spec=CallbackQuery) cb.from_user = make_user(id=123) cb.data = "some_data" cb.message = MagicMock() cb.message.chat = make_chat(id=-10012345) update.callback_query = cb result = adapter.normalize_inbound(update) assert result.event_type == EventType.CARD_ACTION assert result.content == "some_data" # ------------------------------------------------------------------ # send methods - app not initialized # ------------------------------------------------------------------ @pytest.mark.asyncio async def test_send_not_initialized(self, adapter): identity = ChannelIdentity( channel_id="telegram", channel_type=ChannelType.TELEGRAM, channel_user_id="123", channel_chat_id="-100", ) response = ChannelResponse(identity=identity, content="test") result = await adapter.send(response) assert result.success is False assert "not initialized" in result.error.lower() @pytest.mark.asyncio async def test_send_media_not_initialized(self, adapter): result = await adapter.send_media("123", "image", b"data") assert result.success is False @pytest.mark.asyncio async def test_edit_message_not_initialized(self, adapter): result = await adapter.edit_message("123", "1", "text") assert result.success is False @pytest.mark.asyncio async def test_delete_message_not_initialized(self, adapter): result = await adapter.delete_message("123", "1") assert result.success is False @pytest.mark.asyncio async def test_send_reaction_not_initialized(self, adapter): result = await adapter.send_reaction("123", "1", "\U0001f44d") assert result.success is False @pytest.mark.asyncio async def test_send_chat_action_not_initialized(self, adapter): result = await adapter.send_chat_action("123") assert result.success is False @pytest.mark.asyncio async def test_pin_message_not_initialized(self, adapter): result = await adapter.pin_message("123", "1") assert result.success is False @pytest.mark.asyncio async def test_unpin_message_not_initialized(self, adapter): result = await adapter.unpin_message("123", "1") assert result.success is False @pytest.mark.asyncio async def test_unpin_all_not_initialized(self, adapter): result = await adapter.unpin_all_messages("123") assert result.success is False @pytest.mark.asyncio async def test_forward_message_not_initialized(self, adapter): result = await adapter.forward_message("123", "456", "1") assert result.success is False @pytest.mark.asyncio async def test_send_media_group_not_initialized(self, adapter): result = await adapter.send_media_group("123", []) assert result.success is False @pytest.mark.asyncio async def test_send_location_not_initialized(self, adapter): result = await adapter.send_location("123", 0, 0) assert result.success is False @pytest.mark.asyncio async def test_send_contact_not_initialized(self, adapter): result = await adapter.send_contact("123", "12345", "Test") assert result.success is False @pytest.mark.asyncio async def test_create_forum_topic_not_initialized(self, adapter): result = await adapter.create_forum_topic("123", "test") assert result.success is False @pytest.mark.asyncio async def test_send_dice_not_initialized(self, adapter): result = await adapter.send_dice("123") assert result.success is False @pytest.mark.asyncio async def test_edit_forum_topic_not_initialized(self, adapter): result = await adapter.edit_forum_topic("123", "456") assert result.success is False @pytest.mark.asyncio async def test_close_forum_topic_not_initialized(self, adapter): result = await adapter.close_forum_topic("123", "456") assert result.success is False @pytest.mark.asyncio async def test_reopen_forum_topic_not_initialized(self, adapter): result = await adapter.reopen_forum_topic("123", "456") assert result.success is False @pytest.mark.asyncio async def test_delete_forum_topic_not_initialized(self, adapter): result = await adapter.delete_forum_topic("123", "456") assert result.success is False @pytest.mark.asyncio async def test_kick_member_not_initialized(self, adapter): result = await adapter.kick_member("123", "456") assert result.success is False @pytest.mark.asyncio async def test_ban_member_not_initialized(self, adapter): result = await adapter.ban_member("123", "456") assert result.success is False @pytest.mark.asyncio async def test_unban_member_not_initialized(self, adapter): result = await adapter.unban_member("123", "456") assert result.success is False @pytest.mark.asyncio async def test_mute_member_not_initialized(self, adapter): result = await adapter.mute_member("123", "456") assert result.success is False @pytest.mark.asyncio async def test_unmute_member_not_initialized(self, adapter): result = await adapter.unmute_member("123", "456") assert result.success is False @pytest.mark.asyncio async def test_set_chat_permissions_not_initialized(self, adapter): result = await adapter.set_chat_permissions("123", {}) assert result.success is False @pytest.mark.asyncio async def test_set_chat_photo_not_initialized(self, adapter): result = await adapter.set_chat_photo("123", b"photo") assert result.success is False @pytest.mark.asyncio async def test_delete_chat_photo_not_initialized(self, adapter): result = await adapter.delete_chat_photo("123") assert result.success is False @pytest.mark.asyncio async def test_rename_group_not_initialized(self, adapter): result = await adapter.rename_group("123", "new name") assert result.success is False @pytest.mark.asyncio async def test_set_chat_description_not_initialized(self, adapter): result = await adapter.set_chat_description("123", "desc") assert result.success is False @pytest.mark.asyncio async def test_promote_admin_not_initialized(self, adapter): result = await adapter.promote_admin("123", "456") assert result.success is False @pytest.mark.asyncio async def test_demote_admin_not_initialized(self, adapter): result = await adapter.demote_admin("123", "456") assert result.success is False @pytest.mark.asyncio async def test_create_chat_invite_link_not_initialized(self, adapter): result = await adapter.create_chat_invite_link("123") assert result.success is False @pytest.mark.asyncio async def test_export_chat_invite_link_not_initialized(self, adapter): result = await adapter.export_chat_invite_link("123") assert result.success is False @pytest.mark.asyncio async def test_leave_chat_not_initialized(self, adapter): result = await adapter.leave_chat("123") assert result.success is False # ------------------------------------------------------------------ # send_media size check # ------------------------------------------------------------------ @pytest.mark.asyncio async def test_send_media_too_large(self, adapter): adapter._application = MagicMock() adapter.max_media_size_mb = 1 large_data = b"x" * (2 * 1024 * 1024) result = await adapter.send_media("123", "image", large_data) assert result.success is False assert "exceeds" in result.error.lower() # ------------------------------------------------------------------ # send_media unsupported type # ------------------------------------------------------------------ @pytest.mark.asyncio async def test_send_media_unsupported_type(self, adapter): adapter._application = MagicMock() result = await adapter.send_media("123", "unsupported_type", b"data") assert result.success is False assert "unsupported" in result.error.lower() # ------------------------------------------------------------------ # edit_message_caption not initialized # ------------------------------------------------------------------ @pytest.mark.asyncio async def test_edit_message_caption_not_initialized(self, adapter): result = await adapter.edit_message_caption("123", "1", "caption") assert result.success is False # ------------------------------------------------------------------ # edit_message_reply_markup not initialized # ------------------------------------------------------------------ @pytest.mark.asyncio async def test_edit_message_reply_markup_not_initialized(self, adapter): result = await adapter.edit_message_reply_markup("123", "1") assert result.success is False # ------------------------------------------------------------------ # get_user_info not initialized # ------------------------------------------------------------------ @pytest.mark.asyncio async def test_get_user_info_not_initialized(self, adapter): result = await adapter.get_user_info("123") assert result == {} # ------------------------------------------------------------------ # download_media not initialized # ------------------------------------------------------------------ @pytest.mark.asyncio async def test_download_media_not_initialized(self, adapter): from yuxi.channels.exceptions import ChannelNotConnectedError with pytest.raises(ChannelNotConnectedError): await adapter.download_media("file_123") # ------------------------------------------------------------------ # health_check disabled # ------------------------------------------------------------------ @pytest.mark.asyncio async def test_health_check_disabled(self): adapter = TelegramAdapter(config={"bot_token": "123:abc", "enabled": False}) result = await adapter.health_check() assert result.status == "degraded" assert result.metadata["reason"] == "channel_disabled" # ------------------------------------------------------------------ # connect disabled # ------------------------------------------------------------------ @pytest.mark.asyncio async def test_connect_disabled(self): adapter = TelegramAdapter(config={"bot_token": "123:abc", "enabled": False}) await adapter.connect() assert adapter._status == ChannelStatus.DISABLED # ------------------------------------------------------------------ # connect already connected # ------------------------------------------------------------------ @pytest.mark.asyncio async def test_connect_already_connected(self): adapter = TelegramAdapter(config={"bot_token": "123:abc"}) adapter._status = ChannelStatus.CONNECTED await adapter.connect() assert adapter._status == ChannelStatus.CONNECTED # ------------------------------------------------------------------ # disconnect already disconnected # ------------------------------------------------------------------ @pytest.mark.asyncio async def test_disconnect_already_disconnected(self): adapter = TelegramAdapter(config={"bot_token": "123:abc"}) await adapter.disconnect() assert adapter._status == ChannelStatus.DISCONNECTED # ------------------------------------------------------------------ # receive # ------------------------------------------------------------------ @pytest.mark.asyncio async def test_receive_yields_nothing(self, adapter): results = [] async for msg in adapter.receive(): results.append(msg) assert len(results) == 0 # ------------------------------------------------------------------ # stream_chunk finished delegates to edit_message # ------------------------------------------------------------------ @pytest.mark.asyncio async def test_send_stream_chunk_finished_not_initialized(self, adapter): result = await adapter.send_stream_chunk("123", "1", "final", True) assert result.success is False @pytest.mark.asyncio async def test_send_stream_chunk_not_finished(self, adapter): adapter._application = MagicMock() adapter._application.bot = AsyncMock() adapter._application.bot.edit_message_text = AsyncMock() result = await adapter.send_stream_chunk("123", "1", "chunk", False) assert result is None