Skip to content

Commit ffe9ccc

Browse files
committed
fix(im): dingtalk @ mention + sender identity + codex \n escape
- IM bridge: propagate sender identity (name/id) through all adapters - Dingtalk: resolve @ mention targets using staffId instead of generic senderId - Codex runtime: normalize double-escaped \n in message text - Tests: dingtalk at_mention, sender identity, mcp bool coercion
1 parent e25a07a commit ffe9ccc

File tree

11 files changed

+509
-46
lines changed

11 files changed

+509
-46
lines changed

src/cccc/ports/im/adapters/base.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,19 @@ def poll(self) -> List[Dict[str, Any]]:
8585
pass
8686

8787
@abstractmethod
88-
def send_message(self, chat_id: str, text: str, thread_id: Optional[int] = None) -> bool:
88+
def send_message(
89+
self,
90+
chat_id: str,
91+
text: str,
92+
thread_id: Optional[int] = None,
93+
*,
94+
mention_user_ids: Optional[List[str]] = None,
95+
) -> bool:
8996
"""
9097
Send a message to a chat.
9198
Returns True if successful.
9299
"""
100+
_ = mention_user_ids
93101
pass
94102

95103
@abstractmethod

src/cccc/ports/im/adapters/dingtalk.py

Lines changed: 63 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,8 @@ def __init__(
131131
# so throttle state survives across begin/update/end invocations.
132132
self._card_client: Optional[Any] = None
133133

134-
# Cache: last inbound sender per conversation (for outbound @mention)
134+
# Cache: last inbound mentionable sender per conversation (for backward-compatible
135+
# outbound @mention fallback). Only senderStaffId is safe to reuse here.
135136
# conversation_id -> (staff_id, nick) — bounded to _LAST_SENDER_MAX entries
136137
self._last_sender: Dict[str, tuple[str, str]] = {}
137138

@@ -567,8 +568,11 @@ def _enqueue_message(self, event: Dict[str, Any]) -> bool:
567568
# DingTalk event structure varies by type
568569
# Robot callback format
569570
msg_type = event.get("msgtype", "")
570-
conversation_id = event.get("conversationId", "")
571-
sender_id = event.get("senderStaffId", "") or event.get("senderId", "")
571+
conversation_id = str(event.get("conversationId", "") or "").strip()
572+
sender_staff_id = str(event.get("senderStaffId", "") or "").strip()
573+
sender_id = str(event.get("senderId", "") or "").strip()
574+
sender_display_id = sender_staff_id or sender_id
575+
mention_user_ids = [sender_staff_id] if sender_staff_id else []
572576
sender_nick = event.get("senderNick", "user")
573577
msg_id = event.get("msgId", "")
574578

@@ -679,16 +683,19 @@ def _enqueue_message(self, event: Dict[str, Any]) -> bool:
679683
self._session_webhook_cache[conversation_id] = (session_webhook, expires_at)
680684
self._log(f"[webhook] Cached: id={conversation_id}, expires_raw={session_expires}, expires_at={expires_at:.0f}")
681685

682-
# Cache sender for outbound @mention (group chats only)
683-
if sender_id and conversation_id:
686+
# Cache sender for outbound @mention fallback (group chats only).
687+
# senderId is not necessarily a valid atUserIds target, so only keep staffId.
688+
if sender_staff_id and conversation_id:
684689
# Evict oldest entries when cache is full
685690
if len(self._last_sender) >= self._LAST_SENDER_MAX:
686691
try:
687692
oldest_key = next(iter(self._last_sender))
688693
del self._last_sender[oldest_key]
689694
except StopIteration:
690695
pass
691-
self._last_sender[conversation_id] = (sender_id, sender_nick)
696+
self._last_sender[conversation_id] = (sender_staff_id, sender_nick)
697+
elif conversation_id:
698+
self._last_sender.pop(conversation_id, None)
692699

693700
# Normalize message
694701
normalized = {
@@ -699,8 +706,9 @@ def _enqueue_message(self, event: Dict[str, Any]) -> bool:
699706
"thread_id": 0, # DingTalk doesn't have threading like this
700707
"text": text,
701708
"attachments": attachments,
702-
"from_user": sender_nick or sender_id,
703-
"from_user_id": sender_id,
709+
"from_user": sender_nick or sender_display_id,
710+
"from_user_id": sender_display_id,
711+
"mention_user_ids": mention_user_ids,
704712
"message_id": msg_id,
705713
"timestamp": self._parse_event_time(event.get("createAt")),
706714
# Keep sessionWebhook for potential reply use
@@ -770,19 +778,27 @@ def _get_conversation_title_cached(self, conversation_id: str) -> str:
770778
self._conversation_cache[conversation_id] = title
771779
return title
772780

781+
def _build_markdown_payload(self, text: str, at_user_ids: Optional[List[str]] = None) -> Dict[str, Any]:
782+
"""Build a DingTalk markdown payload with optional real-mention metadata."""
783+
payload: Dict[str, Any] = {
784+
"title": text[:20] if len(text) > 20 else text,
785+
"text": text,
786+
}
787+
cleaned_at_user_ids = [str(x).strip() for x in (at_user_ids or []) if str(x).strip()]
788+
if cleaned_at_user_ids:
789+
payload["at"] = {"atUserIds": cleaned_at_user_ids}
790+
return payload
791+
773792
def _send_via_webhook(self, webhook_url: str, text: str,
774793
at_user_ids: Optional[List[str]] = None) -> bool:
775794
"""Send message via sessionWebhook (most reliable for groups)."""
776-
title = text[:20] if len(text) > 20 else text
777795
body: Dict[str, Any] = {
778796
"msgtype": "markdown",
779-
"markdown": {
780-
"title": title,
781-
"text": text,
782-
},
797+
"markdown": self._build_markdown_payload(text),
783798
}
784-
if at_user_ids:
785-
body["at"] = {"atUserIds": at_user_ids}
799+
cleaned_at_user_ids = [str(x).strip() for x in (at_user_ids or []) if str(x).strip()]
800+
if cleaned_at_user_ids:
801+
body["at"] = {"atUserIds": cleaned_at_user_ids}
786802
data = json.dumps(body, ensure_ascii=False).encode('utf-8')
787803

788804
req = urllib.request.Request(webhook_url, data=data, method="POST")
@@ -800,7 +816,14 @@ def _send_via_webhook(self, webhook_url: str, text: str,
800816
self._log(f"[webhook] Error: {e}")
801817
return False
802818

803-
def send_message(self, chat_id: str, text: str, thread_id: Optional[int] = None) -> bool:
819+
def send_message(
820+
self,
821+
chat_id: str,
822+
text: str,
823+
thread_id: Optional[int] = None,
824+
*,
825+
mention_user_ids: Optional[List[str]] = None,
826+
) -> bool:
804827
"""
805828
Send a text message to a conversation.
806829
@@ -821,11 +844,16 @@ def send_message(self, chat_id: str, text: str, thread_id: Optional[int] = None)
821844
safe_text = self._compose_safe(text)
822845

823846
# Resolve @mention targets for group conversations
824-
at_user_ids: Optional[List[str]] = None
825-
if chat_id.startswith("cid") and chat_id in self._last_sender:
826-
staff_id, _nick = self._last_sender[chat_id]
827-
if staff_id:
828-
at_user_ids = [staff_id]
847+
at_user_ids: Optional[List[str]]
848+
if mention_user_ids is not None:
849+
cleaned_explicit_ids = [str(x).strip() for x in mention_user_ids if str(x).strip()]
850+
at_user_ids = cleaned_explicit_ids or None
851+
else:
852+
at_user_ids = None
853+
if chat_id.startswith("cid") and chat_id in self._last_sender:
854+
staff_id, _nick = self._last_sender[chat_id]
855+
if staff_id:
856+
at_user_ids = [staff_id]
829857

830858
# Rate limit
831859
self._rate_limiter.wait_and_acquire(chat_id)
@@ -847,18 +875,16 @@ def send_message(self, chat_id: str, text: str, thread_id: Optional[int] = None)
847875
if not self.robot_code:
848876
if chat_id.startswith("cid"):
849877
self._log("[send] Missing robot_code; cannot use new API fallback. Trying legacy API.")
850-
return self._send_message_legacy(chat_id, safe_text)
878+
return self._send_message_legacy(chat_id, safe_text, at_user_ids=at_user_ids)
851879
self._log("[send] Missing robot_code; cannot send via API fallback. Configure DINGTALK_ROBOT_CODE.")
852880
return False
853881

854882
# Use robot message API
883+
markdown_payload = self._build_markdown_payload(safe_text, at_user_ids=at_user_ids)
855884
body: Dict[str, Any] = {
856885
"robotCode": self.robot_code,
857886
"msgKey": "sampleMarkdown",
858-
"msgParam": json.dumps({
859-
"title": safe_text[:20] if len(safe_text) > 20 else safe_text,
860-
"text": safe_text,
861-
}, ensure_ascii=False),
887+
"msgParam": json.dumps(markdown_payload, ensure_ascii=False),
862888
}
863889

864890
# Determine if group or 1:1
@@ -878,24 +904,28 @@ def send_message(self, chat_id: str, text: str, thread_id: Optional[int] = None)
878904

879905
# Try alternative API for older bots
880906
if "code" in resp or "errcode" in resp:
881-
return self._send_message_legacy(chat_id, safe_text)
907+
return self._send_message_legacy(chat_id, safe_text, at_user_ids=at_user_ids)
882908

883909
self._log(f"[send] Failed to chat {chat_id}: {resp}")
884910
return False
885911

886-
def _send_message_legacy(self, chat_id: str, text: str) -> bool:
912+
def _send_message_legacy(
913+
self,
914+
chat_id: str,
915+
text: str,
916+
at_user_ids: Optional[List[str]] = None,
917+
) -> bool:
887918
"""Send message using legacy API (for older bot types)."""
888-
title = text[:20] if len(text) > 20 else text
889919
body = {
890920
"chatid": chat_id,
891921
"msg": {
892922
"msgtype": "markdown",
893-
"markdown": {
894-
"title": title,
895-
"text": text,
896-
},
923+
"markdown": self._build_markdown_payload(text),
897924
},
898925
}
926+
cleaned_at_user_ids = [str(x).strip() for x in (at_user_ids or []) if str(x).strip()]
927+
if cleaned_at_user_ids:
928+
body["msg"]["at"] = {"atUserIds": cleaned_at_user_ids}
899929

900930
resp = self._api_old("POST", "/chat/send", body)
901931

src/cccc/ports/im/adapters/discord.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,10 +276,18 @@ async def _resolve_channel(self, chat_id: str) -> Any:
276276
self._log(f"[warn] Channel {chat_id} not found (cache miss + fetch failed: {e})")
277277
return None
278278

279-
def send_message(self, chat_id: str, text: str, thread_id: Optional[int] = None) -> bool:
279+
def send_message(
280+
self,
281+
chat_id: str,
282+
text: str,
283+
thread_id: Optional[int] = None,
284+
*,
285+
mention_user_ids: Optional[List[str]] = None,
286+
) -> bool:
280287
"""
281288
Send a message to a Discord channel.
282289
"""
290+
_ = mention_user_ids
283291
_ = thread_id # Discord threads are not wired yet (future work).
284292
if not self._connected or not self._client or not self._loop:
285293
return False

src/cccc/ports/im/adapters/feishu.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -610,7 +610,14 @@ def _get_chat_title_cached(self, chat_id: str) -> str:
610610
self._chat_title_cache[chat_id] = title
611611
return title
612612

613-
def send_message(self, chat_id: str, text: str, thread_id: Optional[int] = None) -> bool:
613+
def send_message(
614+
self,
615+
chat_id: str,
616+
text: str,
617+
thread_id: Optional[int] = None,
618+
*,
619+
mention_user_ids: Optional[List[str]] = None,
620+
) -> bool:
614621
"""
615622
Send a text message to a chat.
616623
@@ -619,6 +626,7 @@ def send_message(self, chat_id: str, text: str, thread_id: Optional[int] = None)
619626
text: Message text
620627
thread_id: Optional root_id for threading
621628
"""
629+
_ = mention_user_ids
622630
if not text:
623631
return True
624632

src/cccc/ports/im/adapters/slack.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,12 +245,20 @@ def poll(self) -> List[Dict[str, Any]]:
245245

246246
return messages
247247

248-
def send_message(self, chat_id: str, text: str, thread_id: Optional[int] = None) -> bool:
248+
def send_message(
249+
self,
250+
chat_id: str,
251+
text: str,
252+
thread_id: Optional[int] = None,
253+
*,
254+
mention_user_ids: Optional[List[str]] = None,
255+
) -> bool:
249256
"""
250257
Send a message to a Slack channel.
251258
252259
chat_id is actually a channel ID string in Slack.
253260
"""
261+
_ = mention_user_ids
254262
_ = thread_id # Slack threads not wired yet (future work).
255263
if not self._connected or not self._web_client:
256264
return False

src/cccc/ports/im/adapters/telegram.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,14 @@ def send_file(
365365
self._log(f"[send_file] failed: {e}")
366366
return False
367367

368-
def send_message(self, chat_id: str, text: str, thread_id: Optional[int] = None) -> bool:
368+
def send_message(
369+
self,
370+
chat_id: str,
371+
text: str,
372+
thread_id: Optional[int] = None,
373+
*,
374+
mention_user_ids: Optional[List[str]] = None,
375+
) -> bool:
369376
"""
370377
Send a message to a chat.
371378
@@ -374,6 +381,7 @@ def send_message(self, chat_id: str, text: str, thread_id: Optional[int] = None)
374381
- Message length limits
375382
- Retry on failure
376383
"""
384+
_ = mention_user_ids
377385
if not text:
378386
return True
379387

src/cccc/ports/im/bridge.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,10 @@ def __init__(
293293
# Inbound health monitoring: periodic log every 5 minutes
294294
self._last_health_log: float = 0.0
295295
self._inbound_count: int = 0 # messages processed since last health log
296+
# Per chat/thread mention targets for outbound replies.
297+
# The bridge computes this from inbound metadata and passes it explicitly
298+
# to adapters instead of letting adapters guess from their own caches.
299+
self._mention_targets: Dict[str, List[str]] = {}
296300

297301
def _should_process_inbound(self, *, chat_id: str, thread_id: int, message_id: str) -> bool:
298302
"""
@@ -432,6 +436,7 @@ def _process_inbound(self) -> None:
432436
if not self._should_process_inbound(chat_id=chat_id, thread_id=thread_id, message_id=message_id):
433437
continue
434438
self._inbound_count += 1
439+
self._remember_mention_targets(chat_id, thread_id, msg)
435440

436441
# Authorization check: unauthorized chats may only /subscribe.
437442
self._log(f"[inbound] Checking auth for chat_id={chat_id} thread={thread_id}")
@@ -581,6 +586,53 @@ def _display_actor_token(self, token: str, actor_labels: Dict[str, str]) -> str:
581586
def _stream_target_key(chat_id: str, thread_id: int) -> str:
582587
return f"{chat_id}:{int(thread_id or 0)}"
583588

589+
def _remember_mention_targets(self, chat_id: str, thread_id: int, msg: Dict[str, Any]) -> None:
590+
"""Cache the latest explicit mention target for this chat/thread."""
591+
target_key = self._stream_target_key(chat_id, thread_id)
592+
raw_ids = msg.get("mention_user_ids")
593+
mention_user_ids = (
594+
[str(x).strip() for x in raw_ids if str(x).strip()]
595+
if isinstance(raw_ids, list)
596+
else []
597+
)
598+
if mention_user_ids:
599+
self._mention_targets[target_key] = mention_user_ids
600+
else:
601+
self._mention_targets.pop(target_key, None)
602+
603+
# Keep the cache bounded without introducing another dependency.
604+
while len(self._mention_targets) > 256:
605+
oldest_key = next(iter(self._mention_targets))
606+
self._mention_targets.pop(oldest_key, None)
607+
608+
def _resolve_outbound_mention_targets(
609+
self,
610+
*,
611+
event: Dict[str, Any],
612+
sub: Any,
613+
is_user_facing: bool,
614+
) -> Optional[List[str]]:
615+
"""Resolve explicit mention targets for an outbound IM message."""
616+
if not is_user_facing:
617+
return None
618+
619+
platform = str(getattr(self.adapter, "platform", "") or "").strip().lower()
620+
if platform != "dingtalk":
621+
return None
622+
623+
data = event.get("data", {})
624+
if isinstance(data, dict):
625+
raw_ids = data.get("mention_user_ids")
626+
if isinstance(raw_ids, list):
627+
cleaned = [str(x).strip() for x in raw_ids if str(x).strip()]
628+
return cleaned
629+
630+
target_key = self._stream_target_key(sub.chat_id, sub.thread_id)
631+
cached = self._mention_targets.get(target_key)
632+
if cached is None:
633+
return None
634+
return list(cached)
635+
584636
def _forward_stream_event(self, event: Dict[str, Any]) -> None:
585637
"""Forward a chat.stream event to subscribed chats via adapter streaming methods.
586638
@@ -780,7 +832,22 @@ def _forward_event(self, event: Dict[str, Any], *, actor_labels: Optional[Dict[s
780832

781833
# If we didn't send any files, or if there's text with no files, send message.
782834
if formatted and not sent_any_file and not skip_text_due_to_stream:
783-
sent_msg = bool(self.adapter.send_message(sub.chat_id, formatted, thread_id=sub.thread_id))
835+
mention_user_ids = self._resolve_outbound_mention_targets(
836+
event=event,
837+
sub=sub,
838+
is_user_facing=is_user_facing,
839+
)
840+
if mention_user_ids is None:
841+
sent_msg = bool(self.adapter.send_message(sub.chat_id, formatted, thread_id=sub.thread_id))
842+
else:
843+
sent_msg = bool(
844+
self.adapter.send_message(
845+
sub.chat_id,
846+
formatted,
847+
thread_id=sub.thread_id,
848+
mention_user_ids=mention_user_ids,
849+
)
850+
)
784851
if sent_msg and is_user_facing:
785852
delivered_user_facing = True
786853

0 commit comments

Comments
 (0)