deer-flow/backend/tests/test_dingtalk_channel.py
He Wang 08afdcb907
feat(channels): add DingTalk channel integration (#2628)
* feat(channels): add DingTalk channel integration

Add a new DingTalk messaging channel using the dingtalk-stream SDK
with Stream Push (WebSocket), requiring no public IP. Supports both
plain sampleMarkdown replies and optional AI Card streaming for a
typewriter effect when card_template_id is configured.

- Add DingTalkChannel implementation with token management, message
  routing, allowed_users filtering, and markdown adaptation
- Register dingtalk in channel service registry and capability map
- Propagate inbound metadata to outbound messages in ChannelManager
  for DingTalk sender context (sender_staff_id, conversation_type)
- Add dingtalk-stream dependency to pyproject.toml
- Add configuration examples in config.example.yaml and .env.example
- Update all README translations with setup instructions
- Add comprehensive test suite (test_dingtalk_channel.py) and
  metadata propagation test in test_channels.py
- Update backend CLAUDE.md to document DingTalk channel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(channels): address PR review feedback for DingTalk integration

- Replace runtime mutation of CHANNEL_CAPABILITIES with a
  `supports_streaming` property on the Channel base class, overridden
  by DingTalkChannel, FeishuChannel, and WeComChannel
- Store stream client reference and attempt graceful disconnect in
  stop(); guard _on_chatbot_message with _running check to prevent
  post-stop message processing
- Use msg.chat_id as the primary routing key in send/send_file via
  a shared _resolve_routing helper, with metadata as fallback
- Fix process() return type annotation from tuple[str, str] to
  tuple[int, str] to match AckMessage.STATUS_OK
- Protect _incoming_messages with threading.Lock for cross-thread
  safety between the Stream Push thread and the asyncio loop
- Re-add Docker Compose URL guidance removed during DingTalk setup
  docs addition in README.md
- Fix incomplete sentence in README_zh.md (missing verb "启用")

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(docs): restore plain paragraph format for Docker Compose note

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(channels): fix isinstance TypeError and add file size guard in DingTalk channel

Use tuple syntax for isinstance() type check to avoid runtime TypeError
with PEP 604 union types. Add upload size limit (20MB) before reading
files into memory. Narrow exception handlers to specific types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(channels): propagate markdown fallback errors and validate access token response

- Re-raise exceptions in _send_markdown_fallback to prevent partial
  deliveries (files sent without accompanying text)
- Validate _get_access_token response: reject non-dict bodies, empty
  tokens, and coerce invalid expireIn to a safe default
- Add tests for both fixes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(channels): validate upload response and broaden send_file exception handling

- Validate _upload_media JSON response: handle JSONDecodeError and
  non-dict payloads gracefully by returning None
- Broaden send_file exception tuple to include TypeError and
  AttributeError for unexpected JSON shapes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(channels): fix streaming race on channel registration and slim outbound metadata

- Register channel in service before calling start() to avoid race
  where background receiver publishes inbound before registration,
  causing manager to fall back to static CHANNEL_CAPABILITIES
- Strip known-large metadata keys (raw_message, ref_msg) from outbound
  messages to prevent memory bloat from propagated inbound payloads

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Update service.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update CLAUDE.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-30 11:25:33 +08:00

1555 lines
52 KiB
Python

"""Tests for the DingTalk channel implementation."""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from app.channels.commands import KNOWN_CHANNEL_COMMANDS
from app.channels.dingtalk import (
_CONVERSATION_TYPE_GROUP,
_CONVERSATION_TYPE_P2P,
DingTalkChannel,
_adapt_markdown_for_dingtalk,
_convert_markdown_table,
_DingTalkMessageHandler,
_extract_text_from_rich_text,
_is_dingtalk_command,
_normalize_allowed_users,
_normalize_conversation_type,
)
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage
def _run(coro):
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
# ---------------------------------------------------------------------------
# Helper: build mock ChatbotMessage
# ---------------------------------------------------------------------------
def _make_chatbot_message(
*,
text: str = "hello",
message_type: str = "text",
conversation_type: str | int = _CONVERSATION_TYPE_P2P,
sender_staff_id: str = "user_001",
sender_nick: str = "Test User",
conversation_id: str = "conv_001",
message_id: str = "msg_001",
rich_text_list: list | None = None,
):
"""Build a minimal mock object mimicking dingtalk_stream.ChatbotMessage."""
msg = SimpleNamespace()
msg.message_type = message_type
msg.conversation_type = conversation_type
msg.sender_staff_id = sender_staff_id
msg.sender_nick = sender_nick
msg.conversation_id = conversation_id
msg.message_id = message_id
if message_type == "text":
msg.text = SimpleNamespace(content=text)
msg.rich_text_content = None
elif message_type == "richText":
msg.text = None
msg.rich_text_content = SimpleNamespace(rich_text_list=rich_text_list or [])
else:
msg.text = None
msg.rich_text_content = None
return msg
# ---------------------------------------------------------------------------
# _DingTalkMessageHandler SDK contract
# ---------------------------------------------------------------------------
class TestDingTalkMessageHandlerSdkContract:
def test_pre_start_exists_and_noop(self):
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
handler = _DingTalkMessageHandler(channel)
handler.pre_start()
def test_raw_process_returns_ack(self):
pytest.importorskip("dingtalk_stream")
async def go():
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
channel._on_chatbot_message = MagicMock()
handler = _DingTalkMessageHandler(channel)
cb = MagicMock()
cb.headers.message_id = "mid-1"
cb.data = {
"msgtype": "text",
"text": {"content": "hi"},
"senderStaffId": "u1",
"conversationType": "1",
"msgId": "m1",
}
ack = await handler.raw_process(cb)
assert ack.code == 200
assert ack.headers.message_id == "mid-1"
assert ack.data == {"response": "OK"}
channel._on_chatbot_message.assert_called_once()
_run(go())
# ---------------------------------------------------------------------------
# _normalize_allowed_users tests
# ---------------------------------------------------------------------------
class TestNormalizeAllowedUsers:
def test_none_returns_empty(self):
assert _normalize_allowed_users(None) == set()
def test_empty_list_returns_empty(self):
assert _normalize_allowed_users([]) == set()
def test_list_of_strings(self):
result = _normalize_allowed_users(["user1", "user2"])
assert result == {"user1", "user2"}
def test_single_string(self):
result = _normalize_allowed_users("user1")
assert result == {"user1"}
def test_numeric_values_converted_to_string(self):
result = _normalize_allowed_users([123, 456])
assert result == {"123", "456"}
def test_scalar_treated_as_single_value(self):
result = _normalize_allowed_users(12345)
assert result == {"12345"}
# ---------------------------------------------------------------------------
# _normalize_conversation_type tests
# ---------------------------------------------------------------------------
class TestNormalizeConversationType:
def test_group_int_or_str(self):
assert _normalize_conversation_type(2) == _CONVERSATION_TYPE_GROUP
assert _normalize_conversation_type("2") == _CONVERSATION_TYPE_GROUP
def test_p2p_or_none(self):
assert _normalize_conversation_type(1) == _CONVERSATION_TYPE_P2P
assert _normalize_conversation_type(None) == _CONVERSATION_TYPE_P2P
# ---------------------------------------------------------------------------
# _is_dingtalk_command tests
# ---------------------------------------------------------------------------
class TestIsDingTalkCommand:
@pytest.mark.parametrize("command", sorted(KNOWN_CHANNEL_COMMANDS))
def test_known_commands_recognized(self, command):
assert _is_dingtalk_command(command) is True
@pytest.mark.parametrize(
"text",
[
"/unknown",
"/mnt/user-data/outputs/report.md",
"hello",
"",
"not a command",
],
)
def test_non_commands_rejected(self, text):
assert _is_dingtalk_command(text) is False
# ---------------------------------------------------------------------------
# _extract_text_from_rich_text tests
# ---------------------------------------------------------------------------
class TestExtractTextFromRichText:
def test_single_text_item(self):
result = _extract_text_from_rich_text([{"text": "hello"}])
assert result == "hello"
def test_multiple_text_items(self):
result = _extract_text_from_rich_text([{"text": "hello"}, {"text": "world"}])
assert result == "hello world"
def test_non_text_items_ignored(self):
result = _extract_text_from_rich_text(
[
{"downloadCode": "abc123"},
{"text": "caption"},
]
)
assert result == "caption"
def test_empty_list(self):
assert _extract_text_from_rich_text([]) == ""
# ---------------------------------------------------------------------------
# DingTalkChannel._extract_text tests
# ---------------------------------------------------------------------------
class TestExtractText:
def test_plain_text(self):
msg = _make_chatbot_message(text="Hello World")
assert DingTalkChannel._extract_text(msg) == "Hello World"
def test_plain_text_stripped(self):
msg = _make_chatbot_message(text=" Hello ")
assert DingTalkChannel._extract_text(msg) == "Hello"
def test_rich_text(self):
msg = _make_chatbot_message(
message_type="richText",
rich_text_list=[{"text": "Part 1"}, {"text": "Part 2"}],
)
assert DingTalkChannel._extract_text(msg) == "Part 1 Part 2"
def test_unknown_type_returns_empty(self):
msg = _make_chatbot_message(message_type="picture")
assert DingTalkChannel._extract_text(msg) == ""
# ---------------------------------------------------------------------------
# DingTalkChannel._on_chatbot_message tests (inbound parsing)
# ---------------------------------------------------------------------------
class TestOnChatbotMessage:
def test_p2p_message_produces_correct_inbound(self):
async def go():
bus = MessageBus()
bus.publish_inbound = AsyncMock()
channel = DingTalkChannel(bus, config={})
channel._client_id = "test_key"
channel._main_loop = asyncio.get_event_loop()
channel._running = True
msg = _make_chatbot_message(
text="hello from dingtalk",
conversation_type=_CONVERSATION_TYPE_P2P,
sender_staff_id="user_001",
message_id="msg_001",
)
channel._send_running_reply = AsyncMock()
channel._on_chatbot_message(msg)
await asyncio.sleep(0.1)
bus.publish_inbound.assert_awaited_once()
inbound = bus.publish_inbound.await_args.args[0]
assert inbound.channel_name == "dingtalk"
assert inbound.chat_id == "user_001"
assert inbound.user_id == "user_001"
assert inbound.text == "hello from dingtalk"
assert inbound.topic_id is None
assert inbound.metadata["conversation_type"] == _CONVERSATION_TYPE_P2P
assert inbound.metadata["sender_staff_id"] == "user_001"
_run(go())
def test_group_message_produces_correct_inbound(self):
async def go():
bus = MessageBus()
bus.publish_inbound = AsyncMock()
channel = DingTalkChannel(bus, config={})
channel._client_id = "test_key"
channel._main_loop = asyncio.get_event_loop()
channel._running = True
msg = _make_chatbot_message(
text="hello group",
conversation_type=_CONVERSATION_TYPE_GROUP,
sender_staff_id="user_002",
conversation_id="conv_group_001",
message_id="msg_group_001",
)
channel._send_running_reply = AsyncMock()
channel._on_chatbot_message(msg)
await asyncio.sleep(0.1)
bus.publish_inbound.assert_awaited_once()
inbound = bus.publish_inbound.await_args.args[0]
assert inbound.channel_name == "dingtalk"
assert inbound.chat_id == "conv_group_001"
assert inbound.user_id == "user_002"
assert inbound.text == "hello group"
assert inbound.topic_id == "msg_group_001"
assert inbound.metadata["conversation_type"] == _CONVERSATION_TYPE_GROUP
assert inbound.metadata["conversation_id"] == "conv_group_001"
_run(go())
def test_group_message_integer_conversation_type_normalized(self):
"""SDK may deliver conversationType as int 2 — must still route as group."""
async def go():
bus = MessageBus()
bus.publish_inbound = AsyncMock()
channel = DingTalkChannel(bus, config={})
channel._client_id = "test_key"
channel._main_loop = asyncio.get_event_loop()
channel._running = True
msg = _make_chatbot_message(
text="hello group",
conversation_type=2,
sender_staff_id="user_002",
conversation_id="conv_group_001",
message_id="msg_group_002",
)
channel._send_running_reply = AsyncMock()
channel._on_chatbot_message(msg)
await asyncio.sleep(0.1)
bus.publish_inbound.assert_awaited_once()
inbound = bus.publish_inbound.await_args.args[0]
assert inbound.chat_id == "conv_group_001"
assert inbound.topic_id == "msg_group_002"
assert inbound.metadata["conversation_type"] == _CONVERSATION_TYPE_GROUP
_run(go())
def test_command_classified_correctly(self):
async def go():
bus = MessageBus()
bus.publish_inbound = AsyncMock()
channel = DingTalkChannel(bus, config={})
channel._client_id = "test_key"
channel._main_loop = asyncio.get_event_loop()
channel._running = True
msg = _make_chatbot_message(text="/help")
channel._send_running_reply = AsyncMock()
channel._on_chatbot_message(msg)
await asyncio.sleep(0.1)
bus.publish_inbound.assert_awaited_once()
inbound = bus.publish_inbound.await_args.args[0]
assert inbound.msg_type == InboundMessageType.COMMAND
_run(go())
def test_non_command_classified_as_chat(self):
async def go():
bus = MessageBus()
bus.publish_inbound = AsyncMock()
channel = DingTalkChannel(bus, config={})
channel._client_id = "test_key"
channel._main_loop = asyncio.get_event_loop()
channel._running = True
msg = _make_chatbot_message(text="just chatting")
channel._send_running_reply = AsyncMock()
channel._on_chatbot_message(msg)
await asyncio.sleep(0.1)
bus.publish_inbound.assert_awaited_once()
inbound = bus.publish_inbound.await_args.args[0]
assert inbound.msg_type == InboundMessageType.CHAT
_run(go())
def test_empty_text_ignored(self):
async def go():
bus = MessageBus()
bus.publish_inbound = AsyncMock()
channel = DingTalkChannel(bus, config={})
channel._client_id = "test_key"
channel._main_loop = asyncio.get_event_loop()
channel._running = True
msg = _make_chatbot_message(text=" ")
channel._on_chatbot_message(msg)
await asyncio.sleep(0.1)
bus.publish_inbound.assert_not_awaited()
_run(go())
# ---------------------------------------------------------------------------
# allowed_users filtering tests
# ---------------------------------------------------------------------------
class TestAllowedUsersFiltering:
def test_allowed_user_passes(self):
async def go():
bus = MessageBus()
bus.publish_inbound = AsyncMock()
channel = DingTalkChannel(bus, config={"allowed_users": ["user_001"]})
channel._client_id = "test_key"
channel._main_loop = asyncio.get_event_loop()
channel._running = True
msg = _make_chatbot_message(sender_staff_id="user_001")
channel._send_running_reply = AsyncMock()
channel._on_chatbot_message(msg)
await asyncio.sleep(0.1)
bus.publish_inbound.assert_awaited_once()
_run(go())
def test_non_allowed_user_blocked(self):
async def go():
bus = MessageBus()
bus.publish_inbound = AsyncMock()
channel = DingTalkChannel(bus, config={"allowed_users": ["user_001"]})
channel._client_id = "test_key"
channel._main_loop = asyncio.get_event_loop()
channel._running = True
msg = _make_chatbot_message(sender_staff_id="user_blocked")
channel._on_chatbot_message(msg)
await asyncio.sleep(0.1)
bus.publish_inbound.assert_not_awaited()
_run(go())
def test_empty_allowed_users_allows_all(self):
async def go():
bus = MessageBus()
bus.publish_inbound = AsyncMock()
channel = DingTalkChannel(bus, config={"allowed_users": []})
channel._client_id = "test_key"
channel._main_loop = asyncio.get_event_loop()
channel._running = True
msg = _make_chatbot_message(sender_staff_id="anyone")
channel._send_running_reply = AsyncMock()
channel._on_chatbot_message(msg)
await asyncio.sleep(0.1)
bus.publish_inbound.assert_awaited_once()
_run(go())
# ---------------------------------------------------------------------------
# send routing tests (P2P vs Group)
# ---------------------------------------------------------------------------
class TestMarkdownFallbackPropagation:
def test_fallback_raises_on_failure(self):
async def go():
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
channel._client_id = "test_key"
channel._cached_token = "tok"
channel._token_expires_at = float("inf")
channel._send_p2p_message = AsyncMock(side_effect=ConnectionError("send failed"))
with pytest.raises(ConnectionError, match="send failed"):
await channel._send_markdown_fallback("test_key", _CONVERSATION_TYPE_P2P, "user_001", "", "hello")
_run(go())
class TestSendRouting:
def test_p2p_send_uses_oto_endpoint(self):
async def go():
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
channel._client_id = "test_key"
channel._client_secret = "test_secret"
channel._send_p2p_message = AsyncMock()
channel._send_group_message = AsyncMock()
msg = OutboundMessage(
channel_name="dingtalk",
chat_id="user_001",
thread_id="thread_001",
text="Hello P2P",
metadata={
"conversation_type": _CONVERSATION_TYPE_P2P,
"sender_staff_id": "user_001",
"conversation_id": "",
},
)
await channel.send(msg)
channel._send_p2p_message.assert_awaited_once_with("test_key", "user_001", "Hello P2P")
channel._send_group_message.assert_not_awaited()
_run(go())
def test_group_send_uses_group_endpoint(self):
async def go():
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
channel._client_id = "test_key"
channel._client_secret = "test_secret"
channel._send_p2p_message = AsyncMock()
channel._send_group_message = AsyncMock()
msg = OutboundMessage(
channel_name="dingtalk",
chat_id="conv_001",
thread_id="thread_001",
text="Hello Group",
metadata={
"conversation_type": _CONVERSATION_TYPE_GROUP,
"sender_staff_id": "user_001",
"conversation_id": "conv_001",
},
)
await channel.send(msg)
channel._send_group_message.assert_awaited_once_with("test_key", "conv_001", "Hello Group", at_user_ids=["user_001"])
channel._send_p2p_message.assert_not_awaited()
_run(go())
def test_default_metadata_uses_p2p(self):
async def go():
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
channel._client_id = "test_key"
channel._client_secret = "test_secret"
channel._send_p2p_message = AsyncMock()
channel._send_group_message = AsyncMock()
msg = OutboundMessage(
channel_name="dingtalk",
chat_id="user_001",
thread_id="thread_001",
text="Hello",
metadata={},
)
await channel.send(msg)
channel._send_p2p_message.assert_awaited_once()
channel._send_group_message.assert_not_awaited()
_run(go())
# ---------------------------------------------------------------------------
# send retry tests
# ---------------------------------------------------------------------------
class TestSendRetry:
def test_retries_on_failure_then_succeeds(self):
async def go():
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
channel._client_id = "test_key"
channel._client_secret = "test_secret"
call_count = 0
async def flaky_send(robot_code, user_id, text):
nonlocal call_count
call_count += 1
if call_count < 3:
raise ConnectionError("network error")
channel._send_p2p_message = AsyncMock(side_effect=flaky_send)
msg = OutboundMessage(
channel_name="dingtalk",
chat_id="user_001",
thread_id="thread_001",
text="hello",
metadata={"conversation_type": _CONVERSATION_TYPE_P2P, "sender_staff_id": "user_001"},
)
await channel.send(msg)
assert call_count == 3
_run(go())
def test_raises_after_all_retries_exhausted(self):
async def go():
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
channel._client_id = "test_key"
channel._client_secret = "test_secret"
channel._send_p2p_message = AsyncMock(side_effect=ConnectionError("fail"))
msg = OutboundMessage(
channel_name="dingtalk",
chat_id="user_001",
thread_id="thread_001",
text="hello",
metadata={"conversation_type": _CONVERSATION_TYPE_P2P, "sender_staff_id": "user_001"},
)
with pytest.raises(ConnectionError):
await channel.send(msg)
assert channel._send_p2p_message.await_count == 3
_run(go())
def test_raises_runtime_error_when_no_attempts_configured(self):
async def go():
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
channel._client_id = "test_key"
channel._client_secret = "test_secret"
msg = OutboundMessage(
channel_name="dingtalk",
chat_id="user_001",
thread_id="thread_001",
text="hello",
metadata={"conversation_type": _CONVERSATION_TYPE_P2P, "sender_staff_id": "user_001"},
)
with pytest.raises(RuntimeError, match="without an exception"):
await channel.send(msg, _max_retries=0)
_run(go())
# ---------------------------------------------------------------------------
# topic_id mapping tests
# ---------------------------------------------------------------------------
class TestTopicIdMapping:
def test_p2p_topic_is_none(self):
async def go():
bus = MessageBus()
bus.publish_inbound = AsyncMock()
channel = DingTalkChannel(bus, config={})
channel._client_id = "test_key"
channel._main_loop = asyncio.get_event_loop()
channel._running = True
msg = _make_chatbot_message(
conversation_type=_CONVERSATION_TYPE_P2P,
message_id="msg_p2p_001",
)
channel._send_running_reply = AsyncMock()
channel._on_chatbot_message(msg)
await asyncio.sleep(0.1)
inbound = bus.publish_inbound.await_args.args[0]
assert inbound.topic_id is None
_run(go())
def test_group_topic_is_message_id(self):
async def go():
bus = MessageBus()
bus.publish_inbound = AsyncMock()
channel = DingTalkChannel(bus, config={})
channel._client_id = "test_key"
channel._main_loop = asyncio.get_event_loop()
channel._running = True
msg = _make_chatbot_message(
conversation_type=_CONVERSATION_TYPE_GROUP,
message_id="msg_group_001",
conversation_id="conv_001",
)
channel._send_running_reply = AsyncMock()
channel._on_chatbot_message(msg)
await asyncio.sleep(0.1)
inbound = bus.publish_inbound.await_args.args[0]
assert inbound.topic_id == "msg_group_001"
_run(go())
# ---------------------------------------------------------------------------
# Token caching tests
# ---------------------------------------------------------------------------
class TestAccessTokenValidation:
def test_rejects_non_dict_response(self):
async def go():
from unittest.mock import patch
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
channel._client_id = "k"
channel._client_secret = "s"
class FakeResponse:
def raise_for_status(self):
pass
def json(self):
return "not a dict"
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def post(self, url, **kwargs):
return FakeResponse()
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
with pytest.raises(ValueError, match="JSON object"):
await channel._get_access_token()
_run(go())
def test_rejects_empty_access_token(self):
async def go():
from unittest.mock import patch
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
channel._client_id = "k"
channel._client_secret = "s"
class FakeResponse:
def raise_for_status(self):
pass
def json(self):
return {"accessToken": "", "expireIn": 7200}
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def post(self, url, **kwargs):
return FakeResponse()
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
with pytest.raises(ValueError, match="usable accessToken"):
await channel._get_access_token()
_run(go())
def test_invalid_expire_in_uses_default(self):
async def go():
import time
from unittest.mock import patch
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
channel._client_id = "k"
channel._client_secret = "s"
class FakeResponse:
def raise_for_status(self):
pass
def json(self):
return {"accessToken": "tok_ok", "expireIn": "invalid"}
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def post(self, url, **kwargs):
return FakeResponse()
before = time.monotonic()
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
token = await channel._get_access_token()
assert token == "tok_ok"
assert channel._token_expires_at > before
_run(go())
class TestTokenCaching:
def test_token_is_cached_across_calls(self):
async def go():
from unittest.mock import patch
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
channel._client_id = "test_key"
channel._client_secret = "test_secret"
call_count = 0
class FakeResponse:
def raise_for_status(self):
pass
def json(self):
return {"accessToken": "tok_abc", "expireIn": 7200}
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def post(self, url, **kwargs):
nonlocal call_count
call_count += 1
return FakeResponse()
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
t1 = await channel._get_access_token()
t2 = await channel._get_access_token()
assert t1 == "tok_abc"
assert t2 == "tok_abc"
assert call_count == 1
_run(go())
# ---------------------------------------------------------------------------
# Group message @ mention format tests
# ---------------------------------------------------------------------------
class TestGroupMessageMarkdownFormat:
def test_at_user_ids_still_use_markdown(self):
"""groupMessages/send uses sampleMarkdown; @{userId} in body returns 400 so at_user_ids is ignored."""
async def go():
from unittest.mock import patch
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
channel._client_id = "test_key"
channel._client_secret = "test_secret"
channel._cached_token = "tok_test"
channel._token_expires_at = float("inf")
captured_json: list[dict] = []
class FakeResponse:
def raise_for_status(self):
pass
def json(self):
return {"processQueryKey": "ok"}
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def post(self, url, **kwargs):
captured_json.append(kwargs.get("json", {}))
return FakeResponse()
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
await channel._send_group_message("bot", "conv1", "hello", at_user_ids=["staff_001"])
assert len(captured_json) == 1
payload = captured_json[0]
assert payload["msgKey"] == "sampleMarkdown"
import json
param = json.loads(payload["msgParam"])
assert param["text"] == "hello"
assert "@" not in json.dumps(param)
_run(go())
def test_no_at_user_ids_uses_markdown(self):
async def go():
from unittest.mock import patch
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
channel._client_id = "test_key"
channel._client_secret = "test_secret"
channel._cached_token = "tok_test"
channel._token_expires_at = float("inf")
captured_json: list[dict] = []
class FakeResponse:
def raise_for_status(self):
pass
def json(self):
return {"processQueryKey": "ok"}
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def post(self, url, **kwargs):
captured_json.append(kwargs.get("json", {}))
return FakeResponse()
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
await channel._send_group_message("bot", "conv1", "hello")
assert len(captured_json) == 1
payload = captured_json[0]
assert payload["msgKey"] == "sampleMarkdown"
_run(go())
class TestAdaptMarkdownForDingtalk:
def test_fenced_code_block_to_blockquote(self):
text = "Hello\n```python\ndef foo():\n return 1\n```\nDone"
result = _adapt_markdown_for_dingtalk(text)
assert "```" not in result
assert "> **python**" in result
assert "> def foo():" in result
assert "> return 1" in result
def test_fenced_code_block_no_language(self):
text = "```\nplain code\n```"
result = _adapt_markdown_for_dingtalk(text)
assert "```" not in result
assert "> plain code" in result
def test_inline_code_to_bold(self):
text = "Use `pip install` to install"
result = _adapt_markdown_for_dingtalk(text)
assert result == "Use **pip install** to install"
def test_horizontal_rule_to_unicode(self):
text = "Above\n---\nBelow"
result = _adapt_markdown_for_dingtalk(text)
assert "───────────" in result
assert "---" not in result
def test_supported_markdown_preserved(self):
text = "# Title\n**bold** and *italic*\n- list item\n> quote\n[link](http://example.com)"
result = _adapt_markdown_for_dingtalk(text)
assert result == text
def test_plain_text_unchanged(self):
text = "Hello world, no markdown here."
assert _adapt_markdown_for_dingtalk(text) == text
def test_combined_elements(self):
text = "# Report\n\nRun `make test` then:\n\n```bash\npytest -v\n```\n\n---\n\nDone."
result = _adapt_markdown_for_dingtalk(text)
assert "# Report" in result
assert "**make test**" in result
assert "> **bash**" in result
assert "> pytest -v" in result
assert "───────────" in result
assert "Done." in result
class TestConvertMarkdownTable:
def test_simple_table(self):
text = "| Name | Age |\n|------|-----|\n| Alice | 30 |\n| Bob | 25 |"
result = _convert_markdown_table(text)
assert "> **Name**: Alice" in result
assert "> **Age**: 30" in result
assert "> **Name**: Bob" in result
assert "> **Age**: 25" in result
assert "|" not in result
def test_table_with_surrounding_text(self):
text = "Results:\n\n| Key | Value |\n|-----|-------|\n| a | 1 |\n\nEnd."
result = _convert_markdown_table(text)
assert "Results:" in result
assert "> **Key**: a" in result
assert "> **Value**: 1" in result
assert "End." in result
def test_no_table(self):
text = "Just plain text\nwith lines"
assert _convert_markdown_table(text) == text
def test_alignment_separators(self):
text = "| Left | Center | Right |\n|:-----|:------:|------:|\n| a | b | c |"
result = _convert_markdown_table(text)
assert "> **Left**: a" in result
assert "> **Center**: b" in result
assert "> **Right**: c" in result
class TestUploadMediaValidation:
def test_non_dict_response_returns_none(self):
async def go():
from unittest.mock import patch
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
channel._client_id = "k"
channel._client_secret = "s"
channel._cached_token = "tok"
channel._token_expires_at = float("inf")
class FakeResponse:
def raise_for_status(self):
pass
def json(self):
return ["not", "a", "dict"]
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def post(self, url, **kwargs):
return FakeResponse()
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
result = await channel._upload_media("/tmp/test.png", "image")
assert result is None
_run(go())
def test_json_decode_error_returns_none(self):
async def go():
import json as json_mod
from unittest.mock import patch
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
channel._client_id = "k"
channel._client_secret = "s"
channel._cached_token = "tok"
channel._token_expires_at = float("inf")
class FakeResponse:
def raise_for_status(self):
pass
def json(self):
raise json_mod.JSONDecodeError("err", "", 0)
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def post(self, url, **kwargs):
return FakeResponse()
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
result = await channel._upload_media("/tmp/test.png", "image")
assert result is None
_run(go())
class TestChannelRegistration:
def test_dingtalk_in_channel_registry(self):
from app.channels.service import _CHANNEL_REGISTRY
assert "dingtalk" in _CHANNEL_REGISTRY
assert _CHANNEL_REGISTRY["dingtalk"] == "app.channels.dingtalk:DingTalkChannel"
def test_dingtalk_in_credential_keys(self):
from app.channels.service import _CHANNEL_CREDENTIAL_KEYS
assert "dingtalk" in _CHANNEL_CREDENTIAL_KEYS
assert "client_id" in _CHANNEL_CREDENTIAL_KEYS["dingtalk"]
assert "client_secret" in _CHANNEL_CREDENTIAL_KEYS["dingtalk"]
def test_dingtalk_in_channel_capabilities(self):
from app.channels.manager import CHANNEL_CAPABILITIES
assert "dingtalk" in CHANNEL_CAPABILITIES
assert CHANNEL_CAPABILITIES["dingtalk"]["supports_streaming"] is False
# ---------------------------------------------------------------------------
# AI Card streaming mode tests
# ---------------------------------------------------------------------------
class TestCardMode:
def test_card_mode_enabled_supports_streaming(self):
bus = MessageBus()
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
assert channel.supports_streaming is True
def test_non_card_mode_no_streaming(self):
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
assert channel.supports_streaming is False
def test_non_card_mode_unchanged(self):
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
assert channel._card_template_id == ""
assert channel._card_track_ids == {}
assert channel._card_repliers == {}
assert channel._incoming_messages == {}
assert channel._dingtalk_client is None
def test_card_source_key_matches_inbound_using_message_id_metadata(self):
"""Outbound correlation must match inbound ``message_id`` even if ``thread_ts`` drifts."""
async def go():
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
inbound = channel._make_inbound(
chat_id="x",
user_id="u",
text="hi",
thread_ts="ts_fallback",
metadata={
"conversation_type": _CONVERSATION_TYPE_P2P,
"sender_staff_id": "user_001",
"conversation_id": "",
"message_id": "msg_real",
},
)
out = OutboundMessage(
channel_name="dingtalk",
chat_id="x",
thread_id="t",
text="ok",
thread_ts="wrong_ts",
metadata=dict(inbound.metadata),
)
assert channel._make_card_source_key(inbound) == channel._make_card_source_key_from_outbound(out)
_run(go())
def test_running_reply_creates_card(self):
async def go():
bus = MessageBus()
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
channel._client_id = "test_key"
channel._create_and_deliver_card = AsyncMock(return_value="track_001")
inbound = channel._make_inbound(
chat_id="user_001",
user_id="user_001",
text="hello",
metadata={
"conversation_type": _CONVERSATION_TYPE_P2P,
"sender_staff_id": "user_001",
"conversation_id": "",
"message_id": "msg_001",
},
)
mock_chatbot_msg = MagicMock()
source_key = channel._make_card_source_key(inbound)
channel._incoming_messages[source_key] = mock_chatbot_msg
await channel._send_running_reply("user_001", inbound)
channel._create_and_deliver_card.assert_awaited_once_with(
"\u23f3 Working on it...",
chatbot_message=mock_chatbot_msg,
)
assert channel._card_track_ids[source_key] == "track_001"
_run(go())
def test_send_streams_to_card(self):
async def go():
bus = MessageBus()
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
channel._client_id = "test_key"
channel._stream_update_card = AsyncMock()
# Pre-populate card tracking
source_key = f"{_CONVERSATION_TYPE_P2P}:user_001::msg_001"
channel._card_track_ids[source_key] = "track_001"
msg = OutboundMessage(
channel_name="dingtalk",
chat_id="user_001",
thread_id="thread_001",
text="Partial response...",
is_final=False,
thread_ts="msg_001",
metadata={
"conversation_type": _CONVERSATION_TYPE_P2P,
"sender_staff_id": "user_001",
"conversation_id": "",
},
)
await channel.send(msg)
channel._stream_update_card.assert_awaited_once_with(
"track_001",
"Partial response...",
is_finalize=False,
)
# Track ID should still exist (not final)
assert source_key in channel._card_track_ids
_run(go())
def test_send_finalizes_card(self):
async def go():
bus = MessageBus()
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
channel._client_id = "test_key"
channel._stream_update_card = AsyncMock()
source_key = f"{_CONVERSATION_TYPE_P2P}:user_001::msg_001"
channel._card_track_ids[source_key] = "track_001"
msg = OutboundMessage(
channel_name="dingtalk",
chat_id="user_001",
thread_id="thread_001",
text="Final answer.",
is_final=True,
thread_ts="msg_001",
metadata={
"conversation_type": _CONVERSATION_TYPE_P2P,
"sender_staff_id": "user_001",
"conversation_id": "",
},
)
await channel.send(msg)
channel._stream_update_card.assert_awaited_once_with(
"track_001",
"Final answer.",
is_finalize=True,
)
# Track ID should be cleaned up after final
assert source_key not in channel._card_track_ids
_run(go())
def test_card_mode_skips_markdown_adaptation(self):
async def go():
bus = MessageBus()
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
channel._client_id = "test_key"
raw_markdown = "```python\ndef foo():\n pass\n```"
captured_content: list[str] = []
async def capture_stream(out_track_id, content, *, is_finalize=False, is_error=False):
captured_content.append(content)
channel._stream_update_card = AsyncMock(side_effect=capture_stream)
source_key = f"{_CONVERSATION_TYPE_P2P}:user_001::msg_001"
channel._card_track_ids[source_key] = "track_001"
msg = OutboundMessage(
channel_name="dingtalk",
chat_id="user_001",
thread_id="thread_001",
text=raw_markdown,
is_final=True,
thread_ts="msg_001",
metadata={
"conversation_type": _CONVERSATION_TYPE_P2P,
"sender_staff_id": "user_001",
"conversation_id": "",
},
)
await channel.send(msg)
# Raw markdown should be passed through without adaptation
assert captured_content[0] == raw_markdown
_run(go())
def test_card_fallback_on_creation_failure(self):
async def go():
bus = MessageBus()
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
channel._client_id = "test_key"
# Card creation returns None (failure)
channel._create_and_deliver_card = AsyncMock(return_value=None)
channel._send_text_message_to_user = AsyncMock()
inbound = channel._make_inbound(
chat_id="user_001",
user_id="user_001",
text="hello",
metadata={
"conversation_type": _CONVERSATION_TYPE_P2P,
"sender_staff_id": "user_001",
"conversation_id": "",
"message_id": "msg_001",
},
)
source_key = channel._make_card_source_key(inbound)
channel._incoming_messages[source_key] = MagicMock()
await channel._send_running_reply("user_001", inbound)
# Should fall through to text message
channel._send_text_message_to_user.assert_awaited_once()
assert len(channel._card_track_ids) == 0
_run(go())
def test_send_skips_non_final_without_card_track_when_template_configured(self):
"""Without a live card track, Manager streaming would duplicate sampleMarkdown sends."""
async def go():
bus = MessageBus()
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
channel._client_id = "test_key"
channel._send_group_message = AsyncMock()
channel._send_p2p_message = AsyncMock()
meta = {
"conversation_type": _CONVERSATION_TYPE_P2P,
"sender_staff_id": "user_001",
"conversation_id": "",
}
await channel.send(
OutboundMessage(
channel_name="dingtalk",
chat_id="user_001",
thread_id="t1",
text="partial",
is_final=False,
thread_ts="msg_001",
metadata=meta,
)
)
channel._send_p2p_message.assert_not_called()
channel._send_group_message.assert_not_called()
await channel.send(
OutboundMessage(
channel_name="dingtalk",
chat_id="user_001",
thread_id="t1",
text="final answer",
is_final=True,
thread_ts="msg_001",
metadata=meta,
)
)
channel._send_p2p_message.assert_awaited_once()
_run(go())
def test_card_fallback_on_stream_failure(self):
async def go():
bus = MessageBus()
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
channel._client_id = "test_key"
channel._stream_update_card = AsyncMock(side_effect=ConnectionError("stream failed"))
channel._send_markdown_fallback = AsyncMock()
source_key = f"{_CONVERSATION_TYPE_P2P}:user_001::msg_001"
channel._card_track_ids[source_key] = "track_001"
msg = OutboundMessage(
channel_name="dingtalk",
chat_id="user_001",
thread_id="thread_001",
text="Final answer.",
is_final=True,
thread_ts="msg_001",
metadata={
"conversation_type": _CONVERSATION_TYPE_P2P,
"sender_staff_id": "user_001",
"conversation_id": "",
},
)
await channel.send(msg)
# Should fallback to markdown
channel._send_markdown_fallback.assert_awaited_once_with(
"test_key",
_CONVERSATION_TYPE_P2P,
"user_001",
"",
"Final answer.",
)
# Track ID should be cleaned up
assert source_key not in channel._card_track_ids
_run(go())
def test_pre_start_stores_dingtalk_client(self):
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
handler = _DingTalkMessageHandler(channel)
mock_client = MagicMock()
handler.dingtalk_client = mock_client
handler.pre_start()
assert channel._dingtalk_client is mock_client
def test_chatbot_message_stored_for_card_mode(self):
bus = MessageBus()
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
mock_message = MagicMock()
mock_message.sender_staff_id = "user_001"
mock_message.conversation_type = "1"
mock_message.conversation_id = ""
mock_message.message_id = "msg_001"
mock_message.sender_nick = "TestUser"
mock_message.message_type = "text"
mock_message.text = MagicMock(content="hello")
mock_message.rich_text_content = None
channel._main_loop = MagicMock()
channel._main_loop.is_running.return_value = False
channel._allowed_users = set()
channel._running = True
channel._on_chatbot_message(mock_message)
assert len(channel._incoming_messages) == 1
stored_msg = list(channel._incoming_messages.values())[0]
assert stored_msg is mock_message
def test_card_replier_cleanup_on_final(self):
async def go():
bus = MessageBus()
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
channel._client_id = "test_key"
channel._stream_update_card = AsyncMock()
source_key = f"{_CONVERSATION_TYPE_P2P}:user_001::msg_001"
channel._card_track_ids[source_key] = "track_001"
channel._card_repliers["track_001"] = MagicMock()
msg = OutboundMessage(
channel_name="dingtalk",
chat_id="user_001",
thread_id="thread_001",
text="Final answer.",
is_final=True,
thread_ts="msg_001",
metadata={
"conversation_type": _CONVERSATION_TYPE_P2P,
"sender_staff_id": "user_001",
"conversation_id": "",
},
)
await channel.send(msg)
assert source_key not in channel._card_track_ids
assert "track_001" not in channel._card_repliers
_run(go())
def test_card_creation_without_sdk_client_returns_none(self):
async def go():
bus = MessageBus()
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
channel._dingtalk_client = None
result = await channel._create_and_deliver_card(
"test",
chatbot_message=MagicMock(),
)
assert result is None
_run(go())
def test_card_creation_without_chatbot_message_returns_none(self):
async def go():
bus = MessageBus()
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
channel._dingtalk_client = MagicMock()
result = await channel._create_and_deliver_card(
"test",
chatbot_message=None,
)
assert result is None
_run(go())
def test_stream_update_card_raises_without_replier(self):
async def go():
bus = MessageBus()
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
with pytest.raises(RuntimeError, match="No AICardReplier found"):
await channel._stream_update_card("nonexistent_track", "content")
_run(go())
def test_stop_clears_card_state(self):
async def go():
bus = MessageBus()
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
channel._running = True
channel._dingtalk_client = MagicMock()
channel._incoming_messages["key"] = MagicMock()
channel._card_repliers["track"] = MagicMock()
channel._card_track_ids["source"] = "track"
await channel.stop()
assert channel._dingtalk_client is None
assert channel._incoming_messages == {}
assert channel._card_repliers == {}
assert channel._card_track_ids == {}
_run(go())