Commit b339a5dd6ae4f24e4e0d035fbd1adc293a264055
1 parent
252f05f4
feat(wechat-auto-reply): 环节4 策略引擎 + wechat-viewer 审批/白名单 UI
wechat_reply_policy.py:确定性安全闸 decide()→skip|auto|review,default-deny +敏感词硬override(钱/承诺/约时间白名单也拉回),demo 8/8 过。配置与审批 UI 落在 手机端 ~/wechat-viewer(Flask):新表 reply_whitelist/reply_queue、/whitelist 白名单页、/approvals 审批页(可编辑草稿+发送/取消),发送复用 send_text_via_hook。 真机验证白名单增删查、发送到 filehelper(sent)、取消(cancelled) 全通。viewer 改 动全文见 wechat-viewer-changes.md + viewer_templates/(不复制用户整个 app)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
5 changed files
with
417 additions
and
1 deletions
md/wechat-auto-reply-requirements.md
| ... | ... | @@ -77,7 +77,17 @@ |
| 77 | 77 | - ⚠️ 观察:SOUL.md 那句「约时间交本人确认」是**软约束**,约饭案例里模型仍主动接话——真正的自动/审核安全门必须落在**环节4 策略层**,不能只靠人设。 |
| 78 | 78 | - 待办:一次结构化调用同时出 `{should_reply, confidence, draft, risk_flags}` 留给环节4 一起做。 |
| 79 | 79 | |
| 80 | -### 环节 4 · 自动 vs 审核 —— **白名单直发,其余进 viewer 待回复** | |
| 80 | +### 环节 4 · 自动 vs 审核 —— ✅ **确定性策略引擎 + viewer 审批/白名单 UI(2026-07-10)** | |
| 81 | +- **策略引擎**(代码,非 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 通过。 | |
| 82 | +- **配置 + 审批 UI 落在手机端 `~/wechat-viewer`(Flask)**,非独立文件(详见 `wechat-viewer-changes.md`): | |
| 83 | + - 新表 `reply_whitelist` + `reply_queue`(`local_db.py`)。 | |
| 84 | + - `/whitelist` 白名单管理页;`/approvals` 审批页(待审列表 + **可编辑草稿** + 【发送】【取消】)。 | |
| 85 | + - 发送复用 `send_text_via_hook`(LSPosed 内部 API)。 | |
| 86 | + - 真机验证:白名单增删查、审批发送到 filehelper(status=sent)、取消(cancelled) 全通。 | |
| 87 | +- ⚠️ 印证环节3 的发现:安全门是**确定性代码**(敏感词硬拦),不靠人设软约束。 | |
| 88 | +- 待接:编排器把环节1 队列串到策略+队列(见环节5)。 | |
| 89 | + | |
| 90 | +### (原)环节 4 · 自动 vs 审核 —— **白名单直发,其余进 viewer 待回复** | |
| 81 | 91 | - **白名单**(联系人 / 群 / 群里某发言人)+ 生成成功 → **直接发送**。 |
| 82 | 92 | - **非白名单** → 写入 **Pixel 本地 wechat viewer 的"待回复列表"**,人工点击"发送"才真正发出。 |
| 83 | 93 | - 落地新增:①「待回复队列」存储(sqlite/json);② wechat viewer 的"发送"按钮接到发送链路。 | ... | ... |
wechat_plain/scripts/auto-reply/viewer_templates/approvals.html
0 → 100644
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}自动回复审批 - WeChat Viewer{% endblock %} | |
| 3 | +{% block body %} | |
| 4 | +<style> | |
| 5 | +body{overflow-y:auto!important;height:auto!important;} | |
| 6 | +.ap-container{max-width:960px;margin:0 auto;padding:20px;} | |
| 7 | +.ap-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;} | |
| 8 | +.ap-header h1{font-size:20px;color:#2c3e50;} | |
| 9 | +.btn-refresh{background:#95a5a6;color:white;padding:6px 14px;border:none;border-radius:6px;cursor:pointer;font-size:13px;} | |
| 10 | +.tabs{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap;} | |
| 11 | +.tab{padding:6px 14px;border-radius:6px;cursor:pointer;font-size:13px;border:1px solid #ddd;background:white;} | |
| 12 | +.tab.active{background:#2c3e50;color:white;border-color:#2c3e50;} | |
| 13 | +.ap-card{background:white;border-radius:8px;padding:14px 18px;margin-bottom:10px;box-shadow:0 1px 2px rgba(0,0,0,0.05);} | |
| 14 | +.ap-card.pending{border-left:3px solid #e67e22;} | |
| 15 | +.ap-card.sent{border-left:3px solid #27ae60;opacity:.75;} | |
| 16 | +.ap-card.auto_sent{border-left:3px solid #3498db;opacity:.8;} | |
| 17 | +.ap-card.cancelled,.ap-card.failed{border-left:3px solid #95a5a6;opacity:.65;} | |
| 18 | +.ap-top{display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap;} | |
| 19 | +.ap-name{font-weight:600;font-size:14px;} | |
| 20 | +.kind-badge{font-size:10px;padding:2px 8px;border-radius:10px;font-weight:600;background:#eef;color:#3a51c6;} | |
| 21 | +.kind-badge.group_at_me{background:#fdebd0;color:#b9770e}.kind-badge.group_quote_me{background:#e8daef;color:#6c3483} | |
| 22 | +.status-badge{font-size:10px;padding:2px 8px;border-radius:10px;margin-left:auto;} | |
| 23 | +.status-badge.sent{background:#d5f5e3;color:#1e8449}.status-badge.auto_sent{background:#d6eaf8;color:#1a5276} | |
| 24 | +.status-badge.cancelled,.status-badge.failed{background:#f2f3f4;color:#7f8c8d} | |
| 25 | +.reason{font-size:10px;color:#e67e22;margin-bottom:6px;} | |
| 26 | +.incoming{background:#f7f7f8;border-radius:6px;padding:8px 10px;font-size:12px;color:#444;margin-bottom:8px;white-space:pre-wrap;word-break:break-word;} | |
| 27 | +.incoming .lbl{color:#999;font-size:10px;display:block;margin-bottom:2px;} | |
| 28 | +.draft-box{width:100%;box-sizing:border-box;border:1px solid #cfd8e3;border-radius:6px;padding:8px 10px;font-size:13px;resize:vertical;min-height:44px;font-family:inherit;} | |
| 29 | +.ap-actions{display:flex;gap:8px;margin-top:8px;} | |
| 30 | +.ap-actions button{cursor:pointer;padding:7px 16px;border:none;border-radius:6px;font-size:13px;} | |
| 31 | +.btn-send{background:#27ae60;color:white}.btn-send:hover{background:#219a52} | |
| 32 | +.btn-cancel{background:#e74c3c;color:white}.btn-cancel:hover{background:#c0392b} | |
| 33 | +.sent-reply{font-size:13px;color:#1e8449;margin-top:4px;} | |
| 34 | +.ts{font-size:10px;color:#aaa;} | |
| 35 | +.empty-state{text-align:center;padding:60px 20px;color:#999;} | |
| 36 | +.loading{text-align:center;padding:40px;color:#999;} | |
| 37 | +</style> | |
| 38 | +<div class="ap-container"> | |
| 39 | +<div class="ap-header"> | |
| 40 | +<h1>✅ 自动回复审批</h1> | |
| 41 | +<button class="btn-refresh" onclick="load()">🔄 刷新</button> | |
| 42 | +</div> | |
| 43 | +<div class="tabs"> | |
| 44 | +<div class="tab active" data-s="pending" onclick="switchTab('pending',this)">待审批</div> | |
| 45 | +<div class="tab" data-s="" onclick="switchTab('',this)">全部</div> | |
| 46 | +<div class="tab" data-s="sent" onclick="switchTab('sent',this)">已发送</div> | |
| 47 | +<div class="tab" data-s="auto_sent" onclick="switchTab('auto_sent',this)">自动已发</div> | |
| 48 | +<div class="tab" data-s="cancelled" onclick="switchTab('cancelled',this)">已取消</div> | |
| 49 | +</div> | |
| 50 | +<div id="list"></div> | |
| 51 | +</div> | |
| 52 | +<script> | |
| 53 | +var curStatus='pending'; | |
| 54 | +function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');} | |
| 55 | +var STATUS_CN={pending:'待审批',sent:'已发送',auto_sent:'自动已发',cancelled:'已取消',failed:'发送失败'}; | |
| 56 | +var KIND_CN={private:'私聊',group_at_me:'群@我',group_quote_me:'群引用我'}; | |
| 57 | + | |
| 58 | +function switchTab(s,el){curStatus=s;document.querySelectorAll('.tabs .tab').forEach(function(t){t.classList.remove('active')});el.classList.add('active');load();} | |
| 59 | + | |
| 60 | +function load(){ | |
| 61 | + var el=document.getElementById('list'); | |
| 62 | + el.innerHTML='<div class="loading">加载中...</div>'; | |
| 63 | + fetch('/api/replies?status='+encodeURIComponent(curStatus)).then(function(r){return r.json()}).then(function(j){ | |
| 64 | + if(!j.ok){el.innerHTML='<div class="empty-state">加载失败: '+esc(j.error)+'</div>';return;} | |
| 65 | + if(!j.data.length){el.innerHTML='<div class="empty-state"><p>暂无</p></div>';return;} | |
| 66 | + var h=''; | |
| 67 | + for(var i=0;i<j.data.length;i++){ | |
| 68 | + var t=j.data[i]; | |
| 69 | + var pending=t.status==='pending'; | |
| 70 | + h+='<div class="ap-card '+t.status+'">'; | |
| 71 | + h+='<div class="ap-top"><span class="ap-name">'+esc(t.sender_name||t.sender||t.talker)+'</span>'; | |
| 72 | + h+='<span class="kind-badge '+t.kind+'">'+(KIND_CN[t.kind]||t.kind)+'</span>'; | |
| 73 | + if(!pending)h+='<span class="status-badge '+t.status+'">'+(STATUS_CN[t.status]||t.status)+'</span>'; | |
| 74 | + h+='</div>'; | |
| 75 | + if(pending&&t.reason)h+='<div class="reason">判定:'+esc(t.reason)+'</div>'; | |
| 76 | + h+='<div class="incoming"><span class="lbl">对方说:</span>'+esc(t.incoming)+'</div>'; | |
| 77 | + if(pending){ | |
| 78 | + h+='<textarea class="draft-box" id="d'+t.id+'">'+esc(t.draft)+'</textarea>'; | |
| 79 | + h+='<div class="ap-actions">'; | |
| 80 | + h+='<button class="btn-send" onclick="send('+t.id+')">✅ 发送</button>'; | |
| 81 | + h+='<button class="btn-cancel" onclick="cancel('+t.id+')">🗑 取消</button>'; | |
| 82 | + h+='</div>'; | |
| 83 | + }else{ | |
| 84 | + h+='<div class="sent-reply">回复:'+esc(t.draft)+'</div>'; | |
| 85 | + h+='<div class="ts">'+esc(t.sent_at||t.created_at)+'</div>'; | |
| 86 | + } | |
| 87 | + h+='</div>'; | |
| 88 | + } | |
| 89 | + el.innerHTML=h; | |
| 90 | + }).catch(function(e){el.innerHTML='<div class="empty-state">加载失败: '+e.message+'</div>';}); | |
| 91 | +} | |
| 92 | + | |
| 93 | +function send(id){ | |
| 94 | + var txt=document.getElementById('d'+id).value; | |
| 95 | + if(!txt.trim()){alert('回复内容为空');return;} | |
| 96 | + fetch('/api/reply/'+id+'/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:txt})}) | |
| 97 | + .then(function(r){return r.json()}).then(function(j){ | |
| 98 | + if(j.ok){load();}else{alert('发送失败: '+(j.error||JSON.stringify(j.data||{})));load();} | |
| 99 | + }); | |
| 100 | +} | |
| 101 | +function cancel(id){ | |
| 102 | + fetch('/api/reply/'+id+'/cancel',{method:'POST'}).then(function(r){return r.json()}).then(function(j){ | |
| 103 | + if(j.ok){load();}else{alert('失败: '+(j.error||''));} | |
| 104 | + }); | |
| 105 | +} | |
| 106 | +load(); | |
| 107 | +</script> | |
| 108 | +{% endblock %} | ... | ... |
wechat_plain/scripts/auto-reply/viewer_templates/whitelist.html
0 → 100644
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}回复白名单 - WeChat Viewer{% endblock %} | |
| 3 | +{% block body %} | |
| 4 | +<style> | |
| 5 | +body{overflow-y:auto!important;height:auto!important;} | |
| 6 | +.wl-container{max-width:760px;margin:0 auto;padding:20px;} | |
| 7 | +.wl-header h1{font-size:20px;color:#2c3e50;margin-bottom:6px;} | |
| 8 | +.wl-hint{font-size:12px;color:#888;margin-bottom:16px;line-height:1.5;} | |
| 9 | +.add-box{background:white;border-radius:8px;padding:14px 16px;margin-bottom:18px;box-shadow:0 1px 2px rgba(0,0,0,0.05);display:flex;gap:8px;flex-wrap:wrap;align-items:center;} | |
| 10 | +.add-box input{border:1px solid #cfd8e3;border-radius:6px;padding:8px 10px;font-size:13px;} | |
| 11 | +.add-box input.wxid{flex:1;min-width:180px;} | |
| 12 | +.add-box input.name{width:140px;} | |
| 13 | +.btn-add{background:#27ae60;color:white;border:none;border-radius:6px;padding:8px 16px;font-size:13px;cursor:pointer;} | |
| 14 | +.wl-card{background:white;border-radius:8px;padding:12px 16px;margin-bottom:8px;box-shadow:0 1px 2px rgba(0,0,0,0.05);display:flex;align-items:center;gap:10px;} | |
| 15 | +.kb{font-size:10px;padding:2px 8px;border-radius:10px;font-weight:600;} | |
| 16 | +.kb.contact{background:#d6eaf8;color:#1a5276}.kb.group{background:#fdebd0;color:#b9770e} | |
| 17 | +.wl-name{font-weight:600;font-size:14px;} | |
| 18 | +.wl-wxid{font-size:11px;color:#999;} | |
| 19 | +.btn-del{margin-left:auto;background:#e74c3c;color:white;border:none;border-radius:6px;padding:6px 12px;font-size:12px;cursor:pointer;} | |
| 20 | +.empty-state{text-align:center;padding:50px 20px;color:#999;} | |
| 21 | +</style> | |
| 22 | +<div class="wl-container"> | |
| 23 | +<div class="wl-header"><h1>⭐ 回复白名单</h1></div> | |
| 24 | +<div class="wl-hint">在白名单里的联系人 / 群,其消息可被<b>自动回复</b>(仍受敏感词等安全规则拦截,命中会转人工审批)。不在名单里的一律进「自动回复审批」等你手动发。<br>群 id 以 <code>@chatroom</code> 结尾会自动识别为群。</div> | |
| 25 | +<div class="add-box"> | |
| 26 | +<input class="wxid" id="wxid" placeholder="wxid 或 群id(xxx@chatroom)"> | |
| 27 | +<input class="name" id="name" placeholder="备注名(可选)"> | |
| 28 | +<button class="btn-add" onclick="add()">➕ 添加</button> | |
| 29 | +</div> | |
| 30 | +<div id="list"></div> | |
| 31 | +</div> | |
| 32 | +<script> | |
| 33 | +function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');} | |
| 34 | +function load(){ | |
| 35 | + var el=document.getElementById('list'); | |
| 36 | + fetch('/api/whitelist').then(function(r){return r.json()}).then(function(j){ | |
| 37 | + if(!j.ok){el.innerHTML='<div class="empty-state">加载失败</div>';return;} | |
| 38 | + if(!j.data.length){el.innerHTML='<div class="empty-state"><p>白名单为空 — 当前所有回复都会进人工审批</p></div>';return;} | |
| 39 | + var h=''; | |
| 40 | + for(var i=0;i<j.data.length;i++){ | |
| 41 | + var t=j.data[i]; | |
| 42 | + h+='<div class="wl-card">'; | |
| 43 | + h+='<span class="kb '+t.kind+'">'+(t.kind==='group'?'群':'联系人')+'</span>'; | |
| 44 | + h+='<span class="wl-name">'+esc(t.name||'(未命名)')+'</span>'; | |
| 45 | + h+='<span class="wl-wxid">'+esc(t.wxid)+'</span>'; | |
| 46 | + h+='<button class="btn-del" onclick="del('+t.id+')">删除</button>'; | |
| 47 | + h+='</div>'; | |
| 48 | + } | |
| 49 | + el.innerHTML=h; | |
| 50 | + }); | |
| 51 | +} | |
| 52 | +function add(){ | |
| 53 | + var wxid=document.getElementById('wxid').value.trim(); | |
| 54 | + var name=document.getElementById('name').value.trim(); | |
| 55 | + if(!wxid){alert('请输入 wxid 或群id');return;} | |
| 56 | + fetch('/api/whitelist',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({wxid:wxid,name:name})}) | |
| 57 | + .then(function(r){return r.json()}).then(function(j){ | |
| 58 | + if(j.ok){document.getElementById('wxid').value='';document.getElementById('name').value='';load();} | |
| 59 | + else{alert('失败: '+(j.error||''));} | |
| 60 | + }); | |
| 61 | +} | |
| 62 | +function del(id){ | |
| 63 | + fetch('/api/whitelist/'+id+'/delete',{method:'POST'}).then(function(r){return r.json()}).then(function(j){ | |
| 64 | + if(j.ok){load();}else{alert('失败: '+(j.error||''));} | |
| 65 | + }); | |
| 66 | +} | |
| 67 | +load(); | |
| 68 | +</script> | |
| 69 | +{% endblock %} | ... | ... |
wechat_plain/scripts/auto-reply/wechat-viewer-changes.md
0 → 100644
| 1 | +# wechat-viewer 集成改动(环节4 策略 + 审批/白名单 UI) | |
| 2 | + | |
| 3 | +> 落地在手机端 Flask 应用 `~/wechat-viewer`(该 app 有自己的 git 工作树,本文件只记录本次为 | |
| 4 | +> 微信自动回复新增的部分;两个新模板全文见同目录 `viewer_templates/`)。 | |
| 5 | + | |
| 6 | +## 新增数据表(`local_db.py` init_db) | |
| 7 | + | |
| 8 | +```sql | |
| 9 | +CREATE TABLE reply_whitelist ( -- 自动回复白名单 | |
| 10 | + id INTEGER PK, kind TEXT, -- 'contact'|'group' | |
| 11 | + wxid TEXT UNIQUE, name TEXT, added_at TEXT); | |
| 12 | + | |
| 13 | +CREATE TABLE reply_queue ( -- 策略判定后落地 | |
| 14 | + id INTEGER PK, msg_id INTEGER, talker TEXT, sender TEXT, sender_name TEXT, | |
| 15 | + kind TEXT, incoming TEXT, draft TEXT, | |
| 16 | + action TEXT, -- 'auto'|'review' | |
| 17 | + reason TEXT, | |
| 18 | + status TEXT, -- pending|sent|cancelled|auto_sent|failed | |
| 19 | + created_at TEXT, sent_at TEXT); | |
| 20 | +``` | |
| 21 | + | |
| 22 | +`local_db.py` 新增辅助函数:`whitelist_list/add/remove/wxids`、`reply_add/list/get/update`。 | |
| 23 | + | |
| 24 | +## 新增路由(`app.py`) | |
| 25 | + | |
| 26 | +| 路由 | 作用 | | |
| 27 | +|---|---| | |
| 28 | +| `GET /whitelist` | 白名单管理页 | | |
| 29 | +| `GET /api/whitelist` / `POST /api/whitelist` / `POST /api/whitelist/<id>/delete` | 白名单增删查(`xxx@chatroom` 自动识别为群) | | |
| 30 | +| `GET /approvals` | 自动回复审批页 | | |
| 31 | +| `GET /api/replies?status=` | 队列列表(pending / sent / auto_sent / cancelled / 全部) | | |
| 32 | +| `POST /api/reply/<id>/send` | 用(可编辑的)草稿发到会话 → `send_text_via_hook(talker,text)` → 标 sent/failed | | |
| 33 | +| `POST /api/reply/<id>/cancel` | 标 cancelled | | |
| 34 | + | |
| 35 | +发送复用 `~/.hermes/event_bus/wechat_hook_send.py` 的 `send_text_via_hook`(LSPosed 内部 API 广播 `SEND_MSG_INTERNAL`)。 | |
| 36 | + | |
| 37 | +## 新增页面(`templates/`,见 viewer_templates/) | |
| 38 | +- `approvals.html`:待审批列表,每条含对方原文 + **可编辑草稿框** + 【✅发送】【🗑取消】;另有已发送/自动已发/已取消筛选 tab。 | |
| 39 | +- `whitelist.html`:白名单增删列表。 | |
| 40 | + | |
| 41 | +`base.html` 抽屉导航加两个入口:✅自动回复审批 / ⭐回复白名单。 | |
| 42 | + | |
| 43 | +## 真机验证(2026-07-10) | |
| 44 | +- 页面 `/whitelist` `/approvals` 均 200。 | |
| 45 | +- 白名单:加「测试好友」→ 列表可见 → 删除 OK。 | |
| 46 | +- 审批:插入 pending → `/api/replies` 返回 → 点发送 → `send_text_via_hook` 广播到 filehelper(result=0, status=sent)→ 草稿按编辑后内容更新、状态转 sent、sent_at 记录。 | |
| 47 | +- 取消:pending → cancel → status=cancelled。 | |
| 48 | +- 测试数据已清空。 | |
| 49 | + | |
| 50 | +## 待接(环节5 / 编排) | |
| 51 | +- **编排器**:把环节1 队列 `incoming.jsonl` → 上下文 → Hermes 生成 → 策略 `decide()`(白名单读 `reply_whitelist`)→ auto 直接 `send_text_via_hook` 并 `reply_add(status=auto_sent)` / review 写 `reply_add(status=pending)`。 | |
| 52 | +- 群 @回复的 `@昵称` 前缀、原生引用气泡(A11y)。 | ... | ... |
wechat_plain/scripts/auto-reply/wechat_reply_policy.py
0 → 100644
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""环节4 · 策略判定 —— 确定性安全闸(自动发 / 进审核 / 跳过) | |
| 3 | + | |
| 4 | +为什么是代码不是让 LLM 判:环节3 已验证「SOUL.md 里写的软约束会漏」(约饭案例模型仍 | |
| 5 | +主动接话)。真正的自动/审核安全门必须是确定性、可审计、LLM 说服不动的硬规则。 | |
| 6 | + | |
| 7 | +输入:watcher 事件 + Hermes 生成的草稿 + (可选)模型置信/should_reply 信号 | |
| 8 | +输出:{action: auto|review|skip, reason} | |
| 9 | + skip —— 压根不该回(群里没@我、公众号、系统消息、自己发的、模型说别回) | |
| 10 | + auto —— 白名单 + 文本 + 无敏感词 + 总开关开 → 直接发(环节5) | |
| 11 | + review —— 其余一律进审核队列 pending.jsonl,人工在 wechat viewer 里点发送 | |
| 12 | + | |
| 13 | +设计=default-deny:不在白名单、命中敏感词、低置信、拿不准 → 全部 review。 | |
| 14 | +先保守起步(auto_send_enabled 默认 False = 全审核),跑顺了再对白名单放开自动。 | |
| 15 | + | |
| 16 | +配置 ~/.hermes/wechat_reply/policy_config.json(首次自动生成默认值)。 | |
| 17 | +""" | |
| 18 | +import json | |
| 19 | +import os | |
| 20 | +import time | |
| 21 | + | |
| 22 | +WORK_DIR = os.environ.get( | |
| 23 | + "WECHAT_REPLY_DIR", | |
| 24 | + "/data/data/com.termux/files/home/.hermes/wechat_reply") | |
| 25 | +CONFIG_FILE = f"{WORK_DIR}/policy_config.json" | |
| 26 | +PENDING_FILE = f"{WORK_DIR}/pending.jsonl" # 审核队列(环节5 viewer 读) | |
| 27 | +DECISION_LOG = f"{WORK_DIR}/decisions.jsonl" | |
| 28 | + | |
| 29 | +DEFAULT_CONFIG = { | |
| 30 | + "auto_send_enabled": False, # 总开关:False = 一切都进审核(保守起步) | |
| 31 | + "whitelist_contacts": [], # 允许自动发的 wxid(私聊) | |
| 32 | + "whitelist_groups": [], # 允许 @我 时自动发的 chatroom id | |
| 33 | + "min_confidence": 0.75, # 低于此→审核(模型给了置信才生效) | |
| 34 | + # 命中即强制 review(即使白名单也拉回人工)——钱/承诺/约时间/隐私 | |
| 35 | + "block_keywords": [ | |
| 36 | + "钱", "转账", "红包", "借", "还款", "付款", "打款", "汇款", "定金", "报价", | |
| 37 | + "价格", "多少钱", "合同", "签", "承诺", "保证", "担保", | |
| 38 | + "几点", "明天", "后天", "周末", "约", "见面", "面基", "碰头", "地址在", | |
| 39 | + "身份证", "银行卡", "验证码", "密码", "手机号", "住址", | |
| 40 | + ], | |
| 41 | +} | |
| 42 | + | |
| 43 | +# 非文本消息类型(草稿通常无意义) | |
| 44 | +NON_TEXT_TYPES = {3, 34, 43, 47, 42, 48, 49, 62, 10000} | |
| 45 | +SYSTEM_MARKERS = ("撤回了一条消息", "拍了拍", "拍一拍", "添加到通讯录", "以上是打招呼的内容") | |
| 46 | + | |
| 47 | + | |
| 48 | +def load_config(): | |
| 49 | + try: | |
| 50 | + with open(CONFIG_FILE) as f: | |
| 51 | + cfg = json.load(f) | |
| 52 | + return {**DEFAULT_CONFIG, **cfg} | |
| 53 | + except (OSError, ValueError): | |
| 54 | + os.makedirs(WORK_DIR, exist_ok=True) | |
| 55 | + with open(CONFIG_FILE, "w") as f: | |
| 56 | + json.dump(DEFAULT_CONFIG, f, ensure_ascii=False, indent=2) | |
| 57 | + return dict(DEFAULT_CONFIG) | |
| 58 | + | |
| 59 | + | |
| 60 | +def _hit_keyword(text, keywords): | |
| 61 | + for k in keywords: | |
| 62 | + if k and k in text: | |
| 63 | + return k | |
| 64 | + return None | |
| 65 | + | |
| 66 | + | |
| 67 | +def decide(event, draft, confidence=None, should_reply=True, self_wxid="", cfg=None): | |
| 68 | + """核心判定。返回 {action, reason, hit?}。纯函数、无副作用(写队列在 route())。""" | |
| 69 | + cfg = cfg or load_config() | |
| 70 | + kind = event.get("kind", "") | |
| 71 | + sender = event.get("sender") or event.get("talker") or "" | |
| 72 | + talker = event.get("talker", "") | |
| 73 | + body = event.get("body", "") or "" | |
| 74 | + mtype = event.get("type", 0) | |
| 75 | + | |
| 76 | + # ── skip 区:压根不回 ── | |
| 77 | + if sender and self_wxid and sender == self_wxid: | |
| 78 | + return {"action": "skip", "reason": "self:自己发的"} | |
| 79 | + if kind == "group_other": | |
| 80 | + return {"action": "skip", "reason": "group:群里没@我也没引用我"} | |
| 81 | + if str(talker).startswith("gh_") or str(sender).startswith("gh_"): | |
| 82 | + return {"action": "skip", "reason": "official:公众号/服务号推送"} | |
| 83 | + if any(m in body for m in SYSTEM_MARKERS): | |
| 84 | + return {"action": "skip", "reason": "system:系统消息(撤回/拍一拍/加好友)"} | |
| 85 | + if not should_reply: | |
| 86 | + return {"action": "skip", "reason": "model:模型判定无需回复"} | |
| 87 | + if not (draft or "").strip(): | |
| 88 | + if mtype in NON_TEXT_TYPES: | |
| 89 | + return {"action": "skip", "reason": f"nontext:非文本(type={mtype})且无草稿"} | |
| 90 | + return {"action": "skip", "reason": "empty:草稿为空"} | |
| 91 | + | |
| 92 | + # ── 硬安全override:命中敏感词一律 review(白名单也拉回)── | |
| 93 | + hit = _hit_keyword(body + "\n" + (draft or ""), cfg["block_keywords"]) | |
| 94 | + if hit: | |
| 95 | + return {"action": "review", "reason": f"sensitive:命中敏感词「{hit}」", "hit": hit} | |
| 96 | + | |
| 97 | + # ── 低置信 → review ── | |
| 98 | + if confidence is not None and confidence < cfg["min_confidence"]: | |
| 99 | + return {"action": "review", "reason": f"lowconf:置信{confidence}<{cfg['min_confidence']}"} | |
| 100 | + | |
| 101 | + # ── 白名单 + 总开关 → auto ── | |
| 102 | + is_group = event.get("isGroup") or str(talker).endswith("@chatroom") | |
| 103 | + whitelisted = ( | |
| 104 | + (not is_group and sender in cfg["whitelist_contacts"]) or | |
| 105 | + (is_group and talker in cfg["whitelist_groups"]) | |
| 106 | + ) | |
| 107 | + if whitelisted and cfg["auto_send_enabled"]: | |
| 108 | + return {"action": "auto", "reason": "whitelist:白名单+总开关开→直接发"} | |
| 109 | + | |
| 110 | + # ── default-deny:其余全部人工审核 ── | |
| 111 | + if not cfg["auto_send_enabled"]: | |
| 112 | + return {"action": "review", "reason": "conservative:总开关关(全审核)"} | |
| 113 | + return {"action": "review", "reason": "notwhitelist:不在白名单→人工"} | |
| 114 | + | |
| 115 | + | |
| 116 | +def route(event, draft, decision, meta=None): | |
| 117 | + """按判定落地:review→写审核队列 pending.jsonl;一切→记 decisions.jsonl 审计。""" | |
| 118 | + os.makedirs(WORK_DIR, exist_ok=True) | |
| 119 | + rec = { | |
| 120 | + "ts": int(time.time()), | |
| 121 | + "msgId": event.get("msgId"), | |
| 122 | + "talker": event.get("talker"), | |
| 123 | + "sender": event.get("sender"), | |
| 124 | + "kind": event.get("kind"), | |
| 125 | + "action": decision["action"], | |
| 126 | + "reason": decision["reason"], | |
| 127 | + "draft": draft, | |
| 128 | + **(meta or {}), | |
| 129 | + } | |
| 130 | + with open(DECISION_LOG, "a") as f: | |
| 131 | + f.write(json.dumps(rec, ensure_ascii=False) + "\n") | |
| 132 | + if decision["action"] == "review": | |
| 133 | + item = {**rec, "status": "pending"} | |
| 134 | + with open(PENDING_FILE, "a") as f: | |
| 135 | + f.write(json.dumps(item, ensure_ascii=False) + "\n") | |
| 136 | + return rec | |
| 137 | + | |
| 138 | + | |
| 139 | +# ── 自检 demo(lazy 规则:留一个可跑的检查)── | |
| 140 | +def _demo(): | |
| 141 | + cfg = dict(DEFAULT_CONFIG) | |
| 142 | + cfg["whitelist_contacts"] = ["wxid_friend"] | |
| 143 | + cfg["auto_send_enabled"] = True | |
| 144 | + cases = [ | |
| 145 | + ("陌生猎头(非白名单)", {"kind": "private", "talker": "wxid_x", "sender": "wxid_x", | |
| 146 | + "body": "有急招岗位吗", "type": 1}, "暂不考虑,谢谢", None, "review"), | |
| 147 | + ("白名单好友闲聊", {"kind": "private", "talker": "wxid_friend", "sender": "wxid_friend", | |
| 148 | + "body": "在干嘛", "type": 1}, "摸鱼呢哈哈", None, "auto"), | |
| 149 | + ("白名单好友约饭(敏感词)", {"kind": "private", "talker": "wxid_friend", "sender": "wxid_friend", | |
| 150 | + "body": "明天中午一起吃饭?", "type": 1}, "可以啊几点", None, "review"), | |
| 151 | + ("白名单好友谈钱", {"kind": "private", "talker": "wxid_friend", "sender": "wxid_friend", | |
| 152 | + "body": "借我5000周转下", "type": 1}, "行", None, "review"), | |
| 153 | + ("群里没@我", {"kind": "group_other", "talker": "x@chatroom", "sender": "wxid_a", | |
| 154 | + "isGroup": True, "body": "哈哈", "type": 1}, "草稿", None, "skip"), | |
| 155 | + ("公众号推送", {"kind": "private", "talker": "gh_abc", "sender": "gh_abc", | |
| 156 | + "body": "<msg>文章</msg>", "type": 49}, "", None, "skip"), | |
| 157 | + ("撤回系统消息", {"kind": "group_at_me", "talker": "x@chatroom", "sender": "wxid_a", | |
| 158 | + "body": "\"张三\" 撤回了一条消息", "type": 10000}, "", None, "skip"), | |
| 159 | + ("低置信", {"kind": "private", "talker": "wxid_friend", "sender": "wxid_friend", | |
| 160 | + "body": "你觉得呢", "type": 1}, "嗯", 0.4, "review"), | |
| 161 | + ] | |
| 162 | + ok = True | |
| 163 | + for name, ev, draft, conf, expect in cases: | |
| 164 | + d = decide(ev, draft, confidence=conf, cfg=cfg) | |
| 165 | + flag = "OK " if d["action"] == expect else "FAIL" | |
| 166 | + if d["action"] != expect: | |
| 167 | + ok = False | |
| 168 | + print(f"[{flag}] {name}: 期望={expect} 实得={d['action']} ({d['reason']})") | |
| 169 | + print("=== ALL PASS ===" if ok else "=== 有 FAIL ===") | |
| 170 | + return ok | |
| 171 | + | |
| 172 | + | |
| 173 | +if __name__ == "__main__": | |
| 174 | + import sys | |
| 175 | + if "--demo" in sys.argv: | |
| 176 | + sys.exit(0 if _demo() else 1) | |
| 177 | + print("用法: wechat_reply_policy.py --demo (或作为库 import decide/route)") | ... | ... |