feat(dingtalk): AI Cards streaming, emoji reactions, and media handling

Cherry-picked from #10985 by pedh, adapted to current main:

* Keeps main's full group-chat gating (require_mention + allowed_users +
  free_response_chats + mention_patterns) — PR's simpler subset dropped.
* Keeps main's fire-and-forget process() dispatch + session_webhook
  fallback for SDK >= 0.24.
* Picks up PR's REQUIRES_EDIT_FINALIZE capability flag on
  BasePlatformAdapter + finalize kwarg on edit_message(), plumbed through
  stream_consumer.  Default False so Telegram/Slack/Discord/Matrix stay
  on the zero-overhead fast path.
* DingTalk AI Card lifecycle: per-chat _message_contexts, two-card flow
  (tool-progress + final response) with sibling auto-close driven by
  reply_to, idempotent 🤔Thinking → 🥳Done swap, $alibabacloud-dingtalk$
  for media URL resolution (replaces raw HTTP that was 403-ing).
* pyproject: dingtalk extra now dingtalk-stream>=0.20,<1 +
  alibabacloud-dingtalk>=2.0.0 + qrcode.

Closes #10991

Co-authored-by: pedh
This commit is contained in:
pedh
2026-04-17 19:13:09 -07:00
committed by Teknium
parent d7ef562a05
commit 4459913f40
7 changed files with 1482 additions and 82 deletions

View File

