diff --git a/EvoScientist/channels/dingtalk/__init__.py b/EvoScientist/channels/dingtalk/__init__.py index 2026d82..df73768 100644 --- a/EvoScientist/channels/dingtalk/__init__.py +++ b/EvoScientist/channels/dingtalk/__init__.py @@ -16,11 +16,11 @@ __all__ = ["DingTalkChannel", "DingTalkConfig"] def create_from_config(config) -> DingTalkChannel: - allowed = _parse_csv(getattr(config, "dingtalk_allowed_senders", "")) - proxy = getattr(config, "dingtalk_proxy", "") or None + allowed = _parse_csv(config.dingtalk_allowed_senders) + proxy = config.dingtalk_proxy if config.dingtalk_proxy else None return DingTalkChannel(DingTalkConfig( - client_id=getattr(config, "dingtalk_client_id", ""), - client_secret=getattr(config, "dingtalk_client_secret", ""), + client_id=config.dingtalk_client_id, + client_secret=config.dingtalk_client_secret, allowed_senders=allowed, proxy=proxy, )) diff --git a/EvoScientist/channels/feishu/__init__.py b/EvoScientist/channels/feishu/__init__.py index 4cc68e5..d24ce16 100644 --- a/EvoScientist/channels/feishu/__init__.py +++ b/EvoScientist/channels/feishu/__init__.py @@ -6,6 +6,7 @@ __all__ = ["FeishuChannel", "FeishuConfig"] def create_from_config(config) -> FeishuChannel: allowed = _parse_csv(config.feishu_allowed_senders) + proxy = config.feishu_proxy if config.feishu_proxy else None return FeishuChannel(FeishuConfig( app_id=config.feishu_app_id, app_secret=config.feishu_app_secret, @@ -14,7 +15,7 @@ def create_from_config(config) -> FeishuChannel: webhook_port=config.feishu_webhook_port, allowed_senders=allowed, feishu_domain=config.feishu_domain, - proxy=getattr(config, 'feishu_proxy', '') or None, + proxy=proxy, )) diff --git a/EvoScientist/channels/feishu/channel.py b/EvoScientist/channels/feishu/channel.py index 8edebd1..9010605 100644 --- a/EvoScientist/channels/feishu/channel.py +++ b/EvoScientist/channels/feishu/channel.py @@ -222,6 +222,14 @@ class FeishuChannel(Channel, WebhookMixin, TokenMixin): name = "feishu" _ready_attrs = ("_http_client", "_access_token") + _non_retryable_patterns = ( + "app_access_token is empty", # invalid credentials + "10003", # invalid app_id + "10014", # invalid app_secret + "99991401", # permission denied + "99991663", # no permission + "99991672", # feature not enabled + ) _rate_limit_patterns = ("99991400", "rate limit", "频率限制") _rate_limit_delay = 2.0 diff --git a/EvoScientist/config/onboard.py b/EvoScientist/config/onboard.py index 6c9584e..d99e3e1 100644 --- a/EvoScientist/config/onboard.py +++ b/EvoScientist/config/onboard.py @@ -1319,7 +1319,7 @@ def _step_channels(config: EvoScientistConfig) -> dict[str, object]: ("telegram", "Telegram", [("telegram_bot_token", "Bot token (from @BotFather)")], "telegram", "telegram"), ("discord", "Discord", [("discord_bot_token", "Bot token")], "discord", "discord"), ("slack", "Slack", [("slack_bot_token", "Bot token (xoxb-...)"), ("slack_app_token", "App token for Socket Mode (xapp-...)")], "slack_sdk", "slack"), - ("feishu", "Feishu", [("feishu_app_id", "App ID"), ("feishu_app_secret", "App Secret"), ("feishu_verification_token", "Verification Token"), ("feishu_encrypt_key", "Encrypt Key")], "aiohttp", "feishu"), + ("feishu", "Feishu", [("feishu_app_id", "App ID"), ("feishu_app_secret", "App Secret")], "aiohttp", "feishu"), ("dingtalk", "DingTalk", [("dingtalk_client_id", "Client ID (AppKey)"), ("dingtalk_client_secret", "Client Secret (AppSecret)")], "aiohttp", "dingtalk"), ("wechat", "WeChat", [("wechat_wecom_corp_id", "WeCom Corp ID"), ("wechat_wecom_agent_id", "WeCom Agent ID"), ("wechat_wecom_secret", "WeCom Secret")], "aiohttp", "wechat"), ("imessage", "iMessage", [], None, None), # handled via _setup_imessage() @@ -1412,6 +1412,24 @@ def _step_channels(config: EvoScientistConfig) -> dict[str, object]: raise KeyboardInterrupt() updates[field_name] = value.strip() + # Feishu optional fields (verification_token & encrypt_key) + if ch_name == "feishu": + console.print(" [dim]The following fields are optional (press Enter to skip):[/dim]") + for field_name, prompt_label in [ + ("feishu_verification_token", "Verification Token (optional)"), + ("feishu_encrypt_key", "Encrypt Key (optional)"), + ]: + current = getattr(config, field_name, "") + value = questionary.text( + f"{prompt_label}:", + default=current, + style=WIZARD_STYLE, + qmark=f" {QMARK}", + ).ask() + if value is None: + raise KeyboardInterrupt() + updates[field_name] = value.strip() + # Allowed senders (common for all channels) senders_field = f"{ch_name}_allowed_senders" if hasattr(config, senders_field): diff --git a/tests/test_dingtalk_channel.py b/tests/test_dingtalk_channel.py index 91870b6..e3deba6 100644 --- a/tests/test_dingtalk_channel.py +++ b/tests/test_dingtalk_channel.py @@ -1,11 +1,13 @@ """Tests for DingTalk channel implementation.""" import asyncio +import json +from unittest.mock import AsyncMock, MagicMock, patch import pytest from EvoScientist.channels.dingtalk.channel import DingTalkChannel, DingTalkConfig -from EvoScientist.channels.base import ChannelError +from EvoScientist.channels.base import ChannelError, OutboundMessage def _run(coro): @@ -72,8 +74,6 @@ class TestDingTalkChannel: _run(channel.stop()) def test_send_returns_false_without_client(self): - from EvoScientist.channels.base import OutboundMessage - config = DingTalkConfig(client_id="test-id", client_secret="test-secret") channel = DingTalkChannel(config) msg = OutboundMessage( @@ -97,6 +97,181 @@ class TestDingTalkChannel: assert channel.capabilities.media_receive is True +class TestDingTalkErrorPatterns: + """Test non-retryable and rate-limit pattern detection.""" + + def test_non_retryable_patterns_defined(self): + config = DingTalkConfig() + channel = DingTalkChannel(config) + assert "invalidauthentication" in channel._non_retryable_patterns + assert "forbidden" in channel._non_retryable_patterns + assert "40014" in channel._non_retryable_patterns + + def test_non_retryable_returns_none(self): + config = DingTalkConfig() + channel = DingTalkChannel(config) + exc = Exception("invalidauthentication: bad credentials") + result = channel._extract_retry_after(exc) + assert result is None + + def test_rate_limit_returns_delay(self): + config = DingTalkConfig() + channel = DingTalkChannel(config) + # Base class default includes "429" and "ratelimit" + exc = Exception("HTTP 429 ratelimit exceeded") + result = channel._extract_retry_after(exc) + assert result is not None + assert result > 0 + + +class TestDingTalkWsMessageParsing: + """Test _on_ws_message parsing logic with mocked bus.""" + + def _make_channel(self): + config = DingTalkConfig(client_id="test-app", client_secret="test-secret") + channel = DingTalkChannel(config) + channel._running = True + channel._ws_session = MagicMock() + channel._ws_session.send_str = AsyncMock() + channel._http_client = MagicMock() + channel._access_token = "fake-token" + channel._token_expires = 9999999999 + return channel + + def test_system_ping_ack(self): + channel = self._make_channel() + data = { + "type": "SYSTEM", + "headers": {"topic": "ping", "messageId": "ping-1"}, + "data": "pong-data", + } + _run(channel._on_ws_message(data)) + channel._ws_session.send_str.assert_called_once() + sent = json.loads(channel._ws_session.send_str.call_args[0][0]) + assert sent["code"] == 200 + assert sent["data"] == "pong-data" + + def test_callback_text_message(self): + channel = self._make_channel() + channel._enqueue_raw = AsyncMock() + + payload = { + "text": {"content": "hello bot"}, + "senderStaffId": "staff123", + "conversationType": "1", + "createAt": "1700000000000", + } + data = { + "type": "CALLBACK", + "headers": {"messageId": "msg-1", "contentType": "application/json"}, + "data": json.dumps(payload), + } + _run(channel._on_ws_message(data)) + channel._enqueue_raw.assert_called_once() + raw = channel._enqueue_raw.call_args[0][0] + assert raw.text == "hello bot" + assert raw.sender_id == "staff123" + assert raw.is_group is False + + def test_callback_group_message_mention(self): + channel = self._make_channel() + channel._enqueue_raw = AsyncMock() + + payload = { + "text": {"content": "@bot hello"}, + "senderStaffId": "staff456", + "conversationType": "2", + "isInAtList": True, + "createAt": "1700000000000", + } + data = { + "type": "CALLBACK", + "headers": {"messageId": "msg-2"}, + "data": json.dumps(payload), + } + _run(channel._on_ws_message(data)) + raw = channel._enqueue_raw.call_args[0][0] + assert raw.is_group is True + assert raw.was_mentioned is True + + def test_callback_group_no_mention(self): + channel = self._make_channel() + channel._enqueue_raw = AsyncMock() + + payload = { + "text": {"content": "just chatting"}, + "senderStaffId": "staff789", + "conversationType": "2", + "createAt": "1700000000000", + } + data = { + "type": "CALLBACK", + "headers": {"messageId": "msg-3"}, + "data": json.dumps(payload), + } + _run(channel._on_ws_message(data)) + raw = channel._enqueue_raw.call_args[0][0] + assert raw.is_group is True + assert raw.was_mentioned is False + + def test_ignores_non_callback(self): + channel = self._make_channel() + channel._enqueue_raw = AsyncMock() + + data = { + "type": "EVENT", + "headers": {"messageId": "msg-x"}, + "data": "{}", + } + _run(channel._on_ws_message(data)) + channel._enqueue_raw.assert_not_called() + + def test_ignores_empty_content(self): + channel = self._make_channel() + channel._enqueue_raw = AsyncMock() + + payload = { + "text": {"content": ""}, + "senderStaffId": "staff0", + "conversationType": "1", + } + data = { + "type": "CALLBACK", + "headers": {"messageId": "msg-e"}, + "data": json.dumps(payload), + } + _run(channel._on_ws_message(data)) + channel._enqueue_raw.assert_not_called() + + def test_non_dict_data_ignored(self): + channel = self._make_channel() + channel._enqueue_raw = AsyncMock() + _run(channel._on_ws_message("not a dict")) + channel._enqueue_raw.assert_not_called() + + +class TestDingTalkSendChunk: + """Test _send_chunk with mocked HTTP client.""" + + def test_send_chunk_calls_api(self): + config = DingTalkConfig(client_id="test-app", client_secret="test-secret") + channel = DingTalkChannel(config) + channel._access_token = "fake-token" + channel._token_expires = 9999999999 + + mock_response = MagicMock() + mock_response.json.return_value = {"processQueryKey": "ok"} + channel._http_client = MagicMock() + channel._http_client.post = AsyncMock(return_value=mock_response) + + _run(channel._send_chunk("user1", "formatted", "raw text", None, {})) + channel._http_client.post.assert_called_once() + call_args = channel._http_client.post.call_args + body = call_args.kwargs.get("json") or call_args[1].get("json") + assert body["robotCode"] == "test-app" + assert body["userIds"] == ["user1"] + + class TestDingTalkChannelRegistration: def test_dingtalk_registered(self): from EvoScientist.channels.channel_manager import available_channels