pass linter

This commit is contained in:
MuXinCG
2026-02-17 15:40:57 +08:00
parent b85faf7516
commit bec3fc0b67
5 changed files with 211 additions and 9 deletions
+4 -4
View File
@@ -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,
))
+2 -1
View File
@@ -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,
))
+8
View File
@@ -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
+19 -1
View File
@@ -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):
+178 -3
View File
@@ -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