Commit c84301558bffff4a5269196798fe7a69d4d3e99c

Authored by yibo
1 parent d1b70de0

fix(wechat-auto-reply): 看门狗单例保护 + 同步设备侧两周迭代

手机后台卡死到连电源键都无响应:wechat_reply_watcher.py 死循环+
wechat-viewer-watchdog.sh(Hermes cron 每 5 分钟跑一次)检测存活只信
一次 su -c 'ps -A'|grep,系统过载时该探测本身会漏检,导致在卡死的旧
实例上不断叠加新实例,6 天内滚雪球到 877 个同时存活进程,吃满内存
+swap 拖死整机。

修复:watchdog 第 5 步改为每次都先 pkill 清场再起一个(与同文件里
Flask 重启逻辑一致的幂等单例模式),不再依赖探测结果是否可靠。已在
设备上部署验证。

顺带把 wechat_reply_watcher.py / orchestrator.py / policy.py 同步为
设备上过去两周的最新版本(repo 里是旧的,device 端已经跑在更新版本
上——非 root Termux su fallback、admin/member 双角色回复、去重、独
立验证层等),此前只有 daemon.sh 和 service.d 脚本是同步的。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
wechat_plain/scripts/auto-reply/wechat-viewer-watchdog.sh 0 → 100755
  1 +#!/data/data/com.termux/files/usr/bin/bash
  2 +# WeChat Viewer 看门狗 — 检查 Flask + Hermes Gateway + 微信 Watcher 是否存活
  3 +# 由 Hermes cron 每 5 分钟调用一次
  4 +
  5 +export PATH=/data/data/com.termux/files/usr/bin:$PATH
  6 +
  7 +FLASK_PORT=5001
  8 +APP_DIR=/data/data/com.termux/files/home/wechat-viewer
  9 +LOG=$APP_DIR/watchdog.log
  10 +HOME_DIR=/data/data/com.termux/files/home
  11 +
  12 +# ── 1. Flask ──────────────────────────────────────
  13 +
  14 +ALIVE=$(su -c "ss -tlnp" 2>/dev/null | grep -c ":$FLASK_PORT")
  15 +
  16 +if [ "$ALIVE" -eq 0 ]; then
  17 + echo "[$(date)] Flask 不在线,重启中..." | tee -a "$LOG"
  18 + cd "$APP_DIR"
  19 + pkill -f 'python3 app.py' 2>/dev/null
  20 + sleep 1
  21 + fuser -k "$FLASK_PORT/tcp" 2>/dev/null
  22 + nohup python3 app.py > $APP_DIR/flask.log 2>&1 &
  23 + sleep 3
  24 + ALIVE2=$(su -c "ss -tlnp" 2>/dev/null | grep -c ":$FLASK_PORT")
  25 + if [ "$ALIVE2" -gt 0 ]; then
  26 + echo "[$(date)] Flask 重启成功" | tee -a "$LOG"
  27 + else
  28 + echo "[$(date)] Flask 重启失败!" | tee -a "$LOG"
  29 + fi
  30 +fi
  31 +
  32 +# ── 2. Hermes Gateway ──────────────────────────────
  33 +
  34 +GATEWAY_ALIVE=$(pgrep -f "hermes.*gateway" 2>/dev/null | wc -l)
  35 +
  36 +if [ "$GATEWAY_ALIVE" -eq 0 ]; then
  37 + echo "[$(date)] Gateway 不在线,重启中..." | tee -a "$LOG"
  38 + nohup hermes gateway > /tmp/hermes-gateway.log 2>&1 &
  39 + sleep 3
  40 + GATEWAY_ALIVE2=$(pgrep -f "hermes.*gateway" 2>/dev/null | wc -l)
  41 + if [ "$GATEWAY_ALIVE2" -gt 0 ]; then
  42 + echo "[$(date)] Gateway 重启成功" | tee -a "$LOG"
  43 + else
  44 + echo "[$(date)] Gateway 重启失败!" | tee -a "$LOG"
  45 + fi
  46 +fi
  47 +
  48 +# ── 3. Stuck Job Reaper ──────────────────────────
  49 +# 清理因 Flask 重启而孤儿化的 hermes_jobs
  50 +python3 "$APP_DIR/reap_stuck_jobs.py" >> "$LOG" 2>&1
  51 +
  52 +# ── 4. Orphan Report Reaper ─────────────────────
  53 +# 清理因 Flask 重启丢回调而未入库的日报文件
  54 +python3 "$APP_DIR/reap_orphan_reports.py" >> "$LOG" 2>&1
  55 +
  56 +# ── 5. 微信自动回复 Watcher ────────────────────────
  57 +# 监听微信 DB 变化,写入 incoming.jsonl 队列
  58 +# 单例保护:每次都先 pkill 清场再起一个,不能只信一次 ps|grep 的探测结果——
  59 +# 探测在系统过载时会漏检/超时返回空,若不清场会在卡死的旧实例上再叠一个,
  60 +# 雪球式堆积出几百个进程拖垮整机(2026-07-25 事故:堆到 877 个吃满内存+swap)。
  61 +
  62 +REPLY_DIR=$HOME_DIR/.hermes/wechat_reply
  63 +WATCHER_COUNT=$(su -c 'ps -A' 2>/dev/null | grep -c '[w]echat_reply_watcher')
  64 +
  65 +if [ "$WATCHER_COUNT" -ne 1 ]; then
  66 + echo "[$(date)] 微信 Watcher 存活数=$WATCHER_COUNT(期望 1),重置中..." | tee -a "$LOG"
  67 + su -c "pkill -f wechat_reply_watcher.py" 2>/dev/null
  68 + sleep 1
  69 + su -c "/data/data/com.termux/files/usr/bin/python3 $REPLY_DIR/wechat_reply_watcher.py >> $REPLY_DIR/watcher.log 2>&1 &"
  70 + sleep 2
  71 + WATCHER_COUNT2=$(su -c 'ps -A' 2>/dev/null | grep -c '[w]echat_reply_watcher')
  72 + if [ "$WATCHER_COUNT2" -eq 1 ]; then
  73 + echo "[$(date)] 微信 Watcher 重置成功" | tee -a "$LOG"
  74 + else
  75 + echo "[$(date)] 微信 Watcher 重置后存活数异常: $WATCHER_COUNT2" | tee -a "$LOG"
  76 + fi
  77 +fi
  78 +
  79 +# ── 6. 微信自动回复 Orchestrator Drain ──────────────
  80 +# 消费 incoming.jsonl 队列,处理候选事件
  81 +
  82 +ORCH_LOCK=$REPLY_DIR/orch.lock
  83 +# 清理超过 5 分钟的残留锁
  84 +if [ -f "$ORCH_LOCK" ]; then
  85 + LOCK_AGE=$(($(date +%s) - $(stat -c %Y "$ORCH_LOCK" 2>/dev/null || echo 0)))
  86 + if [ "$LOCK_AGE" -gt 300 ]; then
  87 + rm -f "$ORCH_LOCK"
  88 + fi
  89 +fi
  90 +
  91 +su -c "/data/data/com.termux/files/usr/bin/python3 $REPLY_DIR/wechat_reply_orchestrator.py --drain >> $REPLY_DIR/orchestrator.log 2>&1"
