Commit 58baa21db898c02f7cc530cc3ccb9fc7fc31f8f2
1 parent
a393337d
feat(wechat-auto-reply): 环节1 通知触发 — inotify 事件驱动的新消息监听器
wechat_reply_watcher.py:纯 ctypes inotify 监听当前账号 EnMicroMsg.db 目录
(SELinux 放行、零轮询),失败退回 stat mtime;解密增量读 message(isSend=0
& msgId>last)→ 分类(私聊/群@我/群引用我/群其它)→ 写 incoming.jsonl 队列。
账号运行时按 MD5("mm"+uin) 自适应,首跑建基线不重放历史。真机验证 inotify
触发读取、多账号切换自适应、解密/分类均通过。含 --selftest/--once/--tail。
配套需求文档 md/wechat-auto-reply-requirements.md 标注环节1 完成。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
2 changed files
with
533 additions
and
0 deletions
md/wechat-auto-reply-requirements.md
0 → 100644
| 1 | +# 微信自动回复 · 需求文档 | |
| 2 | + | |
| 3 | +> 日期:2026-07-09 | |
| 4 | +> 设备:Pixel(`59171FDCG0009N`),微信 8.0.74,LSPosed + Termux + Hermes Agent | |
| 5 | +> 定位:Pixel 上的 Hermes Agent 自动处理"别人发给小火子的微信"——私聊 / 群里 @ / 群里引用——判断要不要回、怎么回、自动发还是人工审核。 | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## 0. 目标 | |
| 10 | + | |
| 11 | +有人给小火子发微信(私聊、群 @我、群引用我)时,Hermes 能: | |
| 12 | +1. 接收到这条消息 | |
| 13 | +2. 读取会话上下文 | |
| 14 | +3. 基于上下文 + SOUL.md 生成回复 | |
| 15 | +4. 判断"直接发"还是"进人工审核队列" | |
| 16 | +5. 发送回复(含微信原生引用气泡) | |
| 17 | + | |
| 18 | +约束:尽量低电量 / 低资源;纯端上(Hermes 在设备 Termux 内跑,模型走 Kimi 或本地 llama-swap)。 | |
| 19 | + | |
| 20 | +--- | |
| 21 | + | |
| 22 | +## 1. 设备现状(建这套的地基,2026-07 核对过) | |
| 23 | + | |
| 24 | +| 能力 | 现状 | 备注 | | |
| 25 | +|---|---|---| | |
| 26 | +| 微信 DB 解密读 | ✅ 可用 | 主账号目录 `ec4f423b316d41e9af5e8398155cf105`,**key `1986554`**(`MD5("1234567890ABCDEF"+uin257966415)[:7]`);参数 `cipher_compatibility=1`+`page_size=1024`。skill `~/.hermes/skills/wechat-db-decrypt/` 已就位 | | |
| 27 | +| 发送文本 | ✅ 可用 | LSPosed 内部 API(`am broadcast com.hermes.wechat.SEND_MSG`);`WeChatHook.java` 已打通文本/语音/图片 | | |
| 28 | +| 发送带原生引用气泡 | ❌ 未实现 | 见环节 5 | | |
| 29 | +| 通知接收 | 待搭 | NotificationListenerService / dumpsys,见环节 1 | | |
| 30 | +| lifelog 人物库 | 明文 SQLite | `com.lemon.lifelog/databases/lifelog.db`(4.8MB,`Person` 表),二期打通 | | |
| 31 | +| SOUL.md | ✅ 存在 | `~/.hermes/SOUL.md`(Hermes 人格) | | |
| 32 | + | |
| 33 | +> ⚠️ 过时提醒:旧文档 `md/wechat-auto-reply-status.md`(2026-05-23)里的 key `e03ce81` / 目录 `653cdc…` 是**次账号**,已不是当前主账号;旧文档主推的 Frida daemon 发送已废弃(常驻注入微信触发反调试崩溃)。以本文件为准。 | |
| 34 | + | |
| 35 | +--- | |
| 36 | + | |
| 37 | +## 2. 五环节需求 | |
| 38 | + | |
| 39 | +### 环节 1 · 接收通知 —— ✅ **已实现并真机验证(2026-07-09)** | |
| 40 | +落地:`wechat_plain/scripts/auto-reply/wechat_reply_watcher.py`(设备 `~/.hermes/wechat_reply/`)。 | |
| 41 | +- **触发**:纯 ctypes inotify 监听当前账号 DB 目录(SELinux 放行,零轮询);建 watch 失败自动退回 stat mtime 轮询。真机验证 `[inotify] +N` 事件驱动读取成立。 | |
| 42 | +- **账号自适应**:运行时 `MD5("mm"+uin)` 定位当前登录账号(验证时账号已从 火星人 切到 小火子,daemon 自动跟上,无硬编码)。 | |
| 43 | +- **增量+去重+基线**:持久化 `last_msgId`;只 `isSend=0 且 msgId>last`;首跑基线=当前最大 msgId,不重放历史。 | |
| 44 | +- **分类**:private / group_at_me / group_quote_me / group_other;群消息剥 `wxid:\n` 前缀取 sender+body;自身 wxid/昵称从 userinfo 自动检测。 | |
| 45 | +- **输出**:`incoming.jsonl` 事件队列(供环节 2-5 消费)。自检 `--selftest`、单读 `--once`、`--tail N`。 | |
| 46 | +- 待接:常驻自启(Termux:Boot / Hermes cron),env4 里再过滤 gh_ 公众号与系统消息(撤回/拍一拍)。 | |
| 47 | + | |
| 48 | +原始设计记录: | |
| 49 | + | |
| 50 | +### (原)环节 1 · 接收通知 —— **事件驱动,非定时轮询** | |
| 51 | +根因:费电的不是 cron 循环,是"每次轮询都 su 拷 90MB 的 EnMicroMsg.db"。定 5s 紧循环拷 90MB → 电量扛不住。 | |
| 52 | + | |
| 53 | +- **主触发**:常驻 NotificationListenerService。系统主动 push,空闲近零成本;微信来消息才被唤醒 → 这时才读库。没消息 = 不动。 | |
| 54 | +- **兜底**:Hermes cron 每几分钟扫一遍,捞免打扰 / 漏掉的消息。**不要跑紧循环。** | |
| 55 | +- **读库**:优先只读 in-place 打开(immutable);锁住再拷。因为改成事件驱动,拷库只在真有消息时发生,频率极低,可接受。 | |
| 56 | +- **消息源**:主账号 `ec4f…/EnMicroMsg.db` 的 `message` 表,`WHERE isSend=0 AND msgId>last_processed_id`。 | |
| 57 | +- **分流(本环节难点)**: | |
| 58 | + - 私聊:直接是一条 | |
| 59 | + - 群 @我:群消息 content 前缀 `wxid:\n`,需匹配我的昵称判 @ | |
| 60 | + - 群引用我:看 refermsg 是否指向我的 svrid | |
| 61 | + | |
| 62 | +### 环节 2 · 读取上下文 | |
| 63 | +- 从 `message` 表取该会话最近 N 条 + 发送者身份(`rcontact` 昵称/备注)。 | |
| 64 | +- **二期加分**:回链 lifelog `Person` 库做人物增强("这个人是谁、跟小火子什么关系")。一期不做。 | |
| 65 | + | |
| 66 | +### 环节 3 · 生成回复 | |
| 67 | +- Hermes(Kimi / 本地 llama-swap):**上下文 + SOUL.md → 回复**。 | |
| 68 | +- 建议一次结构化调用同时出 `{should_reply, confidence, draft, risk_flags}`,别拆两次。 | |
| 69 | +- ⚠️ 若回复要以"小火子本人"口吻发出,SOUL.md 里可能需加一段"小火子语气"约定(否则像 agent 替他说话)。 | |
| 70 | + | |
| 71 | +### 环节 4 · 自动 vs 审核 —— **白名单直发,其余进 viewer 待回复** | |
| 72 | +- **白名单**(联系人 / 群 / 群里某发言人)+ 生成成功 → **直接发送**。 | |
| 73 | +- **非白名单** → 写入 **Pixel 本地 wechat viewer 的"待回复列表"**,人工点击"发送"才真正发出。 | |
| 74 | +- 落地新增:①「待回复队列」存储(sqlite/json);② wechat viewer 的"发送"按钮接到发送链路。 | |
| 75 | +- 安全红线(贯穿):涉钱 / 承诺 / 约时间 / 低置信 → 即使白名单也建议进审核。 | |
| 76 | + | |
| 77 | +### 环节 5 · 发送回复带引用(原生气泡) | |
| 78 | +需求 = 点原文→引用→发送后显示引用气泡。两条实现路: | |
| 79 | +- **(a) A11y UI 自动化(一期推荐)**:长按目标气泡 → 点"引用" → 输入 → 发送。驱动微信自己 UI → **产出货真价实的原生引用气泡,零逆向**。代价:需微信前台、脚本略脆、比 API 慢。已有 A11y 发送服务底子。 | |
| 80 | +- **(b) 内部 API 构造 refermsg(二期优化)**:微信引用 = type 49 appmsg + refermsg(type 57) 指向被引 svrid。一整块新逆向,量级同当初啃发图,重。留作提速。 | |
| 81 | + | |
| 82 | +--- | |
| 83 | + | |
| 84 | +## 3. 贯穿层(不做会出事) | |
| 85 | +- **防循环/去重**:只处理 `isSend=0`;持久化 per-talker `last_processed_msgId`;绝不回自己发的、绝不重放。 | |
| 86 | +- **限流**:每人/每群回复冷却,防抖防刷屏。 | |
| 87 | +- **安全红线**:陌生人 / 涉钱 / 承诺 / 约时间 → 永不自动发,硬编码兜底。 | |
| 88 | + | |
| 89 | +--- | |
| 90 | + | |
| 91 | +## 4. 一期最小可跑(MVP) | |
| 92 | + | |
| 93 | +``` | |
| 94 | +通知触发(NotificationListener) | |
| 95 | + └→ 读库(主账号 ec4f/1986554, message 表, isSend=0 & msgId>last) | |
| 96 | + └→ 分流(私聊/群@我/群引用我) + 取上下文(近 N 条 + rcontact 身份) | |
| 97 | + └→ Hermes 生成(上下文 + SOUL.md → {should_reply,confidence,draft}) | |
| 98 | + ├→ 白名单 → A11y 发送(纯文本+@;引用气泡) | |
| 99 | + └→ 非白名单 → 写入 wechat viewer 待回复队列 → 人工点发送 | |
| 100 | +``` | |
| 101 | + | |
| 102 | +**二期**:lifelog `Person` 人物增强;内部 API refermsg 引用提速;cron 兜底调优。 | |
| 103 | + | |
| 104 | +--- | |
| 105 | + | |
| 106 | +## 5. 待决策 / 待确认 | |
| 107 | +- [ ] 白名单如何维护(配置文件 / viewer 里管理 / 从 lifelog 拉) | |
| 108 | +- [ ] 待回复队列 + wechat viewer 的具体形态(现有 `wechat-web-viewer` skill 扩展?) | |
| 109 | +- [ ] SOUL.md 是否加"小火子语气"段 | |
| 110 | +- [ ] 群 @我 / 引用我的判定用昵称还是 wxid(昵称易误判,wxid 更准) | |
| 111 | +- [ ] in-place 只读打开能否稳定绕过 WCDB 锁(否则回退拷库) | |
| 112 | +- [ ] 环节 2 lifelog join key:`Person` 里存的微信标识字段(二期确认) | ... | ... |
wechat_plain/scripts/auto-reply/wechat_reply_watcher.py
0 → 100644
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""环节1 · 通知触发 —— 微信新消息事件监听器 | |
| 3 | + | |
| 4 | +inotify 监听当前账号 EnMicroMsg.db 的写入(纯 ctypes,零额外 pip 包)→ 文件变化时 | |
| 5 | +解密增量读 message 表(isSend=0 且 msgId>last)→ 分流(私聊/群@我/群引用我/群其它) | |
| 6 | +→ 追加到 incoming.jsonl 队列,供后续环节(读上下文/生成/发送)消费。 | |
| 7 | + | |
| 8 | +作为 root 在 Termux 内跑。inotify 建 watch 失败(SELinux/环境)时自动退回 stat mtime 轮询。 | |
| 9 | + | |
| 10 | +设备事实(2026-07 验证): | |
| 11 | + 账号目录 = MD5("mm"+uin) ;key = MD5("1234567890ABCDEF"+uin)[:7] | |
| 12 | + SQLCipher: cipher_compatibility=1 + page_size=1024 | |
| 13 | + 自己 wxid=userinfo[2],昵称=userinfo[4] | |
| 14 | +群消息 content 形如 "wxid_sender:\\n<正文>",需剥前缀;引用是 type=49 的 refermsg XML。 | |
| 15 | + | |
| 16 | +用法: | |
| 17 | + python wechat_reply_watcher.py --selftest # 自检:推导 key/解密/inotify 是否都 OK | |
| 18 | + python wechat_reply_watcher.py --once # 单次:读一遍新消息、打印、入队(测试用) | |
| 19 | + python wechat_reply_watcher.py --tail 10 # 打印最近 N 条 inbound(不改状态) | |
| 20 | + python wechat_reply_watcher.py # 常驻 daemon(inotify 事件驱动) | |
| 21 | + | |
| 22 | +ponytail: 读库走「拷贝整库→sqlcipher」。整库现 ~96MB,拷一次 ~2-3s;靠事件驱动+ | |
| 23 | +MIN_READ_INTERVAL 合并突发把频率压到很低。上限=活跃群里瞬时多条→仍是一次读(去抖合并)。 | |
| 24 | +升级路径:sqlcipher in-place immutable 只读打开省掉拷贝(WCDB 并发写会 database is locked, | |
| 25 | +需 immutable=1 URI 验证),或换真 NotificationListenerService 做 push。 | |
| 26 | +""" | |
| 27 | +import ctypes | |
| 28 | +import glob | |
| 29 | +import hashlib | |
| 30 | +import json | |
| 31 | +import os | |
| 32 | +import re | |
| 33 | +import select | |
| 34 | +import subprocess | |
| 35 | +import sys | |
| 36 | +import time | |
| 37 | + | |
| 38 | +MM_BASE = "/data/user/0/com.tencent.mm" | |
| 39 | +TERMUX_BIN = "/data/data/com.termux/files/usr/bin" | |
| 40 | +SQLCIPHER = f"{TERMUX_BIN}/sqlcipher" | |
| 41 | +HERMES_HOME = "/data/data/com.termux/files/home/.hermes" | |
| 42 | +WORK_DIR = f"{HERMES_HOME}/wechat_reply" | |
| 43 | +STATE_FILE = f"{WORK_DIR}/state.json" | |
| 44 | +CONFIG_FILE = f"{WORK_DIR}/config.json" | |
| 45 | +QUEUE_FILE = f"{WORK_DIR}/incoming.jsonl" | |
| 46 | +LOG_FILE = f"{WORK_DIR}/watcher.log" | |
| 47 | +IMEI = "1234567890ABCDEF" | |
| 48 | + | |
| 49 | +# inotify 常量 | |
| 50 | +IN_MODIFY = 0x00000002 | |
| 51 | +IN_CLOSE_WRITE = 0x00000008 | |
| 52 | +IN_MOVED_TO = 0x00000080 | |
| 53 | +IN_CREATE = 0x00000100 | |
| 54 | + | |
| 55 | +MIN_READ_INTERVAL = 2.0 # 两次实际读库最小间隔(秒),合并突发 | |
| 56 | +DEBOUNCE = 0.4 # 收到事件后等一小会儿让突发写落定 | |
| 57 | +MTIME_POLL = 4.0 # inotify 不可用时的兜底轮询间隔 | |
| 58 | + | |
| 59 | + | |
| 60 | +def log(msg): | |
| 61 | + line = f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}" | |
| 62 | + print(line, flush=True) | |
| 63 | + try: | |
| 64 | + with open(LOG_FILE, "a") as f: | |
| 65 | + f.write(line + "\n") | |
| 66 | + except OSError: | |
| 67 | + pass | |
| 68 | + | |
| 69 | + | |
| 70 | +def md5hex(s: str) -> str: | |
| 71 | + return hashlib.md5(s.encode()).hexdigest() | |
| 72 | + | |
| 73 | + | |
| 74 | +# ── 账号 / key 推导 ──────────────────────────────────────────────────────── | |
| 75 | +def read_uin() -> str: | |
| 76 | + for xml in glob.glob(f"{MM_BASE}/shared_prefs/*.xml"): | |
| 77 | + try: | |
| 78 | + with open(xml, "r", errors="ignore") as f: | |
| 79 | + m = re.search(r'last_login_uin">(-?\d+)', f.read()) | |
| 80 | + if m: | |
| 81 | + return m.group(1) | |
| 82 | + except OSError: | |
| 83 | + continue | |
| 84 | + raise RuntimeError("没读到微信 last_login_uin(未登录?没 root?)") | |
| 85 | + | |
| 86 | + | |
| 87 | +def account_paths(): | |
| 88 | + uin = read_uin() | |
| 89 | + acc_dir = md5hex("mm" + uin) # = MD5("mm"+uin),当前登录账号目录 | |
| 90 | + db = f"{MM_BASE}/MicroMsg/{acc_dir}/EnMicroMsg.db" | |
| 91 | + if not os.path.exists(db): # 兜底:定位不到就取任一个 | |
| 92 | + cands = glob.glob(f"{MM_BASE}/MicroMsg/*/EnMicroMsg.db") | |
| 93 | + if not cands: | |
| 94 | + raise RuntimeError("找不到 EnMicroMsg.db") | |
| 95 | + db = cands[0] | |
| 96 | + key = md5hex(IMEI + uin)[:7] | |
| 97 | + return uin, db, key | |
| 98 | + | |
| 99 | + | |
| 100 | +# ── SQLCipher 读 ─────────────────────────────────────────────────────────── | |
| 101 | +def sqlcipher_query(db_copy: str, key: str, sql: str): | |
| 102 | + """返回 list[list[str]]。content 一律用 hex() 传出,躲开分隔符/换行问题。""" | |
| 103 | + # PRAGMA key/compatibility 会往 stdout 各吐一行 "ok",用 sentinel 只取其后的真结果。 | |
| 104 | + sentinel = "<<<ROWS>>>" | |
| 105 | + script = ( | |
| 106 | + f"PRAGMA key='{key}';\n" | |
| 107 | + "PRAGMA cipher_compatibility=1;\n" | |
| 108 | + "PRAGMA cipher_page_size=1024;\n" | |
| 109 | + ".mode list\n" | |
| 110 | + ".separator \"\\t\"\n" | |
| 111 | + ".headers off\n" | |
| 112 | + f".print {sentinel}\n" | |
| 113 | + f"{sql}\n" | |
| 114 | + ) | |
| 115 | + p = subprocess.run([SQLCIPHER, db_copy], input=script, | |
| 116 | + capture_output=True, text=True, timeout=60) | |
| 117 | + if p.returncode != 0 or "file is not a database" in p.stderr: | |
| 118 | + raise RuntimeError(f"sqlcipher 失败: {p.stderr.strip()[:120]}") | |
| 119 | + rows = [] | |
| 120 | + started = False | |
| 121 | + for line in p.stdout.splitlines(): | |
| 122 | + if not started: | |
| 123 | + if line == sentinel: | |
| 124 | + started = True | |
| 125 | + continue | |
| 126 | + if line == "": | |
| 127 | + continue | |
| 128 | + rows.append(line.split("\t")) | |
| 129 | + return rows | |
| 130 | + | |
| 131 | + | |
| 132 | +def copy_db(db: str) -> str: | |
| 133 | + """拷当前库(含 journal)到 Termux 私有 tmp,返回副本路径。root 直接文件拷,不经 /sdcard。""" | |
| 134 | + os.makedirs(f"{WORK_DIR}/_stage", exist_ok=True) | |
| 135 | + dst = f"{WORK_DIR}/_stage/en.db" | |
| 136 | + subprocess.run(["cp", db, dst], check=True, timeout=60) | |
| 137 | + for ext in ("-journal", "-wal", "-shm"): | |
| 138 | + if os.path.exists(db + ext): | |
| 139 | + subprocess.run(["cp", db + ext, dst + ext], timeout=60) | |
| 140 | + return dst | |
| 141 | + | |
| 142 | + | |
| 143 | +# ── 分类 / 解析 ──────────────────────────────────────────────────────────── | |
| 144 | +def parse_group_content(raw: str): | |
| 145 | + """群消息 content = 'wxid:\\n正文' → (sender_wxid, body)。私聊无前缀 → (None, raw)。""" | |
| 146 | + m = re.match(r"^([A-Za-z0-9_@.\-]+):\n(.*)$", raw, re.S) | |
| 147 | + if m: | |
| 148 | + return m.group(1), m.group(2) | |
| 149 | + return None, raw | |
| 150 | + | |
| 151 | + | |
| 152 | +def classify(msg_type: int, talker: str, raw: str, self_wxid: str, my_names): | |
| 153 | + is_group = talker.endswith("@chatroom") | |
| 154 | + if not is_group: | |
| 155 | + return {"kind": "private", "sender": talker, "body": raw} | |
| 156 | + sender, body = parse_group_content(raw) | |
| 157 | + kind = "group_other" | |
| 158 | + # 引用我:type 49 的 refermsg 里被引消息作者=我 | |
| 159 | + if msg_type == 49 and re.search( | |
| 160 | + rf"<chatusr>\s*{re.escape(self_wxid)}\s*</chatusr>", raw): | |
| 161 | + kind = "group_quote_me" | |
| 162 | + else: | |
| 163 | + # @我:正文里 '@昵称'(微信 @name 后跟 U+2005 窄空格,子串匹配即可) | |
| 164 | + for nm in my_names: | |
| 165 | + if nm and ("@" + nm) in body: | |
| 166 | + kind = "group_at_me" | |
| 167 | + break | |
| 168 | + return {"kind": kind, "sender": sender, "body": body} | |
| 169 | + | |
| 170 | + | |
| 171 | +# ── 状态 / 配置 ──────────────────────────────────────────────────────────── | |
| 172 | +def load_json(path, default): | |
| 173 | + try: | |
| 174 | + with open(path) as f: | |
| 175 | + return json.load(f) | |
| 176 | + except (OSError, ValueError): | |
| 177 | + return default | |
| 178 | + | |
| 179 | + | |
| 180 | +def hermes_owner(): | |
| 181 | + try: | |
| 182 | + st = os.stat(HERMES_HOME) | |
| 183 | + return st.st_uid, st.st_gid | |
| 184 | + except OSError: | |
| 185 | + return None | |
| 186 | + | |
| 187 | + | |
| 188 | +def chown_to_hermes(path): | |
| 189 | + o = hermes_owner() | |
| 190 | + if o: | |
| 191 | + try: | |
| 192 | + os.chown(path, o[0], o[1]) | |
| 193 | + except OSError: | |
| 194 | + pass | |
| 195 | + | |
| 196 | + | |
| 197 | +def save_state(state): | |
| 198 | + with open(STATE_FILE, "w") as f: | |
| 199 | + json.dump(state, f) | |
| 200 | + chown_to_hermes(STATE_FILE) | |
| 201 | + | |
| 202 | + | |
| 203 | +def detect_self(db_copy, key): | |
| 204 | + rows = sqlcipher_query(db_copy, key, | |
| 205 | + "SELECT id, value FROM userinfo WHERE id IN (2,4);") | |
| 206 | + self_wxid, nickname = "", "" | |
| 207 | + for r in rows: | |
| 208 | + if r[0] == "2": | |
| 209 | + self_wxid = r[1] | |
| 210 | + elif r[0] == "4": | |
| 211 | + nickname = r[1] | |
| 212 | + return self_wxid, nickname | |
| 213 | + | |
| 214 | + | |
| 215 | +# ── 核心:读增量 → 入队 ───────────────────────────────────────────────────── | |
| 216 | +def read_new(emit=True): | |
| 217 | + _, db, key = account_paths() | |
| 218 | + stage = copy_db(db) | |
| 219 | + try: | |
| 220 | + state = load_json(STATE_FILE, {}) | |
| 221 | + cfg = load_json(CONFIG_FILE, {}) | |
| 222 | + if "last_msgId" not in state: | |
| 223 | + # 首跑:基线 = 当前最大 msgId,不重放历史(否则把 2000+ 条旧消息当新消息) | |
| 224 | + mx = sqlcipher_query(stage, key, "SELECT COALESCE(MAX(msgId),0) FROM message;") | |
| 225 | + base = int(mx[0][0]) if mx and mx[0] else 0 | |
| 226 | + state["last_msgId"] = base | |
| 227 | + save_state(state) | |
| 228 | + log(f"首跑建立基线 last_msgId={base}(历史不重放)") | |
| 229 | + return [] | |
| 230 | + last_id = int(state["last_msgId"]) | |
| 231 | + self_wxid = cfg.get("my_wxid") or "" | |
| 232 | + nickname = "" | |
| 233 | + if not self_wxid: | |
| 234 | + self_wxid, nickname = detect_self(stage, key) | |
| 235 | + my_names = list(cfg.get("my_names", [])) | |
| 236 | + if nickname and nickname not in my_names: | |
| 237 | + my_names.append(nickname) | |
| 238 | + | |
| 239 | + rows = sqlcipher_query(stage, key, | |
| 240 | + "SELECT msgId, type, talker, createTime, hex(content) " | |
| 241 | + f"FROM message WHERE isSend=0 AND msgId>{last_id} " | |
| 242 | + "ORDER BY msgId ASC LIMIT 200;") | |
| 243 | + events = [] | |
| 244 | + max_id = last_id | |
| 245 | + for r in rows: | |
| 246 | + if len(r) < 5: | |
| 247 | + continue | |
| 248 | + msg_id, mtype, talker, ctime, chex = r | |
| 249 | + msg_id = int(msg_id) | |
| 250 | + max_id = max(max_id, msg_id) | |
| 251 | + try: | |
| 252 | + raw = bytes.fromhex(chex).decode("utf-8", "ignore") | |
| 253 | + except ValueError: | |
| 254 | + raw = "" | |
| 255 | + info = classify(int(mtype), talker, raw, self_wxid, my_names) | |
| 256 | + ev = { | |
| 257 | + "msgId": msg_id, | |
| 258 | + "type": int(mtype), | |
| 259 | + "talker": talker, | |
| 260 | + "createTime": int(ctime) if ctime.isdigit() else ctime, | |
| 261 | + "isGroup": talker.endswith("@chatroom"), | |
| 262 | + **info, | |
| 263 | + } | |
| 264 | + events.append(ev) | |
| 265 | + | |
| 266 | + if emit and events: | |
| 267 | + with open(QUEUE_FILE, "a") as f: | |
| 268 | + for ev in events: | |
| 269 | + f.write(json.dumps(ev, ensure_ascii=False) + "\n") | |
| 270 | + chown_to_hermes(QUEUE_FILE) | |
| 271 | + if max_id > last_id: | |
| 272 | + state["last_msgId"] = max_id | |
| 273 | + state["self_wxid"] = self_wxid | |
| 274 | + save_state(state) | |
| 275 | + return events | |
| 276 | + finally: | |
| 277 | + # 删明文副本 | |
| 278 | + for p in glob.glob(f"{WORK_DIR}/_stage/en.db*"): | |
| 279 | + try: | |
| 280 | + os.remove(p) | |
| 281 | + except OSError: | |
| 282 | + pass | |
| 283 | + | |
| 284 | + | |
| 285 | +# ── inotify ──────────────────────────────────────────────────────────────── | |
| 286 | +def make_inotify(watch_dir): | |
| 287 | + libc = ctypes.CDLL(None, use_errno=True) | |
| 288 | + fd = libc.inotify_init() | |
| 289 | + if fd < 0: | |
| 290 | + return None, None | |
| 291 | + mask = IN_MODIFY | IN_CLOSE_WRITE | IN_CREATE | IN_MOVED_TO | |
| 292 | + wd = libc.inotify_add_watch(fd, watch_dir.encode(), mask) | |
| 293 | + if wd < 0: | |
| 294 | + os.close(fd) | |
| 295 | + return None, None | |
| 296 | + return libc, fd | |
| 297 | + | |
| 298 | + | |
| 299 | +def daemon(): | |
| 300 | + _, db, _ = account_paths() | |
| 301 | + watch_dir = os.path.dirname(db) | |
| 302 | + os.makedirs(WORK_DIR, exist_ok=True) | |
| 303 | + chown_to_hermes(WORK_DIR) | |
| 304 | + libc, fd = make_inotify(watch_dir) | |
| 305 | + if fd is not None: | |
| 306 | + log(f"inotify 监听 {watch_dir}") | |
| 307 | + _watch_loop_inotify(fd) | |
| 308 | + else: | |
| 309 | + log(f"inotify 不可用,退回 mtime 轮询 {db}") | |
| 310 | + _watch_loop_mtime(db) | |
| 311 | + | |
| 312 | + | |
| 313 | +def _process(reason): | |
| 314 | + try: | |
| 315 | + evs = read_new() | |
| 316 | + if evs: | |
| 317 | + kinds = ",".join(e["kind"] for e in evs) | |
| 318 | + log(f"[{reason}] +{len(evs)} 条 → {kinds} (至 msgId {evs[-1]['msgId']})") | |
| 319 | + except Exception as e: # noqa: BLE001 — daemon 不能因单次读失败退出 | |
| 320 | + log(f"[{reason}] 读失败: {e}") | |
| 321 | + | |
| 322 | + | |
| 323 | +def _watch_loop_inotify(fd): | |
| 324 | + _process("startup") | |
| 325 | + last_read = time.time() | |
| 326 | + while True: | |
| 327 | + r, _, _ = select.select([fd], [], [], None) | |
| 328 | + if not r: | |
| 329 | + continue | |
| 330 | + os.read(fd, 8192) # 清空事件(内容不重要,只当触发信号) | |
| 331 | + time.sleep(DEBOUNCE) # 让突发写落定 | |
| 332 | + try: # 榨干去抖期内堆积的事件 | |
| 333 | + while True: | |
| 334 | + rr, _, _ = select.select([fd], [], [], 0) | |
| 335 | + if not rr: | |
| 336 | + break | |
| 337 | + os.read(fd, 8192) | |
| 338 | + except OSError: | |
| 339 | + pass | |
| 340 | + wait = MIN_READ_INTERVAL - (time.time() - last_read) | |
| 341 | + if wait > 0: | |
| 342 | + time.sleep(wait) | |
| 343 | + _process("inotify") | |
| 344 | + last_read = time.time() | |
| 345 | + | |
| 346 | + | |
| 347 | +def _watch_loop_mtime(db): | |
| 348 | + _process("startup") | |
| 349 | + last_mtime = 0.0 | |
| 350 | + while True: | |
| 351 | + try: | |
| 352 | + m = os.stat(db).st_mtime | |
| 353 | + except OSError: | |
| 354 | + m = last_mtime | |
| 355 | + if m != last_mtime: | |
| 356 | + last_mtime = m | |
| 357 | + _process("mtime") | |
| 358 | + time.sleep(MTIME_POLL) | |
| 359 | + | |
| 360 | + | |
| 361 | +# ── 自检 / CLI ───────────────────────────────────────────────────────────── | |
| 362 | +def selftest(): | |
| 363 | + ok = True | |
| 364 | + try: | |
| 365 | + uin, db, key = account_paths() | |
| 366 | + print(f"[OK] uin={uin} key={key}\n db={db}") | |
| 367 | + except Exception as e: # noqa: BLE001 | |
| 368 | + print(f"[FAIL] 账号/key 推导: {e}") | |
| 369 | + return False | |
| 370 | + try: | |
| 371 | + stage = copy_db(db) | |
| 372 | + n = sqlcipher_query(stage, key, "SELECT count(*) FROM message;")[0][0] | |
| 373 | + sw, nick = detect_self(stage, key) | |
| 374 | + print(f"[OK] 解密成功 message={n} 条; self_wxid={sw} 昵称={nick}") | |
| 375 | + for p in glob.glob(f"{WORK_DIR}/_stage/en.db*"): | |
| 376 | + os.remove(p) | |
| 377 | + except Exception as e: # noqa: BLE001 | |
| 378 | + print(f"[FAIL] 解密: {e}") | |
| 379 | + ok = False | |
| 380 | + try: | |
| 381 | + libc, fd = make_inotify(os.path.dirname(db)) | |
| 382 | + if fd is not None: | |
| 383 | + print("[OK] inotify add_watch 成功(事件驱动可用)") | |
| 384 | + os.close(fd) | |
| 385 | + else: | |
| 386 | + print("[WARN] inotify 不可用 → 会退回 mtime 轮询(仍能工作)") | |
| 387 | + except Exception as e: # noqa: BLE001 | |
| 388 | + print(f"[WARN] inotify 探测异常: {e} → 退回 mtime") | |
| 389 | + return ok | |
| 390 | + | |
| 391 | + | |
| 392 | +def main(): | |
| 393 | + os.makedirs(WORK_DIR, exist_ok=True) | |
| 394 | + args = sys.argv[1:] | |
| 395 | + if "--selftest" in args: | |
| 396 | + sys.exit(0 if selftest() else 1) | |
| 397 | + if "--tail" in args: | |
| 398 | + n = int(args[args.index("--tail") + 1]) if len(args) > args.index("--tail") + 1 else 10 | |
| 399 | + _, db, key = account_paths() | |
| 400 | + stage = copy_db(db) | |
| 401 | + rows = sqlcipher_query(stage, key, | |
| 402 | + "SELECT msgId, type, talker, hex(content) FROM message " | |
| 403 | + f"WHERE isSend=0 ORDER BY msgId DESC LIMIT {n};") | |
| 404 | + for r in rows: | |
| 405 | + body = bytes.fromhex(r[3]).decode("utf-8", "ignore").replace("\n", " ")[:60] | |
| 406 | + print(f" #{r[0]} type={r[1]} {r[2]}: {body}") | |
| 407 | + for p in glob.glob(f"{WORK_DIR}/_stage/en.db*"): | |
| 408 | + os.remove(p) | |
| 409 | + return | |
| 410 | + if "--once" in args: | |
| 411 | + evs = read_new() | |
| 412 | + print(f"新消息 {len(evs)} 条:") | |
| 413 | + for e in evs: | |
| 414 | + print(f" [{e['kind']}] #{e['msgId']} {e.get('sender')}: " | |
| 415 | + f"{str(e.get('body',''))[:60]!r}") | |
| 416 | + return | |
| 417 | + daemon() | |
| 418 | + | |
| 419 | + | |
| 420 | +if __name__ == "__main__": | |
| 421 | + main() | ... | ... |