@@ -198,7 +198,7 @@ class TestSend:
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
adapter._http_client = mock_client
adapter._session_webhooks["chat-123"] = "https://cached.example/webhook"
adapter._session_webhooks["chat-123"] = ("https://cached.example/webhook", 9999999999999)
result = await adapter.send("chat-123", "Hello!")
assert result.success is True
@@ -681,3 +681,290 @@ class TestIncomingHandlerProcess:
processing_gate.set()
await asyncio.sleep(0.05)
# ---------------------------------------------------------------------------
# Text extraction — mention preservation + platform sanity
# ---------------------------------------------------------------------------
class TestExtractTextMentions:
def test_preserves_at_mentions_in_text(self):
"""@mentions are routing signals (via isInAtList), not text to strip.
Stripping all @handles collateral-damages emails, SSH URLs, and
literal references the user wrote.
"""
from gateway.platforms.dingtalk import DingTalkAdapter
cases = [
("@bot hello", "@bot hello"),
("contact alice@example.com", "contact alice@example.com"),
("git@github.com:foo/bar.git", "git@github.com:foo/bar.git"),
("what does @openai think", "what does @openai think"),
("@机器人 转发给 @老王", "@机器人 转发给 @老王"),
]
for text, expected in cases:
msg = MagicMock()
msg.text = text
msg.rich_text = None
msg.rich_text_content = None
assert DingTalkAdapter._extract_text(msg) == expected, (
f"mangled: {text!r} -> {DingTalkAdapter._extract_text(msg)!r}"
)
def test_dingtalk_in_platform_enum(self):
assert Platform.DINGTALK.value == "dingtalk"
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Concurrency — chat-scoped message context
# ---------------------------------------------------------------------------
class TestMessageContextIsolation:
def test_contexts_keyed_by_chat_id(self):
"""Two concurrent chats must not clobber each other's context."""
from gateway.platforms.dingtalk import DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
msg_a = MagicMock(conversation_id="chat-A", sender_staff_id="user-A")
msg_b = MagicMock(conversation_id="chat-B", sender_staff_id="user-B")
adapter._message_contexts["chat-A"] = msg_a
adapter._message_contexts["chat-B"] = msg_b
assert adapter._message_contexts["chat-A"] is msg_a
assert adapter._message_contexts["chat-B"] is msg_b
# ---------------------------------------------------------------------------
# Card lifecycle: finalize via metadata["streaming"]
# ---------------------------------------------------------------------------
class TestCardLifecycle:
@pytest.fixture
def adapter_with_card(self):
from gateway.platforms.dingtalk import DingTalkAdapter
a = DingTalkAdapter(PlatformConfig(
enabled=True,
extra={"card_template_id": "tmpl-1"},
))
a._card_sdk = MagicMock()
a._card_sdk.create_card_with_options_async = AsyncMock()
a._card_sdk.deliver_card_with_options_async = AsyncMock()
a._card_sdk.streaming_update_with_options_async = AsyncMock()
a._http_client = AsyncMock()
a._get_access_token = AsyncMock(return_value="token")
# Minimal message context
msg = MagicMock(
conversation_id="chat-1",
conversation_type="1",
sender_staff_id="staff-1",
message_id="user-msg-1",
)
a._message_contexts["chat-1"] = msg
a._session_webhooks["chat-1"] = (
"https://api.dingtalk.com/x", 9999999999999,
)
return a
@pytest.mark.asyncio
async def test_final_reply_finalizes_card(self, adapter_with_card):
"""send(reply_to=...) creates a closed card (final response path)."""
a = adapter_with_card
result = await a.send("chat-1", "Hello", reply_to="user-msg-1")
assert result.success
call = a._card_sdk.streaming_update_with_options_async.call_args
assert call[0][0].is_finalize is True
# Not tracked as streaming — it's already closed.
assert "chat-1" not in a._streaming_cards
@pytest.mark.asyncio
async def test_intermediate_send_stays_streaming(self, adapter_with_card):
"""send() without reply_to creates an OPEN card (tool progress /
commentary / streaming first chunk). No flicker closed→streaming
when edit_message follows."""
a = adapter_with_card
result = await a.send("chat-1", "💻 terminal: ls")
assert result.success
call = a._card_sdk.streaming_update_with_options_async.call_args
assert call[0][0].is_finalize is False
# Tracked for sibling cleanup.
assert result.message_id in a._streaming_cards.get("chat-1", {})
@pytest.mark.asyncio
async def test_done_fires_only_when_reply_to_is_set(self, adapter_with_card):
"""reply_to distinguishes final response (base.py) from tool-progress
sends (run.py). Done must only fire for the former."""
a = adapter_with_card
fired: list[str] = []
a._fire_done_reaction = lambda cid: fired.append(cid)
# Tool-progress / commentary path: no reply_to — no Done.
await a.send("chat-1", "tool line")
assert fired == []
# Final response path: reply_to set — Done fires.
await a.send("chat-1", "final", reply_to="user-msg-1")
assert fired == ["chat-1"]
@pytest.mark.asyncio
async def test_edit_message_finalize_fires_done(self, adapter_with_card):
"""Stream consumer's final edit_message(finalize=True) fires Done."""
a = adapter_with_card
fired: list[str] = []
a._fire_done_reaction = lambda cid: fired.append(cid)
await a.send("chat-1", "initial")
# Reopen via edit_message(finalize=False) then close.
await a.edit_message(
chat_id="chat-1", message_id="track-X",
content="streaming...", finalize=False,
)
await a.edit_message(
chat_id="chat-1", message_id="track-X",
content="final", finalize=True,
)
assert "chat-1" in fired
@pytest.mark.asyncio
async def test_edit_message_finalize_false_tracks_sibling(self, adapter_with_card):
"""After edit_message(finalize=False), card is tracked as open."""
a = adapter_with_card
await a.edit_message(
chat_id="chat-1", message_id="track-1",
content="partial", finalize=False,
)
assert "chat-1" in a._streaming_cards
assert a._streaming_cards["chat-1"].get("track-1") == "partial"
@pytest.mark.asyncio
async def test_next_send_auto_closes_sibling_streaming_cards(
self, adapter_with_card,
):
"""Tool-progress card left open (send without reply_to + edits) must
be auto-closed when the final-reply send arrives."""
a = adapter_with_card
# First tool: intermediate send — card stays open.
r1 = await a.send("chat-1", "💻 tool1")
# Second tool: edit_message(finalize=False) — keeps streaming.
await a.edit_message(
chat_id="chat-1", message_id=r1.message_id,
content="💻 tool1\n💻 tool2", finalize=False,
)
assert r1.message_id in a._streaming_cards.get("chat-1", {})
a._card_sdk.streaming_update_with_options_async.reset_mock()
# Final response send auto-closes the sibling.
await a.send("chat-1", "final answer", reply_to="user-msg")
calls = a._card_sdk.streaming_update_with_options_async.call_args_list
assert len(calls) >= 2
# First call was the sibling close with last-seen tool-progress content.
first_req = calls[0][0][0]
assert first_req.out_track_id == r1.message_id
assert first_req.is_finalize is True
assert "tool1" in first_req.content
# Streaming tracking is cleared after close.
assert "chat-1" not in a._streaming_cards
@pytest.mark.asyncio
async def test_edit_message_requires_message_id(self, adapter_with_card):
a = adapter_with_card
result = await a.edit_message(
chat_id="chat-1", message_id="", content="x", finalize=True,
)
assert result.success is False
a._card_sdk.streaming_update_with_options_async.assert_not_called()
def test_fire_done_reaction_is_idempotent(self, adapter_with_card):
a = adapter_with_card
captured = []
def _capture(coro):
captured.append(coro)
a._spawn_bg = _capture
a._fire_done_reaction("chat-1")
a._fire_done_reaction("chat-1")
assert len(captured) == 1
captured[0].close()
# ---------------------------------------------------------------------------
# AI Card Tests
# ---------------------------------------------------------------------------
class TestDingTalkAdapterAICards:
@pytest.fixture
def config(self):
return PlatformConfig(
enabled=True,
extra={
"client_id": "test_id",
"client_secret": "test_secret",
"card_template_id": "test_card_template",
},
)
@pytest.fixture
def mock_stream_client(self):
client = MagicMock()
client.get_access_token = MagicMock(return_value="test_token")
return client
@pytest.fixture
def mock_http_client(self):
return AsyncMock()
@pytest.fixture
def mock_message(self):
msg = MagicMock()
msg.message_id = "test_msg_id"
msg.conversation_id = "test_conv_id"
msg.conversation_type = "1"
msg.sender_id = "sender1"
msg.sender_nick = "Test User"
msg.sender_staff_id = "staff1"
msg.text = MagicMock(content="Hello")
msg.session_webhook = "https://api.dingtalk.com/robot/sendBySession?session=test"
msg.session_webhook_expired_time = 999999999999
msg.create_at = int(datetime.now(tz=timezone.utc).timestamp() * 1000)
msg.at_users = []
return msg
@pytest.mark.asyncio
async def test_send_uses_ai_card_if_configured(self, config, mock_stream_client, mock_http_client, mock_message):
from gateway.platforms.dingtalk import DingTalkAdapter
adapter = DingTalkAdapter(config)
adapter._stream_client = mock_stream_client
adapter._http_client = mock_http_client
adapter._message_contexts["test_conv_id"] = mock_message
adapter._session_webhooks = {"test_conv_id": ("https://api.dingtalk.com/robot/sendBySession?session=test", 9999999999999)}
adapter._card_template_id = "test_card_template"
# Mock the card SDK with proper async methods
mock_card_sdk = MagicMock()
mock_card_sdk.create_card_with_options_async = AsyncMock()
mock_card_sdk.deliver_card_with_options_async = AsyncMock()
mock_card_sdk.streaming_update_with_options_async = AsyncMock()
adapter._card_sdk = mock_card_sdk
# Mock access token
adapter._get_access_token = AsyncMock(return_value="test_token")
result = await adapter.send("test_conv_id", "Hello World")
mock_card_sdk.create_card_with_options_async.assert_called_once()
mock_card_sdk.deliver_card_with_options_async.assert_called_once()
mock_card_sdk.streaming_update_with_options_async.assert_called_once()
assert result.success is True