... ...
wechat_plain/scripts/auto-reply/wechat_reply_orchestrator.py
... ... @@ -2,17 +2,11 @@
2 2 """环节 1→5 编排器 —— 把整条自动回复链串起来
3 3  
4 4 消费 watcher 的 incoming.jsonl(cursor 增量)→ 对候选事件(私聊/群@我/群引用我):
5   - env2 查上下文(发送者身份 + 最近历史)→ env3 hermes -z 生成草稿 →
  5 + env2 查上下文(发送者身份 + 最近历史)→ env3 hermes 生成草稿 →
6 6 env4 policy.decide()(白名单读 wechat-viewer 的 circle.db)→
7   - env5 落地:auto 直接 send_text_via_hook + 入队(auto_sent) / review 入队(pending,在 /approvals 等人工)
  7 + env5 落地:auto 直接 send_text_via_hook + 入队(auto_sent) / review 入队(pending)
8 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 游标+轮转。
  9 +策略权威参考:wechat-auto-reply skill (v3.0)。代码是参考实现,skill 定义逻辑。
16 10 """
17 11 import json
18 12 import os
... ... @@ -36,7 +30,30 @@ HERMES = &quot;/data/data/com.termux/files/usr/bin/hermes&quot;
36 30  
37 31 CANDIDATE_KINDS = {"private", "group_at_me", "group_quote_me"}
38 32 HISTORY_N = 12
39   -GEN_TIMEOUT = 160
  33 +GEN_TIMEOUT = 600 # admin 任务超时(10分钟),GFW 下 GitHub 访问慢
  34 +PROCESSED_FILE = f"{WORK_DIR}/processed_ids.json"
  35 +# 去重:已处理的 msgId 集合。drain 时加载,处理后持久化
  36 +_processed_ids = None
  37 +
  38 +
  39 +def _load_processed():
  40 + global _processed_ids
  41 + if _processed_ids is not None:
  42 + return _processed_ids
  43 + try:
  44 + with open(PROCESSED_FILE) as f:
  45 + _processed_ids = set(json.load(f))
  46 + except (OSError, ValueError):
  47 + _processed_ids = set()
  48 + return _processed_ids
  49 +
  50 +
  51 +def _save_processed():
  52 + try:
  53 + with open(PROCESSED_FILE, "w") as f:
  54 + json.dump(list(_processed_ids or set()), f)
  55 + except OSError:
  56 + pass
