From b339a5dd6ae4f24e4e0d035fbd1adc293a264055 Mon Sep 17 00:00:00 2001 From: yibo Date: Fri, 10 Jul 2026 00:03:35 +0800 Subject: [PATCH] feat(wechat-auto-reply): 环节4 策略引擎 + wechat-viewer 审批/白名单 UI --- md/wechat-auto-reply-requirements.md | 12 +++++++++++- wechat_plain/scripts/auto-reply/viewer_templates/approvals.html | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/auto-reply/viewer_templates/whitelist.html | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/auto-reply/wechat-viewer-changes.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/auto-reply/wechat_reply_policy.py | 177 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 417 insertions(+), 1 deletion(-) create mode 100644 wechat_plain/scripts/auto-reply/viewer_templates/approvals.html create mode 100644 wechat_plain/scripts/auto-reply/viewer_templates/whitelist.html create mode 100644 wechat_plain/scripts/auto-reply/wechat-viewer-changes.md create mode 100644 wechat_plain/scripts/auto-reply/wechat_reply_policy.py diff --git a/md/wechat-auto-reply-requirements.md b/md/wechat-auto-reply-requirements.md index 908a261..45c70e4 100644 --- a/md/wechat-auto-reply-requirements.md +++ b/md/wechat-auto-reply-requirements.md @@ -77,7 +77,17 @@ - ⚠️ 观察:SOUL.md 那句「约时间交本人确认」是**软约束**,约饭案例里模型仍主动接话——真正的自动/审核安全门必须落在**环节4 策略层**,不能只靠人设。 - 待办:一次结构化调用同时出 `{should_reply, confidence, draft, risk_flags}` 留给环节4 一起做。 -### 环节 4 · 自动 vs 审核 —— **白名单直发,其余进 viewer 待回复** +### 环节 4 · 自动 vs 审核 —— ✅ **确定性策略引擎 + viewer 审批/白名单 UI(2026-07-10)** +- **策略引擎**(代码,非 LLM 判)`wechat_plain/scripts/auto-reply/wechat_reply_policy.py`:`decide(event,draft,confidence)` → `skip|auto|review`。default-deny + 硬安全 override:群未@我/公众号/系统消息/自己发的/模型说别回 → skip;命中敏感词(钱/承诺/约时间/隐私,白名单也拉回)或低置信 → review;仅白名单+总开关开 → auto;其余 → review。demo 8/8 通过。 +- **配置 + 审批 UI 落在手机端 `~/wechat-viewer`(Flask)**,非独立文件(详见 `wechat-viewer-changes.md`): + - 新表 `reply_whitelist` + `reply_queue`(`local_db.py`)。 + - `/whitelist` 白名单管理页;`/approvals` 审批页(待审列表 + **可编辑草稿** + 【发送】【取消】)。 + - 发送复用 `send_text_via_hook`(LSPosed 内部 API)。 + - 真机验证:白名单增删查、审批发送到 filehelper(status=sent)、取消(cancelled) 全通。 +- ⚠️ 印证环节3 的发现:安全门是**确定性代码**(敏感词硬拦),不靠人设软约束。 +- 待接:编排器把环节1 队列串到策略+队列(见环节5)。 + +### (原)环节 4 · 自动 vs 审核 —— **白名单直发,其余进 viewer 待回复** - **白名单**(联系人 / 群 / 群里某发言人)+ 生成成功 → **直接发送**。 - **非白名单** → 写入 **Pixel 本地 wechat viewer 的"待回复列表"**,人工点击"发送"才真正发出。 - 落地新增:①「待回复队列」存储(sqlite/json);② wechat viewer 的"发送"按钮接到发送链路。 diff --git a/wechat_plain/scripts/auto-reply/viewer_templates/approvals.html b/wechat_plain/scripts/auto-reply/viewer_templates/approvals.html new file mode 100644 index 0000000..abae8b8 --- /dev/null +++ b/wechat_plain/scripts/auto-reply/viewer_templates/approvals.html @@ -0,0 +1,108 @@ +{% extends "base.html" %} +{% block title %}自动回复审批 - WeChat Viewer{% endblock %} +{% block body %} + +
+
+

✅ 自动回复审批

+ +
+
+
待审批
+
全部
+
已发送
+
自动已发
+
已取消
+
+
+
+ +{% endblock %} diff --git a/wechat_plain/scripts/auto-reply/viewer_templates/whitelist.html b/wechat_plain/scripts/auto-reply/viewer_templates/whitelist.html new file mode 100644 index 0000000..fcc6cd1 --- /dev/null +++ b/wechat_plain/scripts/auto-reply/viewer_templates/whitelist.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% block title %}回复白名单 - WeChat Viewer{% endblock %} +{% block body %} + +
+