View File

@@ -88,6 +88,51 @@ class TestCleanForDisplay:
# ── Integration: _send_or_edit strips MEDIA: ─────────────────────────────
class TestFinalizeCapabilityGate:
"""Verify REQUIRES_EDIT_FINALIZE gates the redundant final edit.
Platforms that don't need an explicit finalize signal (Telegram,
Slack, Matrix, …) should skip the redundant final edit when the
mid-stream edit already delivered the final content. Platforms that
*do* need it (DingTalk AI Cards) must always receive a finalize=True
edit at the end of the stream.
"""
@pytest.mark.asyncio
async def test_identical_text_skip_respects_adapter_flag(self):
"""_send_or_edit short-circuits identical-text only when the
adapter doesn't require an explicit finalize signal."""
# Adapter without finalize requirement — should skip identical edit.
plain = MagicMock()
plain.REQUIRES_EDIT_FINALIZE = False
plain.send = AsyncMock(return_value=SimpleNamespace(
success=True, message_id="m1",
))
plain.edit_message = AsyncMock()
plain.MAX_MESSAGE_LENGTH = 4096
c1 = GatewayStreamConsumer(plain, "chat_1")
await c1._send_or_edit("hello") # first send
await c1._send_or_edit("hello", finalize=True) # identical → skip
plain.edit_message.assert_not_called()
# Adapter that requires finalize — must still fire the edit.
picky = MagicMock()
picky.REQUIRES_EDIT_FINALIZE = True
picky.send = AsyncMock(return_value=SimpleNamespace(
success=True, message_id="m1",
))
picky.edit_message = AsyncMock(return_value=SimpleNamespace(
success=True, message_id="m1",
))
picky.MAX_MESSAGE_LENGTH = 4096
c2 = GatewayStreamConsumer(picky, "chat_1")
await c2._send_or_edit("hello")
await c2._send_or_edit("hello", finalize=True)
# Finalize edit must go through even on identical content.
picky.edit_message.assert_called_once()
assert picky.edit_message.call_args[1]["finalize"] is True
class TestSendOrEditMediaStripping:
"""Verify _send_or_edit strips MEDIA: before sending to the platform."""