40 57  
41 58  
42 59 def log(msg):
... ... @@ -108,11 +125,43 @@ def build_context(stage, key, event, self_wxid):
108 125  
109 126  
110 127 # ── env3:hermes 生成 ─────────────────────────────────
111   -def generate(context_text):
112   - prompt = (
113   - "【任务】下面是别人发到微信来的一条消息,请以你本人(火星人)的口吻,写一条你会真的发出去的"
114   - "微信回复。只输出这条回复本身,不要解释、不要引号、不要用任何工具、不要真的发送。\n\n"
115   - "【上下文】\n" + context_text)
  128 +def generate(context_text, event=None, role="member"):
  129 + """调用 Hermes 生成回复。
  130 +
  131 + role='admin' → hermes chat(完整 CLI,可调工具/skill/API,执行复杂任务)
  132 + role='member' → hermes -z(零工具纯文本补全,简单闲聊回复)
  133 + """
  134 + talker = (event or {}).get("talker", "")
  135 + body = (event or {}).get("body", "") or ""
  136 + is_group = talker.endswith("@chatroom")
  137 +
  138 + if role == "admin":
  139 + is_task = _is_task_request(body)
  140 + result_file = f"{WORK_DIR}/_reply_result_{int(time.time())}.txt"
  141 + if is_task:
  142 + prompt = (
  143 + f"你是火星人的微信助手。用 reply-task-executor skill 执行任务。\n\n"
  144 + f"任务:{body}\n\n"
  145 + f"上下文:\n{context_text}\n\n"
  146 + f"重要:先执行任务(调工具、写文件、调API),不要先写报告。\n"
  147 + f"任务完成后,把结果报告写入 {result_file}。"
  148 + )
  149 + else:
  150 + prompt = (
  151 + f"你是火星人的微信助手。有人发了一条消息,分析内容,以火星人口吻回复。\n\n"
  152 + f"【消息内容】{body}\n\n"
  153 + f"【上下文】\n{context_text}\n\n"
  154 + f"把你要发送的回复文本写入文件 {result_file}。只写回复本身,不要解释。"
  155 + )
  156 + cmd = [HERMES, "chat", "-q", prompt, "-s", "reply-task-executor", "--yolo", "-t", "terminal,file,web"]
  157 + else:
  158 + # 简单模式:零工具,纯粹文本补全
  159 + prompt = (
  160 + "【任务】下面是别人发到微信来的一条消息,请以你本人(火星人)的口吻,写一条你会真的发出去的"
  161 + "微信回复。只输出这条回复本身,不要解释、不要引号、不要用任何工具、不要真的发送。\n\n"
  162 + "【上下文】\n" + context_text)
  163 + cmd = [HERMES, "-z", prompt]
  164 +
