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() |