From 58baa21db898c02f7cc530cc3ccb9fc7fc31f8f2 Mon Sep 17 00:00:00 2001 From: yibo Date: Thu, 9 Jul 2026 22:48:26 +0800 Subject: [PATCH] feat(wechat-auto-reply): 环节1 通知触发 — inotify 事件驱动的新消息监听器 --- md/wechat-auto-reply-requirements.md | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/auto-reply/wechat_reply_watcher.py | 421 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 533 insertions(+), 0 deletions(-) create mode 100644 md/wechat-auto-reply-requirements.md create mode 100644 wechat_plain/scripts/auto-reply/wechat_reply_watcher.py diff --git a/md/wechat-auto-reply-requirements.md b/md/wechat-auto-reply-requirements.md new file mode 100644 index 0000000..e457552 --- /dev/null +++ b/md/wechat-auto-reply-requirements.md @@ -0,0 +1,112 @@ +# 微信自动回复 · 需求文档 + +> 日期:2026-07-09 +> 设备:Pixel(`59171FDCG0009N`),微信 8.0.74,LSPosed + Termux + Hermes Agent +> 定位:Pixel 上的 Hermes Agent 自动处理"别人发给小火子的微信"——私聊 / 群里 @ / 群里引用——判断要不要回、怎么回、自动发还是人工审核。 + +--- + +## 0. 目标 + +有人给小火子发微信(私聊、群 @我、群引用我)时,Hermes 能: +1. 接收到这条消息 +2. 读取会话上下文 +3. 基于上下文 + SOUL.md 生成回复 +4. 判断"直接发"还是"进人工审核队列" +5. 发送回复(含微信原生引用气泡) + +约束:尽量低电量 / 低资源;纯端上(Hermes 在设备 Termux 内跑,模型走 Kimi 或本地 llama-swap)。 + +--- + +## 1. 设备现状(建这套的地基,2026-07 核对过) + +| 能力 | 现状 | 备注 | +|---|---|---| +| 微信 DB 解密读 | ✅ 可用 | 主账号目录 `ec4f423b316d41e9af5e8398155cf105`,**key `1986554`**(`MD5("1234567890ABCDEF"+uin257966415)[:7]`);参数 `cipher_compatibility=1`+`page_size=1024`。skill `~/.hermes/skills/wechat-db-decrypt/` 已就位 | +| 发送文本 | ✅ 可用 | LSPosed 内部 API(`am broadcast com.hermes.wechat.SEND_MSG`);`WeChatHook.java` 已打通文本/语音/图片 | +| 发送带原生引用气泡 | ❌ 未实现 | 见环节 5 | +| 通知接收 | 待搭 | NotificationListenerService / dumpsys,见环节 1 | +| lifelog 人物库 | 明文 SQLite | `com.lemon.lifelog/databases/lifelog.db`(4.8MB,`Person` 表),二期打通 | +| SOUL.md | ✅ 存在 | `~/.hermes/SOUL.md`(Hermes 人格) | + +> ⚠️ 过时提醒:旧文档 `md/wechat-auto-reply-status.md`(2026-05-23)里的 key `e03ce81` / 目录 `653cdc…` 是**次账号**,已不是当前主账号;旧文档主推的 Frida daemon 发送已废弃(常驻注入微信触发反调试崩溃)。以本文件为准。 + +--- + +## 2. 五环节需求 + +### 环节 1 · 接收通知 —— ✅ **已实现并真机验证(2026-07-09)** +落地:`wechat_plain/scripts/auto-reply/wechat_reply_watcher.py`(设备 `~/.hermes/wechat_reply/`)。 +- **触发**:纯 ctypes inotify 监听当前账号 DB 目录(SELinux 放行,零轮询);建 watch 失败自动退回 stat mtime 轮询。真机验证 `[inotify] +N` 事件驱动读取成立。 +- **账号自适应**:运行时 `MD5("mm"+uin)` 定位当前登录账号(验证时账号已从 火星人 切到 小火子,daemon 自动跟上,无硬编码)。 +- **增量+去重+基线**:持久化 `last_msgId`;只 `isSend=0 且 msgId>last`;首跑基线=当前最大 msgId,不重放历史。 +- **分类**:private / group_at_me / group_quote_me / group_other;群消息剥 `wxid:\n` 前缀取 sender+body;自身 wxid/昵称从 userinfo 自动检测。 +- **输出**:`incoming.jsonl` 事件队列(供环节 2-5 消费)。自检 `--selftest`、单读 `--once`、`--tail N`。 +- 待接:常驻自启(Termux:Boot / Hermes cron),env4 里再过滤 gh_ 公众号与系统消息(撤回/拍一拍)。 + +原始设计记录: + +### (原)环节 1 · 接收通知 —— **事件驱动,非定时轮询** +根因:费电的不是 cron 循环,是"每次轮询都 su 拷 90MB 的 EnMicroMsg.db"。定 5s 紧循环拷 90MB → 电量扛不住。 + +- **主触发**:常驻 NotificationListenerService。系统主动 push,空闲近零成本;微信来消息才被唤醒 → 这时才读库。没消息 = 不动。 +- **兜底**:Hermes cron 每几分钟扫一遍,捞免打扰 / 漏掉的消息。**不要跑紧循环。** +- **读库**:优先只读 in-place 打开(immutable);锁住再拷。因为改成事件驱动,拷库只在真有消息时发生,频率极低,可接受。 +- **消息源**:主账号 `ec4f…/EnMicroMsg.db` 的 `message` 表,`WHERE isSend=0 AND msgId>last_processed_id`。 +- **分流(本环节难点)**: + - 私聊:直接是一条 + - 群 @我:群消息 content 前缀 `wxid:\n`,需匹配我的昵称判 @ + - 群引用我:看 refermsg 是否指向我的 svrid + +### 环节 2 · 读取上下文 +- 从 `message` 表取该会话最近 N 条 + 发送者身份(`rcontact` 昵称/备注)。 +- **二期加分**:回链 lifelog `Person` 库做人物增强("这个人是谁、跟小火子什么关系")。一期不做。 + +### 环节 3 · 生成回复 +- Hermes(Kimi / 本地 llama-swap):**上下文 + SOUL.md → 回复**。 +- 建议一次结构化调用同时出 `{should_reply, confidence, draft, risk_flags}`,别拆两次。 +- ⚠️ 若回复要以"小火子本人"口吻发出,SOUL.md 里可能需加一段"小火子语气"约定(否则像 agent 替他说话)。 + +### 环节 4 · 自动 vs 审核 —— **白名单直发,其余进 viewer 待回复** +- **白名单**(联系人 / 群 / 群里某发言人)+ 生成成功 → **直接发送**。 +- **非白名单** → 写入 **Pixel 本地 wechat viewer 的"待回复列表"**,人工点击"发送"才真正发出。 +- 落地新增:①「待回复队列」存储(sqlite/json);② wechat viewer 的"发送"按钮接到发送链路。 +- 安全红线(贯穿):涉钱 / 承诺 / 约时间 / 低置信 → 即使白名单也建议进审核。 + +### 环节 5 · 发送回复带引用(原生气泡) +需求 = 点原文→引用→发送后显示引用气泡。两条实现路: +- **(a) A11y UI 自动化(一期推荐)**:长按目标气泡 → 点"引用" → 输入 → 发送。驱动微信自己 UI → **产出货真价实的原生引用气泡,零逆向**。代价:需微信前台、脚本略脆、比 API 慢。已有 A11y 发送服务底子。 +- **(b) 内部 API 构造 refermsg(二期优化)**:微信引用 = type 49 appmsg + refermsg(type 57) 指向被引 svrid。一整块新逆向,量级同当初啃发图,重。留作提速。 + +--- + +## 3. 贯穿层(不做会出事) +- **防循环/去重**:只处理 `isSend=0`;持久化 per-talker `last_processed_msgId`;绝不回自己发的、绝不重放。 +- **限流**:每人/每群回复冷却,防抖防刷屏。 +- **安全红线**:陌生人 / 涉钱 / 承诺 / 约时间 → 永不自动发,硬编码兜底。 + +--- + +## 4. 一期最小可跑(MVP) + +``` +通知触发(NotificationListener) + └→ 读库(主账号 ec4f/1986554, message 表, isSend=0 & msgId>last) + └→ 分流(私聊/群@我/群引用我) + 取上下文(近 N 条 + rcontact 身份) + └→ Hermes 生成(上下文 + SOUL.md → {should_reply,confidence,draft}) + ├→ 白名单 → A11y 发送(纯文本+@;引用气泡) + └→ 非白名单 → 写入 wechat viewer 待回复队列 → 人工点发送 +``` + +**二期**:lifelog `Person` 人物增强;内部 API refermsg 引用提速;cron 兜底调优。 + +--- + +## 5. 待决策 / 待确认 +- [ ] 白名单如何维护(配置文件 / viewer 里管理 / 从 lifelog 拉) +- [ ] 待回复队列 + wechat viewer 的具体形态(现有 `wechat-web-viewer` skill 扩展?) +- [ ] SOUL.md 是否加"小火子语气"段 +- [ ] 群 @我 / 引用我的判定用昵称还是 wxid(昵称易误判,wxid 更准) +- [ ] in-place 只读打开能否稳定绕过 WCDB 锁(否则回退拷库) +- [ ] 环节 2 lifelog join key:`Person` 里存的微信标识字段(二期确认) diff --git a/wechat_plain/scripts/auto-reply/wechat_reply_watcher.py b/wechat_plain/scripts/auto-reply/wechat_reply_watcher.py new file mode 100644 index 0000000..f2ba86e --- /dev/null +++ b/wechat_plain/scripts/auto-reply/wechat_reply_watcher.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python3 +"""环节1 · 通知触发 —— 微信新消息事件监听器 + +inotify 监听当前账号 EnMicroMsg.db 的写入(纯 ctypes,零额外 pip 包)→ 文件变化时 +解密增量读 message 表(isSend=0 且 msgId>last)→ 分流(私聊/群@我/群引用我/群其它) +→ 追加到 incoming.jsonl 队列,供后续环节(读上下文/生成/发送)消费。 + +作为 root 在 Termux 内跑。inotify 建 watch 失败(SELinux/环境)时自动退回 stat mtime 轮询。 + +设备事实(2026-07 验证): + 账号目录 = MD5("mm"+uin) ;key = MD5("1234567890ABCDEF"+uin)[:7] + SQLCipher: cipher_compatibility=1 + page_size=1024 + 自己 wxid=userinfo[2],昵称=userinfo[4] +群消息 content 形如 "wxid_sender:\\n<正文>",需剥前缀;引用是 type=49 的 refermsg XML。 + +用法: + python wechat_reply_watcher.py --selftest # 自检:推导 key/解密/inotify 是否都 OK + python wechat_reply_watcher.py --once # 单次:读一遍新消息、打印、入队(测试用) + python wechat_reply_watcher.py --tail 10 # 打印最近 N 条 inbound(不改状态) + python wechat_reply_watcher.py # 常驻 daemon(inotify 事件驱动) + +ponytail: 读库走「拷贝整库→sqlcipher」。整库现 ~96MB,拷一次 ~2-3s;靠事件驱动+ +MIN_READ_INTERVAL 合并突发把频率压到很低。上限=活跃群里瞬时多条→仍是一次读(去抖合并)。 +升级路径:sqlcipher in-place immutable 只读打开省掉拷贝(WCDB 并发写会 database is locked, +需 immutable=1 URI 验证),或换真 NotificationListenerService 做 push。 +""" +import ctypes +import glob +import hashlib +import json +import os +import re +import select +import subprocess +import sys +import time + +MM_BASE = "/data/user/0/com.tencent.mm" +TERMUX_BIN = "/data/data/com.termux/files/usr/bin" +SQLCIPHER = f"{TERMUX_BIN}/sqlcipher" +HERMES_HOME = "/data/data/com.termux/files/home/.hermes" +WORK_DIR = f"{HERMES_HOME}/wechat_reply" +STATE_FILE = f"{WORK_DIR}/state.json" +CONFIG_FILE = f"{WORK_DIR}/config.json" +QUEUE_FILE = f"{WORK_DIR}/incoming.jsonl" +LOG_FILE = f"{WORK_DIR}/watcher.log" +IMEI = "1234567890ABCDEF" + +# inotify 常量 +IN_MODIFY = 0x00000002 +IN_CLOSE_WRITE = 0x00000008 +IN_MOVED_TO = 0x00000080 +IN_CREATE = 0x00000100 + +MIN_READ_INTERVAL = 2.0 # 两次实际读库最小间隔(秒),合并突发 +DEBOUNCE = 0.4 # 收到事件后等一小会儿让突发写落定 +MTIME_POLL = 4.0 # inotify 不可用时的兜底轮询间隔 + + +def log(msg): + line = f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}" + print(line, flush=True) + try: + with open(LOG_FILE, "a") as f: + f.write(line + "\n") + except OSError: + pass + + +def md5hex(s: str) -> str: + return hashlib.md5(s.encode()).hexdigest() + + +# ── 账号 / key 推导 ──────────────────────────────────────────────────────── +def read_uin() -> str: + for xml in glob.glob(f"{MM_BASE}/shared_prefs/*.xml"): + try: + with open(xml, "r", errors="ignore") as f: + m = re.search(r'last_login_uin">(-?\d+)', f.read()) + if m: + return m.group(1) + except OSError: + continue + raise RuntimeError("没读到微信 last_login_uin(未登录?没 root?)") + + +def account_paths(): + uin = read_uin() + acc_dir = md5hex("mm" + uin) # = MD5("mm"+uin),当前登录账号目录 + db = f"{MM_BASE}/MicroMsg/{acc_dir}/EnMicroMsg.db" + if not os.path.exists(db): # 兜底:定位不到就取任一个 + cands = glob.glob(f"{MM_BASE}/MicroMsg/*/EnMicroMsg.db") + if not cands: + raise RuntimeError("找不到 EnMicroMsg.db") + db = cands[0] + key = md5hex(IMEI + uin)[:7] + return uin, db, key + + +# ── SQLCipher 读 ─────────────────────────────────────────────────────────── +def sqlcipher_query(db_copy: str, key: str, sql: str): + """返回 list[list[str]]。content 一律用 hex() 传出,躲开分隔符/换行问题。""" + # PRAGMA key/compatibility 会往 stdout 各吐一行 "ok",用 sentinel 只取其后的真结果。 + sentinel = "<<>>" + script = ( + f"PRAGMA key='{key}';\n" + "PRAGMA cipher_compatibility=1;\n" + "PRAGMA cipher_page_size=1024;\n" + ".mode list\n" + ".separator \"\\t\"\n" + ".headers off\n" + f".print {sentinel}\n" + f"{sql}\n" + ) + p = subprocess.run([SQLCIPHER, db_copy], input=script, + capture_output=True, text=True, timeout=60) + if p.returncode != 0 or "file is not a database" in p.stderr: + raise RuntimeError(f"sqlcipher 失败: {p.stderr.strip()[:120]}") + rows = [] + started = False + for line in p.stdout.splitlines(): + if not started: + if line == sentinel: + started = True + continue + if line == "": + continue + rows.append(line.split("\t")) + return rows + + +def copy_db(db: str) -> str: + """拷当前库(含 journal)到 Termux 私有 tmp,返回副本路径。root 直接文件拷,不经 /sdcard。""" + os.makedirs(f"{WORK_DIR}/_stage", exist_ok=True) + dst = f"{WORK_DIR}/_stage/en.db" + subprocess.run(["cp", db, dst], check=True, timeout=60) + for ext in ("-journal", "-wal", "-shm"): + if os.path.exists(db + ext): + subprocess.run(["cp", db + ext, dst + ext], timeout=60) + return dst + + +# ── 分类 / 解析 ──────────────────────────────────────────────────────────── +def parse_group_content(raw: str): + """群消息 content = 'wxid:\\n正文' → (sender_wxid, body)。私聊无前缀 → (None, raw)。""" + m = re.match(r"^([A-Za-z0-9_@.\-]+):\n(.*)$", raw, re.S) + if m: + return m.group(1), m.group(2) + return None, raw + + +def classify(msg_type: int, talker: str, raw: str, self_wxid: str, my_names): + is_group = talker.endswith("@chatroom") + if not is_group: + return {"kind": "private", "sender": talker, "body": raw} + sender, body = parse_group_content(raw) + kind = "group_other" + # 引用我:type 49 的 refermsg 里被引消息作者=我 + if msg_type == 49 and re.search( + rf"\s*{re.escape(self_wxid)}\s*", raw): + kind = "group_quote_me" + else: + # @我:正文里 '@昵称'(微信 @name 后跟 U+2005 窄空格,子串匹配即可) + for nm in my_names: + if nm and ("@" + nm) in body: + kind = "group_at_me" + break + return {"kind": kind, "sender": sender, "body": body} + + +# ── 状态 / 配置 ──────────────────────────────────────────────────────────── +def load_json(path, default): + try: + with open(path) as f: + return json.load(f) + except (OSError, ValueError): + return default + + +def hermes_owner(): + try: + st = os.stat(HERMES_HOME) + return st.st_uid, st.st_gid + except OSError: + return None + + +def chown_to_hermes(path): + o = hermes_owner() + if o: + try: + os.chown(path, o[0], o[1]) + except OSError: + pass + + +def save_state(state): + with open(STATE_FILE, "w") as f: + json.dump(state, f) + chown_to_hermes(STATE_FILE) + + +def detect_self(db_copy, key): + rows = sqlcipher_query(db_copy, key, + "SELECT id, value FROM userinfo WHERE id IN (2,4);") + self_wxid, nickname = "", "" + for r in rows: + if r[0] == "2": + self_wxid = r[1] + elif r[0] == "4": + nickname = r[1] + return self_wxid, nickname + + +# ── 核心:读增量 → 入队 ───────────────────────────────────────────────────── +def read_new(emit=True): + _, db, key = account_paths() + stage = copy_db(db) + try: + state = load_json(STATE_FILE, {}) + cfg = load_json(CONFIG_FILE, {}) + if "last_msgId" not in state: + # 首跑:基线 = 当前最大 msgId,不重放历史(否则把 2000+ 条旧消息当新消息) + mx = sqlcipher_query(stage, key, "SELECT COALESCE(MAX(msgId),0) FROM message;") + base = int(mx[0][0]) if mx and mx[0] else 0 + state["last_msgId"] = base + save_state(state) + log(f"首跑建立基线 last_msgId={base}(历史不重放)") + return [] + last_id = int(state["last_msgId"]) + self_wxid = cfg.get("my_wxid") or "" + nickname = "" + if not self_wxid: + self_wxid, nickname = detect_self(stage, key) + my_names = list(cfg.get("my_names", [])) + if nickname and nickname not in my_names: + my_names.append(nickname) + + rows = sqlcipher_query(stage, key, + "SELECT msgId, type, talker, createTime, hex(content) " + f"FROM message WHERE isSend=0 AND msgId>{last_id} " + "ORDER BY msgId ASC LIMIT 200;") + events = [] + max_id = last_id + for r in rows: + if len(r) < 5: + continue + msg_id, mtype, talker, ctime, chex = r + msg_id = int(msg_id) + max_id = max(max_id, msg_id) + try: + raw = bytes.fromhex(chex).decode("utf-8", "ignore") + except ValueError: + raw = "" + info = classify(int(mtype), talker, raw, self_wxid, my_names) + ev = { + "msgId": msg_id, + "type": int(mtype), + "talker": talker, + "createTime": int(ctime) if ctime.isdigit() else ctime, + "isGroup": talker.endswith("@chatroom"), + **info, + } + events.append(ev) + + if emit and events: + with open(QUEUE_FILE, "a") as f: + for ev in events: + f.write(json.dumps(ev, ensure_ascii=False) + "\n") + chown_to_hermes(QUEUE_FILE) + if max_id > last_id: + state["last_msgId"] = max_id + state["self_wxid"] = self_wxid + save_state(state) + return events + finally: + # 删明文副本 + for p in glob.glob(f"{WORK_DIR}/_stage/en.db*"): + try: + os.remove(p) + except OSError: + pass + + +# ── inotify ──────────────────────────────────────────────────────────────── +def make_inotify(watch_dir): + libc = ctypes.CDLL(None, use_errno=True) + fd = libc.inotify_init() + if fd < 0: + return None, None + mask = IN_MODIFY | IN_CLOSE_WRITE | IN_CREATE | IN_MOVED_TO + wd = libc.inotify_add_watch(fd, watch_dir.encode(), mask) + if wd < 0: + os.close(fd) + return None, None + return libc, fd + + +def daemon(): + _, db, _ = account_paths() + watch_dir = os.path.dirname(db) + os.makedirs(WORK_DIR, exist_ok=True) + chown_to_hermes(WORK_DIR) + libc, fd = make_inotify(watch_dir) + if fd is not None: + log(f"inotify 监听 {watch_dir}") + _watch_loop_inotify(fd) + else: + log(f"inotify 不可用,退回 mtime 轮询 {db}") + _watch_loop_mtime(db) + + +def _process(reason): + try: + evs = read_new() + if evs: + kinds = ",".join(e["kind"] for e in evs) + log(f"[{reason}] +{len(evs)} 条 → {kinds} (至 msgId {evs[-1]['msgId']})") + except Exception as e: # noqa: BLE001 — daemon 不能因单次读失败退出 + log(f"[{reason}] 读失败: {e}") + + +def _watch_loop_inotify(fd): + _process("startup") + last_read = time.time() + while True: + r, _, _ = select.select([fd], [], [], None) + if not r: + continue + os.read(fd, 8192) # 清空事件(内容不重要,只当触发信号) + time.sleep(DEBOUNCE) # 让突发写落定 + try: # 榨干去抖期内堆积的事件 + while True: + rr, _, _ = select.select([fd], [], [], 0) + if not rr: + break + os.read(fd, 8192) + except OSError: + pass + wait = MIN_READ_INTERVAL - (time.time() - last_read) + if wait > 0: + time.sleep(wait) + _process("inotify") + last_read = time.time() + + +def _watch_loop_mtime(db): + _process("startup") + last_mtime = 0.0 + while True: + try: + m = os.stat(db).st_mtime + except OSError: + m = last_mtime + if m != last_mtime: + last_mtime = m + _process("mtime") + time.sleep(MTIME_POLL) + + +# ── 自检 / CLI ───────────────────────────────────────────────────────────── +def selftest(): + ok = True + try: + uin, db, key = account_paths() + print(f"[OK] uin={uin} key={key}\n db={db}") + except Exception as e: # noqa: BLE001 + print(f"[FAIL] 账号/key 推导: {e}") + return False + try: + stage = copy_db(db) + n = sqlcipher_query(stage, key, "SELECT count(*) FROM message;")[0][0] + sw, nick = detect_self(stage, key) + print(f"[OK] 解密成功 message={n} 条; self_wxid={sw} 昵称={nick}") + for p in glob.glob(f"{WORK_DIR}/_stage/en.db*"): + os.remove(p) + except Exception as e: # noqa: BLE001 + print(f"[FAIL] 解密: {e}") + ok = False + try: + libc, fd = make_inotify(os.path.dirname(db)) + if fd is not None: + print("[OK] inotify add_watch 成功(事件驱动可用)") + os.close(fd) + else: + print("[WARN] inotify 不可用 → 会退回 mtime 轮询(仍能工作)") + except Exception as e: # noqa: BLE001 + print(f"[WARN] inotify 探测异常: {e} → 退回 mtime") + return ok + + +def main(): + os.makedirs(WORK_DIR, exist_ok=True) + args = sys.argv[1:] + if "--selftest" in args: + sys.exit(0 if selftest() else 1) + if "--tail" in args: + n = int(args[args.index("--tail") + 1]) if len(args) > args.index("--tail") + 1 else 10 + _, db, key = account_paths() + stage = copy_db(db) + rows = sqlcipher_query(stage, key, + "SELECT msgId, type, talker, hex(content) FROM message " + f"WHERE isSend=0 ORDER BY msgId DESC LIMIT {n};") + for r in rows: + body = bytes.fromhex(r[3]).decode("utf-8", "ignore").replace("\n", " ")[:60] + print(f" #{r[0]} type={r[1]} {r[2]}: {body}") + for p in glob.glob(f"{WORK_DIR}/_stage/en.db*"): + os.remove(p) + return + if "--once" in args: + evs = read_new() + print(f"新消息 {len(evs)} 条:") + for e in evs: + print(f" [{e['kind']}] #{e['msgId']} {e.get('sender')}: " + f"{str(e.get('body',''))[:60]!r}") + return + daemon() + + +if __name__ == "__main__": + main() -- libgit2 0.21.4