Commit c84301558bffff4a5269196798fe7a69d4d3e99c
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>
Showing
4 changed files
with
500 additions
and
54 deletions
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,17 +2,11 @@ | ||
| 2 | """环节 1→5 编排器 —— 把整条自动回复链串起来 | 2 | """环节 1→5 编排器 —— 把整条自动回复链串起来 |
| 3 | 3 | ||
| 4 | 消费 watcher 的 incoming.jsonl(cursor 增量)→ 对候选事件(私聊/群@我/群引用我): | 4 | 消费 watcher 的 incoming.jsonl(cursor 增量)→ 对候选事件(私聊/群@我/群引用我): |
| 5 | - env2 查上下文(发送者身份 + 最近历史)→ env3 hermes -z 生成草稿 → | 5 | + env2 查上下文(发送者身份 + 最近历史)→ env3 hermes 生成草稿 → |
| 6 | env4 policy.decide()(白名单读 wechat-viewer 的 circle.db)→ | 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 | import json | 11 | import json |
| 18 | import os | 12 | import os |
| @@ -36,7 +30,30 @@ HERMES = "/data/data/com.termux/files/usr/bin/hermes" | @@ -36,7 +30,30 @@ HERMES = "/data/data/com.termux/files/usr/bin/hermes" | ||
| 36 | 30 | ||
| 37 | CANDIDATE_KINDS = {"private", "group_at_me", "group_quote_me"} | 31 | CANDIDATE_KINDS = {"private", "group_at_me", "group_quote_me"} |
| 38 | HISTORY_N = 12 | 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 | def log(msg): | 59 | def log(msg): |
| @@ -108,11 +125,43 @@ def build_context(stage, key, event, self_wxid): | @@ -108,11 +125,43 @@ def build_context(stage, key, event, self_wxid): | ||
| 108 | 125 | ||
| 109 | 126 | ||
| 110 | # ── env3:hermes 生成 ───────────────────────────────── | 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 | env = dict(os.environ) | 165 | env = dict(os.environ) |
| 117 | env.update({ | 166 | env.update({ |
| 118 | "HOME": "/data/data/com.termux/files/home", | 167 | "HOME": "/data/data/com.termux/files/home", |
| @@ -121,40 +170,114 @@ def generate(context_text): | @@ -121,40 +170,114 @@ def generate(context_text): | ||
| 121 | "LD_LIBRARY_PATH": "/data/data/com.termux/files/usr/lib", | 170 | "LD_LIBRARY_PATH": "/data/data/com.termux/files/usr/lib", |
| 122 | }) | 171 | }) |
| 123 | try: | 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 | timeout=GEN_TIMEOUT, env=env) | 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 | except subprocess.TimeoutExpired: | 190 | except subprocess.TimeoutExpired: |
| 130 | log("生成超时") | 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 | log(f"生成异常: {e}") | 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 | def _load_whitelist(): | 259 | def _load_whitelist(): |
| 139 | - contacts, groups = [], [] | 260 | + """返回 {wxid: role} 映射。role: 'admin' | 'member'。""" |
| 261 | + roles = {} | ||
| 140 | try: | 262 | try: |
| 141 | con = sqlite3.connect(CIRCLE_DB) | 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 | con.close() | 266 | con.close() |
| 145 | except Exception: | 267 | except Exception: |
| 146 | pass | 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 | con = sqlite3.connect(CIRCLE_DB) | 274 | con = sqlite3.connect(CIRCLE_DB) |
| 152 | con.execute( | 275 | con.execute( |
| 153 | """INSERT INTO reply_queue | 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 | con.commit() | 281 | con.commit() |
| 159 | con.close() | 282 | con.close() |
| 160 | 283 | ||
| @@ -209,6 +332,73 @@ def _sent_status(db, key, talker, base_msgid, text): | @@ -209,6 +332,73 @@ def _sent_status(db, key, talker, base_msgid, text): | ||
| 209 | return None | 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 | def _send_text_verified(to_wxid, text, key): | 402 | def _send_text_verified(to_wxid, text, key): |
| 213 | """可靠发送:切前台唤醒微信 → 广播发送 → 轮询 status 直到 =2(确认送达)。 | 403 | """可靠发送:切前台唤醒微信 → 广播发送 → 轮询 status 直到 =2(确认送达)。 |
| 214 | 超时仍卡在发送中/失败 → 返回 ok=False,由调用方标 failed 进审核(不假装成功)。 | 404 | 超时仍卡在发送中/失败 → 返回 ok=False,由调用方标 failed 进审核(不假装成功)。 |
| @@ -260,12 +450,89 @@ def _send_text_verified(to_wxid, text, key): | @@ -260,12 +450,89 @@ def _send_text_verified(to_wxid, text, key): | ||
| 260 | 450 | ||
| 261 | 451 | ||
| 262 | def process_event(event, stage, key, self_wxid, cfg): | 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 | ctx, sender_disp = build_context(stage, key, event, self_wxid) | 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 | action, reason = d["action"], d["reason"] | 528 | action, reason = d["action"], d["reason"] |
| 267 | if action == "skip": | 529 | if action == "skip": |
| 268 | log(f"skip #{event.get('msgId')} {sender_disp}: {reason}") | 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 | return | 536 | return |
| 270 | # 群里回复自动 @ 触发者:昵称前缀 + U+2005(微信原生 @ 分隔符)。安全判定(decide)已在 | 537 | # 群里回复自动 @ 触发者:昵称前缀 + U+2005(微信原生 @ 分隔符)。安全判定(decide)已在 |
| 271 | # 原始 draft 上跑过,前缀只影响发送/展示的文本。 | 538 | # 原始 draft 上跑过,前缀只影响发送/展示的文本。 |
| @@ -275,12 +542,28 @@ def process_event(event, stage, key, self_wxid, cfg): | @@ -275,12 +542,28 @@ def process_event(event, stage, key, self_wxid, cfg): | ||
| 275 | if event.get("isGroup") and sender_disp: | 542 | if event.get("isGroup") and sender_disp: |
| 276 | send_text = f"@{sender_disp} {draft}" | 543 | send_text = f"@{sender_disp} {draft}" |
| 277 | if action == "auto": | 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 | else: # review | 567 | else: # review |
| 285 | _queue_insert(event, send_text, sender_disp, "review", reason, "pending") | 568 | _queue_insert(event, send_text, sender_disp, "review", reason, "pending") |
| 286 | log(f"review #{event.get('msgId')} {sender_disp}: {reason} → 草稿 {send_text!r}") | 569 | log(f"review #{event.get('msgId')} {sender_disp}: {reason} → 草稿 {send_text!r}") |
| @@ -351,7 +634,10 @@ def _drain_inner(): | @@ -351,7 +634,10 @@ def _drain_inner(): | ||
| 351 | try: | 634 | try: |
| 352 | self_wxid, _ = w.detect_self(stage, key) | 635 | self_wxid, _ = w.detect_self(stage, key) |
| 353 | cfg = policy.load_config() | 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 | log(f"drain:{len(todo)} 条候选(auto_send={cfg['auto_send_enabled']})") | 641 | log(f"drain:{len(todo)} 条候选(auto_send={cfg['auto_send_enabled']})") |
| 356 | for e in todo: | 642 | for e in todo: |
| 357 | try: | 643 | try: |
wechat_plain/scripts/auto-reply/wechat_reply_policy.py
| @@ -64,8 +64,12 @@ def _hit_keyword(text, keywords): | @@ -64,8 +64,12 @@ def _hit_keyword(text, keywords): | ||
| 64 | return None | 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 | cfg = cfg or load_config() | 73 | cfg = cfg or load_config() |
| 70 | kind = event.get("kind", "") | 74 | kind = event.get("kind", "") |
| 71 | sender = event.get("sender") or event.get("talker") or "" | 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="", cfg=N | @@ -89,15 +93,19 @@ def decide(event, draft, confidence=None, should_reply=True, self_wxid="", cfg=N | ||
| 89 | return {"action": "skip", "reason": f"nontext:非文本(type={mtype})且无草稿"} | 93 | return {"action": "skip", "reason": f"nontext:非文本(type={mtype})且无草稿"} |
| 90 | return {"action": "skip", "reason": "empty:草稿为空"} | 94 | return {"action": "skip", "reason": "empty:草稿为空"} |
| 91 | 95 | ||
| 92 | - # ── 硬安全override:命中敏感词一律 review(白名单也拉回)── | 96 | + # ── 硬安全override:命中敏感词一律 review(admin 豁免——可执行命令/任务)── |
| 93 | hit = _hit_keyword(body + "\n" + (draft or ""), cfg["block_keywords"]) | 97 | hit = _hit_keyword(body + "\n" + (draft or ""), cfg["block_keywords"]) |
| 94 | - if hit: | 98 | + if hit and sender_role != "admin": |
| 95 | return {"action": "review", "reason": f"sensitive:命中敏感词「{hit}」", "hit": hit} | 99 | return {"action": "review", "reason": f"sensitive:命中敏感词「{hit}」", "hit": hit} |
| 96 | 100 | ||
| 97 | # ── 低置信 → review ── | 101 | # ── 低置信 → review ── |
| 98 | if confidence is not None and confidence < cfg["min_confidence"]: | 102 | if confidence is not None and confidence < cfg["min_confidence"]: |
| 99 | return {"action": "review", "reason": f"lowconf:置信{confidence}<{cfg['min_confidence']}"} | 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 | # ── 白名单 + 总开关 → auto ── | 109 | # ── 白名单 + 总开关 → auto ── |
| 102 | is_group = event.get("isGroup") or str(talker).endswith("@chatroom") | 110 | is_group = event.get("isGroup") or str(talker).endswith("@chatroom") |
| 103 | whitelisted = ( | 111 | whitelisted = ( |
wechat_plain/scripts/auto-reply/wechat_reply_watcher.py
| @@ -73,6 +73,7 @@ def md5hex(s: str) -> str: | @@ -73,6 +73,7 @@ def md5hex(s: str) -> str: | ||
| 73 | 73 | ||
| 74 | # ── 账号 / key 推导 ──────────────────────────────────────────────────────── | 74 | # ── 账号 / key 推导 ──────────────────────────────────────────────────────── |
| 75 | def read_uin() -> str: | 75 | def read_uin() -> str: |
| 76 | + # 先尝试直接 glob(root 环境下有效) | ||
| 76 | for xml in glob.glob(f"{MM_BASE}/shared_prefs/*.xml"): | 77 | for xml in glob.glob(f"{MM_BASE}/shared_prefs/*.xml"): |
| 77 | try: | 78 | try: |
| 78 | with open(xml, "r", errors="ignore") as f: | 79 | with open(xml, "r", errors="ignore") as f: |
| @@ -81,6 +82,25 @@ def read_uin() -> str: | @@ -81,6 +82,25 @@ def read_uin() -> str: | ||
| 81 | return m.group(1) | 82 | return m.group(1) |
| 82 | except OSError: | 83 | except OSError: |
| 83 | continue | 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 | raise RuntimeError("没读到微信 last_login_uin(未登录?没 root?)") | 104 | raise RuntimeError("没读到微信 last_login_uin(未登录?没 root?)") |
| 85 | 105 | ||
| 86 | 106 | ||
| @@ -91,8 +111,28 @@ def account_paths(): | @@ -91,8 +111,28 @@ def account_paths(): | ||
| 91 | if not os.path.exists(db): # 兜底:定位不到就取任一个 | 111 | if not os.path.exists(db): # 兜底:定位不到就取任一个 |
| 92 | cands = glob.glob(f"{MM_BASE}/MicroMsg/*/EnMicroMsg.db") | 112 | cands = glob.glob(f"{MM_BASE}/MicroMsg/*/EnMicroMsg.db") |
| 93 | if not cands: | 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 | key = md5hex(IMEI + uin)[:7] | 136 | key = md5hex(IMEI + uin)[:7] |
| 97 | return uin, db, key | 137 | return uin, db, key |
| 98 | 138 | ||
| @@ -132,13 +172,27 @@ def sqlcipher_query(db_copy: str, key: str, sql: str): | @@ -132,13 +172,27 @@ def sqlcipher_query(db_copy: str, key: str, sql: str): | ||
| 132 | def copy_db(db: str, subdir: str = "_stage") -> str: | 172 | def copy_db(db: str, subdir: str = "_stage") -> str: |
| 133 | """拷当前库(含 journal)到独立暂存目录,返回副本路径。root 直接文件拷,不经 /sdcard。 | 173 | """拷当前库(含 journal)到独立暂存目录,返回副本路径。root 直接文件拷,不经 /sdcard。 |
| 134 | subdir 隔离并发方(watcher=_stage / orchestrator=_stage_orch),避免共用文件互相删/覆盖 | 174 | subdir 隔离并发方(watcher=_stage / orchestrator=_stage_orch),避免共用文件互相删/覆盖 |
| 135 | - 导致 'no such table' 竞态。""" | 175 | + 导致 'no such table' 竞态。非root Termux 环境用 su -c cp fallback + chown。""" |
| 136 | os.makedirs(f"{WORK_DIR}/{subdir}", exist_ok=True) | 176 | os.makedirs(f"{WORK_DIR}/{subdir}", exist_ok=True) |
| 137 | dst = f"{WORK_DIR}/{subdir}/en.db" | 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 | for ext in ("-journal", "-wal", "-shm"): | 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 | return dst | 196 | return dst |
| 143 | 197 | ||
| 144 | 198 | ||
| @@ -193,7 +247,14 @@ def chown_to_hermes(path): | @@ -193,7 +247,14 @@ def chown_to_hermes(path): | ||
| 193 | try: | 247 | try: |
| 194 | os.chown(path, o[0], o[1]) | 248 | os.chown(path, o[0], o[1]) |
| 195 | except OSError: | 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 | def save_state(state): | 260 | def save_state(state): |