# 企微主动私信的"干净直发"原语 + 错发根因 + 账号白名单 > 背景:贾茜(Maggie)反映"给 Doro 的消息错发给她"。排查发现根因在企微 > adapter 的"回复兜底"机制。本文记录干净直发的可靠原语、错发机制、以及 > 三方交叉验证过的真实 userid 白名单。 ## 一、错发根因:adapter.send() 的"回复兜底"会串号 `gateway/platforms/wecom.py` 的 `WeComAdapter.send()`(约 1430–1445 行)有一条 fallback: ```python 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`**, 永不退化成回复。一条消息 = 一次目标唯一确定的主动私信。已实测真发成功。 ```bash # 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.yaml` 的 `gateway.platforms.wecom.extra`(bot_id/secret)读取。 后续可把后台脚本(`workflow-watchdog.sh` 的 `notify()` 等)的通知改成调用此脚本, 彻底脱离会串号的旧 `_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/*.log` 里 `platform=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.py` 的 `chat_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.sh` → `notify_doro()`(当前唯一活跃运行) - `workflow-watchdog.sh` → `notify()`(历史脚本,当前未调度;注意它仍可能硬编码 `_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 验证。