Commit 7f20b54e3662936a3b67f563d3ba45fa9c45e7b9

Authored by yibo
1 parent 48d37f79

feat(wechat-auto-reply): 群回复自动 @ 触发者(文字前缀)

- process_event 里确定性拼接 @昵称+U+2005(微信原生分隔符)前缀,仅群消息(isGroup)
- 确定性代码不靠 LLM/skill(否则会漏会不一致);安全判定仍在原始 draft 上跑
- 审批页/自动发的文本都带 @;送达校验用带 @ 的文本匹配
- ⚠️ 仅文字@(可见),不带 atuserlist,未必真 ping 红点;真通知需 hook 加 at 列表(README 待收尾)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
wechat_plain/scripts/auto-reply/README.md
... ... @@ -92,10 +92,11 @@ python wechat_reply_orchestrator.py --drain # 手动跑一轮编排
92 92  
93 93 ## 待收尾(都非阻塞,系统已可用)
94 94  
95   -1. **原生引用气泡 / 群@昵称前缀**:现发纯文本;微信原生引用(A11y 长按→引用)是环节5 增强。
96   -2. **日志轮转**:watcher/orchestrator log 24/7 会长(ponytail 已标)。
97   -3. **lifelog 人物增强**(二期):回链 `com.lemon.lifelog` 的 Person 库让回复带人物记忆。
98   -4. ~~auto_send 开关搬进 viewer UI~~ ✅ **已做(2026-07-12)**:`/whitelist` 页顶部开关卡片,`GET/POST /api/auto_send` 读写 `policy_config.json`。
  95 +1. **群回复 @ 触发者**:✅ **文字前缀已做(2026-07-12)**——群消息回复自动加 `@昵称 `(U+2005=微信原生分隔符),`process_event` 里确定性拼接(不靠 LLM)。⚠️ 仅**文字 @**(看得见),**不带 `atuserlist`** → 未必真 ping 对方红点「有人@我」;真通知要给 `SEND_MSG_INTERNAL` hook 加 at 列表 + 逆向微信发送 API 的 at 字段(大工程,待定)。
  96 +2. **原生引用气泡**:现发纯文本;微信原生引用(A11y 长按→引用)是环节5 增强。
  97 +3. **日志轮转**:watcher/orchestrator log 24/7 会长(ponytail 已标)。
  98 +4. **lifelog 人物增强**(二期):回链 `com.lemon.lifelog` 的 Person 库让回复带人物记忆。
  99 +5. ~~auto_send 开关搬进 viewer UI~~ ✅ **已做(2026-07-12)**:`/whitelist` 页顶部开关卡片,`GET/POST /api/auto_send` 读写 `policy_config.json`。
99 100  
100 101 ## 相关文档
101 102 - `../../../md/wechat-auto-reply-requirements.md` — 需求 + 逐环节设计/状态
... ...
wechat_plain/scripts/auto-reply/wechat_reply_orchestrator.py
... ... @@ -267,16 +267,23 @@ def process_event(event, stage, key, self_wxid, cfg):
267 267 if action == "skip":
268 268 log(f"skip #{event.get('msgId')} {sender_disp}: {reason}")
269 269 return
  270 + # 群里回复自动 @ 触发者:昵称前缀 + U+2005(微信原生 @ 分隔符)。安全判定(decide)已在
  271 + # 原始 draft 上跑过,前缀只影响发送/展示的文本。
  272 + # ponytail: 只是文字 @(看得见),不带 atuserlist → 未必真 ping 到对方红点;真通知要改
  273 + # SEND_MSG_INTERNAL hook 加 at 列表(见 README 环节5 待收尾)。
  274 + send_text = draft
  275 + if event.get("isGroup") and sender_disp:
  276 + send_text = f"@{sender_disp} {draft}"
270 277 if action == "auto":
271   - res = _send_text_verified(event.get("talker"), draft, key)
  278 + res = _send_text_verified(event.get("talker"), send_text, key)
272 279 ok = res["ok"]
273   - _queue_insert(event, draft, sender_disp, "auto",
  280 + _queue_insert(event, send_text, sender_disp, "auto",
274 281 reason, "auto_sent" if ok else "failed")
275 282 log(f"auto {'OK ' + res['reason'] if ok else 'FAILED ' + res['reason']} "
276   - f"#{event.get('msgId')} {sender_disp} → {draft!r}")
  283 + f"#{event.get('msgId')} {sender_disp} → {send_text!r}")
277 284 else: # review
278   - _queue_insert(event, draft, sender_disp, "review", reason, "pending")
279   - log(f"review #{event.get('msgId')} {sender_disp}: {reason} → 草稿 {draft!r}")
  285 + _queue_insert(event, send_text, sender_disp, "review", reason, "pending")
  286 + log(f"review #{event.get('msgId')} {sender_disp}: {reason} → 草稿 {send_text!r}")
280 287  
281 288  
282 289 # ── drain:消费 incoming.jsonl ─────────────────────────
... ...