⭐ 回复白名单

+
在白名单里的联系人 / 群,其消息可被自动回复(仍受敏感词等安全规则拦截,命中会转人工审批)。不在名单里的一律进「自动回复审批」等你手动发。
群 id 以 @chatroom 结尾会自动识别为群。
+
+ + + +
+
+
+ +{% endblock %} diff --git a/wechat_plain/scripts/auto-reply/wechat-viewer-changes.md b/wechat_plain/scripts/auto-reply/wechat-viewer-changes.md new file mode 100644 index 0000000..ea5c37c --- /dev/null +++ b/wechat_plain/scripts/auto-reply/wechat-viewer-changes.md @@ -0,0 +1,52 @@ +# wechat-viewer 集成改动(环节4 策略 + 审批/白名单 UI) + +> 落地在手机端 Flask 应用 `~/wechat-viewer`(该 app 有自己的 git 工作树,本文件只记录本次为 +> 微信自动回复新增的部分;两个新模板全文见同目录 `viewer_templates/`)。 + +## 新增数据表(`local_db.py` init_db) + +```sql +CREATE TABLE reply_whitelist ( -- 自动回复白名单 + id INTEGER PK, kind TEXT, -- 'contact'|'group' + wxid TEXT UNIQUE, name TEXT, added_at TEXT); + +CREATE TABLE reply_queue ( -- 策略判定后落地 + id INTEGER PK, msg_id INTEGER, talker TEXT, sender TEXT, sender_name TEXT, + kind TEXT, incoming TEXT, draft TEXT, + action TEXT, -- 'auto'|'review' + reason TEXT, + status TEXT, -- pending|sent|cancelled|auto_sent|failed + created_at TEXT, sent_at TEXT); +``` + +`local_db.py` 新增辅助函数:`whitelist_list/add/remove/wxids`、`reply_add/list/get/update`。 + +## 新增路由(`app.py`) + +| 路由 | 作用 | +|---|---| +| `GET /whitelist` | 白名单管理页 | +| `GET /api/whitelist` / `POST /api/whitelist` / `POST /api/whitelist//delete` | 白名单增删查(`xxx@chatroom` 自动识别为群) | +| `GET /approvals` | 自动回复审批页 | +| `GET /api/replies?status=` | 队列列表(pending / sent / auto_sent / cancelled / 全部) | +| `POST /api/reply//send` | 用(可编辑的)草稿发到会话 → `send_text_via_hook(talker,text)` → 标 sent/failed | +| `POST /api/reply//cancel` | 标 cancelled | + +发送复用 `~/.hermes/event_bus/wechat_hook_send.py` 的 `send_text_via_hook`(LSPosed 内部 API 广播 `SEND_MSG_INTERNAL`)。 + +## 新增页面(`templates/`,见 viewer_templates/) +- `approvals.html`:待审批列表,每条含对方原文 + **可编辑草稿框** + 【✅发送】【🗑取消】;另有已发送/自动已发/已取消筛选 tab。 +- `whitelist.html`:白名单增删列表。 + +`base.html` 抽屉导航加两个入口:✅自动回复审批 / ⭐回复白名单。 + +## 真机验证(2026-07-10) +- 页面 `/whitelist` `/approvals` 均 200。 +- 白名单:加「测试好友」→ 列表可见 → 删除 OK。 +- 审批:插入 pending → `/api/replies` 返回 → 点发送 → `send_text_via_hook` 广播到 filehelper(result=0, status=sent)→ 草稿按编辑后内容更新、状态转 sent、sent_at 记录。 +- 取消:pending → cancel → status=cancelled。 +- 测试数据已清空。 + +## 待接(环节5 / 编排) +- **编排器**:把环节1 队列 `incoming.jsonl` → 上下文 → Hermes 生成 → 策略 `decide()`(白名单读 `reply_whitelist`)→ auto 直接 `send_text_via_hook` 并 `reply_add(status=auto_sent)` / review 写 `reply_add(status=pending)`。 +- 群 @回复的 `@昵称` 前缀、原生引用气泡(A11y)。 diff --git a/wechat_plain/scripts/auto-reply/wechat_reply_policy.py b/wechat_plain/scripts/auto-reply/wechat_reply_policy.py new file mode 100644 index 0000000..b98aef0 --- /dev/null +++ b/wechat_plain/scripts/auto-reply/wechat_reply_policy.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""环节4 · 策略判定 —— 确定性安全闸(自动发 / 进审核 / 跳过) + +为什么是代码不是让 LLM 判:环节3 已验证「SOUL.md 里写的软约束会漏」(约饭案例模型仍 +主动接话)。真正的自动/审核安全门必须是确定性、可审计、LLM 说服不动的硬规则。 + +输入:watcher 事件 + Hermes 生成的草稿 + (可选)模型置信/should_reply 信号 +输出:{action: auto|review|skip, reason} + skip —— 压根不该回(群里没@我、公众号、系统消息、自己发的、模型说别回) + auto —— 白名单 + 文本 + 无敏感词 + 总开关开 → 直接发(环节5) + review —— 其余一律进审核队列 pending.jsonl,人工在 wechat viewer 里点发送 + +设计=default-deny:不在白名单、命中敏感词、低置信、拿不准 → 全部 review。 +先保守起步(auto_send_enabled 默认 False = 全审核),跑顺了再对白名单放开自动。 + +配置 ~/.hermes/wechat_reply/policy_config.json(首次自动生成默认值)。 +""" +import json +import os +import time + +WORK_DIR = os.environ.get( + "WECHAT_REPLY_DIR", + "/data/data/com.termux/files/home/.hermes/wechat_reply") +CONFIG_FILE = f"{WORK_DIR}/policy_config.json" +PENDING_FILE = f"{WORK_DIR}/pending.jsonl" # 审核队列(环节5 viewer 读) +DECISION_LOG = f"{WORK_DIR}/decisions.jsonl" + +DEFAULT_CONFIG = { + "auto_send_enabled": False, # 总开关:False = 一切都进审核(保守起步) + "whitelist_contacts": [], # 允许自动发的 wxid(私聊) + "whitelist_groups": [], # 允许 @我 时自动发的 chatroom id + "min_confidence": 0.75, # 低于此→审核(模型给了置信才生效) + # 命中即强制 review(即使白名单也拉回人工)——钱/承诺/约时间/隐私 + "block_keywords": [ + "钱", "转账", "红包", "借", "还款", "付款", "打款", "汇款", "定金", "报价", + "价格", "多少钱", "合同", "签", "承诺", "保证", "担保", + "几点", "明天", "后天", "周末", "约", "见面", "面基", "碰头", "地址在", + "身份证", "银行卡", "验证码", "密码", "手机号", "住址", + ], +} + +# 非文本消息类型(草稿通常无意义) +NON_TEXT_TYPES = {3, 34, 43, 47, 42, 48, 49, 62, 10000} +SYSTEM_MARKERS = ("撤回了一条消息", "拍了拍", "拍一拍", "添加到通讯录", "以上是打招呼的内容") + + +def load_config(): + try: + with open(CONFIG_FILE) as f: + cfg = json.load(f) + return {**DEFAULT_CONFIG, **cfg} + except (OSError, ValueError): + os.makedirs(WORK_DIR, exist_ok=True) + with open(CONFIG_FILE, "w") as f: + json.dump(DEFAULT_CONFIG, f, ensure_ascii=False, indent=2) + return dict(DEFAULT_CONFIG) + + +def _hit_keyword(text, keywords): + for k in keywords: + if k and k in text: + return k + return None + + +def decide(event, draft, confidence=None, should_reply=True, self_wxid="", cfg=None): + """核心判定。返回 {action, reason, hit?}。纯函数、无副作用(写队列在 route())。""" + cfg = cfg or load_config() + kind = event.get("kind", "") + sender = event.get("sender") or event.get("talker") or "" + talker = event.get("talker", "") + body = event.get("body", "") or "" + mtype = event.get("type", 0) + + # ── skip 区:压根不回 ── + if sender and self_wxid and sender == self_wxid: + return {"action": "skip", "reason": "self:自己发的"} + if kind == "group_other": + return {"action": "skip", "reason": "group:群里没@我也没引用我"} + if str(talker).startswith("gh_") or str(sender).startswith("gh_"): + return {"action": "skip", "reason": "official:公众号/服务号推送"} + if any(m in body for m in SYSTEM_MARKERS): + return {"action": "skip", "reason": "system:系统消息(撤回/拍一拍/加好友)"} + if not should_reply: + return {"action": "skip", "reason": "model:模型判定无需回复"} + if not (draft or "").strip(): + if mtype in NON_TEXT_TYPES: + return {"action": "skip", "reason": f"nontext:非文本(type={mtype})且无草稿"} + return {"action": "skip", "reason": "empty:草稿为空"} + + # ── 硬安全override:命中敏感词一律 review(白名单也拉回)── + hit = _hit_keyword(body + "\n" + (draft or ""), cfg["block_keywords"]) + if hit: + return {"action": "review", "reason": f"sensitive:命中敏感词「{hit}」", "hit": hit} + + # ── 低置信 → review ── + if confidence is not None and confidence < cfg["min_confidence"]: + return {"action": "review", "reason": f"lowconf:置信{confidence}<{cfg['min_confidence']}"} + + # ── 白名单 + 总开关 → auto ── + is_group = event.get("isGroup") or str(talker).endswith("@chatroom") + whitelisted = ( + (not is_group and sender in cfg["whitelist_contacts"]) or + (is_group and talker in cfg["whitelist_groups"]) + ) + if whitelisted and cfg["auto_send_enabled"]: + return {"action": "auto", "reason": "whitelist:白名单+总开关开→直接发"} + + # ── default-deny:其余全部人工审核 ── + if not cfg["auto_send_enabled"]: + return {"action": "review", "reason": "conservative:总开关关(全审核)"} + return {"action": "review", "reason": "notwhitelist:不在白名单→人工"} + + +def route(event, draft, decision, meta=None): + """按判定落地:review→写审核队列 pending.jsonl;一切→记 decisions.jsonl 审计。""" + os.makedirs(WORK_DIR, exist_ok=True) + rec = { + "ts": int(time.time()), + "msgId": event.get("msgId"), + "talker": event.get("talker"), + "sender": event.get("sender"), + "kind": event.get("kind"), + "action": decision["action"], + "reason": decision["reason"], + "draft": draft, + **(meta or {}), + } + with open(DECISION_LOG, "a") as f: + f.write(json.dumps(rec, ensure_ascii=False) + "\n") + if decision["action"] == "review": + item = {**rec, "status": "pending"} + with open(PENDING_FILE, "a") as f: + f.write(json.dumps(item, ensure_ascii=False) + "\n") + return rec + + +# ── 自检 demo(lazy 规则:留一个可跑的检查)── +def _demo(): + cfg = dict(DEFAULT_CONFIG) + cfg["whitelist_contacts"] = ["wxid_friend"] + cfg["auto_send_enabled"] = True + cases = [ + ("陌生猎头(非白名单)", {"kind": "private", "talker": "wxid_x", "sender": "wxid_x", + "body": "有急招岗位吗", "type": 1}, "暂不考虑,谢谢", None, "review"), + ("白名单好友闲聊", {"kind": "private", "talker": "wxid_friend", "sender": "wxid_friend", + "body": "在干嘛", "type": 1}, "摸鱼呢哈哈", None, "auto"), + ("白名单好友约饭(敏感词)", {"kind": "private", "talker": "wxid_friend", "sender": "wxid_friend", + "body": "明天中午一起吃饭?", "type": 1}, "可以啊几点", None, "review"), + ("白名单好友谈钱", {"kind": "private", "talker": "wxid_friend", "sender": "wxid_friend", + "body": "借我5000周转下", "type": 1}, "行", None, "review"), + ("群里没@我", {"kind": "group_other", "talker": "x@chatroom", "sender": "wxid_a", + "isGroup": True, "body": "哈哈", "type": 1}, "草稿", None, "skip"), + ("公众号推送", {"kind": "private", "talker": "gh_abc", "sender": "gh_abc", + "body": "文章", "type": 49}, "", None, "skip"), + ("撤回系统消息", {"kind": "group_at_me", "talker": "x@chatroom", "sender": "wxid_a", + "body": "\"张三\" 撤回了一条消息", "type": 10000}, "", None, "skip"), + ("低置信", {"kind": "private", "talker": "wxid_friend", "sender": "wxid_friend", + "body": "你觉得呢", "type": 1}, "嗯", 0.4, "review"), + ] + ok = True + for name, ev, draft, conf, expect in cases: + d = decide(ev, draft, confidence=conf, cfg=cfg) + flag = "OK " if d["action"] == expect else "FAIL" + if d["action"] != expect: + ok = False + print(f"[{flag}] {name}: 期望={expect} 实得={d['action']} ({d['reason']})") + print("=== ALL PASS ===" if ok else "=== 有 FAIL ===") + return ok + + +if __name__ == "__main__": + import sys + if "--demo" in sys.argv: + sys.exit(0 if _demo() else 1) + print("用法: wechat_reply_policy.py --demo (或作为库 import decide/route)") -- libgit2 0.21.4