116 165 env = dict(os.environ)
117 166 env.update({
118 167 "HOME": "/data/data/com.termux/files/home",
... ... @@ -121,40 +170,114 @@ def generate(context_text):
121 170 "LD_LIBRARY_PATH": "/data/data/com.termux/files/usr/lib",
122 171 })
123 172 try:
124   - p = subprocess.run([HERMES, "-z", prompt], capture_output=True, text=True,
  173 + p = subprocess.run(cmd, capture_output=True, text=True,
125 174 timeout=GEN_TIMEOUT, env=env)
126   - out = (p.stdout or "").strip()
127   - # 取最后一段非空文本(躲开可能的日志行)
128   - return out.split("\n")[-1].strip() if out else ""
  175 + # admin 模式从文件读结果,不用 stdout
  176 + if role == "admin" and result_file:
  177 + try:
  178 + with open(result_file) as f:
  179 + draft = f.read().strip()
  180 + try: os.remove(result_file)
  181 + except: pass
  182 + except OSError:
  183 + draft = ""
  184 + else:
  185 + # member 模式:从 stdout 取最后一行
  186 + out = (p.stdout or "").strip()
  187 + draft = out.split("\n")[-1].strip() if out else ""
  188 + stderr = (p.stderr or "")[:200]
  189 + return draft, p.returncode, stderr, result_file if role == "admin" else ""
129 190 except subprocess.TimeoutExpired:
130 191 log("生成超时")
131   - return ""
132   - except Exception as e: # noqa: BLE001
  192 + return "", -1, "timeout", result_file if role == "admin" else ""
  193 + except Exception as e:
133 194 log(f"生成异常: {e}")
134   - return ""
135   -
136   -
137   -# ── env4→5:落地 ─────────────────────────────────────
  195 + return "", -1, str(e)[:200], result_file if role == "admin" else ""
  196 +
  197 +
  198 +# ── V 层:独立验证 ──────────────────────────────────
  199 +def _verify_draft(draft, role="member"):
  200 + """独立验证回复质量。不能由 agent 自己宣布完成。
  201 +
  202 + admin 生成的回复需要更严格检查(因为可能调了错误工具/产生幻觉)。
  203 + member 的回复比较简单,主要防胡话。
  204 + 返回 (ok: bool, reason: str)
  205 + """
  206 + if not draft or not draft.strip():
  207 + return False, "empty_draft"
  208 + text = draft.strip()
  209 + # 噪音行——hermes 输出里的元数据/分隔线
  210 + if all(c in "─═━═─ \t" for c in text):
  211 + return False, "noise_line"
  212 + for noise_start in ["Messages:", "Session:", "Duration:", "Tool:",
  213 + "Exit code:", "📱", "Model:", "Provider:",
  214 + "Tokens:", "Cost:", "$", "hermes ",
  215 + "Resume this", "Session saved"]:
  216 + if text.startswith(noise_start):
  217 + return False, f"noise:{noise_start}"
  218 + # 错误标记——hermes 调用失败时可能输出的内容
  219 + for marker in ["[Hermes", "[调用失败", "[超时", "[错误", "[Error",
  220 + "抱歉,我无法", "I cannot", "I'm unable"]:
  221 + if marker in text:
  222 + return False, f"error_marker:{marker}"
  223 + # 过短——不太可能是有效回复(admin 尤甚,它应该产出实质性内容)
  224 + if role == "admin" and len(text) < 5:
  225 + return False, "admin_too_short"
  226 + if len(text) < 2:
  227 + return False, "too_short"
  228 + # admin 回复不应该只是 emoji/单字(说明 hermes 没认真执行)
  229 + if role == "admin" and len(text) < 10 and not any('\u4e00' <= c <= '\u9fff' for c in text):
  230 + return False, "admin_no_chinese"
  231 + return True, "ok"
  232 +
  233 +
  234 +# ── L 层:Task Contract ──────────────────────────────
  235 +def _write_contract(event, role, exit_code, output, err):
  236 + """admin 的 hermes chat 失败时写 task contract——包含复现所需全部信息。"""
  237 + import __main__
  238 + contract_dir = os.path.join(WORK_DIR, "_q")
  239 + os.makedirs(contract_dir, exist_ok=True)
  240 + contract = {
  241 + "ts": time.time(),
  242 + "msgId": event.get("msgId"),
  243 + "talker": event.get("talker"),
  244 + "sender": event.get("sender"),
  245 + "body": (event.get("body") or "")[:300],
  246 + "role": role,
  247 + "exit_code": exit_code,
  248 + "output": (output or "")[:500],
  249 + "stderr": (err or "")[:200],
  250 + }
  251 + fname = os.path.join(contract_dir, f"ct_{int(time.time())}_{event.get('msgId')}.json")
  252 + try:
  253 + with open(fname, "w") as f:
  254 + json.dump(contract, f, ensure_ascii=False, indent=2)
  255 + w.chown_to_hermes(fname)
  256 + log(f"contract written: {fname}")
  257 + except Exception as e: # noqa: BLE001
  258 + log(f"contract write failed: {e}")
138 259 def _load_whitelist():
139   - contacts, groups = [], []
  260 + """返回 {wxid: role} 映射。role: 'admin' | 'member'。"""
  261 + roles = {}
140 262 try:
141 263 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)
  264 + for wxid, role in con.execute("SELECT wxid, COALESCE(role,'member') FROM reply_whitelist"):
  265 + roles[wxid] = role or "member"
144 266 con.close()
145 267 except Exception:
146 268 pass
147   - return contacts, groups
  269 + return roles
148 270  
149 271  
150   -def _queue_insert(event, draft, sender_disp, action, reason, status):
  272 +def _queue_insert(event, draft, sender_disp, action, reason, status, result_file="", override_talker=""):
  273 + talker = override_talker or event.get("talker")
151 274 con = sqlite3.connect(CIRCLE_DB)
152 275 con.execute(
153 276 """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))
  277 + (msg_id, talker, sender, sender_name, kind, incoming, draft, result_file, action, reason, status, sent_at)
  278 + VALUES (?,?,?,?,?,?,?,?,?,?,?, CASE WHEN ?='auto_sent' THEN datetime('now','localtime') ELSE NULL END)""",
  279 + (event.get("msgId"), talker, event.get("sender"), sender_disp,
  280 + event.get("kind"), event.get("body"), draft, result_file, action, reason, status, status))
