pass linter
This commit is contained in:
@@ -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,
|
||||
))
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user