deer-flow/backend/tests/test_dingtalk_channel.py
Hyeonsang Cho 48949482c5
fix(channels): give DingTalk AI cards the channel token, not the SDK fetch (#7597)
* fix(channels): give DingTalk AI cards the channel token, not the SDK fetch

AICardReplier's async card calls read dingtalk_client.get_access_token()
inline, and dingtalk-stream refreshes it with requests.post and no
timeout. Card creation and card streaming both run on the Gateway loop,
so the first card and every SDK refresh after it (about every two hours)
blocked the loop for the whole token request, without bound if DingTalk
stalled.

Hand the replier a _CardTokenClient that serves the channel's own
httpx token (10s timeout), set before card creation and before every
streamed update. A token failure while streaming now raises into send's
existing sampleMarkdown fallback instead of being skipped silently.

Pin it with a strict blocking-I/O anchor over a real DingTalkStreamClient
and AICardReplier, a gate meta-check, and unit tests for token rotation
and failure. LocalHttpServer also records each request's method, path
and headers.

* docs(changelog): reference #7597 for the DingTalk AI card token fetch

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-28 10:50:30 +08:00

3221 lines
121 KiB
Python

"""Tests for the DingTalk channel implementation."""
from __future__ import annotations
import asyncio
import json
import logging
import threading
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from support.symlinks import symlink_or_skip
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_conversation_type,
_parse_allowed_users,
)
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
from deerflow.sandbox.lease import discard_sandbox_lease_manager, get_sandbox_lease_manager
def _run(coro):
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
async def _take_inbound(bus: MessageBus):
inbound = await asyncio.wait_for(bus.get_inbound(), timeout=1)
bus.inbound_task_done()
return inbound
# ---------------------------------------------------------------------------
# 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())
# ---------------------------------------------------------------------------
# _parse_allowed_users tests
# ---------------------------------------------------------------------------
class TestParseAllowedUsers:
@pytest.mark.parametrize("allowed_users", [None, [], (), set(), frozenset(), "", " "])
def test_unset_or_empty_allows_everyone_quietly(self, allowed_users, caplog):
with caplog.at_level(logging.WARNING, logger="app.channels.dingtalk"):
assert _parse_allowed_users(allowed_users) is None
assert caplog.records == []
@pytest.mark.parametrize("collection", [list, tuple, set, frozenset])
def test_listed_staff_ids(self, collection):
assert _parse_allowed_users(collection(["user1", " user2 "])) == {"user1", "user2"}
def test_single_string_is_one_entry(self):
assert _parse_allowed_users("user1") == {"user1"}
def test_numeric_values_converted_to_string(self):
assert _parse_allowed_users([123, 456]) == {"123", "456"}
def test_numeric_scalar_is_one_entry(self):
assert _parse_allowed_users(12345) == {"12345"}
@pytest.mark.parametrize("allowed_users", ["user1,user2", ","])
def test_scalar_with_separators_stays_one_literal_id_and_warns(self, allowed_users, caplog):
with caplog.at_level(logging.WARNING, logger="app.channels.dingtalk"):
assert _parse_allowed_users(allowed_users) == {allowed_users}
assert "YAML list" in caplog.text
def test_invalid_entries_are_dropped_with_a_warning(self, caplog):
with caplog.at_level(logging.WARNING, logger="app.channels.dingtalk"):
assert _parse_allowed_users(["user1", True, 4.2, None, " "]) == {"user1"}
assert "True" in caplog.text
assert "4.2" in caplog.text
assert not any(record.levelno == logging.ERROR for record in caplog.records)
@pytest.mark.parametrize(
"allowed_users",
[[""], ["", " "], [None], [True], {"user1": True}, {}, True, 4.2],
ids=["blank-entry", "blank-entries", "null-entry", "bool-entry", "mapping", "empty-mapping", "bool-scalar", "float-scalar"],
)
def test_allowlist_without_a_valid_staff_id_denies_everyone(self, allowed_users, caplog):
# A mapping must not authorize its keys, and a list whose entries are
# all blank (e.g. ``[$DINGTALK_STAFF_ID]`` with the variable set to an
# empty string) must not turn into "allow all".
with caplog.at_level(logging.ERROR, logger="app.channels.dingtalk"):
assert _parse_allowed_users(allowed_users) == frozenset()
assert any(record.levelno == logging.ERROR for record in caplog.records)
# ---------------------------------------------------------------------------
# _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)
inbound = await _take_inbound(bus)
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)
inbound = await _take_inbound(bus)
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())
@pytest.mark.parametrize("text", ["@bot /new", "@_user_1 /help", "@bot /goal ship it"])
def test_leading_mention_before_command_classifies_and_strips(self, text):
"""DingTalk group chats leave "@bot /new" in the text; classify as COMMAND
and strip the mention so ChannelManager receives the bare command."""
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=text,
conversation_type=_CONVERSATION_TYPE_GROUP,
sender_staff_id="user_002",
conversation_id="conv_group_001",
message_id="msg_mention_cmd",
)
channel._send_running_reply = AsyncMock()
channel._on_chatbot_message(msg)
await asyncio.sleep(0.1)
inbound = await _take_inbound(bus)
assert inbound.msg_type == InboundMessageType.COMMAND, f"{text!r} should be COMMAND"
assert not inbound.text.startswith("@"), "leading mention must be stripped for dispatch"
assert inbound.text.split(maxsplit=1)[0] in KNOWN_CHANNEL_COMMANDS
_run(go())
def test_leading_mention_before_chat_keeps_mention(self):
"""A mentioned non-command stays CHAT and keeps the mention for the agent."""
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="@bot please summarise this",
conversation_type=_CONVERSATION_TYPE_GROUP,
sender_staff_id="user_002",
conversation_id="conv_group_001",
message_id="msg_mention_chat",
)
channel._send_running_reply = AsyncMock()
channel._on_chatbot_message(msg)
await asyncio.sleep(0.1)
inbound = await _take_inbound(bus)
assert inbound.msg_type == InboundMessageType.CHAT
assert inbound.text == "@bot please summarise this"
_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)
inbound = await _take_inbound(bus)
assert inbound.chat_id == "conv_group_001"
assert inbound.topic_id == "msg_group_002"
assert inbound.metadata["conversation_type"] == _CONVERSATION_TYPE_GROUP
_run(go())
@pytest.mark.parametrize("sender_staff_id", ["", None])
def test_p2p_message_without_sender_is_dropped(self, sender_staff_id):
"""A P2P chat_id *is* the sender, so an empty one keys every user to one thread.
``binding_key`` builds ``f"{channel}:{chat_id}"`` for a topic-less conversation,
so publishing this would put every senderless P2P message under the literal
``"dingtalk:"`` — one shared thread, one shared history, across users.
"""
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",
conversation_type=_CONVERSATION_TYPE_P2P,
sender_staff_id=sender_staff_id,
message_id="msg_no_sender",
)
channel._send_running_reply = AsyncMock()
channel._on_chatbot_message(msg)
await asyncio.sleep(0.1)
bus.publish_inbound.assert_not_awaited()
assert bus.inbound_queue.empty()
_run(go())
def test_group_message_without_conversation_id_is_dropped(self):
"""The group route reaches the same degenerate key from the other side."""
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="",
message_id="msg_no_conv",
)
channel._send_running_reply = AsyncMock()
channel._on_chatbot_message(msg)
await asyncio.sleep(0.1)
bus.publish_inbound.assert_not_awaited()
assert bus.inbound_queue.empty()
_run(go())
def test_group_message_with_unknown_sender_is_still_delivered(self):
"""Reverse anchor: the guard is on the conversation identity, not on the sender.
A group message identifies its conversation through ``conversation_id``, so an
unknown sender must not cost the whole message.
"""
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="",
conversation_id="conv_group_003",
message_id="msg_group_003",
)
channel._send_running_reply = AsyncMock()
channel._on_chatbot_message(msg)
await asyncio.sleep(0.1)
inbound = await _take_inbound(bus)
assert inbound.chat_id == "conv_group_003"
assert inbound.topic_id == "msg_group_003"
_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)
inbound = await _take_inbound(bus)
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)
inbound = await _take_inbound(bus)
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()
assert bus.inbound_queue.empty()
_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)
await _take_inbound(bus)
_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()
assert bus.inbound_queue.empty()
_run(go())
def test_non_allowed_user_message_content_not_logged(self, caplog):
import logging
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", text="secret blocked content")
with caplog.at_level(logging.INFO, logger="app.channels.dingtalk"):
channel._on_chatbot_message(msg)
await asyncio.sleep(0.1)
bus.publish_inbound.assert_not_awaited()
assert bus.inbound_queue.empty()
# The parsed-message INFO log (with message content) must not fire for
# a blocked sender — allowed_users still acts as a privacy/noise filter.
assert "parsed message" not in caplog.text
assert "secret blocked content" not in caplog.text
_run(go())
def test_connect_code_bypasses_allowed_users_filter(self):
async def go():
bus = MessageBus()
bus.publish_inbound = AsyncMock()
channel = DingTalkChannel(bus, config={"allowed_users": ["user_001"], "connection_repo": object()})
channel._client_id = "test_key"
channel._main_loop = asyncio.get_event_loop()
channel._running = True
channel._bind_connection_from_connect_code = AsyncMock(return_value=True)
msg = _make_chatbot_message(sender_staff_id="user_blocked", text="/connect dingtalk-bind-code")
channel._on_chatbot_message(msg)
await asyncio.sleep(0.1)
channel._bind_connection_from_connect_code.assert_awaited_once()
bus.publish_inbound.assert_not_awaited()
assert bus.inbound_queue.empty()
_run(go())
@pytest.mark.parametrize(
("allowed_users", "admitted"),
[([""], False), ({"user_001": True}, False), ([" user_001 "], True), (frozenset({"user_001"}), True)],
ids=["blank-entry", "mapping", "padded-entry", "frozenset"],
)
def test_allowlist_gate_uses_parsed_staff_ids(self, allowed_users, admitted):
async def go():
bus = MessageBus()
bus.publish_inbound = AsyncMock()
channel = DingTalkChannel(bus, config={"allowed_users": allowed_users})
channel._client_id = "test_key"
channel._main_loop = asyncio.get_event_loop()
channel._running = True
channel._send_running_reply = AsyncMock()
channel._on_chatbot_message(_make_chatbot_message(sender_staff_id="user_001"))
await asyncio.sleep(0.1)
assert (not bus.inbound_queue.empty()) is admitted
_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)
await _take_inbound(bus)
_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 = await _take_inbound(bus)
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 = await _take_inbound(bus)
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())
@pytest.mark.parametrize("expire_in", ["invalid", float("inf"), 10**400, 0, -1, 1, 100, 299, 300, "300"])
def test_invalid_expire_in_uses_default(self, expire_in):
async def go():
from unittest.mock import patch
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
channel._client_id = "k"
channel._client_secret = "s"
call_count = 0
class FakeResponse:
def raise_for_status(self):
pass
def json(self):
return {"accessToken": "tok_ok", "expireIn": expire_in}
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()),
patch("app.channels.dingtalk.time.monotonic", return_value=1000.0) as clock,
):
token = await channel._get_access_token()
clock.return_value = 1005.0
cached_token = await channel._get_access_token()
assert token == "tok_ok"
assert cached_token == "tok_ok"
assert channel._token_expires_at == 7900.0
assert call_count == 1
_run(go())
class TestTokenCaching:
@pytest.mark.parametrize(("expire_in", "expected_expiry"), [(301, 1001.0), (3600, 4300.0), (7200, 7900.0)])
def test_token_is_cached_across_calls(self, expire_in, expected_expiry):
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": expire_in}
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()),
patch("app.channels.dingtalk.time.monotonic", return_value=1000.0) as clock,
):
t1 = await channel._get_access_token()
clock.return_value = 1000.5
t2 = await channel._get_access_token()
assert channel._token_expires_at == expected_expiry
assert call_count == 1
clock.return_value = expected_expiry
t3 = await channel._get_access_token()
assert t1 == "tok_abc"
assert t2 == "tok_abc"
assert t3 == "tok_abc"
assert call_count == 2
_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:
@pytest.mark.parametrize(
("header", "row", "expected"),
[
("| Rule | Meaning |", r"| foo\|bar | alternative |", "> **Rule**: foo\\|bar\n> **Meaning**: alternative\n"),
(r"| Rule\|Pattern | Meaning |", "| regex | alternative |", "> **Rule\\|Pattern**: regex\n> **Meaning**: alternative\n"),
("| Rule | Meaning |", "|| alternative |", "> **Rule**: \n> **Meaning**: alternative\n"),
("| Rule | Meaning |", "| regex ||", "> **Rule**: regex\n> **Meaning**: \n"),
("| Rule | Meaning |", r"| regex | value\|", "> **Rule**: regex\n> **Meaning**: value\\|\n"),
("| Rule | Meaning |", r"| foo\|bar\|baz | alternative |", "> **Rule**: foo\\|bar\\|baz\n> **Meaning**: alternative\n"),
],
ids=["escaped-body-pipe", "escaped-header-pipe", "leading-empty-cell", "trailing-empty-cell", "escaped-final-pipe", "multiple-escaped-pipes"],
)
def test_cell_boundaries_preserved(self, header, row, expected):
text = f"{header}\n|---|---|\n{row}"
assert _convert_markdown_table(text) == expected
def test_fallback_adapter_preserves_table_values(self):
text = "| Rule | Meaning |\n|---|---|\n| `foo\\|bar` | alternative |"
assert _adapt_markdown_for_dingtalk(text) == "> **Rule**: **foo\\|bar**\n> **Meaning**: alternative\n"
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):
async def go():
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 = asyncio.get_running_loop()
channel._allowed_users = None
channel._running = True
channel._send_running_reply = AsyncMock()
channel._on_chatbot_message(mock_message)
await asyncio.sleep(0.1)
assert len(channel._incoming_messages) == 1
stored_msg = list(channel._incoming_messages.values())[0]
assert stored_msg is mock_message
await _take_inbound(bus)
_run(go())
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_card_calls_use_the_channel_token_not_the_sdk_fetch(self, monkeypatch):
"""The AI card replier sends the channel's httpx token; the SDK never fetches one.
dingtalk-stream's ``get_access_token()`` posts with ``requests`` and no
timeout, inline in the replier's async methods on the Gateway loop.
"""
pytest.importorskip("dingtalk_stream")
import dingtalk_stream
from dingtalk_stream import card_replier
from support.local_sockets import LocalHttpServer
async def go():
async with LocalHttpServer(body={"accessToken": "tok-1", "expireIn": 7200}) as api:
base = api.url.removesuffix("/open")
monkeypatch.setattr("app.channels.dingtalk.DINGTALK_API_BASE", base)
monkeypatch.setattr(card_replier, "DINGTALK_OPENAPI_ENDPOINT", base)
channel = DingTalkChannel(MessageBus(), config={"card_template_id": "tpl_123"})
channel._client_id, channel._client_secret = "id", "secret"
sdk_client = MagicMock()
sdk_client.credential.client_id = "id"
sdk_client.get_access_token.side_effect = AssertionError("the SDK must not fetch a token")
channel._dingtalk_client = sdk_client
incoming = dingtalk_stream.ChatbotMessage()
incoming.conversation_type = _CONVERSATION_TYPE_P2P
incoming.sender_staff_id = "user_001"
track = await channel._create_and_deliver_card("Working on it...", chatbot_message=incoming)
assert track
# An expired channel token is refreshed before the streamed update.
channel._token_expires_at = 0.0
api.body = {"accessToken": "tok-2", "expireIn": 7200}
await channel._stream_update_card(track, "done", is_finalize=True)
sdk_client.get_access_token.assert_not_called()
assert [(method, path, headers.get("x-acs-dingtalk-access-token")) for method, path, headers in api.calls] == [
("POST", "/v1.0/oauth2/accessToken", None),
("POST", "/v1.0/card/instances/createAndDeliver", "tok-1"),
("POST", "/v1.0/oauth2/accessToken", None),
("PUT", "/v1.0/card/streaming", "tok-2"),
]
assert api.requests[0] == {"appKey": "id", "appSecret": "secret"}
assert api.requests[3]["outTrackId"] == track
assert api.requests[3]["isFinalize"] is True
_run(go())
def test_stream_update_card_raises_when_the_channel_token_fails(self):
"""A token failure surfaces, so ``send`` falls back instead of dropping the update."""
async def go():
channel = DingTalkChannel(MessageBus(), config={"card_template_id": "tpl_123"})
replier = MagicMock()
replier.async_streaming = AsyncMock()
channel._card_repliers["track_001"] = replier
channel._get_access_token = AsyncMock(side_effect=ValueError("bad token response"))
with pytest.raises(ValueError, match="bad token response"):
await channel._stream_update_card("track_001", "content", is_finalize=True)
replier.async_streaming.assert_not_awaited()
_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())
class TestStreamTaskLifecycle:
@pytest.mark.parametrize("outcome", ["success", "failure", "cancelled"])
def test_tracks_and_finalizes_message_tasks(self, outcome, monkeypatch, caplog):
async def go():
channel = DingTalkChannel(MessageBus(), config={})
channel._running = True
frames = [{"sequence": index} for index in range(50 if outcome == "success" else 1)]
received = []
handler_tasks = []
keepalive_tasks = []
handlers_started = asyncio.Event()
release_handlers = asyncio.Event()
handlers_finalized = asyncio.Event()
close_stream = asyncio.Event()
keepalive_started = asyncio.Event()
keepalive_stopped = asyncio.Event()
failure = RuntimeError("stream handler failed")
loop = asyncio.get_running_loop()
previous_exception_handler = loop.get_exception_handler()
loop_errors = []
loop.set_exception_handler(lambda _loop, context: loop_errors.append(context))
async def background_task(frame):
received.append(frame)
handler_tasks.append(asyncio.current_task())
if len(received) == len(frames):
handlers_started.set()
await release_handlers.wait()
if outcome == "failure":
raise failure
async def keepalive(_websocket):
keepalive_tasks.append(asyncio.current_task())
keepalive_started.set()
try:
await asyncio.Event().wait()
finally:
keepalive_stopped.set()
class WebSocket:
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def __aiter__(self):
for frame in frames:
yield json.dumps(frame)
await close_stream.wait()
channel._running = False
client = SimpleNamespace(pre_start=MagicMock(), background_task=background_task, keepalive=keepalive)
monkeypatch.setattr(channel, "_open_stream_connection", AsyncMock(return_value={"endpoint": "wss://example.invalid/stream", "ticket": "test-ticket"}))
monkeypatch.setattr("websockets.connect", lambda _uri: WebSocket())
stream_task = asyncio.create_task(channel._serve_stream(client))
try:
await asyncio.wait_for(handlers_started.wait(), timeout=1)
await asyncio.wait_for(keepalive_started.wait(), timeout=1)
assert received == frames
assert channel._background_tasks == set(handler_tasks)
assert all(not task.done() for task in handler_tasks)
remaining = set(handler_tasks)
def finalized(task):
remaining.remove(task)
if not remaining:
handlers_finalized.set()
# These observers run after the production done callbacks.
for task in handler_tasks:
task.add_done_callback(finalized)
if outcome == "cancelled":
task.cancel()
release_handlers.set()
await asyncio.wait_for(handlers_finalized.wait(), timeout=1)
assert channel._background_tasks == set()
if outcome == "cancelled":
assert all(task.cancelled() for task in handler_tasks)
close_stream.set()
await asyncio.wait_for(stream_task, timeout=1)
await asyncio.wait_for(keepalive_stopped.wait(), timeout=1)
assert channel._background_tasks == set()
client.pre_start.assert_called_once_with()
errors = [record for record in caplog.records if record.name == "app.channels.dingtalk" and record.levelno >= logging.ERROR]
if outcome == "failure":
assert len(errors) == 1
assert "stream message task" in errors[0].getMessage()
assert errors[0].exc_info is not None
assert errors[0].exc_info[1] is failure
else:
assert errors == []
assert loop_errors == []
finally:
channel._running = False
close_stream.set()
release_handlers.set()
tasks = [stream_task, *handler_tasks, *keepalive_tasks]
for task in tasks:
if not task.done():
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
loop.set_exception_handler(previous_exception_handler)
_run(go())
class TestStop:
def test_stop_retains_live_stream_thread_after_join_timeout(self):
async def go():
channel = DingTalkChannel(MessageBus(), config={})
thread = MagicMock()
thread.is_alive.return_value = True
channel._thread = thread
channel._running = True
with pytest.raises(RuntimeError, match="still running after stop timeout"):
await channel.stop()
thread.join.assert_called_once_with(timeout=5)
assert channel._thread is thread
_run(go())
@staticmethod
def _channel() -> DingTalkChannel:
return DingTalkChannel(MessageBus(), config={"client_id": "id", "client_secret": "secret"})
def test_stop_closes_live_stream_connection_and_exits_thread(self, monkeypatch):
pytest.importorskip("dingtalk_stream")
import dingtalk_stream
from support.local_sockets import LocalHttpServer
from websockets.asyncio.server import serve
async def go():
connected = asyncio.Event()
closed = asyncio.Event()
async def handler(websocket):
connected.set()
try:
async for _ in websocket:
pass
finally:
closed.set()
async with serve(handler, "127.0.0.1", 0) as server:
port = server.sockets[0].getsockname()[1]
async with LocalHttpServer(body={"endpoint": f"ws://127.0.0.1:{port}", "ticket": "ticket"}) as tickets:
monkeypatch.setattr(dingtalk_stream.DingTalkStreamClient, "OPEN_CONNECTION_API", tickets.url)
channel = self._channel()
await channel.start()
thread = channel._thread
assert thread is not None and thread.is_alive()
await asyncio.wait_for(connected.wait(), 5)
started = time.monotonic()
await channel.stop()
elapsed = time.monotonic() - started
assert elapsed < 5, f"stop() took {elapsed:.2f}s"
await asyncio.wait_for(closed.wait(), 5)
assert not thread.is_alive()
assert channel._thread is None
assert channel._stream_client is None
assert channel.is_running is False
# The ticket request mirrors DingTalkStreamClient.open_connection().
request = tickets.requests[0]
assert request["clientId"] == "id"
assert request["clientSecret"] == "secret"
assert {"type": "CALLBACK", "topic": dingtalk_stream.chatbot.ChatbotMessage.TOPIC} in request["subscriptions"]
assert request["ua"].startswith("dingtalk-sdk-python/v")
assert "localIp" in request
_run(go())
def test_stop_aborts_transport_when_peer_never_acks_close(self, monkeypatch, caplog):
pytest.importorskip("dingtalk_stream")
import dingtalk_stream
from support.local_sockets import LocalHttpServer, StalledWebSocketServer, transport_socket, wait_until
from app.channels import dingtalk as dingtalk_module
caplog.set_level(logging.WARNING, logger="app.channels.dingtalk")
async def go():
async with StalledWebSocketServer() as peer, LocalHttpServer(body={"endpoint": peer.url, "ticket": "ticket"}) as tickets:
monkeypatch.setattr(dingtalk_stream.DingTalkStreamClient, "OPEN_CONNECTION_API", tickets.url)
monkeypatch.setattr(dingtalk_module, "DINGTALK_STREAM_CLOSE_TIMEOUT_SECONDS", 0.5)
channel = self._channel()
await channel.start()
thread = channel._thread
await asyncio.wait_for(peer.accepted.wait(), 5)
websocket = await wait_until(lambda: channel._stream_client is not None and channel._stream_client.websocket)
sock = transport_socket(websocket)
assert sock.fileno() != -1
started = time.monotonic()
await channel.stop()
elapsed = time.monotonic() - started
assert elapsed < 5, f"stop() took {elapsed:.2f}s"
assert not thread.is_alive()
assert channel._thread is None
# The socket is released by stop() itself, not by a later GC pass.
assert sock.fileno() == -1
assert "transport aborted" in caplog.text
await asyncio.wait_for(peer.closed.wait(), 5)
_run(go())
def test_stop_interrupts_open_connection_retry_sleep(self, monkeypatch):
pytest.importorskip("dingtalk_stream")
import dingtalk_stream
from support.local_sockets import LocalHttpServer
async def go():
async with LocalHttpServer(status=500, body={"error": "nope"}) as tickets:
monkeypatch.setattr(dingtalk_stream.DingTalkStreamClient, "OPEN_CONNECTION_API", tickets.url)
channel = self._channel()
await channel.start()
thread = channel._thread
await asyncio.wait_for(tickets.accepted.wait(), 5)
started = time.monotonic()
await channel.stop()
elapsed = time.monotonic() - started
assert elapsed < 5, f"stop() took {elapsed:.2f}s"
assert not thread.is_alive()
assert channel._thread is None
_run(go())
def test_stop_cancels_stalled_ticket_request_without_lingering_threads(self, monkeypatch):
pytest.importorskip("dingtalk_stream")
import dingtalk_stream
from support.local_sockets import LocalHttpServer
def forbidden_open_connection(self):
raise AssertionError("the SDK's timeout-less open_connection() must not run")
async def go():
async with LocalHttpServer(stall=True) as tickets:
monkeypatch.setattr(dingtalk_stream.DingTalkStreamClient, "OPEN_CONNECTION_API", tickets.url)
monkeypatch.setattr(dingtalk_stream.DingTalkStreamClient, "open_connection", forbidden_open_connection)
await asyncio.to_thread(lambda: None) # warm the test loop's own executor before the baseline
baseline = {thread.ident for thread in threading.enumerate()}
channel = self._channel()
await channel.start()
thread = channel._thread
await asyncio.wait_for(tickets.accepted.wait(), 5)
started = time.monotonic()
await channel.stop()
elapsed = time.monotonic() - started
assert elapsed < 5, f"stop() took {elapsed:.2f}s"
assert not thread.is_alive()
# The cancelled request released its connection ...
await asyncio.wait_for(tickets.closed.wait(), 5)
# ... and nothing non-daemon is left to block interpreter exit.
lingering = [t.name for t in threading.enumerate() if t.ident not in baseline and t.is_alive() and not t.daemon]
assert lingering == []
_run(go())
def test_stop_before_stream_client_exists_never_connects(self, monkeypatch):
pytest.importorskip("dingtalk_stream")
import dingtalk_stream
from support.local_sockets import LocalHttpServer
gate = threading.Event()
class _GatedStreamClient(dingtalk_stream.DingTalkStreamClient):
def __init__(self, *args, **kwargs):
gate.wait(timeout=5)
super().__init__(*args, **kwargs)
monkeypatch.setattr(dingtalk_stream, "DingTalkStreamClient", _GatedStreamClient)
async def go():
async with LocalHttpServer(body={"endpoint": "ws://127.0.0.1:1", "ticket": "ticket"}) as tickets:
monkeypatch.setattr(dingtalk_stream.DingTalkStreamClient, "OPEN_CONNECTION_API", tickets.url)
channel = self._channel()
await channel.start()
thread = channel._thread
stop_task = asyncio.create_task(channel.stop())
await asyncio.sleep(0.05)
assert not stop_task.done()
gate.set()
await stop_task
assert not thread.is_alive()
assert channel._thread is None
assert tickets.requests == []
_run(go())
def test_stop_joins_stream_thread_off_the_event_loop(self):
async def go():
channel = DingTalkChannel(MessageBus(), config={})
release = threading.Event()
# dingtalk-stream's start_forever() never returns, so the join in
# stop() waits out its timeout; this thread blocks the same way,
# bounded so a join run on the event loop fails the assertion
# below instead of hanging the test.
stream_thread = threading.Thread(target=release.wait, args=(2,), daemon=True)
stream_thread.start()
channel._thread = stream_thread
channel._running = True
stop_task = asyncio.create_task(channel.stop())
await asyncio.sleep(0.05)
assert not stop_task.done()
release.set()
await stop_task
assert channel._thread is None
_run(go())
# ---------------------------------------------------------------------------
# Inbound file support
# ---------------------------------------------------------------------------
def _make_picture_message(*, download_code: str = "dc_img", **kwargs):
"""Build a mock DingTalk ``picture`` message carrying an ImageContent."""
msg = _make_chatbot_message(text="", message_type="picture", **kwargs)
msg.image_content = SimpleNamespace(download_code=download_code)
return msg
def _make_file_message(*, download_code: str = "dc_file", file_name: str | None = "report.xlsx", **kwargs):
"""Build a mock DingTalk ``file`` (document) message.
dingtalk_stream does not parse ``file`` messages, so the descriptor lives on
the raw callback payload the handler stashes as ``_df_raw_data``.
"""
msg = _make_chatbot_message(text="", message_type="file", **kwargs)
content: dict = {"downloadCode": download_code}
if file_name is not None:
content["fileName"] = file_name
msg._df_raw_data = {"msgtype": "file", "content": content}
return msg
def _patch_uploads(monkeypatch, uploads_dir, *, sandbox_id="local", sandbox=None):
monkeypatch.setattr(
"app.channels.dingtalk.get_paths",
lambda: SimpleNamespace(
ensure_thread_dirs=lambda thread_id, user_id=None: None,
sandbox_uploads_dir=lambda thread_id, user_id=None: uploads_dir,
),
)
monkeypatch.setattr("app.channels.dingtalk.get_effective_user_id", lambda: "default")
async def _acquire_async(thread_id, user_id=None):
return sandbox_id
provider = SimpleNamespace(
uses_thread_data_mounts=sandbox_id == "local" or sandbox_id.startswith("local:"),
acquire_async=_acquire_async,
get=lambda sid: sandbox,
release=lambda sid: None,
)
monkeypatch.setattr("app.channels.dingtalk.get_sandbox_provider", lambda: provider)
return provider
@pytest.fixture
def non_local_upload(tmp_path, monkeypatch):
channel = DingTalkChannel(MessageBus(), config={})
channel._download_by_code = AsyncMock(return_value=b"BYTES")
uploads = tmp_path / "uploads"
uploads.mkdir()
sandbox = SimpleNamespace(update_file=MagicMock(), release_command_scope=MagicMock())
provider = _patch_uploads(monkeypatch, uploads, sandbox_id="aio:box1", sandbox=sandbox)
provider.release = MagicMock()
message = channel._make_inbound(
chat_id="c",
user_id="u",
text="hi",
thread_ts="m",
files=[{"type": "file", "download_code": "dc", "filename": "a.pdf"}],
)
try:
yield SimpleNamespace(channel=channel, provider=provider, sandbox=sandbox, message=message)
finally:
discard_sandbox_lease_manager(provider)
class TestExtractFiles:
def test_text_message_has_no_files(self):
assert DingTalkChannel._extract_files(_make_chatbot_message(text="hi")) == []
def test_picture_message(self):
files = DingTalkChannel._extract_files(_make_picture_message(download_code="dc_1"))
assert files == [{"type": "image", "download_code": "dc_1", "filename": "image.png"}]
def test_picture_message_missing_download_code_ignored(self):
msg = _make_chatbot_message(text="", message_type="picture")
msg.image_content = SimpleNamespace(download_code=None)
assert DingTalkChannel._extract_files(msg) == []
def test_rich_text_images(self):
msg = _make_chatbot_message(text="", message_type="richText")
msg.get_image_list = lambda: ["dc_a", "dc_b"]
files = DingTalkChannel._extract_files(msg)
assert files == [
{"type": "image", "download_code": "dc_a", "filename": "image_0.png"},
{"type": "image", "download_code": "dc_b", "filename": "image_1.png"},
]
def test_rich_text_get_image_list_failure_is_logged_not_silent(self, caplog):
"""An SDK parse failure must be distinguishable from "no inline images"."""
msg = _make_chatbot_message(text="", message_type="richText")
def _boom():
raise RuntimeError("sdk failure")
msg.get_image_list = _boom
with caplog.at_level(logging.WARNING, logger="app.channels.dingtalk"):
assert DingTalkChannel._extract_files(msg) == []
assert any("failed to read inline images" in r.message for r in caplog.records)
def test_file_message_from_raw_data(self):
files = DingTalkChannel._extract_files(_make_file_message(download_code="dc_doc", file_name="报价.xlsx"))
assert files == [{"type": "file", "download_code": "dc_doc", "filename": "报价.xlsx"}]
def test_file_message_default_filename(self):
files = DingTalkChannel._extract_files(_make_file_message(download_code="dc_doc", file_name=None))
assert files == [{"type": "file", "download_code": "dc_doc", "filename": "file.bin"}]
def test_file_message_without_raw_data_ignored(self):
msg = _make_chatbot_message(text="", message_type="file")
assert DingTalkChannel._extract_files(msg) == []
class TestOnChatbotMessageFiles:
def test_picture_message_publishes_inbound_with_files(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
channel._send_running_reply = AsyncMock()
channel._on_chatbot_message(_make_picture_message(download_code="dc_pic", sender_staff_id="u1", message_id="m1"))
await asyncio.sleep(0.1)
inbound = await _take_inbound(bus)
assert inbound.msg_type == InboundMessageType.CHAT
assert inbound.files == [{"type": "image", "download_code": "dc_pic", "filename": "image.png"}]
_run(go())
def test_file_message_publishes_inbound_with_files(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
channel._send_running_reply = AsyncMock()
channel._on_chatbot_message(_make_file_message(download_code="dc_doc", file_name="data.csv", sender_staff_id="u1", message_id="m1"))
await asyncio.sleep(0.1)
inbound = await _take_inbound(bus)
assert inbound.files == [{"type": "file", "download_code": "dc_doc", "filename": "data.csv"}]
_run(go())
def test_message_without_text_or_files_dropped(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
# picture message whose image carries no download_code -> no files, no text
msg = _make_chatbot_message(text="", message_type="picture")
msg.image_content = SimpleNamespace(download_code=None)
channel._on_chatbot_message(msg)
await asyncio.sleep(0.1)
bus.publish_inbound.assert_not_awaited()
assert bus.inbound_queue.empty()
_run(go())
class TestReceiveFile:
def test_no_files_is_noop(self):
async def go():
channel = DingTalkChannel(MessageBus(), config={})
msg = channel._make_inbound(chat_id="c", user_id="u", text="hi", thread_ts="m")
out = await channel.receive_file(msg, "t1")
assert out is msg
assert out.text == "hi"
_run(go())
def test_downloads_persists_and_prepends_path(self, tmp_path, monkeypatch):
async def go():
channel = DingTalkChannel(MessageBus(), config={})
channel._client_id = "robot"
channel._download_by_code = AsyncMock(return_value=b"XLSXDATA")
uploads = tmp_path / "uploads"
uploads.mkdir()
_patch_uploads(monkeypatch, uploads)
msg = channel._make_inbound(
chat_id="c",
user_id="u",
text="please translate",
thread_ts="m",
files=[{"type": "file", "download_code": "dc1", "filename": "quote.xlsx"}],
)
out = await channel.receive_file(msg, "thread-1", user_id="default")
assert out.files == []
assert (uploads / "quote.xlsx").read_bytes() == b"XLSXDATA"
assert f"{VIRTUAL_PATH_PREFIX}/uploads/quote.xlsx" in out.text
assert out.text.endswith("please translate")
channel._download_by_code.assert_awaited_once_with("dc1")
_run(go())
def test_pure_file_message_text_is_only_the_path(self, tmp_path, monkeypatch):
async def go():
channel = DingTalkChannel(MessageBus(), config={})
channel._download_by_code = AsyncMock(return_value=b"IMG")
uploads = tmp_path / "uploads"
uploads.mkdir()
_patch_uploads(monkeypatch, uploads)
msg = channel._make_inbound(
chat_id="c",
user_id="u",
text="",
thread_ts="m",
files=[{"type": "image", "download_code": "dc_i", "filename": "image.png"}],
)
out = await channel.receive_file(msg, "t1", user_id="default")
assert out.text == f"{VIRTUAL_PATH_PREFIX}/uploads/image.png"
_run(go())
def test_download_failure_surfaces_marker(self):
"""A failed download must stay visible to the agent, not vanish silently."""
async def go():
channel = DingTalkChannel(MessageBus(), config={})
channel._download_by_code = AsyncMock(return_value=None)
msg = channel._make_inbound(
chat_id="c",
user_id="u",
text="hi",
thread_ts="m",
files=[{"type": "file", "download_code": "dc1", "filename": "x.pdf"}],
)
out = await channel.receive_file(msg, "t1", user_id="default")
assert out.files == []
assert out.text == "[failed to load file: x.pdf]\n\nhi"
_run(go())
def test_token_failure_yields_marker_not_exception(self):
"""An auth failure during download must degrade to a marker, not an exception.
The manager calls ``receive_file`` without a try, so anything escaping
here kills the whole chat turn with no reply at all.
"""
async def go():
channel = DingTalkChannel(MessageBus(), config={})
channel._client_id = "robot"
channel._get_access_token = AsyncMock(side_effect=ValueError("bad token response"))
msg = channel._make_inbound(
chat_id="c",
user_id="u",
text="hi",
thread_ts="m",
files=[{"type": "file", "download_code": "dc", "filename": "x.pdf"}],
)
out = await channel.receive_file(msg, "t1", user_id="default")
assert out.text == "[failed to load file: x.pdf]\n\nhi"
assert out.files == []
_run(go())
def test_unexpected_error_becomes_marker(self):
"""Any unforeseen per-attachment error must surface as a marker, not escape."""
async def go():
channel = DingTalkChannel(MessageBus(), config={})
channel._receive_single_file = AsyncMock(side_effect=RuntimeError("boom"))
msg = channel._make_inbound(
chat_id="c",
user_id="u",
text="hi",
thread_ts="m",
files=[{"type": "file", "download_code": "dc", "filename": "x.pdf"}],
)
out = await channel.receive_file(msg, "t1", user_id="default")
assert out.text == "[failed to load file: x.pdf]\n\nhi"
assert out.files == []
_run(go())
def test_failure_marker_sanitizes_hostile_filename(self):
"""A hostile filename must not forge extra lines in msg.text or bloat it.
``fileName`` is webhook data: embedding it raw in the marker would let a
newline fake a standalone ``/mnt/user-data/uploads/...`` line, and an
over-long name would balloon the message text.
"""
async def go():
channel = DingTalkChannel(MessageBus(), config={})
channel._download_by_code = AsyncMock(return_value=None)
hostile = "evil\n/mnt/user-data/uploads/fake.pdf\n" + "a" * 300 + ".pdf"
msg = channel._make_inbound(
chat_id="c",
user_id="u",
text="hi",
thread_ts="m",
files=[{"type": "file", "download_code": "dc", "filename": hostile}],
)
out = await channel.receive_file(msg, "t1", user_id="default")
lines = out.text.splitlines()
assert len(lines) == 3, out.text
assert lines[0].startswith("[failed to load file: ")
assert len(lines[0]) <= 120
assert lines[1] == ""
assert lines[2] == "hi"
_run(go())
def test_download_failure_without_filename_uses_type_only_marker(self):
async def go():
channel = DingTalkChannel(MessageBus(), config={})
channel._download_by_code = AsyncMock(return_value=None)
msg = channel._make_inbound(
chat_id="c",
user_id="u",
text="",
thread_ts="m",
files=[{"type": "image", "download_code": "dc1", "filename": ""}],
)
out = await channel.receive_file(msg, "t1", user_id="default")
assert out.text == "[failed to load image]"
_run(go())
def test_partial_failure_keeps_successful_path(self, tmp_path, monkeypatch):
"""One bad attachment must not cost the agent the one that did load."""
async def go():
channel = DingTalkChannel(MessageBus(), config={})
uploads = tmp_path / "uploads"
uploads.mkdir()
_patch_uploads(monkeypatch, uploads)
channel._download_by_code = AsyncMock(side_effect=[b"OK", None])
msg = channel._make_inbound(
chat_id="c",
user_id="u",
text="two files",
thread_ts="m",
files=[
{"type": "file", "download_code": "good", "filename": "ok.pdf"},
{"type": "file", "download_code": "bad", "filename": "broken.pdf"},
],
)
out = await channel.receive_file(msg, "t1", user_id="default")
assert f"{VIRTUAL_PATH_PREFIX}/uploads/ok.pdf" in out.text
assert "[failed to load file: broken.pdf]" in out.text
assert out.text.endswith("two files")
_run(go())
def test_traversal_filename_is_sanitized(self, tmp_path, monkeypatch):
async def go():
channel = DingTalkChannel(MessageBus(), config={})
channel._download_by_code = AsyncMock(return_value=b"D")
uploads = tmp_path / "uploads"
uploads.mkdir()
_patch_uploads(monkeypatch, uploads)
msg = channel._make_inbound(
chat_id="c",
user_id="u",
text="",
thread_ts="m",
files=[{"type": "file", "download_code": "dc", "filename": "../../etc/passwd"}],
)
out = await channel.receive_file(msg, "t1", user_id="default")
written = list(uploads.iterdir())
assert len(written) == 1
assert written[0].name == "passwd"
assert out.text == f"{VIRTUAL_PATH_PREFIX}/uploads/passwd"
_run(go())
def test_rejected_filename_falls_back_to_generated_name(self, tmp_path, monkeypatch):
"""A filename normalize_filename rejects must land on the generated name.
``".."`` raises inside ``normalize_filename`` (unlike ``../../etc/passwd``,
whose basename ``passwd`` is accepted), so this exercises the except branch.
"""
async def go():
channel = DingTalkChannel(MessageBus(), config={})
channel._download_by_code = AsyncMock(return_value=b"D")
uploads = tmp_path / "uploads"
uploads.mkdir()
_patch_uploads(monkeypatch, uploads)
msg = channel._make_inbound(
chat_id="c",
user_id="u",
text="",
thread_ts="m",
files=[{"type": "file", "download_code": "abc123456789", "filename": ".."}],
)
out = await channel.receive_file(msg, "t1", user_id="default")
written = list(uploads.iterdir())
assert len(written) == 1
assert written[0].name == "dingtalk_abc123456789.bin"
assert out.text == f"{VIRTUAL_PATH_PREFIX}/uploads/dingtalk_abc123456789.bin"
_run(go())
def test_fallback_name_sanitizes_download_code(self, tmp_path, monkeypatch):
"""download_code is webhook data: it must not inject path separators."""
async def go():
channel = DingTalkChannel(MessageBus(), config={})
channel._download_by_code = AsyncMock(return_value=b"D")
uploads = tmp_path / "uploads"
uploads.mkdir()
_patch_uploads(monkeypatch, uploads)
msg = channel._make_inbound(
chat_id="c",
user_id="u",
text="",
thread_ts="m",
files=[{"type": "image", "download_code": "../../evil", "filename": ".."}],
)
out = await channel.receive_file(msg, "t1", user_id="default")
written = list(uploads.iterdir())
assert len(written) == 1
assert "/" not in written[0].name
assert written[0].name == "dingtalk_evil.png"
assert out.text == f"{VIRTUAL_PATH_PREFIX}/uploads/dingtalk_evil.png"
_run(go())
def test_second_picture_does_not_overwrite_first(self, tmp_path, monkeypatch):
"""Two picture messages both generate "image.png" and must not collide.
The path handed to the agent must still hold the bytes it referred to.
"""
async def go():
channel = DingTalkChannel(MessageBus(), config={})
uploads = tmp_path / "uploads"
uploads.mkdir()
_patch_uploads(monkeypatch, uploads)
channel._download_by_code = AsyncMock(return_value=b"FIRST")
first = channel._make_inbound(
chat_id="c",
user_id="u",
text="",
thread_ts="m1",
files=[{"type": "image", "download_code": "dc1", "filename": "image.png"}],
)
out_first = await channel.receive_file(first, "t1", user_id="default")
channel._download_by_code = AsyncMock(return_value=b"SECOND")
second = channel._make_inbound(
chat_id="c",
user_id="u",
text="",
thread_ts="m2",
files=[{"type": "image", "download_code": "dc2", "filename": "image.png"}],
)
out_second = await channel.receive_file(second, "t1", user_id="default")
assert out_first.text != out_second.text
first_name = out_first.text.rsplit("/", 1)[-1]
second_name = out_second.text.rsplit("/", 1)[-1]
# Each advertised path must still resolve to its own bytes.
assert (uploads / first_name).read_bytes() == b"FIRST"
assert (uploads / second_name).read_bytes() == b"SECOND"
_run(go())
def test_multiple_images_in_one_message_get_distinct_paths(self, tmp_path, monkeypatch):
async def go():
channel = DingTalkChannel(MessageBus(), config={})
uploads = tmp_path / "uploads"
uploads.mkdir()
_patch_uploads(monkeypatch, uploads)
channel._download_by_code = AsyncMock(side_effect=[b"A", b"B"])
msg = channel._make_inbound(
chat_id="c",
user_id="u",
text="",
thread_ts="m",
files=[
{"type": "image", "download_code": "dc1", "filename": "image_0.png"},
{"type": "image", "download_code": "dc2", "filename": "image_0.png"},
],
)
out = await channel.receive_file(msg, "t1", user_id="default")
paths = out.text.split("\n")
assert len(paths) == 2
assert paths[0] != paths[1]
assert len(list(uploads.iterdir())) == 2
assert {p.read_bytes() for p in uploads.iterdir()} == {b"A", b"B"}
_run(go())
def test_duplicate_document_names_do_not_collide(self, tmp_path, monkeypatch):
"""The same real filename sent twice must not clobber the earlier upload."""
async def go():
channel = DingTalkChannel(MessageBus(), config={})
uploads = tmp_path / "uploads"
uploads.mkdir()
_patch_uploads(monkeypatch, uploads)
channel._download_by_code = AsyncMock(return_value=b"V1")
m1 = channel._make_inbound(chat_id="c", user_id="u", text="", thread_ts="m1", files=[{"type": "file", "download_code": "d1", "filename": "quote.xlsx"}])
await channel.receive_file(m1, "t1", user_id="default")
channel._download_by_code = AsyncMock(return_value=b"V2")
m2 = channel._make_inbound(chat_id="c", user_id="u", text="", thread_ts="m2", files=[{"type": "file", "download_code": "d2", "filename": "quote.xlsx"}])
await channel.receive_file(m2, "t1", user_id="default")
assert (uploads / "quote.xlsx").read_bytes() == b"V1"
assert len(list(uploads.iterdir())) == 2
_run(go())
def test_write_reserves_planted_symlink_name(self, tmp_path, monkeypatch):
"""A symlink planted at the requested name must force a unique suffix.
Upload dirs can be mounted into local sandboxes, so a sandbox process can
leave a symlink at a future upload name; following it would let a
gateway-privileged write land outside the bucket. The symlink name is
treated as occupied so the attachment still loads at the next suffix.
"""
async def go():
channel = DingTalkChannel(MessageBus(), config={})
uploads = tmp_path / "uploads"
uploads.mkdir()
outside = tmp_path / "outside.txt"
symlink_or_skip(uploads / "image.png", outside)
_patch_uploads(monkeypatch, uploads)
channel._download_by_code = AsyncMock(return_value=b"PWNED")
msg = channel._make_inbound(
chat_id="c",
user_id="u",
text="",
thread_ts="m",
files=[{"type": "image", "download_code": "dc", "filename": "image.png"}],
)
out = await channel.receive_file(msg, "t1", user_id="default")
assert not outside.exists()
assert (uploads / "image.png").is_symlink()
assert (uploads / "image_1.png").read_bytes() == b"PWNED"
assert out.text == f"{VIRTUAL_PATH_PREFIX}/uploads/image_1.png"
_run(go())
def test_missing_sandbox_after_acquire_yields_marker(self, tmp_path, monkeypatch):
"""A non-local sandbox that cannot be resolved must not yield a path.
The agent's sandbox cannot see the file in that case, so handing it the
virtual path would point at nothing readable; mirror Feishu and surface
a failed-load marker instead.
"""
async def go():
channel = DingTalkChannel(MessageBus(), config={})
channel._download_by_code = AsyncMock(return_value=b"BYTES")
uploads = tmp_path / "uploads"
uploads.mkdir()
_patch_uploads(monkeypatch, uploads, sandbox_id="aio:box1", sandbox=None)
msg = channel._make_inbound(
chat_id="c",
user_id="u",
text="hi",
thread_ts="m",
files=[{"type": "file", "download_code": "dc", "filename": "a.pdf"}],
)
out = await channel.receive_file(msg, "t1", user_id="default")
assert out.text == "[failed to load file: a.pdf]\n\nhi"
_run(go())
def test_update_file_failure_yields_marker(self, tmp_path, monkeypatch):
"""A failed non-local sandbox sync must not yield a path either.
Same failure mode as the missing-sandbox case: the bytes never reached
the agent's sandbox, so the virtual path would read as nothing. Mirrors
Feishu, whose sync except-branch returns the failure marker.
"""
async def go():
channel = DingTalkChannel(MessageBus(), config={})
channel._download_by_code = AsyncMock(return_value=b"BYTES")
uploads = tmp_path / "uploads"
uploads.mkdir()
broken_sandbox = MagicMock()
broken_sandbox.update_file.side_effect = RuntimeError("sandbox transport down")
_patch_uploads(monkeypatch, uploads, sandbox_id="aio:box1", sandbox=broken_sandbox)
msg = channel._make_inbound(
chat_id="c",
user_id="u",
text="hi",
thread_ts="m",
files=[{"type": "file", "download_code": "dc", "filename": "a.pdf"}],
)
out = await channel.receive_file(msg, "t1", user_id="default")
assert out.text == "[failed to load file: a.pdf]\n\nhi"
_run(go())
def test_non_local_sandbox_is_synced(self, tmp_path, monkeypatch):
async def go():
channel = DingTalkChannel(MessageBus(), config={})
channel._download_by_code = AsyncMock(return_value=b"BYTES")
uploads = tmp_path / "uploads"
uploads.mkdir()
fake_sandbox = MagicMock()
_patch_uploads(monkeypatch, uploads, sandbox_id="aio:box1", sandbox=fake_sandbox)
msg = channel._make_inbound(
chat_id="c",
user_id="u",
text="",
thread_ts="m",
files=[{"type": "file", "download_code": "dc", "filename": "a.pdf"}],
)
await channel.receive_file(msg, "t1", user_id="default")
fake_sandbox.update_file.assert_called_once_with(f"{VIRTUAL_PATH_PREFIX}/uploads/a.pdf", b"BYTES")
_run(go())
def test_non_local_upload_releases_standalone_sandbox(self, non_local_upload):
async def go():
upload = non_local_upload
result = await asyncio.wait_for(upload.channel.receive_file(upload.message, "t1", user_id="default"), timeout=5)
assert result.text == f"{VIRTUAL_PATH_PREFIX}/uploads/a.pdf\n\nhi"
upload.sandbox.update_file.assert_called_once_with(f"{VIRTUAL_PATH_PREFIX}/uploads/a.pdf", b"BYTES")
upload.sandbox.release_command_scope.assert_called_once()
upload.provider.release.assert_called_once_with("aio:box1")
_run(go())
@pytest.mark.parametrize("run_finishes_first", [False, True])
def test_non_local_upload_releases_only_after_upload_and_run_finish(self, non_local_upload, run_finishes_first):
async def go():
upload = non_local_upload
manager = get_sandbox_lease_manager(upload.provider)
await manager.retain_async("active-run", "aio:box1", thread_id="t1", user_id="default")
started = asyncio.Event()
finish_update = threading.Event()
update_finished = threading.Event()
loop = asyncio.get_running_loop()
def update_file(path, content):
loop.call_soon_threadsafe(started.set)
try:
if not finish_update.wait(timeout=10):
raise TimeoutError("test did not release sandbox update")
finally:
update_finished.set()
upload.sandbox.update_file.side_effect = update_file
task = asyncio.create_task(upload.channel.receive_file(upload.message, "t1", user_id="default"))
try:
await asyncio.wait_for(started.wait(), timeout=5)
upload.provider.release.assert_not_called()
assert not update_finished.is_set()
if run_finishes_first:
await manager.release_async("active-run")
upload.provider.release.assert_not_called()
assert not update_finished.is_set()
finish_update.set()
result = await asyncio.wait_for(task, timeout=5)
assert result.text == f"{VIRTUAL_PATH_PREFIX}/uploads/a.pdf\n\nhi"
assert update_finished.is_set()
if not run_finishes_first:
upload.provider.release.assert_not_called()
assert manager.binding_for("active-run") == "aio:box1"
await manager.release_async("active-run")
upload.provider.release.assert_called_once_with("aio:box1")
assert manager.binding_for("active-run") is None
assert upload.sandbox.release_command_scope.call_count == 2
finally:
finish_update.set()
await asyncio.gather(task, return_exceptions=True)
await manager.release_async("active-run")
_run(go())
def test_failed_non_local_upload_releases_standalone_sandbox(self, non_local_upload):
async def go():
upload = non_local_upload
upload.sandbox.update_file.side_effect = RuntimeError("sandbox transport down")
result = await asyncio.wait_for(upload.channel.receive_file(upload.message, "t1", user_id="default"), timeout=5)
assert result.text == "[failed to load file: a.pdf]\n\nhi"
upload.sandbox.update_file.assert_called_once_with(f"{VIRTUAL_PATH_PREFIX}/uploads/a.pdf", b"BYTES")
upload.sandbox.release_command_scope.assert_called_once()
upload.provider.release.assert_called_once_with("aio:box1")
_run(go())
class TestDownloadByCode:
def test_two_step_download(self):
async def go():
from unittest.mock import patch
channel = DingTalkChannel(MessageBus(), config={})
channel._client_id = "robot_x"
channel._get_access_token = AsyncMock(return_value="tok")
class PostResponse:
status_code = 200
@staticmethod
def json():
return {"downloadUrl": "https://dl.dingtalk/xyz"}
captured: dict = {}
class FakeStream:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
def raise_for_status(self):
pass
async def aiter_bytes(self):
yield b"FILE"
yield b"BYTES"
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def post(self, url, **kwargs):
captured["post_url"] = url
captured["json"] = kwargs.get("json")
return PostResponse()
def stream(self, method, url, **kwargs):
captured["get_url"] = url
return FakeStream()
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
data = await channel._download_by_code("dl_code")
assert data == b"FILEBYTES"
assert captured["post_url"].endswith("/v1.0/robot/messageFiles/download")
assert captured["json"] == {"downloadCode": "dl_code", "robotCode": "robot_x"}
assert captured["get_url"] == "https://dl.dingtalk/xyz"
_run(go())
def test_oversized_download_is_dropped(self, monkeypatch):
"""Inbound bytes are buffered in memory; a file over the cap must be refused.
Outbound uploads already enforce a size cap — without an inbound one, a
single large chat attachment balloons gateway memory.
"""
async def go():
from unittest.mock import patch
channel = DingTalkChannel(MessageBus(), config={})
channel._client_id = "robot_x"
channel._get_access_token = AsyncMock(return_value="tok")
monkeypatch.setattr("app.channels.dingtalk._MAX_INBOUND_FILE_SIZE_BYTES", 10)
class PostResponse:
status_code = 200
@staticmethod
def json():
return {"downloadUrl": "https://dl.dingtalk/big"}
class FakeStream:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
def raise_for_status(self):
pass
async def aiter_bytes(self):
for _ in range(4):
yield b"x" * 8
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def post(self, url, **kwargs):
return PostResponse()
def stream(self, method, url, **kwargs):
return FakeStream()
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
assert await channel._download_by_code("dl") is None
_run(go())
def test_post_non_200_returns_none(self):
async def go():
from unittest.mock import patch
channel = DingTalkChannel(MessageBus(), config={})
channel._client_id = "robot_x"
channel._get_access_token = AsyncMock(return_value="tok")
class PostResponse:
status_code = 403
text = "forbidden"
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def post(self, url, **kwargs):
return PostResponse()
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
assert await channel._download_by_code("dl") is None
_run(go())
def test_missing_download_url_returns_none(self):
async def go():
from unittest.mock import patch
channel = DingTalkChannel(MessageBus(), config={})
channel._client_id = "robot_x"
channel._get_access_token = AsyncMock(return_value="tok")
class PostResponse:
status_code = 200
@staticmethod
def json():
return {}
class FakeClient:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
pass
async def post(self, url, **kwargs):
return PostResponse()
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
assert await channel._download_by_code("dl") is None
_run(go())
class TestHandlerStashesRawData:
def test_process_stashes_raw_callback_payload(self):
pytest.importorskip("dingtalk_stream")
async def go():
bus = MessageBus()
channel = DingTalkChannel(bus, config={})
captured: dict = {}
channel._on_chatbot_message = lambda m: captured.update(msg=m)
handler = _DingTalkMessageHandler(channel)
cb = MagicMock()
cb.data = {
"msgtype": "file",
"content": {"downloadCode": "dc_doc", "fileName": "a.xlsx"},
"senderStaffId": "u1",
"conversationType": "1",
"msgId": "m1",
}
await handler.process(cb)
msg = captured["msg"]
assert getattr(msg, "_df_raw_data", None) == cb.data
# _extract_files can now read the document descriptor from the stash.
assert DingTalkChannel._extract_files(msg) == [{"type": "file", "download_code": "dc_doc", "filename": "a.xlsx"}]
_run(go())
class TestDingTalkDownloadGuardLogging:
"""A None (or empty) download result used to drop the attachment with
zero log lines at the receive level — the accurate reason lines inside
_download_by_code fired, but nothing tied them to the file being
received. The caller now logs a neutral guard line, mirroring the
wechat channel's callers and the manager reader."""
def test_none_download_logs_neutral_guard_line(self, caplog):
async def go():
channel = DingTalkChannel(MessageBus(), config={})
channel._download_by_code = AsyncMock(return_value=None)
with caplog.at_level(logging.WARNING, logger="app.channels.dingtalk"):
result = await channel._receive_single_file("dc1", "file", "report.pdf", "t1", user_id="default")
assert result == ""
_run(go())
assert any("inbound file download returned no content" in r.message and "report.pdf" in r.message for r in caplog.records)
def test_empty_download_logs_neutral_guard_line(self, caplog):
async def go():
channel = DingTalkChannel(MessageBus(), config={})
channel._download_by_code = AsyncMock(return_value=b"")
with caplog.at_level(logging.WARNING, logger="app.channels.dingtalk"):
result = await channel._receive_single_file("dc2", "image", "photo.png", "t1", user_id="default")
assert result == ""
_run(go())
assert any("inbound file download returned no content" in r.message and "photo.png" in r.message for r in caplog.records)