158 281 con.commit()
159 282 con.close()
160 283  
... ... @@ -209,6 +332,73 @@ def _sent_status(db, key, talker, base_msgid, text):
209 332 return None
210 333  
211 334  
  335 +def _run_hermes_z(prompt):
  336 + """快速 hermes -z 调用(零工具,纯文本补全),用于生成失败报告"""
  337 + cmd = [HERMES, "-z", prompt]
  338 + env = dict(os.environ)
  339 + env.update({
  340 + "HOME": "/data/data/com.termux/files/home",
  341 + "PREFIX": "/data/data/com.termux/files/usr",
  342 + "PATH": "/data/data/com.termux/files/usr/bin:/system/bin",
  343 + "LD_LIBRARY_PATH": "/data/data/com.termux/files/usr/lib",
  344 + })
  345 + try:
  346 + p = subprocess.run(cmd, capture_output=True, text=True,
  347 + timeout=60, env=env)
  348 + return (p.stdout or "").strip(), p.returncode, (p.stderr or "")[:200]
  349 + except subprocess.TimeoutExpired:
  350 + return "", -1, "timeout"
  351 + except Exception as e:
  352 + return "", -1, str(e)[:200]
  353 +
  354 +
  355 +# ── 两段式回复:任务识别 + ack 生成 ──────────────
  356 +
  357 +TASK_KEYWORDS = ["研究", "学习", "查一下", "分析", "帮我", "看看",
  358 + "安装", "配置", "搭建", "写一个", "改一下",
  359 + "怎么", "什么是", "解释", "总结", "调查",
  360 + "research", "install", "setup", "analyze"]
  361 +
  362 +def _is_task_request(body):
  363 + """判断是否是需要后台执行的复杂任务(vs 简单闲聊)"""
  364 + text = body.lower()
  365 + score = sum(1 for kw in TASK_KEYWORDS if kw in text)
  366 + return score >= 1
  367 +
  368 +def _gen_ack(ctx, body, sender_disp):
  369 + """生成确认收到消息,根据任务内容个性化"""
  370 + if "研究" in body or "学习" in body or "看看" in body:
  371 + return "收到,我来研究,搞定了给你报告"
  372 + if "安装" in body or "配置" in body or "搭建" in body:
  373 + return "好的,我来处理,完了给你结果"
  374 + if "查" in body or "分析" in body:
  375 + return "收到,我查一下,稍等给你报告"
  376 + if "写" in body or "改" in body or "帮" in body:
  377 + return "好的,我来弄,弄好给你报告"
  378 + return "收到,我来处理,有结果跟你说"
  379 +
  380 +
  381 +def _is_complete_report(draft):
  382 + """检查 admin 任务报告是否完整(不只是简短回复)"""
  383 + if not draft:
  384 + return False
  385 + text = draft.strip()
  386 + # 太短 → 不完整
  387 + if len(text) < 80:
  388 + return False
  389 + # 只有一句话没有结构 → 不完整
  390 + lines = [l for l in text.split("\n") if l.strip()]
  391 + if len(lines) < 2:
  392 + return False
  393 + # 至少含一项具体信息(数字/URL/skill名/文件路径/工具名)
  394 + has_specific = any(
  395 + kw in text
  396 + for kw in ["http", "github.com", "skill", "memory", "创建",
  397 + "安装", "pip", "npm", "实现了", "测试", "验证",
  398 + "Star", "star", "⭐"]
  399 + )
  400 + return has_specific
  401 +
