Files

6.6 KiB

企微主动私信的"干净直发"原语 + 错发根因 + 账号白名单

背景:贾茜(Maggie)反映"给 Doro 的消息错发给她"。排查发现根因在企微 adapter 的"回复兜底"机制。本文记录干净直发的可靠原语、错发机制、以及 三方交叉验证过的真实 userid 白名单。

一、错发根因:adapter.send() 的"回复兜底"会串号

gateway/platforms/wecom.pyWeComAdapter.send()(约 1430–1445 行)有一条 fallback:

reply_req_id = self._reply_req_id_for_message(reply_to)
if not reply_req_id and chat_id in self._last_chat_req_ids:
    reply_req_id = self._last_chat_req_ids[chat_id]   # ← 退化点
if reply_req_id:
    response = await self._send_reply_markdown(reply_req_id, content)  # 用历史 req_id "回复"
else:
    # 才是真正的主动私信 aibot_send_msg + chat_type=1

含义:在长期运行的 gateway 进程里,_last_chat_req_ids 会按 chat_id 缓存 最近一条 inbound 的 req_id。多人并发时这个缓存可能串号——"发给 A 的主动消息" 退化成"回复一条 req_id 绑定的历史消息",而那条历史消息的会话上下文可能属于 B, 于是消息落到 B 头上。这是"给 Doro 的通知发到贾茜"的核心机制。

注意:tools/send_message_tool.py 里的 _send_wecom() 每次会 new 一个 全新 adapter_last_chat_req_ids 为空),所以单次 _send_wecom 调用本身 通常走 proactive 分支、不串号。真正高危的是常驻 gateway 进程内复用同一个 adapter 实例的发送,以及旧版 auto_notify_new_file.sh 的 "get_sender() 猜发件人 + 无差别 notify_doro" 叠加。结论:不要依赖 _send_wecom/adapter.send() 的兜底语义来保证"主动私信永远直达"——它不保证。

二、干净直发原语:~/.hermes/scripts/wecom_dm.py

这个脚本完全绕开 adapter.send() 的回复兜底:自己开 WebSocket、 aibot_subscribe 认证、直接 aibot_send_msg + 固定 chat_type=1, 永不退化成回复。一条消息 = 一次目标唯一确定的主动私信。已实测真发成功。

# CLI
python3 ~/.hermes/scripts/wecom_dm.py --to doro --text "内容"
python3 ~/.hermes/scripts/wecom_dm.py --list                  # 白名单
python3 ~/.hermes/scripts/wecom_dm.py --to doro --text "x" --dry-run

# 作为模块(注意:脚本在 ~/.hermes/scripts,需要时 sys.path.append)
from wecom_dm import send_dm
res = send_dm("doro", "内容")          # res["success"], res["message_id"]

特性:

  • 白名单防呆:发给未核准账号会被拦截(除非 --allow-raw)。
  • 真实成功判定:按企微 errcode in {0, None} 判断,不盲报成功;返回 message_id
  • 无第三方依赖:仅需 aiohttp + pyyaml(hermes venv 已有)。
  • 凭据从 config.yamlgateway.platforms.wecom.extra(bot_id/secret)读取。

后续可把后台脚本(workflow-watchdog.shnotify() 等)的通知改成调用此脚本, 彻底脱离会串号的旧 _send_wecom 路径。

三、协议要点(自建发送时照此,已逐行核对源码)

  • WS URL:wss://openws.work.weixin.qq.com(可被 extra.websocket_url 覆盖)
  • 认证:cmd=aibot_subscribe,body={bot_id, secret, device_id},等同 req_id 的 ack
  • 发送:cmd=aibot_send_msg,body={chatid, msgtype:"markdown", markdown:{content}, chat_type:1}
  • 帧格式:{"cmd":..., "headers":{"req_id":...}, "body":...},响应按 req_id 关联
  • 成功判定:响应顶层 errcode in {0, None} 即成功,否则读 errmsg
  • aiohttp 新版兼容:ws_connect(timeout=...) 用 float 会告警,优先 from aiohttp import ClientWSTimeout; ClientWSTimeout(ws_close=...),回退 float

四、已核准 userid 白名单(2026-06-21,三方交叉验证)

验证方法:state.db 的 sessions.user_id + cache/documents/*.meta 的 sender_id + logs/*.logplatform=wecom user=X chat=Y 三方对照。日志里 user==chat 的记录 即"私聊 DM"样本,证明该 userid 是企微可直达的真实私聊 chatid(chat_type=1)。

别名 真实 userid 身份 可信度
doro doro Doro(律师·合同审查指导) ★高 DB+meta+log,私聊×156
jiaqian JiaQian 贾茜 / Maggie(主人) ★高 DB+log,私聊×108
qiuting QiuTing 邱律师(Doro 团队) ★高 DB+log,私聊×71
weiwei WeiWei WeiWei(技术支持) ★高 DB+log,私聊×49
shasha ShaSha 苌莎莎(律师·同团队) △ DB+群 log,私聊无样本
yangayi YanGaYi 颜伽艺(架构师) △ 单源 DB,低频
xiaonan XiaoNan XiaoNan △ 单源 DB,低频

userid 大小写敏感(JiaQian 不是 jiaqian)。脚本白名单同时接受小写别名和 精确 userid。给真人发测试私信前先确认对象——优先发 WeiWei(技术支持,懂测试)。

五、★群聊不可替换(边界铁律)

wecom_dm.pychat_type=1主动私信专用——企微 AI Bot 在群聊不能主动 aibot_send_msg,只能走 adapter 的 RESPONSE 兜底(回复某条历史 inbound)。所以:

  • 本脚本只用于单聊私信,群聊发送绝不能改用它(chat_type=1 在群里无效)。
  • 排查/替换发送点时,先用 chatid 前缀区分:wr 开头 = 群聊(不动),其余 = 单聊(可迁移到 wecom_dm.py)。典型如 notify_new_files.sh 的群发 adapter.send(chat_id='wrbAF...') 必须原样保留

六、已迁移调用点(单聊发送统一走本脚本)

所有发企微单聊私信的后台脚本已从旧 _send_wecom/adapter.send(chat_id='doro') 改为调用 wecom_dm.py,每处加了"为什么不用 _send_wecom"的注释防回退,备份后缀 .bak_replace_<时间戳>

  • auto_notify_new_file.shnotify_doro()(当前唯一活跃运行)
  • workflow-watchdog.shnotify()(历史脚本,当前未调度;注意它仍可能硬编码 _send_wecom(extra,'doro',msg),是未拆隐患)
  • notify_new_files.sh → 单聊提醒部分(历史脚本,群发点原样保留)

改脚本前的标准流程

  1. search_files_send_wecom|aibot_send_msg|adapter.send 找全发送点 → 2. 逐个看 chatid 前缀判单聊/群聊 → 3. 备份 cp 脚本 脚本.bak_replace_$(date +%Y%m%d_%H%M%S) → 4. patch 替换单聊点、群聊点加注释保留 → 5. bash -n 语法 + grep 确认无残留单聊直调且群发点完好 → 6. 临时把目标改 weiwei 发一条测试拿 message_id 验证。