From 205889f9e02179902b20b1c35dda1b59b1bd03b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E6=98=8E=E6=8C=AF?= Date: Mon, 22 Jun 2026 11:51:13 +0800 Subject: [PATCH] feat(dingtalk): prefix group replies with sender mention In group chats, prefix the outbound markdown reply with an H1 naming the sender (# @) so the addressed user can spot it in a busy group. Private replies are sent verbatim. Visual only: DingTalk markdown robot messages do not push real @ notifications (that would require staffId plumbing and a different message type). sender_name is read from OutboundMessage.metadata, which the agent loop already propagates from inbound metadata. Co-Authored-By: Claude --- nanobot/channels/dingtalk/runtime.py | 11 +++- .../dingtalk/tests/test_dingtalk_channel.py | 51 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/dingtalk/runtime.py b/nanobot/channels/dingtalk/runtime.py index b1ae38c2..1a6cb25f 100644 --- a/nanobot/channels/dingtalk/runtime.py +++ b/nanobot/channels/dingtalk/runtime.py @@ -713,8 +713,15 @@ class DingTalkChannel(BaseChannel): if not token: raise RuntimeError("DingTalk access token unavailable") - if msg.content and msg.content.strip(): - if not await self._send_markdown_text(token, msg.chat_id, msg.content.strip()): + content = msg.content.strip() if msg.content else "" + if content: + # In group chats, prefix the reply with a markdown header naming the + # sender so the addressed user can spot the reply. Visual only — + # DingTalk's markdown robot messages do not push real @ notifications. + sender_name = msg.metadata.get("sender_name") if msg.metadata else None + if msg.chat_id.startswith("group:") and sender_name: + content = f"# @{sender_name}\n\n{content}" + if not await self._send_markdown_text(token, msg.chat_id, content): raise RuntimeError("DingTalk text message was not delivered") for media_ref in msg.media or []: diff --git a/nanobot/channels/dingtalk/tests/test_dingtalk_channel.py b/nanobot/channels/dingtalk/tests/test_dingtalk_channel.py index 9004760a..73406f82 100644 --- a/nanobot/channels/dingtalk/tests/test_dingtalk_channel.py +++ b/nanobot/channels/dingtalk/tests/test_dingtalk_channel.py @@ -1,4 +1,5 @@ import asyncio +import json import zipfile from io import BytesIO from types import SimpleNamespace @@ -252,6 +253,56 @@ async def test_group_send_uses_group_messages_api() -> None: assert call["json"]["msgKey"] == "sampleMarkdown" +@pytest.mark.asyncio +async def test_group_send_prepends_sender_mention(monkeypatch) -> None: + """Group replies are prefixed with a markdown header naming the sender.""" + config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]) + channel = DingTalkChannel(config, MessageBus()) + channel._http = _FakeHttp() + + async def _fake_token() -> str: + return "token" + + monkeypatch.setattr(channel, "_get_access_token", _fake_token) + + await channel.send( + OutboundMessage( + channel="dingtalk", + chat_id="group:conv123", + content="hello", + metadata={"sender_name": "Alice"}, + ) + ) + + sent_text = json.loads(channel._http.calls[0]["json"]["msgParam"])["text"] + assert sent_text == "# @Alice\n\nhello" + + +@pytest.mark.asyncio +async def test_private_send_does_not_prepend_mention(monkeypatch) -> None: + """Private replies are sent verbatim, without the sender header.""" + config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]) + channel = DingTalkChannel(config, MessageBus()) + channel._http = _FakeHttp() + + async def _fake_token() -> str: + return "token" + + monkeypatch.setattr(channel, "_get_access_token", _fake_token) + + await channel.send( + OutboundMessage( + channel="dingtalk", + chat_id="user1", # private chat: no "group:" prefix + content="hello", + metadata={"sender_name": "Alice"}, + ) + ) + + sent_text = json.loads(channel._http.calls[0]["json"]["msgParam"])["text"] + assert sent_text == "hello" + + @pytest.mark.asyncio async def test_handler_uses_voice_recognition_text_when_text_is_empty(monkeypatch) -> None: bus = MessageBus()