212 402 def _send_text_verified(to_wxid, text, key):
213 403 """可靠发送:切前台唤醒微信 → 广播发送 → 轮询 status 直到 =2(确认送达)。
214 404 超时仍卡在发送中/失败 → 返回 ok=False,由调用方标 failed 进审核(不假装成功)。
... ... @@ -260,12 +450,89 @@ def _send_text_verified(to_wxid, text, key):
260 450  
261 451  
262 452 def process_event(event, stage, key, self_wxid, cfg):
  453 + # ═══ Step 0: 去重 ═══
  454 + msg_id = event.get("msgId")
  455 + processed = _load_processed()
  456 + if msg_id in processed:
  457 + return # 已处理过,跳过
  458 +
263 459 ctx, sender_disp = build_context(stage, key, event, self_wxid)
264   - draft = generate(ctx)
265   - d = policy.decide(event, draft, self_wxid=self_wxid, cfg=cfg)
  460 + # 查 sender 的 role:admin → 完整 Hermes CLI,member → 简单 hermes -z
  461 + sender_wxid = event.get("sender") or event.get("talker") or ""
  462 + role = cfg.get("whitelist_roles", {}).get(sender_wxid, "member")
  463 +
  464 + body = (event.get("body") or "").strip()
  465 + talker = event.get("talker", "")
  466 +
  467 + # ═══ 两段式回复:admin 复杂任务先回 ack ═══
  468 + if role == "admin" and _is_task_request(body):
  469 + ack = _gen_ack(ctx, body, sender_disp)
  470 + ack_text = f"@{sender_disp} {ack}" if event.get("isGroup") and sender_disp else ack
  471 + _send_text_verified(talker, ack_text, key)
  472 + _queue_insert(event, ack_text, sender_disp, "auto", "ack", "auto_sent")
  473 + log(f"ack #{event.get('msgId')} {sender_disp}: {ack!r}")
  474 +
  475 + # ── 生成回复(V 层:独立验证 + 回退)──
  476 + draft, exit_code, stderr, result_file = generate(ctx, event=event, role=role)
  477 + ok, vreason = _verify_draft(draft, role)
  478 +
  479 + # ═══ 报告完整性检查(admin 任务必须产出完整报告) ═══
  480 + if ok and role == "admin" and _is_task_request(body):
  481 + if not _is_complete_report(draft):
  482 + ok = False
  483 + vreason = f"incomplete_report(len={len(draft)})"
  484 +
  485 + if not ok:
  486 + log(f"V-verify FAIL #{event.get('msgId')} role={role}: {vreason} draft={draft!r}")
  487 + _write_contract(event, role, exit_code, draft, stderr)
  488 +
  489 + if role == "admin":
  490 + is_task = _is_task_request(body)
  491 + if is_task:
  492 + # admin 任务失败 → 调 reply-failure-report skill 生成报告,写入队列
  493 + fail_prompt = (
  494 + f"任务内容:{body}\\n"
  495 + f"失败原因:{vreason}\\n"
  496 + f"对方:{sender_disp}\\n\\n"
  497 + f"按 reply-failure-report skill 生成一条要发给对方的失败报告。只输出消息本身。"
  498 + )
  499 + fail_draft, _, _ = _run_hermes_z(fail_prompt)
  500 + fail_report = (fail_draft or "").strip()
  501 + if not fail_report or len(fail_report) < 5:
  502 + fail_report = f"这个任务没跑成({vreason}),我加到 to-do list 了。"
  503 + fail_text = f"@{sender_disp} {fail_report}" if event.get("isGroup") and sender_disp else fail_report
  504 + # 写入队列,cron 会发送
  505 + _queue_insert(event, fail_text, sender_disp, "auto",
  506 + f"task_failed:{vreason}", "failed")
  507 + log(f"task failed queued #{event.get('msgId')}: {vreason}")
  508 + if msg_id:
  509 + loaded = _load_processed()
  510 + loaded.add(msg_id)
  511 + _save_processed()
  512 + return
  513 + # admin 闲聊失败 → 回退到 member 模式
  514 + log(f"admin fallback → member mode for #{event.get('msgId')}")
  515 + draft, exit_code, stderr, _ = generate(ctx, event=event, role="member")
  516 + ok, vreason = _verify_draft(draft, "member")
  517 + if not ok:
  518 + log(f"V-verify FAIL (member fallback) #{event.get('msgId')}: {vreason}")
  519 + _queue_insert(event, draft or "(验证失败)", sender_disp, "auto",
  520 + f"verify_failed:{vreason}", "failed")
  521 + return
  522 + else:
  523 + _queue_insert(event, draft or "(验证失败)", sender_disp, "auto",
  524 + f"verify_failed:{vreason}", "failed")
  525 + return
  526 +
  527 + d = policy.decide(event, draft, self_wxid=self_wxid, cfg=cfg, sender_role=role)
266 528 action, reason = d["action"], d["reason"]
267 529 if action == "skip":
268 530 log(f"skip #{event.get('msgId')} {sender_disp}: {reason}")
  531 + # ═══ 去重持久化(skip 也记录) ═══
  532 + if msg_id:
  533 + loaded = _load_processed()
  534 + loaded.add(msg_id)
  535 + _save_processed()
269 536 return
270 537 # 群里回复自动 @ 触发者:昵称前缀 + U+2005(微信原生 @ 分隔符)。安全判定(decide)已在
271 538 # 原始 draft 上跑过,前缀只影响发送/展示的文本。
... ... @@ -275,12 +542,28 @@ def process_event(event, stage, key, self_wxid, cfg):
275 542 if event.get("isGroup") and sender_disp:
276 543 send_text = f"@{sender_disp} {draft}"
277 544 if action == "auto":
278   - res = _send_text_verified(event.get("talker"), send_text, key)
279   - ok = res["ok"]
280   - _queue_insert(event, send_text, sender_disp, "auto",
281   - reason, "auto_sent" if ok else "failed")
282   - log(f"auto {'OK ' + res['reason'] if ok else 'FAILED ' + res['reason']} "
283   - f"#{event.get('msgId')} {sender_disp} → {send_text!r}")
  545 + is_task = role == "admin" and _is_task_request(body)
  546 + if is_task:
  547 + # admin 任务结果 → 私聊发 sender,直接发送
  548 + task_talker = sender_wxid if event.get("isGroup") else talker
  549 + res = _send_text_verified(task_talker, send_text, key)
  550 + ok_send = res["ok"]
  551 + _queue_insert(event, send_text, sender_disp, "auto",
  552 + reason, "auto_sent" if ok_send else "failed",
  553 + result_file=result_file, override_talker=task_talker)
  554 + log(f"task result sent #{event.get('msgId')} → {task_talker}: {'OK' if ok_send else 'FAILED'}")
  555 + else:
  556 + # 闲聊 / member → 直接发送
  557 + res = _send_text_verified(event.get("talker"), send_text, key)
  558 + ok_send = res["ok"]
  559 + _queue_insert(event, send_text, sender_disp, "auto",
  560 + reason, "auto_sent" if ok_send else "failed")
  561 + log(f"auto {'OK ' + res['reason'] if ok_send else 'FAILED ' + res['reason']} "
  562 + f"#{event.get('msgId')} {sender_disp} → {send_text!r}")
  563 + if msg_id:
  564 + loaded = _load_processed()
  565 + loaded.add(msg_id)
  566 + _save_processed()
284 567 else: # review
285 568 _queue_insert(event, send_text, sender_disp, "review", reason, "pending")
286 569 log(f"review #{event.get('msgId')} {sender_disp}: {reason} → 草稿 {send_text!r}")
... ... @@ -351,7 +634,10 @@ def _drain_inner():
351 634 try:
352 635 self_wxid, _ = w.detect_self(stage, key)
353 636 cfg = policy.load_config()
354   - cfg["whitelist_contacts"], cfg["whitelist_groups"] = _load_whitelist()
  637 + cfg["whitelist_roles"] = _load_whitelist()
  638 + # backward compat: policy.decide still checks these lists for whitelist membership
  639 + cfg["whitelist_contacts"] = [w for w, r in cfg["whitelist_roles"].items() if not w.endswith("@chatroom")]
  640 + cfg["whitelist_groups"] = [w for w, r in cfg["whitelist_roles"].items() if w.endswith("@chatroom")]
355 641 log(f"drain:{len(todo)} 条候选(auto_send={cfg['auto_send_enabled']})")
356 642 for e in todo:
357 643 try:
... ...
wechat_plain/scripts/auto-reply/wechat_reply_policy.py
... ... @@ -64,8 +64,12 @@ def _hit_keyword(text, keywords):
64 64 return None
65 65  
66 66  
67   -def decide(event, draft, confidence=None, should_reply=True, self_wxid="", cfg=None):
68   - """核心判定。返回 {action, reason, hit?}。纯函数、无副作用(写队列在 route())。"""
  67 +def decide(event, draft, confidence=None, should_reply=True, self_wxid="", cfg=None, sender_role="member"):
  68 + """核心判定。返回 {action, reason, hit?}。纯函数、无副作用(写队列在 route())。
  69 +
  70 + sender_role='admin' → 允许执行命令/任务类消息(跳过部分敏感词、自动发送)
  71 + sender_role='member' → 默认保守策略
  72 + """
69 73 cfg = cfg or load_config()
70 74 kind = event.get("kind", "")
71 75 sender = event.get("sender") or event.get("talker") or ""
... ... @@ -89,15 +93,19 @@ def decide(event, draft, confidence=None, should_reply=True, self_wxid=&quot;&quot;, cfg=N
89 93 return {"action": "skip", "reason": f"nontext:非文本(type={mtype})且无草稿"}
90 94 return {"action": "skip", "reason": "empty:草稿为空"}
91 95  
92   - # ── 硬安全override:命中敏感词一律 review(白名单也拉回)──
  96 + # ── 硬安全override:命中敏感词一律 review(admin 豁免——可执行命令/任务)──
93 97 hit = _hit_keyword(body + "\n" + (draft or ""), cfg["block_keywords"])
94   - if hit:
  98 + if hit and sender_role != "admin":
95 99 return {"action": "review", "reason": f"sensitive:命中敏感词「{hit}」", "hit": hit}
96 100  
97 101 # ── 低置信 → review ──
98 102 if confidence is not None and confidence < cfg["min_confidence"]:
99 103 return {"action": "review", "reason": f"lowconf:置信{confidence}<{cfg['min_confidence']}"}
100 104  
  105 + # ── admin + 总开关 → 直接 auto(信任,无需白名单匹配)──
  106 + if sender_role == "admin" and cfg["auto_send_enabled"]:
  107 + return {"action": "auto", "reason": "admin:管理员信任→直接发"}
  108 +
101 109 # ── 白名单 + 总开关 → auto ──
102 110 is_group = event.get("isGroup") or str(talker).endswith("@chatroom")
103 111 whitelisted = (
... ...
wechat_plain/scripts/auto-reply/wechat_reply_watcher.py
... ... @@ -73,6 +73,7 @@ def md5hex(s: str) -&gt; str:
73 73  
74 74 # ── 账号 / key 推导 ────────────────────────────────────────────────────────
75 75 def read_uin() -> str:
  76 + # 先尝试直接 glob(root 环境下有效)
76 77 for xml in glob.glob(f"{MM_BASE}/shared_prefs/*.xml"):
77 78 try:
78 79 with open(xml, "r", errors="ignore") as f:
... ... @@ -81,6 +82,25 @@ def read_uin() -&gt; str:
81 82 return m.group(1)
82 83 except OSError:
83 84 continue
  85 +
  86 + # fallback: su -c 读取(非root Termux 环境)
  87 + try:
  88 + result = subprocess.run(
  89 + ["su", "-c", f"grep -rl last_login_uin {MM_BASE}/shared_prefs/*.xml"],
  90 + capture_output=True, text=True, timeout=10
  91 + )
  92 + if result.returncode == 0 and result.stdout.strip():
  93 + xml_file = result.stdout.strip().split('\n')[0]
  94 + result2 = subprocess.run(
  95 + ["su", "-c", f"cat {xml_file}"],
  96 + capture_output=True, text=True, timeout=10
  97 + )
  98 + m = re.search(r'last_login_uin">(-?\d+)', result2.stdout)
  99 + if m:
  100 + return m.group(1)
  101 + except Exception:
  102 + pass
  103 +
84 104 raise RuntimeError("没读到微信 last_login_uin(未登录?没 root?)")
85 105  
86 106  
... ... @@ -91,8 +111,28 @@ def account_paths():
91 111 if not os.path.exists(db): # 兜底:定位不到就取任一个
92 112 cands = glob.glob(f"{MM_BASE}/MicroMsg/*/EnMicroMsg.db")
93 113 if not cands:
94   - raise RuntimeError("找不到 EnMicroMsg.db")
95   - db = cands[0]
  114 + # fallback: su -c 找 db(非root Termux 环境)
  115 + try:
  116 + result = subprocess.run(
  117 + ["su", "-c", f"ls {db}"],
  118 + capture_output=True, text=True, timeout=10
  119 + )
  120 + if result.returncode == 0 and result.stdout.strip():
  121 + return uin, result.stdout.strip(), md5hex(IMEI + uin)[:7]
  122 + # 兜底:用 su 找任一个 EnMicroMsg.db
  123 + result = subprocess.run(
  124 + ["su", "-c", f"ls {MM_BASE}/MicroMsg/*/EnMicroMsg.db"],
  125 + capture_output=True, text=True, timeout=10
  126 + )
  127 + if result.returncode == 0 and result.stdout.strip():
  128 + cands = result.stdout.strip().split('\n')
  129 + db = cands[0]
  130 + else:
  131 + raise RuntimeError("找不到 EnMicroMsg.db")
  132 + except RuntimeError:
  133 + raise
  134 + except Exception as e:
  135 + raise RuntimeError(f"找不到 EnMicroMsg.db: {e}")
96 136 key = md5hex(IMEI + uin)[:7]
97 137 return uin, db, key
98 138  
... ... @@ -132,13 +172,27 @@ def sqlcipher_query(db_copy: str, key: str, sql: str):
132 172 def copy_db(db: str, subdir: str = "_stage") -> str:
133 173 """拷当前库(含 journal)到独立暂存目录,返回副本路径。root 直接文件拷,不经 /sdcard。
134 174 subdir 隔离并发方(watcher=_stage / orchestrator=_stage_orch),避免共用文件互相删/覆盖
135   - 导致 'no such table' 竞态。"""
  175 + 导致 'no such table' 竞态。非root Termux 环境用 su -c cp fallback + chown。"""
