@@ -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
0 commit comments