mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-18 13:46:02 +00:00
68ba4198b8
* fix(channels): make channel connect flow deterministic * make format * fix(channels): apply connect-code before allowed_users on telegram and wechat The bind-bootstrap reorder shipped for slack/dingtalk only. Telegram and WeChat still gate _check_user/allowed_users before connect-code dispatch, so a newly allowlisted-but-unbound user is silently rejected when binding via the browser deep-link / connect-code flow — the same deadlock the PR fixes. - telegram: consume the /start deep-link token before the allowed_users gate. - wechat: handle the /connect code before the allowed_users gate, and defer inbound file extraction + context-token tracking past the gate so blocked senders no longer trigger CDN downloads or token bookkeeping. Adds regression tests for both adapters mirroring the slack/dingtalk coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(channels): enforce single-active-owner invariant at the DB layer _revoke_other_active_owners did a SELECT-then-UPDATE in app code with no row lock or constraint covering active rows. Under READ COMMITTED, two concurrent connect-code consumes for the same (provider, external_account_id, workspace_id) from different owners could each observe "no other active owner" and both commit a connected row, leaving find_connection_by_external_identity nondeterministic. - Add a partial unique index on (provider, external_account_id, workspace_id) WHERE status != 'revoked' (portable to SQLite >= 3.8.0 and PostgreSQL) so the database guarantees at most one non-revoked row per external identity. - Reorder upsert_connection to revoke other owners' active rows before the new connected row is flushed (so the index is satisfied at commit), wrapped in a bounded rollback-and-retry loop. A losing concurrent writer now retries against the now-visible state instead of committing a duplicate. Adds DB-constraint, revoked-slot-reuse, and concurrent-upsert regression tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(channels): harden connect-status polling primitive pollChannelConnectionUntilResolved was a free-floating recursive setTimeout started from onSuccess with no cancellation, no per-provider dedup, a redundant second endpoint per tick, and an unbounded loop on a non-finite expires_in. - Extract a framework-agnostic, cancellable poller (connect-poll.ts) that polls only listChannelConnections() and invalidates the providers query once when the bind resolves, instead of fetching both endpoints every tick. - Guard expires_in with a finite check + default window so undefined/NaN can no longer produce a poll loop that runs until the page closes. - Track one active poll handle per provider in useConnectChannelProvider via a ref Map: a new connect cancels the prior poll for that provider, and a useEffect cleanup cancels all polls on unmount. Adds unit tests for resolve-and-stop, cancellation, and non-finite-expiry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(channels): stop leaking blocked-sender content in DingTalk INFO log; document bind semantics Moving the allowed_users gate past _extract_text meant the parsed-message INFO log (text=%r, first 100 chars) fired for senders that allowed_users would have rejected, defeating the filter's noise/privacy role. Move that log to after the allowed_users gate so blocked senders' message text never reaches INFO logs. Also document the two operator-relevant semantic changes in backend/CLAUDE.md: connect-code dispatch runs before allowed_users (so allowed_users is no longer a bind-time defense; the model relies on code confidentiality + 600s TTL + one-time consumption), and the single-active-owner-per-external-identity transfer semantics now backed by the partial unique index. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(channels): note connect-code-vs-allowlist and ownership transfer in operator guide Mirror the backend/CLAUDE.md notes in the operator-facing IM_CHANNEL_CONNECTIONS.md: connect codes are consumed before allowed_users (so a not-yet-allowlisted user can still complete a first bind, and allowed_users is not a bind-time defense), and an external identity has at most one active owner with last-bind-wins transfer enforced at the DB layer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(channels): lift connect-code dispatch into Channel base class Each adapter duplicated the ordering-sensitive boilerplate of extracting a /connect code and guarding on the connection repo before its allowed_users gate. The duplication is what let telegram/wechat drift and keep the gate ahead of the bind. Centralize it: - Move `_connection_repo` onto Channel.__init__ (removing 7 duplicate assignments). - Add Channel._pending_connect_code(text), which guards on the repo and extracts the code, documenting that adapters MUST consult it before authorization so a browser-initiated bind can bootstrap a not-yet-authorized identity. - Route slack, discord, feishu, dingtalk, wechat, and wecom through the helper. This also fixes a latent inconsistency where slack dispatched a bind even when no connection repo was configured. Pure refactor — the full channel suite stays green; adds a direct unit test for the base helper's contract. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * make format * fix(channels): redact DingTalk parsed-message INFO log content Log text_len instead of the first 100 chars of message text, so message content never reaches INFO logs (the after-gate move already keeps blocked senders out entirely). This takes over the redaction from #3584 so only this PR touches dingtalk.py, letting the two PRs merge in any order conflict-free. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1598 lines
54 KiB
Python
1598 lines
54 KiB
Python
"""Tests for the DingTalk channel implementation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from app.channels.commands import KNOWN_CHANNEL_COMMANDS
|
|
from app.channels.dingtalk import (
|
|
_CONVERSATION_TYPE_GROUP,
|
|
_CONVERSATION_TYPE_P2P,
|
|
DingTalkChannel,
|
|
_adapt_markdown_for_dingtalk,
|
|
_convert_markdown_table,
|
|
_DingTalkMessageHandler,
|
|
_extract_text_from_rich_text,
|
|
_is_dingtalk_command,
|
|
_normalize_allowed_users,
|
|
_normalize_conversation_type,
|
|
)
|
|
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage
|
|
|
|
|
|
def _run(coro):
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
return loop.run_until_complete(coro)
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper: build mock ChatbotMessage
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_chatbot_message(
|
|
*,
|
|
text: str = "hello",
|
|
message_type: str = "text",
|
|
conversation_type: str | int = _CONVERSATION_TYPE_P2P,
|
|
sender_staff_id: str = "user_001",
|
|
sender_nick: str = "Test User",
|
|
conversation_id: str = "conv_001",
|
|
message_id: str = "msg_001",
|
|
rich_text_list: list | None = None,
|
|
):
|
|
"""Build a minimal mock object mimicking dingtalk_stream.ChatbotMessage."""
|
|
msg = SimpleNamespace()
|
|
msg.message_type = message_type
|
|
msg.conversation_type = conversation_type
|
|
msg.sender_staff_id = sender_staff_id
|
|
msg.sender_nick = sender_nick
|
|
msg.conversation_id = conversation_id
|
|
msg.message_id = message_id
|
|
|
|
if message_type == "text":
|
|
msg.text = SimpleNamespace(content=text)
|
|
msg.rich_text_content = None
|
|
elif message_type == "richText":
|
|
msg.text = None
|
|
msg.rich_text_content = SimpleNamespace(rich_text_list=rich_text_list or [])
|
|
else:
|
|
msg.text = None
|
|
msg.rich_text_content = None
|
|
|
|
return msg
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _DingTalkMessageHandler SDK contract
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDingTalkMessageHandlerSdkContract:
|
|
def test_pre_start_exists_and_noop(self):
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
handler = _DingTalkMessageHandler(channel)
|
|
handler.pre_start()
|
|
|
|
def test_raw_process_returns_ack(self):
|
|
pytest.importorskip("dingtalk_stream")
|
|
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._on_chatbot_message = MagicMock()
|
|
handler = _DingTalkMessageHandler(channel)
|
|
cb = MagicMock()
|
|
cb.headers.message_id = "mid-1"
|
|
cb.data = {
|
|
"msgtype": "text",
|
|
"text": {"content": "hi"},
|
|
"senderStaffId": "u1",
|
|
"conversationType": "1",
|
|
"msgId": "m1",
|
|
}
|
|
ack = await handler.raw_process(cb)
|
|
assert ack.code == 200
|
|
assert ack.headers.message_id == "mid-1"
|
|
assert ack.data == {"response": "OK"}
|
|
channel._on_chatbot_message.assert_called_once()
|
|
|
|
_run(go())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _normalize_allowed_users tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNormalizeAllowedUsers:
|
|
def test_none_returns_empty(self):
|
|
assert _normalize_allowed_users(None) == set()
|
|
|
|
def test_empty_list_returns_empty(self):
|
|
assert _normalize_allowed_users([]) == set()
|
|
|
|
def test_list_of_strings(self):
|
|
result = _normalize_allowed_users(["user1", "user2"])
|
|
assert result == {"user1", "user2"}
|
|
|
|
def test_single_string(self):
|
|
result = _normalize_allowed_users("user1")
|
|
assert result == {"user1"}
|
|
|
|
def test_numeric_values_converted_to_string(self):
|
|
result = _normalize_allowed_users([123, 456])
|
|
assert result == {"123", "456"}
|
|
|
|
def test_scalar_treated_as_single_value(self):
|
|
result = _normalize_allowed_users(12345)
|
|
assert result == {"12345"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _normalize_conversation_type tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNormalizeConversationType:
|
|
def test_group_int_or_str(self):
|
|
assert _normalize_conversation_type(2) == _CONVERSATION_TYPE_GROUP
|
|
assert _normalize_conversation_type("2") == _CONVERSATION_TYPE_GROUP
|
|
|
|
def test_p2p_or_none(self):
|
|
assert _normalize_conversation_type(1) == _CONVERSATION_TYPE_P2P
|
|
assert _normalize_conversation_type(None) == _CONVERSATION_TYPE_P2P
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _is_dingtalk_command tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIsDingTalkCommand:
|
|
@pytest.mark.parametrize("command", sorted(KNOWN_CHANNEL_COMMANDS))
|
|
def test_known_commands_recognized(self, command):
|
|
assert _is_dingtalk_command(command) is True
|
|
|
|
@pytest.mark.parametrize(
|
|
"text",
|
|
[
|
|
"/unknown",
|
|
"/mnt/user-data/outputs/report.md",
|
|
"hello",
|
|
"",
|
|
"not a command",
|
|
],
|
|
)
|
|
def test_non_commands_rejected(self, text):
|
|
assert _is_dingtalk_command(text) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _extract_text_from_rich_text tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestExtractTextFromRichText:
|
|
def test_single_text_item(self):
|
|
result = _extract_text_from_rich_text([{"text": "hello"}])
|
|
assert result == "hello"
|
|
|
|
def test_multiple_text_items(self):
|
|
result = _extract_text_from_rich_text([{"text": "hello"}, {"text": "world"}])
|
|
assert result == "hello world"
|
|
|
|
def test_non_text_items_ignored(self):
|
|
result = _extract_text_from_rich_text(
|
|
[
|
|
{"downloadCode": "abc123"},
|
|
{"text": "caption"},
|
|
]
|
|
)
|
|
assert result == "caption"
|
|
|
|
def test_empty_list(self):
|
|
assert _extract_text_from_rich_text([]) == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DingTalkChannel._extract_text tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestExtractText:
|
|
def test_plain_text(self):
|
|
msg = _make_chatbot_message(text="Hello World")
|
|
assert DingTalkChannel._extract_text(msg) == "Hello World"
|
|
|
|
def test_plain_text_stripped(self):
|
|
msg = _make_chatbot_message(text=" Hello ")
|
|
assert DingTalkChannel._extract_text(msg) == "Hello"
|
|
|
|
def test_rich_text(self):
|
|
msg = _make_chatbot_message(
|
|
message_type="richText",
|
|
rich_text_list=[{"text": "Part 1"}, {"text": "Part 2"}],
|
|
)
|
|
assert DingTalkChannel._extract_text(msg) == "Part 1 Part 2"
|
|
|
|
def test_unknown_type_returns_empty(self):
|
|
msg = _make_chatbot_message(message_type="picture")
|
|
assert DingTalkChannel._extract_text(msg) == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DingTalkChannel._on_chatbot_message tests (inbound parsing)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestOnChatbotMessage:
|
|
def test_p2p_message_produces_correct_inbound(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
bus.publish_inbound = AsyncMock()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "test_key"
|
|
channel._main_loop = asyncio.get_event_loop()
|
|
channel._running = True
|
|
|
|
msg = _make_chatbot_message(
|
|
text="hello from dingtalk",
|
|
conversation_type=_CONVERSATION_TYPE_P2P,
|
|
sender_staff_id="user_001",
|
|
message_id="msg_001",
|
|
)
|
|
|
|
channel._send_running_reply = AsyncMock()
|
|
channel._on_chatbot_message(msg)
|
|
|
|
await asyncio.sleep(0.1)
|
|
|
|
bus.publish_inbound.assert_awaited_once()
|
|
inbound = bus.publish_inbound.await_args.args[0]
|
|
assert inbound.channel_name == "dingtalk"
|
|
assert inbound.chat_id == "user_001"
|
|
assert inbound.user_id == "user_001"
|
|
assert inbound.text == "hello from dingtalk"
|
|
assert inbound.topic_id is None
|
|
assert inbound.metadata["conversation_type"] == _CONVERSATION_TYPE_P2P
|
|
assert inbound.metadata["sender_staff_id"] == "user_001"
|
|
|
|
_run(go())
|
|
|
|
def test_group_message_produces_correct_inbound(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
bus.publish_inbound = AsyncMock()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "test_key"
|
|
channel._main_loop = asyncio.get_event_loop()
|
|
channel._running = True
|
|
|
|
msg = _make_chatbot_message(
|
|
text="hello group",
|
|
conversation_type=_CONVERSATION_TYPE_GROUP,
|
|
sender_staff_id="user_002",
|
|
conversation_id="conv_group_001",
|
|
message_id="msg_group_001",
|
|
)
|
|
|
|
channel._send_running_reply = AsyncMock()
|
|
channel._on_chatbot_message(msg)
|
|
|
|
await asyncio.sleep(0.1)
|
|
|
|
bus.publish_inbound.assert_awaited_once()
|
|
inbound = bus.publish_inbound.await_args.args[0]
|
|
assert inbound.channel_name == "dingtalk"
|
|
assert inbound.chat_id == "conv_group_001"
|
|
assert inbound.user_id == "user_002"
|
|
assert inbound.text == "hello group"
|
|
assert inbound.topic_id == "msg_group_001"
|
|
assert inbound.metadata["conversation_type"] == _CONVERSATION_TYPE_GROUP
|
|
assert inbound.metadata["conversation_id"] == "conv_group_001"
|
|
|
|
_run(go())
|
|
|
|
def test_group_message_integer_conversation_type_normalized(self):
|
|
"""SDK may deliver conversationType as int 2 — must still route as group."""
|
|
|
|
async def go():
|
|
bus = MessageBus()
|
|
bus.publish_inbound = AsyncMock()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "test_key"
|
|
channel._main_loop = asyncio.get_event_loop()
|
|
channel._running = True
|
|
|
|
msg = _make_chatbot_message(
|
|
text="hello group",
|
|
conversation_type=2,
|
|
sender_staff_id="user_002",
|
|
conversation_id="conv_group_001",
|
|
message_id="msg_group_002",
|
|
)
|
|
|
|
channel._send_running_reply = AsyncMock()
|
|
channel._on_chatbot_message(msg)
|
|
|
|
await asyncio.sleep(0.1)
|
|
|
|
bus.publish_inbound.assert_awaited_once()
|
|
inbound = bus.publish_inbound.await_args.args[0]
|
|
assert inbound.chat_id == "conv_group_001"
|
|
assert inbound.topic_id == "msg_group_002"
|
|
assert inbound.metadata["conversation_type"] == _CONVERSATION_TYPE_GROUP
|
|
|
|
_run(go())
|
|
|
|
def test_command_classified_correctly(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
bus.publish_inbound = AsyncMock()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "test_key"
|
|
channel._main_loop = asyncio.get_event_loop()
|
|
channel._running = True
|
|
|
|
msg = _make_chatbot_message(text="/help")
|
|
channel._send_running_reply = AsyncMock()
|
|
channel._on_chatbot_message(msg)
|
|
|
|
await asyncio.sleep(0.1)
|
|
|
|
bus.publish_inbound.assert_awaited_once()
|
|
inbound = bus.publish_inbound.await_args.args[0]
|
|
assert inbound.msg_type == InboundMessageType.COMMAND
|
|
|
|
_run(go())
|
|
|
|
def test_non_command_classified_as_chat(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
bus.publish_inbound = AsyncMock()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "test_key"
|
|
channel._main_loop = asyncio.get_event_loop()
|
|
channel._running = True
|
|
|
|
msg = _make_chatbot_message(text="just chatting")
|
|
channel._send_running_reply = AsyncMock()
|
|
channel._on_chatbot_message(msg)
|
|
|
|
await asyncio.sleep(0.1)
|
|
|
|
bus.publish_inbound.assert_awaited_once()
|
|
inbound = bus.publish_inbound.await_args.args[0]
|
|
assert inbound.msg_type == InboundMessageType.CHAT
|
|
|
|
_run(go())
|
|
|
|
def test_empty_text_ignored(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
bus.publish_inbound = AsyncMock()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "test_key"
|
|
channel._main_loop = asyncio.get_event_loop()
|
|
channel._running = True
|
|
|
|
msg = _make_chatbot_message(text=" ")
|
|
channel._on_chatbot_message(msg)
|
|
|
|
await asyncio.sleep(0.1)
|
|
bus.publish_inbound.assert_not_awaited()
|
|
|
|
_run(go())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# allowed_users filtering tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAllowedUsersFiltering:
|
|
def test_allowed_user_passes(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
bus.publish_inbound = AsyncMock()
|
|
channel = DingTalkChannel(bus, config={"allowed_users": ["user_001"]})
|
|
channel._client_id = "test_key"
|
|
channel._main_loop = asyncio.get_event_loop()
|
|
channel._running = True
|
|
|
|
msg = _make_chatbot_message(sender_staff_id="user_001")
|
|
channel._send_running_reply = AsyncMock()
|
|
channel._on_chatbot_message(msg)
|
|
|
|
await asyncio.sleep(0.1)
|
|
bus.publish_inbound.assert_awaited_once()
|
|
|
|
_run(go())
|
|
|
|
def test_non_allowed_user_blocked(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
bus.publish_inbound = AsyncMock()
|
|
channel = DingTalkChannel(bus, config={"allowed_users": ["user_001"]})
|
|
channel._client_id = "test_key"
|
|
channel._main_loop = asyncio.get_event_loop()
|
|
channel._running = True
|
|
|
|
msg = _make_chatbot_message(sender_staff_id="user_blocked")
|
|
channel._on_chatbot_message(msg)
|
|
|
|
await asyncio.sleep(0.1)
|
|
bus.publish_inbound.assert_not_awaited()
|
|
|
|
_run(go())
|
|
|
|
def test_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()
|
|
# 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()
|
|
|
|
_run(go())
|
|
|
|
def test_empty_allowed_users_allows_all(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
bus.publish_inbound = AsyncMock()
|
|
channel = DingTalkChannel(bus, config={"allowed_users": []})
|
|
channel._client_id = "test_key"
|
|
channel._main_loop = asyncio.get_event_loop()
|
|
channel._running = True
|
|
|
|
msg = _make_chatbot_message(sender_staff_id="anyone")
|
|
channel._send_running_reply = AsyncMock()
|
|
channel._on_chatbot_message(msg)
|
|
|
|
await asyncio.sleep(0.1)
|
|
bus.publish_inbound.assert_awaited_once()
|
|
|
|
_run(go())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# send routing tests (P2P vs Group)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMarkdownFallbackPropagation:
|
|
def test_fallback_raises_on_failure(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "test_key"
|
|
channel._cached_token = "tok"
|
|
channel._token_expires_at = float("inf")
|
|
|
|
channel._send_p2p_message = AsyncMock(side_effect=ConnectionError("send failed"))
|
|
|
|
with pytest.raises(ConnectionError, match="send failed"):
|
|
await channel._send_markdown_fallback("test_key", _CONVERSATION_TYPE_P2P, "user_001", "", "hello")
|
|
|
|
_run(go())
|
|
|
|
|
|
class TestSendRouting:
|
|
def test_p2p_send_uses_oto_endpoint(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "test_key"
|
|
channel._client_secret = "test_secret"
|
|
|
|
channel._send_p2p_message = AsyncMock()
|
|
channel._send_group_message = AsyncMock()
|
|
|
|
msg = OutboundMessage(
|
|
channel_name="dingtalk",
|
|
chat_id="user_001",
|
|
thread_id="thread_001",
|
|
text="Hello P2P",
|
|
metadata={
|
|
"conversation_type": _CONVERSATION_TYPE_P2P,
|
|
"sender_staff_id": "user_001",
|
|
"conversation_id": "",
|
|
},
|
|
)
|
|
|
|
await channel.send(msg)
|
|
|
|
channel._send_p2p_message.assert_awaited_once_with("test_key", "user_001", "Hello P2P")
|
|
channel._send_group_message.assert_not_awaited()
|
|
|
|
_run(go())
|
|
|
|
def test_group_send_uses_group_endpoint(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "test_key"
|
|
channel._client_secret = "test_secret"
|
|
|
|
channel._send_p2p_message = AsyncMock()
|
|
channel._send_group_message = AsyncMock()
|
|
|
|
msg = OutboundMessage(
|
|
channel_name="dingtalk",
|
|
chat_id="conv_001",
|
|
thread_id="thread_001",
|
|
text="Hello Group",
|
|
metadata={
|
|
"conversation_type": _CONVERSATION_TYPE_GROUP,
|
|
"sender_staff_id": "user_001",
|
|
"conversation_id": "conv_001",
|
|
},
|
|
)
|
|
|
|
await channel.send(msg)
|
|
|
|
channel._send_group_message.assert_awaited_once_with("test_key", "conv_001", "Hello Group", at_user_ids=["user_001"])
|
|
channel._send_p2p_message.assert_not_awaited()
|
|
|
|
_run(go())
|
|
|
|
def test_default_metadata_uses_p2p(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "test_key"
|
|
channel._client_secret = "test_secret"
|
|
|
|
channel._send_p2p_message = AsyncMock()
|
|
channel._send_group_message = AsyncMock()
|
|
|
|
msg = OutboundMessage(
|
|
channel_name="dingtalk",
|
|
chat_id="user_001",
|
|
thread_id="thread_001",
|
|
text="Hello",
|
|
metadata={},
|
|
)
|
|
|
|
await channel.send(msg)
|
|
|
|
channel._send_p2p_message.assert_awaited_once()
|
|
channel._send_group_message.assert_not_awaited()
|
|
|
|
_run(go())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# send retry tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSendRetry:
|
|
def test_retries_on_failure_then_succeeds(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "test_key"
|
|
channel._client_secret = "test_secret"
|
|
|
|
call_count = 0
|
|
|
|
async def flaky_send(robot_code, user_id, text):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count < 3:
|
|
raise ConnectionError("network error")
|
|
|
|
channel._send_p2p_message = AsyncMock(side_effect=flaky_send)
|
|
|
|
msg = OutboundMessage(
|
|
channel_name="dingtalk",
|
|
chat_id="user_001",
|
|
thread_id="thread_001",
|
|
text="hello",
|
|
metadata={"conversation_type": _CONVERSATION_TYPE_P2P, "sender_staff_id": "user_001"},
|
|
)
|
|
|
|
await channel.send(msg)
|
|
assert call_count == 3
|
|
|
|
_run(go())
|
|
|
|
def test_raises_after_all_retries_exhausted(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "test_key"
|
|
channel._client_secret = "test_secret"
|
|
|
|
channel._send_p2p_message = AsyncMock(side_effect=ConnectionError("fail"))
|
|
|
|
msg = OutboundMessage(
|
|
channel_name="dingtalk",
|
|
chat_id="user_001",
|
|
thread_id="thread_001",
|
|
text="hello",
|
|
metadata={"conversation_type": _CONVERSATION_TYPE_P2P, "sender_staff_id": "user_001"},
|
|
)
|
|
|
|
with pytest.raises(ConnectionError):
|
|
await channel.send(msg)
|
|
|
|
assert channel._send_p2p_message.await_count == 3
|
|
|
|
_run(go())
|
|
|
|
def test_raises_runtime_error_when_no_attempts_configured(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "test_key"
|
|
channel._client_secret = "test_secret"
|
|
|
|
msg = OutboundMessage(
|
|
channel_name="dingtalk",
|
|
chat_id="user_001",
|
|
thread_id="thread_001",
|
|
text="hello",
|
|
metadata={"conversation_type": _CONVERSATION_TYPE_P2P, "sender_staff_id": "user_001"},
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="without an exception"):
|
|
await channel.send(msg, _max_retries=0)
|
|
|
|
_run(go())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# topic_id mapping tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTopicIdMapping:
|
|
def test_p2p_topic_is_none(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
bus.publish_inbound = AsyncMock()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "test_key"
|
|
channel._main_loop = asyncio.get_event_loop()
|
|
channel._running = True
|
|
|
|
msg = _make_chatbot_message(
|
|
conversation_type=_CONVERSATION_TYPE_P2P,
|
|
message_id="msg_p2p_001",
|
|
)
|
|
channel._send_running_reply = AsyncMock()
|
|
channel._on_chatbot_message(msg)
|
|
|
|
await asyncio.sleep(0.1)
|
|
inbound = bus.publish_inbound.await_args.args[0]
|
|
assert inbound.topic_id is None
|
|
|
|
_run(go())
|
|
|
|
def test_group_topic_is_message_id(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
bus.publish_inbound = AsyncMock()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "test_key"
|
|
channel._main_loop = asyncio.get_event_loop()
|
|
channel._running = True
|
|
|
|
msg = _make_chatbot_message(
|
|
conversation_type=_CONVERSATION_TYPE_GROUP,
|
|
message_id="msg_group_001",
|
|
conversation_id="conv_001",
|
|
)
|
|
channel._send_running_reply = AsyncMock()
|
|
channel._on_chatbot_message(msg)
|
|
|
|
await asyncio.sleep(0.1)
|
|
inbound = bus.publish_inbound.await_args.args[0]
|
|
assert inbound.topic_id == "msg_group_001"
|
|
|
|
_run(go())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Token caching tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAccessTokenValidation:
|
|
def test_rejects_non_dict_response(self):
|
|
async def go():
|
|
from unittest.mock import patch
|
|
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "k"
|
|
channel._client_secret = "s"
|
|
|
|
class FakeResponse:
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
return "not a dict"
|
|
|
|
class FakeClient:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *a):
|
|
pass
|
|
|
|
async def post(self, url, **kwargs):
|
|
return FakeResponse()
|
|
|
|
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
|
|
with pytest.raises(ValueError, match="JSON object"):
|
|
await channel._get_access_token()
|
|
|
|
_run(go())
|
|
|
|
def test_rejects_empty_access_token(self):
|
|
async def go():
|
|
from unittest.mock import patch
|
|
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "k"
|
|
channel._client_secret = "s"
|
|
|
|
class FakeResponse:
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
return {"accessToken": "", "expireIn": 7200}
|
|
|
|
class FakeClient:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *a):
|
|
pass
|
|
|
|
async def post(self, url, **kwargs):
|
|
return FakeResponse()
|
|
|
|
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
|
|
with pytest.raises(ValueError, match="usable accessToken"):
|
|
await channel._get_access_token()
|
|
|
|
_run(go())
|
|
|
|
def test_invalid_expire_in_uses_default(self):
|
|
async def go():
|
|
import time
|
|
from unittest.mock import patch
|
|
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "k"
|
|
channel._client_secret = "s"
|
|
|
|
class FakeResponse:
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
return {"accessToken": "tok_ok", "expireIn": "invalid"}
|
|
|
|
class FakeClient:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *a):
|
|
pass
|
|
|
|
async def post(self, url, **kwargs):
|
|
return FakeResponse()
|
|
|
|
before = time.monotonic()
|
|
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
|
|
token = await channel._get_access_token()
|
|
|
|
assert token == "tok_ok"
|
|
assert channel._token_expires_at > before
|
|
|
|
_run(go())
|
|
|
|
|
|
class TestTokenCaching:
|
|
def test_token_is_cached_across_calls(self):
|
|
async def go():
|
|
from unittest.mock import patch
|
|
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "test_key"
|
|
channel._client_secret = "test_secret"
|
|
|
|
call_count = 0
|
|
|
|
class FakeResponse:
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
return {"accessToken": "tok_abc", "expireIn": 7200}
|
|
|
|
class FakeClient:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *a):
|
|
pass
|
|
|
|
async def post(self, url, **kwargs):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return FakeResponse()
|
|
|
|
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
|
|
t1 = await channel._get_access_token()
|
|
t2 = await channel._get_access_token()
|
|
|
|
assert t1 == "tok_abc"
|
|
assert t2 == "tok_abc"
|
|
assert call_count == 1
|
|
|
|
_run(go())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Group message @ mention format tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGroupMessageMarkdownFormat:
|
|
def test_at_user_ids_still_use_markdown(self):
|
|
"""groupMessages/send uses sampleMarkdown; @{userId} in body returns 400 so at_user_ids is ignored."""
|
|
|
|
async def go():
|
|
from unittest.mock import patch
|
|
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "test_key"
|
|
channel._client_secret = "test_secret"
|
|
channel._cached_token = "tok_test"
|
|
channel._token_expires_at = float("inf")
|
|
|
|
captured_json: list[dict] = []
|
|
|
|
class FakeResponse:
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
return {"processQueryKey": "ok"}
|
|
|
|
class FakeClient:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *a):
|
|
pass
|
|
|
|
async def post(self, url, **kwargs):
|
|
captured_json.append(kwargs.get("json", {}))
|
|
return FakeResponse()
|
|
|
|
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
|
|
await channel._send_group_message("bot", "conv1", "hello", at_user_ids=["staff_001"])
|
|
|
|
assert len(captured_json) == 1
|
|
payload = captured_json[0]
|
|
assert payload["msgKey"] == "sampleMarkdown"
|
|
import json
|
|
|
|
param = json.loads(payload["msgParam"])
|
|
assert param["text"] == "hello"
|
|
assert "@" not in json.dumps(param)
|
|
|
|
_run(go())
|
|
|
|
def test_no_at_user_ids_uses_markdown(self):
|
|
async def go():
|
|
from unittest.mock import patch
|
|
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "test_key"
|
|
channel._client_secret = "test_secret"
|
|
channel._cached_token = "tok_test"
|
|
channel._token_expires_at = float("inf")
|
|
|
|
captured_json: list[dict] = []
|
|
|
|
class FakeResponse:
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
return {"processQueryKey": "ok"}
|
|
|
|
class FakeClient:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *a):
|
|
pass
|
|
|
|
async def post(self, url, **kwargs):
|
|
captured_json.append(kwargs.get("json", {}))
|
|
return FakeResponse()
|
|
|
|
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
|
|
await channel._send_group_message("bot", "conv1", "hello")
|
|
|
|
assert len(captured_json) == 1
|
|
payload = captured_json[0]
|
|
assert payload["msgKey"] == "sampleMarkdown"
|
|
|
|
_run(go())
|
|
|
|
|
|
class TestAdaptMarkdownForDingtalk:
|
|
def test_fenced_code_block_to_blockquote(self):
|
|
text = "Hello\n```python\ndef foo():\n return 1\n```\nDone"
|
|
result = _adapt_markdown_for_dingtalk(text)
|
|
assert "```" not in result
|
|
assert "> **python**" in result
|
|
assert "> def foo():" in result
|
|
assert "> return 1" in result
|
|
|
|
def test_fenced_code_block_no_language(self):
|
|
text = "```\nplain code\n```"
|
|
result = _adapt_markdown_for_dingtalk(text)
|
|
assert "```" not in result
|
|
assert "> plain code" in result
|
|
|
|
def test_inline_code_to_bold(self):
|
|
text = "Use `pip install` to install"
|
|
result = _adapt_markdown_for_dingtalk(text)
|
|
assert result == "Use **pip install** to install"
|
|
|
|
def test_horizontal_rule_to_unicode(self):
|
|
text = "Above\n---\nBelow"
|
|
result = _adapt_markdown_for_dingtalk(text)
|
|
assert "───────────" in result
|
|
assert "---" not in result
|
|
|
|
def test_supported_markdown_preserved(self):
|
|
text = "# Title\n**bold** and *italic*\n- list item\n> quote\n[link](http://example.com)"
|
|
result = _adapt_markdown_for_dingtalk(text)
|
|
assert result == text
|
|
|
|
def test_plain_text_unchanged(self):
|
|
text = "Hello world, no markdown here."
|
|
assert _adapt_markdown_for_dingtalk(text) == text
|
|
|
|
def test_combined_elements(self):
|
|
text = "# Report\n\nRun `make test` then:\n\n```bash\npytest -v\n```\n\n---\n\nDone."
|
|
result = _adapt_markdown_for_dingtalk(text)
|
|
assert "# Report" in result
|
|
assert "**make test**" in result
|
|
assert "> **bash**" in result
|
|
assert "> pytest -v" in result
|
|
assert "───────────" in result
|
|
assert "Done." in result
|
|
|
|
|
|
class TestConvertMarkdownTable:
|
|
def test_simple_table(self):
|
|
text = "| Name | Age |\n|------|-----|\n| Alice | 30 |\n| Bob | 25 |"
|
|
result = _convert_markdown_table(text)
|
|
assert "> **Name**: Alice" in result
|
|
assert "> **Age**: 30" in result
|
|
assert "> **Name**: Bob" in result
|
|
assert "> **Age**: 25" in result
|
|
assert "|" not in result
|
|
|
|
def test_table_with_surrounding_text(self):
|
|
text = "Results:\n\n| Key | Value |\n|-----|-------|\n| a | 1 |\n\nEnd."
|
|
result = _convert_markdown_table(text)
|
|
assert "Results:" in result
|
|
assert "> **Key**: a" in result
|
|
assert "> **Value**: 1" in result
|
|
assert "End." in result
|
|
|
|
def test_no_table(self):
|
|
text = "Just plain text\nwith lines"
|
|
assert _convert_markdown_table(text) == text
|
|
|
|
def test_alignment_separators(self):
|
|
text = "| Left | Center | Right |\n|:-----|:------:|------:|\n| a | b | c |"
|
|
result = _convert_markdown_table(text)
|
|
assert "> **Left**: a" in result
|
|
assert "> **Center**: b" in result
|
|
assert "> **Right**: c" in result
|
|
|
|
|
|
class TestUploadMediaValidation:
|
|
def test_non_dict_response_returns_none(self):
|
|
async def go():
|
|
from unittest.mock import patch
|
|
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "k"
|
|
channel._client_secret = "s"
|
|
channel._cached_token = "tok"
|
|
channel._token_expires_at = float("inf")
|
|
|
|
class FakeResponse:
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
return ["not", "a", "dict"]
|
|
|
|
class FakeClient:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *a):
|
|
pass
|
|
|
|
async def post(self, url, **kwargs):
|
|
return FakeResponse()
|
|
|
|
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
|
|
result = await channel._upload_media("/tmp/test.png", "image")
|
|
|
|
assert result is None
|
|
|
|
_run(go())
|
|
|
|
def test_json_decode_error_returns_none(self):
|
|
async def go():
|
|
import json as json_mod
|
|
from unittest.mock import patch
|
|
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
channel._client_id = "k"
|
|
channel._client_secret = "s"
|
|
channel._cached_token = "tok"
|
|
channel._token_expires_at = float("inf")
|
|
|
|
class FakeResponse:
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
raise json_mod.JSONDecodeError("err", "", 0)
|
|
|
|
class FakeClient:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *a):
|
|
pass
|
|
|
|
async def post(self, url, **kwargs):
|
|
return FakeResponse()
|
|
|
|
with patch("app.channels.dingtalk.httpx.AsyncClient", return_value=FakeClient()):
|
|
result = await channel._upload_media("/tmp/test.png", "image")
|
|
|
|
assert result is None
|
|
|
|
_run(go())
|
|
|
|
|
|
class TestChannelRegistration:
|
|
def test_dingtalk_in_channel_registry(self):
|
|
from app.channels.service import _CHANNEL_REGISTRY
|
|
|
|
assert "dingtalk" in _CHANNEL_REGISTRY
|
|
assert _CHANNEL_REGISTRY["dingtalk"] == "app.channels.dingtalk:DingTalkChannel"
|
|
|
|
def test_dingtalk_in_credential_keys(self):
|
|
from app.channels.service import _CHANNEL_CREDENTIAL_KEYS
|
|
|
|
assert "dingtalk" in _CHANNEL_CREDENTIAL_KEYS
|
|
assert "client_id" in _CHANNEL_CREDENTIAL_KEYS["dingtalk"]
|
|
assert "client_secret" in _CHANNEL_CREDENTIAL_KEYS["dingtalk"]
|
|
|
|
def test_dingtalk_in_channel_capabilities(self):
|
|
from app.channels.manager import CHANNEL_CAPABILITIES
|
|
|
|
assert "dingtalk" in CHANNEL_CAPABILITIES
|
|
assert CHANNEL_CAPABILITIES["dingtalk"]["supports_streaming"] is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AI Card streaming mode tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCardMode:
|
|
def test_card_mode_enabled_supports_streaming(self):
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
|
|
assert channel.supports_streaming is True
|
|
|
|
def test_non_card_mode_no_streaming(self):
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
assert channel.supports_streaming is False
|
|
|
|
def test_non_card_mode_unchanged(self):
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
assert channel._card_template_id == ""
|
|
assert channel._card_track_ids == {}
|
|
assert channel._card_repliers == {}
|
|
assert channel._incoming_messages == {}
|
|
assert channel._dingtalk_client is None
|
|
|
|
def test_card_source_key_matches_inbound_using_message_id_metadata(self):
|
|
"""Outbound correlation must match inbound ``message_id`` even if ``thread_ts`` drifts."""
|
|
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
inbound = channel._make_inbound(
|
|
chat_id="x",
|
|
user_id="u",
|
|
text="hi",
|
|
thread_ts="ts_fallback",
|
|
metadata={
|
|
"conversation_type": _CONVERSATION_TYPE_P2P,
|
|
"sender_staff_id": "user_001",
|
|
"conversation_id": "",
|
|
"message_id": "msg_real",
|
|
},
|
|
)
|
|
out = OutboundMessage(
|
|
channel_name="dingtalk",
|
|
chat_id="x",
|
|
thread_id="t",
|
|
text="ok",
|
|
thread_ts="wrong_ts",
|
|
metadata=dict(inbound.metadata),
|
|
)
|
|
assert channel._make_card_source_key(inbound) == channel._make_card_source_key_from_outbound(out)
|
|
|
|
_run(go())
|
|
|
|
def test_running_reply_creates_card(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
|
|
channel._client_id = "test_key"
|
|
|
|
channel._create_and_deliver_card = AsyncMock(return_value="track_001")
|
|
|
|
inbound = channel._make_inbound(
|
|
chat_id="user_001",
|
|
user_id="user_001",
|
|
text="hello",
|
|
metadata={
|
|
"conversation_type": _CONVERSATION_TYPE_P2P,
|
|
"sender_staff_id": "user_001",
|
|
"conversation_id": "",
|
|
"message_id": "msg_001",
|
|
},
|
|
)
|
|
|
|
mock_chatbot_msg = MagicMock()
|
|
source_key = channel._make_card_source_key(inbound)
|
|
channel._incoming_messages[source_key] = mock_chatbot_msg
|
|
|
|
await channel._send_running_reply("user_001", inbound)
|
|
|
|
channel._create_and_deliver_card.assert_awaited_once_with(
|
|
"\u23f3 Working on it...",
|
|
chatbot_message=mock_chatbot_msg,
|
|
)
|
|
assert channel._card_track_ids[source_key] == "track_001"
|
|
|
|
_run(go())
|
|
|
|
def test_send_streams_to_card(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
|
|
channel._client_id = "test_key"
|
|
|
|
channel._stream_update_card = AsyncMock()
|
|
|
|
# Pre-populate card tracking
|
|
source_key = f"{_CONVERSATION_TYPE_P2P}:user_001::msg_001"
|
|
channel._card_track_ids[source_key] = "track_001"
|
|
|
|
msg = OutboundMessage(
|
|
channel_name="dingtalk",
|
|
chat_id="user_001",
|
|
thread_id="thread_001",
|
|
text="Partial response...",
|
|
is_final=False,
|
|
thread_ts="msg_001",
|
|
metadata={
|
|
"conversation_type": _CONVERSATION_TYPE_P2P,
|
|
"sender_staff_id": "user_001",
|
|
"conversation_id": "",
|
|
},
|
|
)
|
|
|
|
await channel.send(msg)
|
|
|
|
channel._stream_update_card.assert_awaited_once_with(
|
|
"track_001",
|
|
"Partial response...",
|
|
is_finalize=False,
|
|
)
|
|
# Track ID should still exist (not final)
|
|
assert source_key in channel._card_track_ids
|
|
|
|
_run(go())
|
|
|
|
def test_send_finalizes_card(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
|
|
channel._client_id = "test_key"
|
|
|
|
channel._stream_update_card = AsyncMock()
|
|
|
|
source_key = f"{_CONVERSATION_TYPE_P2P}:user_001::msg_001"
|
|
channel._card_track_ids[source_key] = "track_001"
|
|
|
|
msg = OutboundMessage(
|
|
channel_name="dingtalk",
|
|
chat_id="user_001",
|
|
thread_id="thread_001",
|
|
text="Final answer.",
|
|
is_final=True,
|
|
thread_ts="msg_001",
|
|
metadata={
|
|
"conversation_type": _CONVERSATION_TYPE_P2P,
|
|
"sender_staff_id": "user_001",
|
|
"conversation_id": "",
|
|
},
|
|
)
|
|
|
|
await channel.send(msg)
|
|
|
|
channel._stream_update_card.assert_awaited_once_with(
|
|
"track_001",
|
|
"Final answer.",
|
|
is_finalize=True,
|
|
)
|
|
# Track ID should be cleaned up after final
|
|
assert source_key not in channel._card_track_ids
|
|
|
|
_run(go())
|
|
|
|
def test_card_mode_skips_markdown_adaptation(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
|
|
channel._client_id = "test_key"
|
|
|
|
raw_markdown = "```python\ndef foo():\n pass\n```"
|
|
captured_content: list[str] = []
|
|
|
|
async def capture_stream(out_track_id, content, *, is_finalize=False, is_error=False):
|
|
captured_content.append(content)
|
|
|
|
channel._stream_update_card = AsyncMock(side_effect=capture_stream)
|
|
|
|
source_key = f"{_CONVERSATION_TYPE_P2P}:user_001::msg_001"
|
|
channel._card_track_ids[source_key] = "track_001"
|
|
|
|
msg = OutboundMessage(
|
|
channel_name="dingtalk",
|
|
chat_id="user_001",
|
|
thread_id="thread_001",
|
|
text=raw_markdown,
|
|
is_final=True,
|
|
thread_ts="msg_001",
|
|
metadata={
|
|
"conversation_type": _CONVERSATION_TYPE_P2P,
|
|
"sender_staff_id": "user_001",
|
|
"conversation_id": "",
|
|
},
|
|
)
|
|
|
|
await channel.send(msg)
|
|
|
|
# Raw markdown should be passed through without adaptation
|
|
assert captured_content[0] == raw_markdown
|
|
|
|
_run(go())
|
|
|
|
def test_card_fallback_on_creation_failure(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
|
|
channel._client_id = "test_key"
|
|
|
|
# Card creation returns None (failure)
|
|
channel._create_and_deliver_card = AsyncMock(return_value=None)
|
|
channel._send_text_message_to_user = AsyncMock()
|
|
|
|
inbound = channel._make_inbound(
|
|
chat_id="user_001",
|
|
user_id="user_001",
|
|
text="hello",
|
|
metadata={
|
|
"conversation_type": _CONVERSATION_TYPE_P2P,
|
|
"sender_staff_id": "user_001",
|
|
"conversation_id": "",
|
|
"message_id": "msg_001",
|
|
},
|
|
)
|
|
|
|
source_key = channel._make_card_source_key(inbound)
|
|
channel._incoming_messages[source_key] = MagicMock()
|
|
|
|
await channel._send_running_reply("user_001", inbound)
|
|
|
|
# Should fall through to text message
|
|
channel._send_text_message_to_user.assert_awaited_once()
|
|
assert len(channel._card_track_ids) == 0
|
|
|
|
_run(go())
|
|
|
|
def test_send_skips_non_final_without_card_track_when_template_configured(self):
|
|
"""Without a live card track, Manager streaming would duplicate sampleMarkdown sends."""
|
|
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
|
|
channel._client_id = "test_key"
|
|
channel._send_group_message = AsyncMock()
|
|
channel._send_p2p_message = AsyncMock()
|
|
|
|
meta = {
|
|
"conversation_type": _CONVERSATION_TYPE_P2P,
|
|
"sender_staff_id": "user_001",
|
|
"conversation_id": "",
|
|
}
|
|
await channel.send(
|
|
OutboundMessage(
|
|
channel_name="dingtalk",
|
|
chat_id="user_001",
|
|
thread_id="t1",
|
|
text="partial",
|
|
is_final=False,
|
|
thread_ts="msg_001",
|
|
metadata=meta,
|
|
)
|
|
)
|
|
channel._send_p2p_message.assert_not_called()
|
|
channel._send_group_message.assert_not_called()
|
|
|
|
await channel.send(
|
|
OutboundMessage(
|
|
channel_name="dingtalk",
|
|
chat_id="user_001",
|
|
thread_id="t1",
|
|
text="final answer",
|
|
is_final=True,
|
|
thread_ts="msg_001",
|
|
metadata=meta,
|
|
)
|
|
)
|
|
channel._send_p2p_message.assert_awaited_once()
|
|
|
|
_run(go())
|
|
|
|
def test_card_fallback_on_stream_failure(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
|
|
channel._client_id = "test_key"
|
|
|
|
channel._stream_update_card = AsyncMock(side_effect=ConnectionError("stream failed"))
|
|
channel._send_markdown_fallback = AsyncMock()
|
|
|
|
source_key = f"{_CONVERSATION_TYPE_P2P}:user_001::msg_001"
|
|
channel._card_track_ids[source_key] = "track_001"
|
|
|
|
msg = OutboundMessage(
|
|
channel_name="dingtalk",
|
|
chat_id="user_001",
|
|
thread_id="thread_001",
|
|
text="Final answer.",
|
|
is_final=True,
|
|
thread_ts="msg_001",
|
|
metadata={
|
|
"conversation_type": _CONVERSATION_TYPE_P2P,
|
|
"sender_staff_id": "user_001",
|
|
"conversation_id": "",
|
|
},
|
|
)
|
|
|
|
await channel.send(msg)
|
|
|
|
# Should fallback to markdown
|
|
channel._send_markdown_fallback.assert_awaited_once_with(
|
|
"test_key",
|
|
_CONVERSATION_TYPE_P2P,
|
|
"user_001",
|
|
"",
|
|
"Final answer.",
|
|
)
|
|
# Track ID should be cleaned up
|
|
assert source_key not in channel._card_track_ids
|
|
|
|
_run(go())
|
|
|
|
def test_pre_start_stores_dingtalk_client(self):
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={})
|
|
handler = _DingTalkMessageHandler(channel)
|
|
|
|
mock_client = MagicMock()
|
|
handler.dingtalk_client = mock_client
|
|
handler.pre_start()
|
|
|
|
assert channel._dingtalk_client is mock_client
|
|
|
|
def test_chatbot_message_stored_for_card_mode(self):
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
|
|
|
|
mock_message = MagicMock()
|
|
mock_message.sender_staff_id = "user_001"
|
|
mock_message.conversation_type = "1"
|
|
mock_message.conversation_id = ""
|
|
mock_message.message_id = "msg_001"
|
|
mock_message.sender_nick = "TestUser"
|
|
mock_message.message_type = "text"
|
|
mock_message.text = MagicMock(content="hello")
|
|
mock_message.rich_text_content = None
|
|
|
|
channel._main_loop = MagicMock()
|
|
channel._main_loop.is_running.return_value = False
|
|
channel._allowed_users = set()
|
|
channel._running = True
|
|
|
|
channel._on_chatbot_message(mock_message)
|
|
|
|
assert len(channel._incoming_messages) == 1
|
|
stored_msg = list(channel._incoming_messages.values())[0]
|
|
assert stored_msg is mock_message
|
|
|
|
def test_card_replier_cleanup_on_final(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
|
|
channel._client_id = "test_key"
|
|
|
|
channel._stream_update_card = AsyncMock()
|
|
|
|
source_key = f"{_CONVERSATION_TYPE_P2P}:user_001::msg_001"
|
|
channel._card_track_ids[source_key] = "track_001"
|
|
channel._card_repliers["track_001"] = MagicMock()
|
|
|
|
msg = OutboundMessage(
|
|
channel_name="dingtalk",
|
|
chat_id="user_001",
|
|
thread_id="thread_001",
|
|
text="Final answer.",
|
|
is_final=True,
|
|
thread_ts="msg_001",
|
|
metadata={
|
|
"conversation_type": _CONVERSATION_TYPE_P2P,
|
|
"sender_staff_id": "user_001",
|
|
"conversation_id": "",
|
|
},
|
|
)
|
|
|
|
await channel.send(msg)
|
|
|
|
assert source_key not in channel._card_track_ids
|
|
assert "track_001" not in channel._card_repliers
|
|
|
|
_run(go())
|
|
|
|
def test_card_creation_without_sdk_client_returns_none(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
|
|
channel._dingtalk_client = None
|
|
|
|
result = await channel._create_and_deliver_card(
|
|
"test",
|
|
chatbot_message=MagicMock(),
|
|
)
|
|
assert result is None
|
|
|
|
_run(go())
|
|
|
|
def test_card_creation_without_chatbot_message_returns_none(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
|
|
channel._dingtalk_client = MagicMock()
|
|
|
|
result = await channel._create_and_deliver_card(
|
|
"test",
|
|
chatbot_message=None,
|
|
)
|
|
assert result is None
|
|
|
|
_run(go())
|
|
|
|
def test_stream_update_card_raises_without_replier(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
|
|
|
|
with pytest.raises(RuntimeError, match="No AICardReplier found"):
|
|
await channel._stream_update_card("nonexistent_track", "content")
|
|
|
|
_run(go())
|
|
|
|
def test_stop_clears_card_state(self):
|
|
async def go():
|
|
bus = MessageBus()
|
|
channel = DingTalkChannel(bus, config={"card_template_id": "tpl_123"})
|
|
channel._running = True
|
|
channel._dingtalk_client = MagicMock()
|
|
channel._incoming_messages["key"] = MagicMock()
|
|
channel._card_repliers["track"] = MagicMock()
|
|
channel._card_track_ids["source"] = "track"
|
|
|
|
await channel.stop()
|
|
|
|
assert channel._dingtalk_client is None
|
|
assert channel._incoming_messages == {}
|
|
assert channel._card_repliers == {}
|
|
assert channel._card_track_ids == {}
|
|
|
|
_run(go())
|