136 176 os.makedirs(f"{WORK_DIR}/{subdir}", exist_ok=True)
137 177 dst = f"{WORK_DIR}/{subdir}/en.db"
138   - subprocess.run(["cp", db, dst], check=True, timeout=60)
  178 + # 先尝试普通 cp(root 环境下有效)
  179 + p = subprocess.run(["cp", db, dst], capture_output=True, timeout=60)
  180 + if p.returncode != 0:
  181 + # fallback: su -c cp(非root Termux 环境)
  182 + subprocess.run(["su", "-c", f"cp {db} {dst}"], check=True, timeout=60)
  183 + # su cp 的文件属主是 root,需要 chown 给 hermes 用户
  184 + chown_to_hermes(dst)
139 185 for ext in ("-journal", "-wal", "-shm"):
140   - if os.path.exists(db + ext):
141   - subprocess.run(["cp", db + ext, dst + ext], timeout=60)
  186 + src_ext = db + ext
  187 + dst_ext = dst + ext
  188 + if os.path.exists(src_ext):
  189 + subprocess.run(["cp", src_ext, dst_ext], timeout=60)
  190 + else:
  191 + # 非root 看不到 src_ext 时,用 su 探测并 cp
  192 + pe = subprocess.run(["su", "-c", f"test -f {src_ext} && cp {src_ext} {dst_ext}"],
  193 + capture_output=True, timeout=60)
  194 + if pe.returncode == 0 and os.path.exists(dst_ext):
  195 + chown_to_hermes(dst_ext)
142 196 return dst
143 197  
144 198  
... ... @@ -193,7 +247,14 @@ def chown_to_hermes(path):
193 247 try:
194 248 os.chown(path, o[0], o[1])
195 249 except OSError:
196   - pass
  250 + # 非root 环境下 os.chown 无权限,fallback 到 su -c chown
  251 + try:
  252 + subprocess.run(
  253 + ["su", "-c", f"chown {o[0]}:{o[1]} {path}"],
  254 + capture_output=True, timeout=10
  255 + )
  256 + except Exception:
  257 + pass
197 258  
198 259  
199 260 def save_state(state):
... ...