Commit 20815fb380e0380377c6ccc52e75f9d465974931
1 parent
b339a5dd
feat(wechat-auto-reply): 编排器串起 1→5 全链路 + watcher 接线
wechat_reply_orchestrator.py:消费 watcher incoming.jsonl(cursor增量+单实例锁+ 首跑基线)→ 候选事件(私聊/@我/引用我)→ env2 查上下文(rcontact身份+历史,一次拷库 全批复用)→ env3 hermes -z 生成 → env4 policy.decide(白名单读 circle.db)→ env5 落地(auto:send_text_via_hook+auto_sent / review:pending 进 /approvals)。生成失败 即静默不发,绝不误发。watcher._trigger_orchestrator 读到新消息后 fire-and-forget 拉起 drain(独立进程不阻塞采集)。真机验证:接线自动触发、真实猎头上下文生成火星人 口吻草稿并正确进审核队列。auto_send 默认关=全审核(保守起步)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
3 changed files
with
303 additions
and
0 deletions
md/wechat-auto-reply-requirements.md
| ... | ... | @@ -93,6 +93,13 @@ |
| 93 | 93 | - 落地新增:①「待回复队列」存储(sqlite/json);② wechat viewer 的"发送"按钮接到发送链路。 |
| 94 | 94 | - 安全红线(贯穿):涉钱 / 承诺 / 约时间 / 低置信 → 即使白名单也建议进审核。 |
| 95 | 95 | |
| 96 | +### 编排器 · 把 1→5 串起来 —— ✅ **已实现并真机验证(2026-07-10)** | |
| 97 | +`wechat_plain/scripts/auto-reply/wechat_reply_orchestrator.py`(设备 `~/.hermes/wechat_reply/`): | |
| 98 | +- watcher 读到新消息 → 写 `incoming.jsonl` → **fire-and-forget 触发** `orchestrator --drain`(watcher `_trigger_orchestrator`,独立进程不阻塞采集)。 | |
| 99 | +- drain(非阻塞单实例锁 + cursor 增量 + 首跑基线):对候选事件(私聊/群@我/群引用我)→ env2 查上下文(rcontact 身份 + 最近历史,一次拷库全批复用)→ env3 `hermes -z` 生成 → env4 `policy.decide()`(白名单读 circle.db `reply_whitelist`)→ env5 落地:auto → `send_text_via_hook` + `reply_queue(auto_sent)` / review → `reply_queue(pending)`。 | |
| 100 | +- 安全:生成超时/失败 → 无草稿 → policy skip → 不发(失败即静默,绝不误发)。 | |
| 101 | +- 真机验证:接线(watcher inotify → 自动拉起编排器 drain)通过;真实上下文(猎头 婷婷)生成「哈哈婷婷,我不是什么总,就是个写代码的,不负责招人这块哈」→ 正确进 /approvals 待审。 | |
| 102 | + | |
| 96 | 103 | ### 环节 5 · 发送回复带引用(原生气泡) |
| 97 | 104 | 需求 = 点原文→引用→发送后显示引用气泡。两条实现路: |
| 98 | 105 | - **(a) A11y UI 自动化(一期推荐)**:长按目标气泡 → 点"引用" → 输入 → 发送。驱动微信自己 UI → **产出货真价实的原生引用气泡,零逆向**。代价:需微信前台、脚本略脆、比 API 慢。已有 A11y 发送服务底子。 | ... | ... |
wechat_plain/scripts/auto-reply/wechat_reply_orchestrator.py
0 → 100644
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""环节 1→5 编排器 —— 把整条自动回复链串起来 | |
| 3 | + | |
| 4 | +消费 watcher 的 incoming.jsonl(cursor 增量)→ 对候选事件(私聊/群@我/群引用我): | |
| 5 | + env2 查上下文(发送者身份 + 最近历史)→ env3 hermes -z 生成草稿 → | |
| 6 | + env4 policy.decide()(白名单读 wechat-viewer 的 circle.db)→ | |
| 7 | + env5 落地:auto 直接 send_text_via_hook + 入队(auto_sent) / review 入队(pending,在 /approvals 等人工) | |
| 8 | + | |
| 9 | +作为 root 在 Termux 跑(同 watcher 上下文)。由 watcher 每次读到新消息后触发 --drain。 | |
| 10 | + | |
| 11 | +安全:policy 默认 auto_send_enabled=False → 一切进 review(不自动发)。生成超时/失败 → | |
| 12 | +无草稿 → policy skip → 不发(失败即静默,绝不误发)。 | |
| 13 | + | |
| 14 | +ponytail: 每次 drain 拷一次整库(~96MB)供该批所有事件查上下文;事件稀疏可接受。 | |
| 15 | +升级:与 watcher 共享一次拷贝;incoming.jsonl 增长需按 offset 游标+轮转。 | |
| 16 | +""" | |
| 17 | +import json | |
| 18 | +import os | |
| 19 | +import sqlite3 | |
| 20 | +import subprocess | |
| 21 | +import sys | |
| 22 | +import time | |
| 23 | + | |
| 24 | +_HERE = os.path.dirname(os.path.abspath(__file__)) | |
| 25 | +sys.path.insert(0, _HERE) | |
| 26 | +import wechat_reply_watcher as w # 复用解密/查询/账号推导 | |
| 27 | +import wechat_reply_policy as policy | |
| 28 | + | |
| 29 | +WORK_DIR = w.WORK_DIR | |
| 30 | +QUEUE_FILE = f"{WORK_DIR}/incoming.jsonl" | |
| 31 | +CURSOR_FILE = f"{WORK_DIR}/orch_state.json" | |
| 32 | +ORCH_LOG = f"{WORK_DIR}/orchestrator.log" | |
| 33 | +CIRCLE_DB = "/data/data/com.termux/files/home/wechat-viewer/circle.db" | |
| 34 | +EVENT_BUS = "/data/data/com.termux/files/home/.hermes/event_bus" | |
| 35 | +HERMES = "/data/data/com.termux/files/usr/bin/hermes" | |
| 36 | + | |
| 37 | +CANDIDATE_KINDS = {"private", "group_at_me", "group_quote_me"} | |
| 38 | +HISTORY_N = 12 | |
| 39 | +GEN_TIMEOUT = 160 | |
| 40 | + | |
| 41 | + | |
| 42 | +def log(msg): | |
| 43 | + line = f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}" | |
| 44 | + print(line, flush=True) | |
| 45 | + try: | |
| 46 | + with open(ORCH_LOG, "a") as f: | |
| 47 | + f.write(line + "\n") | |
| 48 | + except OSError: | |
| 49 | + pass | |
| 50 | + | |
| 51 | + | |
| 52 | +# ── env2:查上下文 ───────────────────────────────────── | |
| 53 | +def _display_name(stage, key, wxid): | |
| 54 | + if not wxid: | |
| 55 | + return "" | |
| 56 | + rows = w.sqlcipher_query( | |
| 57 | + stage, key, | |
| 58 | + f"SELECT conRemark, nickname, alias FROM rcontact WHERE username='{wxid}';") | |
| 59 | + if rows and rows[0]: | |
| 60 | + r = rows[0] + ["", "", ""] | |
| 61 | + return r[0] or r[1] or r[2] or wxid | |
| 62 | + return wxid | |
| 63 | + | |
| 64 | + | |
| 65 | +def _placeholder(mtype, text): | |
| 66 | + if mtype == 1: | |
| 67 | + return text | |
| 68 | + return {3: "[图片]", 34: "[语音]", 43: "[视频]", 47: "[表情]", | |
| 69 | + 42: "[名片]", 49: "[链接/文件]", 48: "[位置]"}.get(mtype, text[:40] if text else "[消息]") | |
| 70 | + | |
| 71 | + | |
| 72 | +def build_context(stage, key, event, self_wxid): | |
| 73 | + talker = event.get("talker", "") | |
| 74 | + is_group = event.get("isGroup") or talker.endswith("@chatroom") | |
| 75 | + sender_wxid = event.get("sender") or talker | |
| 76 | + sender_disp = _display_name(stage, key, sender_wxid) | |
| 77 | + lines = [f"对方:{sender_disp}"] | |
| 78 | + if is_group: | |
| 79 | + gname = _display_name(stage, key, talker) | |
| 80 | + lines.insert(0, f"[群] {gname}") | |
| 81 | + # 最近历史 | |
| 82 | + rows = w.sqlcipher_query( | |
| 83 | + stage, key, | |
| 84 | + "SELECT isSend, type, createTime, hex(content) FROM message " | |
| 85 | + f"WHERE talker='{talker}' ORDER BY msgId DESC LIMIT {HISTORY_N};") | |
| 86 | + hist = [] | |
| 87 | + for r in reversed(rows): | |
| 88 | + if len(r) < 4: | |
| 89 | + continue | |
| 90 | + is_send, mtype, _, chex = r | |
| 91 | + try: | |
| 92 | + raw = bytes.fromhex(chex).decode("utf-8", "ignore") | |
| 93 | + except ValueError: | |
| 94 | + raw = "" | |
| 95 | + if is_send == "1": | |
| 96 | + who, body = "我", raw | |
| 97 | + else: | |
| 98 | + s, body = w.parse_group_content(raw) if is_group else (sender_wxid, raw) | |
| 99 | + who = _display_name(stage, key, s) if is_group and s else sender_disp | |
| 100 | + txt = _placeholder(int(mtype) if mtype.isdigit() else 0, body).replace("\n", " ").strip() | |
| 101 | + if txt: | |
| 102 | + hist.append(f" {who}: {txt}") | |
| 103 | + ctx = "\n".join(lines) | |
| 104 | + if hist: | |
| 105 | + ctx += "\n最近对话:\n" + "\n".join(hist[-HISTORY_N:]) | |
| 106 | + ctx += f"\n本条:{sender_disp}: {(event.get('body') or '').strip()[:200]}" | |
| 107 | + return ctx, sender_disp | |
| 108 | + | |
| 109 | + | |
| 110 | +# ── env3:hermes 生成 ───────────────────────────────── | |
| 111 | +def generate(context_text): | |
| 112 | + prompt = ( | |
| 113 | + "【任务】下面是别人发到微信来的一条消息,请以你本人(火星人)的口吻,写一条你会真的发出去的" | |
| 114 | + "微信回复。只输出这条回复本身,不要解释、不要引号、不要用任何工具、不要真的发送。\n\n" | |
| 115 | + "【上下文】\n" + context_text) | |
| 116 | + env = dict(os.environ) | |
| 117 | + env.update({ | |
| 118 | + "HOME": "/data/data/com.termux/files/home", | |
| 119 | + "PREFIX": "/data/data/com.termux/files/usr", | |
| 120 | + "PATH": "/data/data/com.termux/files/usr/bin:/system/bin", | |
| 121 | + "LD_LIBRARY_PATH": "/data/data/com.termux/files/usr/lib", | |
| 122 | + }) | |
| 123 | + try: | |
| 124 | + p = subprocess.run([HERMES, "-z", prompt], capture_output=True, text=True, | |
| 125 | + timeout=GEN_TIMEOUT, env=env) | |
| 126 | + out = (p.stdout or "").strip() | |
| 127 | + # 取最后一段非空文本(躲开可能的日志行) | |
| 128 | + return out.split("\n")[-1].strip() if out else "" | |
| 129 | + except subprocess.TimeoutExpired: | |
| 130 | + log("生成超时") | |
| 131 | + return "" | |
| 132 | + except Exception as e: # noqa: BLE001 | |
| 133 | + log(f"生成异常: {e}") | |
| 134 | + return "" | |
| 135 | + | |
| 136 | + | |
| 137 | +# ── env4→5:落地 ───────────────────────────────────── | |
| 138 | +def _load_whitelist(): | |
| 139 | + contacts, groups = [], [] | |
| 140 | + try: | |
| 141 | + con = sqlite3.connect(CIRCLE_DB) | |
| 142 | + for kind, wxid in con.execute("SELECT kind, wxid FROM reply_whitelist"): | |
| 143 | + (groups if kind == "group" else contacts).append(wxid) | |
| 144 | + con.close() | |
| 145 | + except Exception: | |
| 146 | + pass | |
| 147 | + return contacts, groups | |
| 148 | + | |
| 149 | + | |
| 150 | +def _queue_insert(event, draft, sender_disp, action, reason, status): | |
| 151 | + con = sqlite3.connect(CIRCLE_DB) | |
| 152 | + con.execute( | |
| 153 | + """INSERT INTO reply_queue | |
| 154 | + (msg_id, talker, sender, sender_name, kind, incoming, draft, action, reason, status, sent_at) | |
| 155 | + VALUES (?,?,?,?,?,?,?,?,?,?, CASE WHEN ?='auto_sent' THEN datetime('now','localtime') ELSE NULL END)""", | |
| 156 | + (event.get("msgId"), event.get("talker"), event.get("sender"), sender_disp, | |
| 157 | + event.get("kind"), event.get("body"), draft, action, reason, status, status)) | |
| 158 | + con.commit() | |
| 159 | + con.close() | |
| 160 | + | |
| 161 | + | |
| 162 | +def _send_text(to_wxid, text): | |
| 163 | + sys.path.insert(0, EVENT_BUS) | |
| 164 | + from wechat_hook_send import send_text_via_hook | |
| 165 | + return send_text_via_hook(to_wxid, text) | |
| 166 | + | |
| 167 | + | |
| 168 | +def process_event(event, stage, key, self_wxid, cfg): | |
| 169 | + ctx, sender_disp = build_context(stage, key, event, self_wxid) | |
| 170 | + draft = generate(ctx) | |
| 171 | + d = policy.decide(event, draft, self_wxid=self_wxid, cfg=cfg) | |
| 172 | + action, reason = d["action"], d["reason"] | |
| 173 | + if action == "skip": | |
| 174 | + log(f"skip #{event.get('msgId')} {sender_disp}: {reason}") | |
| 175 | + return | |
| 176 | + if action == "auto": | |
| 177 | + res = _send_text(event.get("talker"), draft) | |
| 178 | + ok = bool(res.get("ok") or res.get("status") == "sent") | |
| 179 | + _queue_insert(event, draft, sender_disp, "auto", | |
| 180 | + reason, "auto_sent" if ok else "failed") | |
| 181 | + log(f"auto {'OK' if ok else 'FAIL'} #{event.get('msgId')} {sender_disp} → {draft!r}") | |
| 182 | + else: # review | |
| 183 | + _queue_insert(event, draft, sender_disp, "review", reason, "pending") | |
| 184 | + log(f"review #{event.get('msgId')} {sender_disp}: {reason} → 草稿 {draft!r}") | |
| 185 | + | |
| 186 | + | |
| 187 | +# ── drain:消费 incoming.jsonl ───────────────────────── | |
| 188 | +def _read_events(): | |
| 189 | + evs = [] | |
| 190 | + try: | |
| 191 | + with open(QUEUE_FILE) as f: | |
| 192 | + for line in f: | |
| 193 | + line = line.strip() | |
| 194 | + if line: | |
| 195 | + try: | |
| 196 | + evs.append(json.loads(line)) | |
| 197 | + except ValueError: | |
| 198 | + pass | |
| 199 | + except OSError: | |
| 200 | + pass | |
| 201 | + return evs | |
| 202 | + | |
| 203 | + | |
| 204 | +def drain(): | |
| 205 | + """非阻塞单实例:已有 drain 在跑就跳过(后续消息的 inotify 会再触发)。""" | |
| 206 | + lock = f"{WORK_DIR}/orch.lock" | |
| 207 | + try: | |
| 208 | + fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY) | |
| 209 | + os.close(fd) | |
| 210 | + except FileExistsError: | |
| 211 | + try: # 5min 以上视为残留锁,抢占 | |
| 212 | + if time.time() - os.path.getmtime(lock) > 300: | |
| 213 | + os.remove(lock) | |
| 214 | + os.close(os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)) | |
| 215 | + else: | |
| 216 | + return | |
| 217 | + except OSError: | |
| 218 | + return | |
| 219 | + try: | |
| 220 | + _drain_inner() | |
| 221 | + finally: | |
| 222 | + try: | |
| 223 | + os.remove(lock) | |
| 224 | + except OSError: | |
| 225 | + pass | |
| 226 | + | |
| 227 | + | |
| 228 | +def _drain_inner(): | |
| 229 | + evs = _read_events() | |
| 230 | + if not evs: | |
| 231 | + return | |
| 232 | + try: | |
| 233 | + with open(CURSOR_FILE) as f: | |
| 234 | + cursor = json.load(f).get("last_msgId", 0) | |
| 235 | + except (OSError, ValueError): | |
| 236 | + cursor = None | |
| 237 | + max_id = max((int(e.get("msgId", 0)) for e in evs), default=0) | |
| 238 | + if cursor is None: # 首跑:基线,不回放 | |
| 239 | + with open(CURSOR_FILE, "w") as f: | |
| 240 | + json.dump({"last_msgId": max_id}, f) | |
| 241 | + w.chown_to_hermes(CURSOR_FILE) | |
| 242 | + log(f"编排器首跑基线 last_msgId={max_id}") | |
| 243 | + return | |
| 244 | + todo = [e for e in evs if int(e.get("msgId", 0)) > cursor | |
| 245 | + and e.get("kind") in CANDIDATE_KINDS] | |
| 246 | + if todo: | |
| 247 | + _, db, key = w.account_paths() | |
| 248 | + stage = w.copy_db(db) | |
| 249 | + try: | |
| 250 | + self_wxid, _ = w.detect_self(stage, key) | |
| 251 | + cfg = policy.load_config() | |
| 252 | + cfg["whitelist_contacts"], cfg["whitelist_groups"] = _load_whitelist() | |
| 253 | + log(f"drain:{len(todo)} 条候选(auto_send={cfg['auto_send_enabled']})") | |
| 254 | + for e in todo: | |
| 255 | + try: | |
| 256 | + process_event(e, stage, key, self_wxid, cfg) | |
| 257 | + except Exception as ex: # noqa: BLE001 | |
| 258 | + log(f"处理 #{e.get('msgId')} 失败: {ex}") | |
| 259 | + finally: | |
| 260 | + for p in __import__("glob").glob(f"{WORK_DIR}/_stage/en.db*"): | |
| 261 | + try: | |
| 262 | + os.remove(p) | |
| 263 | + except OSError: | |
| 264 | + pass | |
| 265 | + with open(CURSOR_FILE, "w") as f: | |
| 266 | + json.dump({"last_msgId": max_id}, f) | |
| 267 | + w.chown_to_hermes(CURSOR_FILE) | |
| 268 | + | |
| 269 | + | |
| 270 | +def main(): | |
| 271 | + args = sys.argv[1:] | |
| 272 | + if "--drain" in args: | |
| 273 | + drain() | |
| 274 | + else: | |
| 275 | + print("用法: wechat_reply_orchestrator.py --drain") | |
| 276 | + | |
| 277 | + | |
| 278 | +if __name__ == "__main__": | |
| 279 | + main() | ... | ... |
wechat_plain/scripts/auto-reply/wechat_reply_watcher.py
| ... | ... | @@ -310,12 +310,29 @@ def daemon(): |
| 310 | 310 | _watch_loop_mtime(db) |
| 311 | 311 | |
| 312 | 312 | |
| 313 | +def _trigger_orchestrator(): | |
| 314 | + """读到新消息后 fire-and-forget 触发编排器 drain(生成/策略/发送在独立进程, | |
| 315 | + 不阻塞 watcher;编排器自身有单实例锁)。缺文件/异常都不影响 watcher。""" | |
| 316 | + orch = os.path.join(os.path.dirname(os.path.abspath(__file__)), | |
| 317 | + "wechat_reply_orchestrator.py") | |
| 318 | + if not os.path.exists(orch): | |
| 319 | + return | |
| 320 | + try: | |
| 321 | + import sys | |
| 322 | + subprocess.Popen([sys.executable, orch, "--drain"], | |
| 323 | + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, | |
| 324 | + start_new_session=True) | |
| 325 | + except Exception as e: # noqa: BLE001 | |
| 326 | + log(f"触发编排器失败: {e}") | |
| 327 | + | |
| 328 | + | |
| 313 | 329 | def _process(reason): |
| 314 | 330 | try: |
| 315 | 331 | evs = read_new() |
| 316 | 332 | if evs: |
| 317 | 333 | kinds = ",".join(e["kind"] for e in evs) |
| 318 | 334 | log(f"[{reason}] +{len(evs)} 条 → {kinds} (至 msgId {evs[-1]['msgId']})") |
| 335 | + _trigger_orchestrator() | |
| 319 | 336 | except Exception as e: # noqa: BLE001 — daemon 不能因单次读失败退出 |
| 320 | 337 | log(f"[{reason}] 读失败: {e}") |
| 321 | 338 | ... | ... |