1. 移除Telegram格式化测试中未使用的导入项 2. 修复Teams测试用例,添加monkeypatch参数并配置通配符开关 3. 更新钉钉适配器测试,替换弃用的流属性检查 4. 修正Twitch规范化测试,更新ROOMSTATE测试逻辑 5. 重构会话映射测试,完善数据库执行结果模拟 6. 格式化Slack块构建测试的长参数调用 7. 修复LINE适配器测试,更新能力断言和异步锁使用 8. 修正Slack会话解析测试,修复聊天类型判断错误 9. 更新能力测试,补充缺失的字段检查 10. 修复Matrix适配器测试,修正位置参数和配置校验逻辑 11. 为飞书分析模块测试添加跳过标记 12. 新增微信能力、限流、链接格式、会话路由等模块的单元测试 13. 修复Twitch适配器导入路径和测试断言 14. 新增Discord Webhook、Nextcloud Talk、Signal多账户等模块的单元测试 15. 修复Manager阶段测试的导入路径 16. 新增iMessage异常和命令处理的单元测试 17. 新增Nostr健康检查和相关模块的单元测试 18. 新增Signal守护进程和SSE重连相关测试
1701 lines
64 KiB
Python
1701 lines
64 KiB
Python
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": "<b>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", "<b>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__") == "<u>hello</u>"
|
|
|
|
def test_convert_spoiler(self):
|
|
assert _convert_spoiler("||secret||") == "<tg-spoiler>secret</tg-spoiler>"
|
|
|
|
def test_convert_strikethrough(self):
|
|
assert _convert_strikethrough("~~deleted~~") == "<s>deleted</s>"
|
|
|
|
def test_convert_bold_direct(self):
|
|
assert _convert_bold("**hello**") == "<b>hello</b>"
|
|
|
|
def test_convert_italic_direct(self):
|
|
assert "<i>hello</i>" in _convert_italic("*hello*")
|
|
|
|
def test_convert_links_direct(self):
|
|
result = _convert_links("[text](https://example.com)")
|
|
assert '<a href="https://example.com">text</a>' 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("<b>hello</b>")
|
|
assert "hello" in result
|
|
|
|
def test_markdown_to_html_underline(self):
|
|
result = markdown_to_html("__test__")
|
|
assert "<u>test</u>" in result
|
|
|
|
def test_markdown_to_html_mixed(self):
|
|
result = markdown_to_html("**bold** and *italic* and ~~strike~~ and __underline__")
|
|
assert "<b>bold</b>" in result
|
|
assert "<i>italic</i>" in result
|
|
assert "<s>strike</s>" in result
|
|
assert "<u>underline</u>" 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
|