mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* fix(channels): share chat-to-thread bindings through the database ChannelManager kept its IM chat-to-thread bindings (channel_name:chat_id[:topic_id] -> thread_id) in a per-process JSON file, channels/store.json, loaded once and rewritten whole on every change. With several Gateway processes a binding created on one replica was invisible to the others (the next message for the same chat opened a second thread) and concurrent writers clobbered each other's file. - New channel_thread_bindings table (migration 0036_channel_thread_bindings) and harness package deerflow.persistence.channel_thread_bindings: key PK (the legacy composite string), channel_name, chat_id, nullable topic_id, thread_id, user_id, epoch created_at/updated_at; index (channel_name, chat_id). Dialect upsert keeps created_at; prefix remove and batched ON CONFLICT DO NOTHING import. - app.channels.store.ChannelStore is an async protocol: SqlChannelStore for sqlite/postgres, JsonChannelStore (file I/O via asyncio.to_thread) for database.backend: memory. resolve_channel_store picks at ChannelService construction; ChannelService.start() imports an existing store.json once into an empty table and renames it store.json.migrated (a concurrently importing peer neither duplicates nor clobbers; a populated table leaves the file alone). - Callers updated: manager, connection_identity.lookup_thread_id, Feishu (send() awaits the mapping writer; the synchronous lark callback bridges its two store calls to the Gateway loop with a bounded wait), Discord (channels_dir). - Head pins in the 0027_0028/0029/0032/0034/0035 migration tests point at the new head; docs (channels and migrations AGENTS.md, CONFIGURATION.md, README) and CHANGELOG updated. * docs(changelog): link the channel thread bindings entry to #6558 * fix(channels): reject only the Gateway loop in the Feishu store bridge lark-oapi 1.5.5 invokes the message callback synchronously from its async frame handler, on the private event loop that FeishuChannel._run_ws starts for the SDK thread. The bridge's guard treated any running loop as unsafe, so every real inbound message skipped the stored-topic lookup and the pending-mapping restore: a reply whose parent card was mapped but whose root was not fell back to the unknown root, and could consume an unrelated pending clarification. Only a wait issued from the Gateway loop itself (the loop that executes the submitted coroutine) is refused now; the SDK's own loop may block for the bounded wait, as that thread blocked on the JSON rewrite before. Tests drive _on_message from a worker thread running its own loop, the production shape, for both the lookup and the write path, and pin the Gateway-loop refusal. * fix(channels): defer unresolved Feishu lookups and harden the store.json import Review follow-ups on the chat-to-thread binding store (#6558): - Import: a legacy entry whose key, channel_name, chat_id, topic_id, thread_id or user_id exceeds its column width is skipped and counted with the malformed ones instead of failing the whole batch on PostgreSQL (SQLite silently accepted the same row, so the backends diverged on one file). The corrupt-file warning now says what each caller does: the JSON store starts fresh, the import is skipped and the file kept. - Feishu bridge: store calls run on the Gateway loop under a 3s asyncio.wait_for (the SDK loop also services ws keepalives; the old 10s bound could stall it per message), and the lark-thread wait is that budget plus 1s. A deadline or store error is StoreLookupUnresolved, never a miss: _on_message no longer routes such a reply onto its unknown root (which created a second thread and later overwrote the parent alias). The message joins a per-chat FIFO drained on the Gateway loop with backoff for up to 30s, then is dropped with a warning. _on_message is split into parse / resolve / dispatch so the deferred path dispatches the same way; later messages for the chat queue behind the deferred one. * fix(tests): expect follow_redirects in the Jina client kwargs after #5808 #5808 made JinaClient pass follow_redirects=True to httpx.AsyncClient and updated test_jina_client.py, but test_jina_response_limit.py and test_jina_retry_after.py still pin the exact client kwargs to proxy and trust_env, so every backend-unit-tests shard that lands on them fails on main (the backend unit-test workflow only runs on pull requests, which is why the push to main did not surface it). Update the two expectations to include the new keyword. (cherry picked from commit d5aa23156ce19ce36d95df03953442f83ae6d050) * Revert "fix(tests): expect follow_redirects in the Jina client kwargs after #5808" This reverts commit 5bee00bfb09c65a30f77fb4df47a1f82c47ecef7. * fix(channels): bound deferred Feishu routing by the inbound queue capacity Review follow-up on #6558: a store outage let every subsequent message for a chat join the deferred queue with no capacity check (the bus reservation was only taken after routing succeeded), and each queued message started a fresh 30s retry budget when it reached the head, so a burst could be retained for N x 30s and grow without bound. - Intake is reserved BEFORE routing for every store-backed message (bus.reserve_inbound is thread-safe and already used from SDK threads), so deferred and live messages share the one configured bound, channels.inbound_queue_maxsize; a message that cannot be admitted is dropped at once with a warning, never queued or routed as a miss. - The reservation travels with the message into dispatch (committed by _prepare_inbound), is released on the batch path (the flush reserves at publish time), on drop after the budget, on a failed deferral, on dispatch failure and for every leftover when the drain task is cancelled by stop(). - The retry budget runs from the message's arrival: a queued message whose budget is spent when it reaches the head is dropped without a lookup. - The bounded resolver returns the unresolved outcome instead of raising it through the tracked task, which logged an ERROR for an expected, handled store timeout. - Upstream's new blocking-io test constructs the store by its old class name; it now uses JsonChannelStore. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2686 lines
95 KiB
Python
2686 lines
95 KiB
Python
"""Tests for the DingTalk channel implementation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import threading
|
|
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_allowed_users,
|
|
_normalize_conversation_type,
|
|
)
|
|
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage
|
|
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
|
|
|
|
|
|
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())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _normalize_allowed_users tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNormalizeAllowedUsers:
|
|
def test_none_returns_empty(self):
|
|
assert _normalize_allowed_users(None) == set()
|
|
|
|
def test_empty_list_returns_empty(self):
|
|
assert _normalize_allowed_users([]) == set()
|
|
|
|
def test_list_of_strings(self):
|
|
result = _normalize_allowed_users(["user1", "user2"])
|
|
assert result == {"user1", "user2"}
|
|
|
|
def test_single_string(self):
|
|
result = _normalize_allowed_users("user1")
|
|
assert result == {"user1"}
|
|
|
|
def test_numeric_values_converted_to_string(self):
|
|
result = _normalize_allowed_users([123, 456])
|
|
assert result == {"123", "456"}
|
|
|
|
def test_scalar_treated_as_single_value(self):
|
|
result = _normalize_allowed_users(12345)
|
|
assert result == {"12345"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _normalize_conversation_type tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNormalizeConversationType:
|
|
def test_group_int_or_str(self):
|
|
assert _normalize_conversation_type(2) == _CONVERSATION_TYPE_GROUP
|
|
assert _normalize_conversation_type("2") == _CONVERSATION_TYPE_GROUP
|
|
|
|
def test_p2p_or_none(self):
|
|
assert _normalize_conversation_type(1) == _CONVERSATION_TYPE_P2P
|
|
assert _normalize_conversation_type(None) == _CONVERSATION_TYPE_P2P
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _is_dingtalk_command tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIsDingTalkCommand:
|
|
@pytest.mark.parametrize("command", sorted(KNOWN_CHANNEL_COMMANDS))
|
|
def test_known_commands_recognized(self, command):
|
|
assert _is_dingtalk_command(command) is True
|
|
|
|
@pytest.mark.parametrize(
|
|
"text",
|
|
[
|
|
"/unknown",
|
|
"/mnt/user-data/outputs/report.md",
|
|
"hello",
|
|
"",
|
|
"not a command",
|
|
],
|
|
)
|
|
def test_non_commands_rejected(self, text):
|
|
assert _is_dingtalk_command(text) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _extract_text_from_rich_text tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestExtractTextFromRichText:
|
|
def test_single_text_item(self):
|
|
result = _extract_text_from_rich_text([{"text": "hello"}])
|
|
assert result == "hello"
|
|
|
|
def test_multiple_text_items(self):
|
|
result = _extract_text_from_rich_text([{"text": "hello"}, {"text": "world"}])
|
|
assert result == "hello world"
|
|
|
|
def test_non_text_items_ignored(self):
|
|
result = _extract_text_from_rich_text(
|
|
[
|
|
{"downloadCode": "abc123"},
|
|
{"text": "caption"},
|
|
]
|
|
)
|
|
assert result == "caption"
|
|
|
|
def test_empty_list(self):
|
|
assert _extract_text_from_rich_text([]) == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DingTalkChannel._extract_text tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestExtractText:
|
|
def test_plain_text(self):
|
|
msg = _make_chatbot_message(text="Hello World")
|
|
assert DingTalkChannel._extract_text(msg) == "Hello World"
|
|
|
|
def test_plain_text_stripped(self):
|
|
msg = _make_chatbot_message(text=" Hello ")
|
|
assert DingTalkChannel._extract_text(msg) == "Hello"
|
|
|
|
def test_rich_text(self):
|
|
msg = _make_chatbot_message(
|
|
message_type="richText",
|
|
rich_text_list=[{"text": "Part 1"}, {"text": "Part 2"}],
|
|
)
|
|
assert DingTalkChannel._extract_text(msg) == "Part 1 Part 2"
|
|
|
|
def test_unknown_type_returns_empty(self):
|
|
msg = _make_chatbot_message(message_type="picture")
|
|
assert DingTalkChannel._extract_text(msg) == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DingTalkChannel._on_chatbot_message tests (inbound parsing)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestOnChatbotMessage:
|
|
def test_p2p_message_produces_correct_inbound(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
bus.publish_inbound = AsyncMock()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "test_key"
|
|
channel._main_loop = asyncio.get_event_loop()
|
|
channel._running = True
|
|
|
|
msg = _make_chatbot_message(
|
|
text="hello from dingtalk",
|
|
conversation_type=_CONVERSATION_TYPE_P2P,
|
|
sender_staff_id="user_001",
|
|
message_id="msg_001",
|
|
)
|
|
|
|
channel._send_running_reply = AsyncMock()
|
|
channel._on_chatbot_message(msg)
|
|
|
|
await asyncio.sleep(0.1)
|
|
|
|
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())
|
|
|
|
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())
|
|
|
|
def test_invalid_expire_in_uses_default(self):
|
|
async def go():
|
|
import time
|
|
from unittest.mock import patch
|
|
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "k"
|
|
channel._client_secret = "s"
|
|
|
|
class FakeResponse:
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
return {"accessToken": "tok_ok", "expireIn": "invalid"}
|
|
|
|
class FakeClient:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *a):
|
|
pass
|
|
|
|
async def post(self, url, **kwargs):
|
|
return FakeResponse()
|
|
|
|
before = time.monotonic()
|
|
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
|
|
token = await channel._get_access_token()
|
|
|
|
assert token == "tok_ok"
|
|
assert channel._token_expires_at > before
|
|
|
|
_run(go())
|
|
|
|
|
|
class TestTokenCaching:
|
|
def test_token_is_cached_across_calls(self):
|
|
async def go():
|
|
from unittest.mock import patch
|
|
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "test_key"
|
|
channel._client_secret = "test_secret"
|
|
|
|
call_count = 0
|
|
|
|
class FakeResponse:
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
return {"accessToken": "tok_abc", "expireIn": 7200}
|
|
|
|
class FakeClient:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *a):
|
|
pass
|
|
|
|
async def post(self, url, **kwargs):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return FakeResponse()
|
|
|
|
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
|
|
t1 = await channel._get_access_token()
|
|
t2 = await channel._get_access_token()
|
|
|
|
assert t1 == "tok_abc"
|
|
assert t2 == "tok_abc"
|
|
assert call_count == 1
|
|
|
|
_run(go())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Group message @ mention format tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGroupMessageMarkdownFormat:
|
|
def test_at_user_ids_still_use_markdown(self):
|
|
"""groupMessages/send uses sampleMarkdown; @{userId} in body returns 400 so at_user_ids is ignored."""
|
|
|
|
async def go():
|
|
from unittest.mock import patch
|
|
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "test_key"
|
|
channel._client_secret = "test_secret"
|
|
channel._cached_token = "tok_test"
|
|
channel._token_expires_at = float("inf")
|
|
|
|
captured_json: list[dict] = []
|
|
|
|
class FakeResponse:
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
return {"processQueryKey": "ok"}
|
|
|
|
class FakeClient:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *a):
|
|
pass
|
|
|
|
async def post(self, url, **kwargs):
|
|
captured_json.append(kwargs.get("json", {}))
|
|
return FakeResponse()
|
|
|
|
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
|
|
await channel._send_group_message("bot", "conv1", "hello", at_user_ids=["staff_001"])
|
|
|
|
assert len(captured_json) == 1
|
|
payload = captured_json[0]
|
|
assert payload["msgKey"] == "sampleMarkdown"
|
|
import json
|
|
|
|
param = json.loads(payload["msgParam"])
|
|
assert param["text"] == "hello"
|
|
assert "@" not in json.dumps(param)
|
|
|
|
_run(go())
|
|
|
|
def test_no_at_user_ids_uses_markdown(self):
|
|
async def go():
|
|
from unittest.mock import patch
|
|
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "test_key"
|
|
channel._client_secret = "test_secret"
|
|
channel._cached_token = "tok_test"
|
|
channel._token_expires_at = float("inf")
|
|
|
|
captured_json: list[dict] = []
|
|
|
|
class FakeResponse:
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
return {"processQueryKey": "ok"}
|
|
|
|
class FakeClient:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *a):
|
|
pass
|
|
|
|
async def post(self, url, **kwargs):
|
|
captured_json.append(kwargs.get("json", {}))
|
|
return FakeResponse()
|
|
|
|
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
|
|
await channel._send_group_message("bot", "conv1", "hello")
|
|
|
|
assert len(captured_json) == 1
|
|
payload = captured_json[0]
|
|
assert payload["msgKey"] == "sampleMarkdown"
|
|
|
|
_run(go())
|
|
|
|
|
|
class TestAdaptMarkdownForDingtalk:
|
|
def test_fenced_code_block_to_blockquote(self):
|
|
text = "Hello\n```python\ndef foo():\n return 1\n```\nDone"
|
|
result = _adapt_markdown_for_dingtalk(text)
|
|
assert "```" not in result
|
|
assert "> **python**" in result
|
|
assert "> def foo():" in result
|
|
assert "> return 1" in result
|
|
|
|
def test_fenced_code_block_no_language(self):
|
|
text = "```\nplain code\n```"
|
|
result = _adapt_markdown_for_dingtalk(text)
|
|
assert "```" not in result
|
|
assert "> plain code" in result
|
|
|
|
def test_inline_code_to_bold(self):
|
|
text = "Use `pip install` to install"
|
|
result = _adapt_markdown_for_dingtalk(text)
|
|
assert result == "Use **pip install** to install"
|
|
|
|
def test_horizontal_rule_to_unicode(self):
|
|
text = "Above\n---\nBelow"
|
|
result = _adapt_markdown_for_dingtalk(text)
|
|
assert "───────────" in result
|
|
assert "---" not in result
|
|
|
|
def test_supported_markdown_preserved(self):
|
|
text = "# Title\n**bold** and *italic*\n- list item\n> quote\n[link](http://example.com)"
|
|
result = _adapt_markdown_for_dingtalk(text)
|
|
assert result == text
|
|
|
|
def test_plain_text_unchanged(self):
|
|
text = "Hello world, no markdown here."
|
|
assert _adapt_markdown_for_dingtalk(text) == text
|
|
|
|
def test_combined_elements(self):
|
|
text = "# Report\n\nRun `make test` then:\n\n```bash\npytest -v\n```\n\n---\n\nDone."
|
|
result = _adapt_markdown_for_dingtalk(text)
|
|
assert "# Report" in result
|
|
assert "**make test**" in result
|
|
assert "> **bash**" in result
|
|
assert "> pytest -v" in result
|
|
assert "───────────" in result
|
|
assert "Done." in result
|
|
|
|
|
|
class TestConvertMarkdownTable:
|
|
def test_simple_table(self):
|
|
text = "| Name | Age |\n|------|-----|\n| Alice | 30 |\n| Bob | 25 |"
|
|
result = _convert_markdown_table(text)
|
|
assert "> **Name**: Alice" in result
|
|
assert "> **Age**: 30" in result
|
|
assert "> **Name**: Bob" in result
|
|
assert "> **Age**: 25" in result
|
|
assert "|" not in result
|
|
|
|
def test_table_with_surrounding_text(self):
|
|
text = "Results:\n\n| Key | Value |\n|-----|-------|\n| a | 1 |\n\nEnd."
|
|
result = _convert_markdown_table(text)
|
|
assert "Results:" in result
|
|
assert "> **Key**: a" in result
|
|
assert "> **Value**: 1" in result
|
|
assert "End." in result
|
|
|
|
def test_no_table(self):
|
|
text = "Just plain text\nwith lines"
|
|
assert _convert_markdown_table(text) == text
|
|
|
|
def test_alignment_separators(self):
|
|
text = "| Left | Center | Right |\n|:-----|:------:|------:|\n| a | b | c |"
|
|
result = _convert_markdown_table(text)
|
|
assert "> **Left**: a" in result
|
|
assert "> **Center**: b" in result
|
|
assert "> **Right**: c" in result
|
|
|
|
|
|
class TestUploadMediaValidation:
|
|
def test_non_dict_response_returns_none(self):
|
|
async def go():
|
|
from unittest.mock import patch
|
|
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "k"
|
|
channel._client_secret = "s"
|
|
channel._cached_token = "tok"
|
|
channel._token_expires_at = float("inf")
|
|
|
|
class FakeResponse:
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
return ["not", "a", "dict"]
|
|
|
|
class FakeClient:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *a):
|
|
pass
|
|
|
|
async def post(self, url, **kwargs):
|
|
return FakeResponse()
|
|
|
|
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
|
|
result = await channel._upload_media("/tmp/test.png", "image")
|
|
|
|
assert result is None
|
|
|
|
_run(go())
|
|
|
|
def test_json_decode_error_returns_none(self):
|
|
async def go():
|
|
import json as json_mod
|
|
from unittest.mock import patch
|
|
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "k"
|
|
channel._client_secret = "s"
|
|
channel._cached_token = "tok"
|
|
channel._token_expires_at = float("inf")
|
|
|
|
class FakeResponse:
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
raise json_mod.JSONDecodeError("err", "", 0)
|
|
|
|
class FakeClient:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *a):
|
|
pass
|
|
|
|
async def post(self, url, **kwargs):
|
|
return FakeResponse()
|
|
|
|
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
|
|
result = await channel._upload_media("/tmp/test.png", "image")
|
|
|
|
assert result is None
|
|
|
|
_run(go())
|
|
|
|
|
|
class TestChannelRegistration:
|
|
def test_dingtalk_in_channel_registry(self):
|
|
from app.channels.service import _CHANNEL_REGISTRY
|
|
|
|
assert "dingtalk" in _CHANNEL_REGISTRY
|
|
assert _CHANNEL_REGISTRY["dingtalk"] == "app.channels.dingtalk:DingTalkChannel"
|
|
|
|
def test_dingtalk_in_credential_keys(self):
|
|
from app.channels.service import _CHANNEL_CREDENTIAL_KEYS
|
|
|
|
assert "dingtalk" in _CHANNEL_CREDENTIAL_KEYS
|
|
assert "client_id" in _CHANNEL_CREDENTIAL_KEYS["dingtalk"]
|
|
assert "client_secret" in _CHANNEL_CREDENTIAL_KEYS["dingtalk"]
|
|
|
|
def test_dingtalk_in_channel_capabilities(self):
|
|
from app.channels.manager import CHANNEL_CAPABILITIES
|
|
|
|
assert "dingtalk" in CHANNEL_CAPABILITIES
|
|
assert CHANNEL_CAPABILITIES["dingtalk"]["supports_streaming"] is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AI Card streaming mode tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCardMode:
|
|
def test_card_mode_enabled_supports_streaming(self):
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
|
|
assert channel.supports_streaming is True
|
|
|
|
def test_non_card_mode_no_streaming(self):
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
assert channel.supports_streaming is False
|
|
|
|
def test_non_card_mode_unchanged(self):
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
assert channel._card_template_id == ""
|
|
assert channel._card_track_ids == {}
|
|
assert channel._card_repliers == {}
|
|
assert channel._incoming_messages == {}
|
|
assert channel._dingtalk_client is None
|
|
|
|
def test_card_source_key_matches_inbound_using_message_id_metadata(self):
|
|
"""Outbound correlation must match inbound ``message_id`` even if ``thread_ts`` drifts."""
|
|
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
inbound = channel._make_inbound(
|
|
chat_id="x",
|
|
user_id="u",
|
|
text="hi",
|
|
thread_ts="ts_fallback",
|
|
metadata={
|
|
"conversation_type": _CONVERSATION_TYPE_P2P,
|
|
"sender_staff_id": "user_001",
|
|
"conversation_id": "",
|
|
"message_id": "msg_real",
|
|
},
|
|
)
|
|
out = OutboundMessage(
|
|
channel_name="dingtalk",
|
|
chat_id="x",
|
|
thread_id="t",
|
|
text="ok",
|
|
thread_ts="wrong_ts",
|
|
metadata=dict(inbound.metadata),
|
|
)
|
|
assert channel._make_card_source_key(inbound) == channel._make_card_source_key_from_outbound(out)
|
|
|
|
_run(go())
|
|
|
|
def test_running_reply_creates_card(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
|
|
channel._client_id = "test_key"
|
|
|
|
channel._create_and_deliver_card = AsyncMock(return_value="track_001")
|
|
|
|
inbound = channel._make_inbound(
|
|
chat_id="user_001",
|
|
user_id="user_001",
|
|
text="hello",
|
|
metadata={
|
|
"conversation_type": _CONVERSATION_TYPE_P2P,
|
|
"sender_staff_id": "user_001",
|
|
"conversation_id": "",
|
|
"message_id": "msg_001",
|
|
},
|
|
)
|
|
|
|
mock_chatbot_msg = MagicMock()
|
|
source_key = channel._make_card_source_key(inbound)
|
|
channel._incoming_messages[source_key] = mock_chatbot_msg
|
|
|
|
await channel._send_running_reply("user_001", inbound)
|
|
|
|
channel._create_and_deliver_card.assert_awaited_once_with(
|
|
"\u23f3 Working on it...",
|
|
chatbot_message=mock_chatbot_msg,
|
|
)
|
|
assert channel._card_track_ids[source_key] == "track_001"
|
|
|
|
_run(go())
|
|
|
|
def test_send_streams_to_card(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
|
|
channel._client_id = "test_key"
|
|
|
|
channel._stream_update_card = AsyncMock()
|
|
|
|
# Pre-populate card tracking
|
|
source_key = f"{_CONVERSATION_TYPE_P2P}:user_001::msg_001"
|
|
channel._card_track_ids[source_key] = "track_001"
|
|
|
|
msg = OutboundMessage(
|
|
channel_name="dingtalk",
|
|
chat_id="user_001",
|
|
thread_id="thread_001",
|
|
text="Partial response...",
|
|
is_final=False,
|
|
thread_ts="msg_001",
|
|
metadata={
|
|
"conversation_type": _CONVERSATION_TYPE_P2P,
|
|
"sender_staff_id": "user_001",
|
|
"conversation_id": "",
|
|
},
|
|
)
|
|
|
|
await channel.send(msg)
|
|
|
|
channel._stream_update_card.assert_awaited_once_with(
|
|
"track_001",
|
|
"Partial response...",
|
|
is_finalize=False,
|
|
)
|
|
# Track ID should still exist (not final)
|
|
assert source_key in channel._card_track_ids
|
|
|
|
_run(go())
|
|
|
|
def test_send_finalizes_card(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
|
|
channel._client_id = "test_key"
|
|
|
|
channel._stream_update_card = AsyncMock()
|
|
|
|
source_key = f"{_CONVERSATION_TYPE_P2P}:user_001::msg_001"
|
|
channel._card_track_ids[source_key] = "track_001"
|
|
|
|
msg = OutboundMessage(
|
|
channel_name="dingtalk",
|
|
chat_id="user_001",
|
|
thread_id="thread_001",
|
|
text="Final answer.",
|
|
is_final=True,
|
|
thread_ts="msg_001",
|
|
metadata={
|
|
"conversation_type": _CONVERSATION_TYPE_P2P,
|
|
"sender_staff_id": "user_001",
|
|
"conversation_id": "",
|
|
},
|
|
)
|
|
|
|
await channel.send(msg)
|
|
|
|
channel._stream_update_card.assert_awaited_once_with(
|
|
"track_001",
|
|
"Final answer.",
|
|
is_finalize=True,
|
|
)
|
|
# Track ID should be cleaned up after final
|
|
assert source_key not in channel._card_track_ids
|
|
|
|
_run(go())
|
|
|
|
def test_card_mode_skips_markdown_adaptation(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
|
|
channel._client_id = "test_key"
|
|
|
|
raw_markdown = "```python\ndef foo():\n pass\n```"
|
|
captured_content: list[str] = []
|
|
|
|
async def capture_stream(out_track_id, content, *, is_finalize=False, is_error=False):
|
|
captured_content.append(content)
|
|
|
|
channel._stream_update_card = AsyncMock(side_effect=capture_stream)
|
|
|
|
source_key = f"{_CONVERSATION_TYPE_P2P}:user_001::msg_001"
|
|
channel._card_track_ids[source_key] = "track_001"
|
|
|
|
msg = OutboundMessage(
|
|
channel_name="dingtalk",
|
|
chat_id="user_001",
|
|
thread_id="thread_001",
|
|
text=raw_markdown,
|
|
is_final=True,
|
|
thread_ts="msg_001",
|
|
metadata={
|
|
"conversation_type": _CONVERSATION_TYPE_P2P,
|
|
"sender_staff_id": "user_001",
|
|
"conversation_id": "",
|
|
},
|
|
)
|
|
|
|
await channel.send(msg)
|
|
|
|
# Raw markdown should be passed through without adaptation
|
|
assert captured_content[0] == raw_markdown
|
|
|
|
_run(go())
|
|
|
|
def test_card_fallback_on_creation_failure(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
|
|
channel._client_id = "test_key"
|
|
|
|
# Card creation returns None (failure)
|
|
channel._create_and_deliver_card = AsyncMock(return_value=None)
|
|
channel._send_text_message_to_user = AsyncMock()
|
|
|
|
inbound = channel._make_inbound(
|
|
chat_id="user_001",
|
|
user_id="user_001",
|
|
text="hello",
|
|
metadata={
|
|
"conversation_type": _CONVERSATION_TYPE_P2P,
|
|
"sender_staff_id": "user_001",
|
|
"conversation_id": "",
|
|
"message_id": "msg_001",
|
|
},
|
|
)
|
|
|
|
source_key = channel._make_card_source_key(inbound)
|
|
channel._incoming_messages[source_key] = MagicMock()
|
|
|
|
await channel._send_running_reply("user_001", inbound)
|
|
|
|
# Should fall through to text message
|
|
channel._send_text_message_to_user.assert_awaited_once()
|
|
assert len(channel._card_track_ids) == 0
|
|
|
|
_run(go())
|
|
|
|
def test_send_skips_non_final_without_card_track_when_template_configured(self):
|
|
"""Without a live card track, Manager streaming would duplicate sampleMarkdown sends."""
|
|
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
|
|
channel._client_id = "test_key"
|
|
channel._send_group_message = AsyncMock()
|
|
channel._send_p2p_message = AsyncMock()
|
|
|
|
meta = {
|
|
"conversation_type": _CONVERSATION_TYPE_P2P,
|
|
"sender_staff_id": "user_001",
|
|
"conversation_id": "",
|
|
}
|
|
await channel.send(
|
|
OutboundMessage(
|
|
channel_name="dingtalk",
|
|
chat_id="user_001",
|
|
thread_id="t1",
|
|
text="partial",
|
|
is_final=False,
|
|
thread_ts="msg_001",
|
|
metadata=meta,
|
|
)
|
|
)
|
|
channel._send_p2p_message.assert_not_called()
|
|
channel._send_group_message.assert_not_called()
|
|
|
|
await channel.send(
|
|
OutboundMessage(
|
|
channel_name="dingtalk",
|
|
chat_id="user_001",
|
|
thread_id="t1",
|
|
text="final answer",
|
|
is_final=True,
|
|
thread_ts="msg_001",
|
|
metadata=meta,
|
|
)
|
|
)
|
|
channel._send_p2p_message.assert_awaited_once()
|
|
|
|
_run(go())
|
|
|
|
def test_card_fallback_on_stream_failure(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
|
|
channel._client_id = "test_key"
|
|
|
|
channel._stream_update_card = AsyncMock(side_effect=ConnectionError("stream failed"))
|
|
channel._send_markdown_fallback = AsyncMock()
|
|
|
|
source_key = f"{_CONVERSATION_TYPE_P2P}:user_001::msg_001"
|
|
channel._card_track_ids[source_key] = "track_001"
|
|
|
|
msg = OutboundMessage(
|
|
channel_name="dingtalk",
|
|
chat_id="user_001",
|
|
thread_id="thread_001",
|
|
text="Final answer.",
|
|
is_final=True,
|
|
thread_ts="msg_001",
|
|
metadata={
|
|
"conversation_type": _CONVERSATION_TYPE_P2P,
|
|
"sender_staff_id": "user_001",
|
|
"conversation_id": "",
|
|
},
|
|
)
|
|
|
|
await channel.send(msg)
|
|
|
|
# Should fallback to markdown
|
|
channel._send_markdown_fallback.assert_awaited_once_with(
|
|
"test_key",
|
|
_CONVERSATION_TYPE_P2P,
|
|
"user_001",
|
|
"",
|
|
"Final answer.",
|
|
)
|
|
# Track ID should be cleaned up
|
|
assert source_key not in channel._card_track_ids
|
|
|
|
_run(go())
|
|
|
|
def test_pre_start_stores_dingtalk_client(self):
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
handler = _DingTalkMessageHandler(channel)
|
|
|
|
mock_client = MagicMock()
|
|
handler.dingtalk_client = mock_client
|
|
handler.pre_start()
|
|
|
|
assert channel._dingtalk_client is mock_client
|
|
|
|
def test_chatbot_message_stored_for_card_mode(self):
|
|
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 = set()
|
|
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_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 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())
|
|
|
|
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)
|
|
|
|
|
|
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())
|
|
|
|
|
|
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)
|