Commit d7b8a7e5a3b5b4a0bde4e8bbea7b27a5732275bb

Authored by yibo
1 parent 7bd3230b

feat: SQLCipher + Frida 重构微信自动回复链路

- auto-reply.js 接入 hermes-agent OpenAI-compatible API (:8642)
- 新增 android-wechat-tracker-sqlcipher.js 替代 LSPosed 日志 tail
- 新增 android-wechat-sender-frida.js 替代 am broadcast 发送
- 更新 keepalive.sh、README、HermesWeChatModule.java
- 添加 .gitignore SQLite 变体规则
- 新增 docs: 息屏发送尝试记录与屏幕自动唤醒方案

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
.gitignore
@@ -26,6 +26,11 @@ system/agents/*/memory/ @@ -26,6 +26,11 @@ system/agents/*/memory/
26 26
27 # SQLite databases 27 # SQLite databases
28 *.sqlite 28 *.sqlite
  29 +*.sqlite3
  30 +*.db
  31 +*.db-journal
  32 +*.db-shm
  33 +*.db-wal
29 server/db/ 34 server/db/
30 35
31 # Build output 36 # Build output
docs/screen-auto-wake-proposal.md 0 → 100644
  1 +# 息屏自动唤醒亮屏方案讨论
  2 +
  3 +> 背景:Pixel 10 Pro Fold (Android 15) 息屏状态下微信消息发送失败
  4 +> 核心问题:息屏是否必然导致发送失败?能否通过"自动亮屏"绕过限制?
  5 +> 时间:2026-05-23
  6 +
  7 +---
  8 +
  9 +## 一、为什么息屏会导致发送失败?(Android 15 限制分析)
  10 +
  11 +### 1.1 息屏 ≠ 只是屏幕变黑
  12 +
  13 +Android 15 在息屏时会同时激活多层限制:
  14 +
  15 +```
  16 +用户按下电源键 / 超时息屏
  17 + │
  18 + ├──→ Display OFF(屏幕关闭)
  19 + │
  20 + ├──→ Keyguard ON(锁屏界面激活,若有密码需解锁)
  21 + │
  22 + ├──→ Doze Mode(浅睡眠,限制网络/闹钟/JobScheduler)
  23 + │ └── 进入 doze 后:网络批量处理、闹钟延迟、wakelock 忽略
  24 + │
  25 + ├──→ App Standby(应用待机桶,限制后台活动)
  26 + │ └── 微信可能被分到 RARE/RESTRICTED 桶
  27 + │
  28 + └──→ Background Activity Launch 限制
  29 + └── 后台应用(包括通过 su 启动的)禁止启动 Activity
  30 +```
  31 +
  32 +### 1.2 我们遇到的限制层级
  33 +
  34 +| 层级 | 当前是否受影响 | 说明 |
  35 +|------|-------------|------|
  36 +| 屏幕关闭 | ✅ 是 | `am start` 不执行,需要 `input keyevent 26` 点亮 |
  37 +| 锁屏界面 | ⚠️ 未知 | Pixel 是否有密码/指纹?亮屏后是否卡在 Keyguard? |
  38 +| Doze 模式 | ⚠️ 可能 | 即使亮屏,Doze 可能仍在运行,限制网络调度 |
  39 +| 后台启动限制 | ⚠️ 可能 | Termux 通过 `su` 执行命令,但微信进程本身不是前台 |
  40 +| NetScene 队列冻结 | ⚠️ 可能 | 微信内部可能在息屏时暂停消息发送队列 |
  41 +
  42 +**关键结论**:即使屏幕亮了,如果 Doze 仍在运行、或微信 NetSceneDispatcher 未恢复,发送仍可能失败。
  43 +
  44 +---
  45 +
  46 +## 二、自动唤醒亮屏的技术方案(6 种)
  47 +
  48 +### 方案 A:模拟电源键点亮(已部分实现)
  49 +
  50 +**做法**:
  51 +```bash
  52 +input keyevent 26 # 模拟电源键,Toggle 屏幕开关
  53 +```
  54 +
  55 +**限制**:
  56 +- 只点亮屏幕,**不解锁**锁屏界面
  57 +- 如果手机有 PIN/密码/指纹,会卡在 Keyguard
  58 +- Doze 模式**不会退出**(需要传感器运动或插拔充电)
  59 +
  60 +**适用条件**:手机无密码(Swipe only)或 Smart Lock 已信任当前位置/设备
  61 +
  62 +**可行性**:⭐⭐⭐ 部分可行
  63 +
  64 +---
  65 +
  66 +### 方案 B:模拟滑动手势解锁
  67 +
  68 +**做法**:
  69 +```bash
  70 +# 先点亮
  71 +input keyevent 26
  72 +sleep 300ms
  73 +# 模拟从底部向上滑解锁(无密码时)
  74 +input swipe 500 1200 500 500 300
  75 +```
  76 +
  77 +**限制**:
  78 +- 仅适用于**无密码**的 Swipe 解锁
  79 +- 有 PIN/密码/图案/指纹时完全无效
  80 +- 不同手机分辨率不同,滑动坐标需适配
  81 +
  82 +**适用条件**:手机未设置任何安全锁屏
  83 +
  84 +**可行性**:⭐⭐ 受限极大
  85 +
  86 +---
  87 +
  88 +### 方案 C:发送高优先级通知唤醒
  89 +
  90 +**原理**:Android 允许某些通知唤醒屏幕(如来电、闹钟)
  91 +
  92 +**做法**:
  93 +```bash
  94 +# 通过 termux-notification 发送一个 IMPORTANCE_HIGH 通知
  95 +# 并设置唤醒屏幕标志
  96 +```
  97 +
  98 +**限制**:
  99 +- 需要 `termux-api` 应用及后台服务运行
  100 +- Android 15 对通知唤醒屏幕有严格限制,通常只有电话/闹钟应用可以做到
  101 +- 用户需在设置中允许 Termux "唤醒屏幕"
  102 +
  103 +**适用条件**:用户手动授权 + Termux API 常驻
  104 +
  105 +**可行性**:⭐⭐ 不可靠
  106 +
  107 +---
  108 +
  109 +### 方案 D:通过 AlarmManager / 精确闹钟触发
  110 +
  111 +**原理**:设置一个精确闹钟,闹钟触发时系统会短暂退出 Doze,允许应用执行操作
  112 +
  113 +**做法**:
  114 +```bash
  115 +# 通过 termux 的脚本或 Android 应用设置 AlarmManager
  116 +# 触发时执行发送逻辑
  117 +```
  118 +
  119 +**限制**:
  120 +- 闹钟只能唤醒**自己的应用**,不能直接唤醒微信
  121 +- 闹钟触发时屏幕不一定亮
  122 +- Android 15 需要 `SCHEDULE_EXACT_ALARM` 权限
  123 +
  124 +**适用条件**:仅适用于定时任务,不适用于实时消息回复
  125 +
  126 +**可行性**:⭐ 不适合实时回复场景
  127 +
  128 +---
  129 +
  130 +### 方案 E:保持屏幕常亮( prevention 而非 cure )
  131 +
  132 +**原理**:不让手机息屏,从根本上避免问题
  133 +
  134 +**做法**:
  135 +```bash
  136 +# 方法 1:修改系统设置(需要 root)
  137 +su -c "settings put system screen_off_timeout 2147483647"
  138 +
  139 +# 方法 2:使用 WakeLock(需要应用支持)
  140 +# Termux 中运行 termux-wake-lock(但这只保持 CPU,不关屏幕)
  141 +
  142 +# 方法 3:充电时保持亮屏(设置中 Developer Options)
  143 +```
  144 +
  145 +**限制**:
  146 +- 耗电严重
  147 +- OLED 屏幕可能烧屏
  148 +- 用户接受度低
  149 +- 如果手机在口袋里,误触问题
  150 +
  151 +**适用条件**:设备长期插电使用,且用户接受屏幕常亮
  152 +
  153 +**可行性**:⭐⭐⭐ 技术可行,但体验差
  154 +
  155 +---
  156 +
  157 +### 方案 F:利用 Smart Lock / 信任设备自动解锁
  158 +
  159 +**原理**:Android Smart Lock 可以在特定条件下(如连接到信任的蓝牙设备、位于信任的地理位置)自动跳过锁屏。
  160 +
  161 +**做法**:
  162 +1. 用户在手机设置中开启 Smart Lock
  163 +2. 添加信任条件(如连接到家里 Wi-Fi、信任的蓝牙手表/耳机)
  164 +3. 当条件满足时,`input keyevent 26` 点亮屏幕后自动解锁
  165 +
  166 +**限制**:
  167 +- 需要用户手动配置
  168 +- 安全性降低
  169 +- 如果条件不满足,仍然卡在锁屏
  170 +
  171 +**适用条件**:用户愿意配置 Smart Lock,且有稳定的信任条件
  172 +
  173 +**可行性**:⭐⭐⭐ 依赖用户配置
  174 +
  175 +---
  176 +
  177 +## 三、更深层的分析:亮屏后发送就一定成功吗?
  178 +
  179 +### 3.1 当前 Frida 测试结果告诉我们什么?
  180 +
  181 +我们在**亮屏状态**下调用 `u01.m1.a()`,返回 `true`,但 DB 中**没有新消息**。
  182 +
  183 +这说明:
  184 +1. **调用链可能本身就是错的** — `m1.a()` 不是真正发送消息的方法
  185 +2. **或者**,微信内部需要额外的初始化步骤
  186 +
  187 +**关键推论**:即使我们通过某种方式让屏幕亮了,如果调用链不正确,发送仍然失败。
  188 +
  189 +### 3.2 需要先解决"亮屏发送",再解决"息屏唤醒"
  190 +
  191 +当前问题的优先级应该是:
  192 +
  193 +```
  194 +P0: 亮屏 + 微信前台 → 能否成功发送?(Frida 测试 q1.b() / d())
  195 + │
  196 + ├──→ 如果亮屏也失败:调用链有问题,与息屏无关
  197 + │
  198 + └──→ 如果亮屏成功:再解决息屏限制
  199 + │
  200 + ├──→ 方案 A/B/F:自动亮屏+解锁
  201 + └──→ 方案 E:保持常亮
  202 +```
  203 +
  204 +---
  205 +
  206 +## 四、综合方案推荐
  207 +
  208 +### 方案 1:双路径发送(推荐)
  209 +
  210 +```
  211 +消息检测(sqlcipher 轮询 / 通知监听)
  212 + │
  213 + ├──→ 检测到微信在前台 ──→ 直接调用发送 API
  214 + │
  215 + └──→ 检测到息屏 ────────→ input keyevent 26 唤醒
  216 + │
  217 + ├──→ 无密码 / Smart Lock ──→ 自动解锁
  218 + │ └──→ 微信前台 → 发送
  219 + │
  220 + └──→ 有密码且未 Smart Lock ──→ 发送失败,记录待发送队列
  221 + │
  222 + └──→ 下次用户解锁时批量发送
  223 +```
  224 +
  225 +**优点**:
  226 +- 前台时实时发送
  227 +- 息屏时尝试唤醒,成功则发,失败则缓存
  228 +- 不丢失消息
  229 +
  230 +**缺点**:
  231 +- 息屏时回复有延迟(需等用户下次解锁)
  232 +- 需要维护待发送队列
  233 +
  234 +---
  235 +
  236 +### 方案 2:保持微信前台常驻(激进方案)
  237 +
  238 +```bash
  239 +# 1. 设置屏幕常亮(root)
  240 +su -c "settings put system screen_off_timeout 2147483647"
  241 +
  242 +# 2. 微信保持在 LauncherUI 前台
  243 +# 3. 使用 termux-wake-lock 防止 CPU 休眠
  244 +```
  245 +
  246 +**配合硬件**:
  247 +- 手机长期连接充电器
  248 +- 降低屏幕亮度到最低
  249 +- 使用 OLED 黑色主题减少烧屏
  250 +
  251 +**优点**:
  252 +- 完全绕过息屏限制
  253 +- 发送实时性最好
  254 +
  255 +**缺点**:
  256 +- 耗电
  257 +- 烧屏风险
  258 +- 用户体验差(手机一直亮着)
  259 +
  260 +---
  261 +
  262 +### 方案 3:Termux 前台服务 + 通知监听(折中方案)
  263 +
  264 +**原理**:
  265 +1. Termux 运行在**前台服务**模式(显示一个常驻通知)
  266 +2. 前台服务不受 Doze 限制,可以持续运行
  267 +3. 收到微信通知时,通过 `input keyevent 26` 唤醒屏幕
  268 +4. 如果手机无密码/Smart Lock,微信启动后自动前台
  269 +5. 发送消息
  270 +
  271 +**关键条件**:
  272 +- Termux 需要在前台服务模式下运行
  273 +- 手机不能有密码锁,或已配置 Smart Lock
  274 +
  275 +---
  276 +
  277 +## 五、针对 Pixel 10 Pro Fold 的具体建议
  278 +
  279 +### 5.1 先确认锁屏状态
  280 +
  281 +```bash
  282 +# 检查当前 Keyguard 状态
  283 +adb shell dumpsys window | grep -i "keyguard"
  284 +# 或
  285 +adb shell dumpsys deviceidle | grep -i "screen"
  286 +```
  287 +
  288 +**输出分析**:
  289 +- `mDreamingLockscreen=true` → 有锁屏界面
  290 +- `mInputRestricted=true` → 需要解锁才能操作
  291 +
  292 +### 5.2 如果手机有密码
  293 +
  294 +**路径 1**:建议用户开启 Smart Lock
  295 +- 设置 → 安全 → Smart Lock → 信任的地点/设备
  296 +- 添加家里位置或连接的蓝牙耳机
  297 +
  298 +**路径 2**:使用 ADB + 电脑保持连接
  299 +- 电脑端运行 `adb shell svc power stayon usb`
  300 +- 手机连接电脑时屏幕常亮
  301 +- 但这要求电脑一直在旁边
  302 +
  303 +**路径 3**:接受延迟回复
  304 +- 息屏时缓存回复到队列
  305 +- 用户下次解锁手机时自动发送队列中的消息
  306 +
  307 +### 5.3 如果手机无密码
  308 +
  309 +直接实现方案 A + B:
  310 +```javascript
  311 +async function wakeAndUnlock() {
  312 + await shell('input keyevent 26', 3000); // 点亮
  313 + await sleep(500);
  314 + await shell('input swipe 500 1200 500 500 300', 3000); // 上滑解锁
  315 + await sleep(300);
  316 + await shell('am start -n com.tencent.mm/.ui.LauncherUI', 5000); // 启动微信
  317 +}
  318 +```
  319 +
  320 +---
  321 +
  322 +## 六、结论与下一步
  323 +
  324 +### 核心结论
  325 +
  326 +1. **息屏限制是真实存在的**:Android 15 的 Doze + 后台启动限制确实会阻止消息发送
  327 +2. **但当前更大的问题是调用链**:亮屏状态下 `u01.m1.a()` 也不工作,说明不是单纯的息屏问题
  328 +3. **自动亮屏技术上可行**:`input keyevent 26` 已验证可用,但**解锁**是瓶颈
  329 +4. **优先级应该是**:
  330 + - **P0**:用 Frida 测试 `q1.b()` 和 `q1.d()`,找到真正发送方法
  331 + - **P1**:确认亮屏+前台状态下能否稳定发送
  332 + - **P2**:解决息屏唤醒(根据用户锁屏配置选择方案 A/B/F/E)
  333 +
  334 +### 建议用户决策
  335 +
  336 +| 你的手机状态 | 推荐方案 | 实施难度 |
  337 +|-----------|---------|---------|
  338 +| 无密码 / Swipe only | 方案 A + B:唤醒 + 滑动解锁 | 低 |
  339 +| 有密码,可开 Smart Lock | 方案 F:Smart Lock 自动解锁 | 中(需配置) |
  340 +| 有密码,不能开 Smart Lock | 方案 1(双路径):前台实时发,息屏缓存延迟发 | 中 |
  341 +| 长期插电放在桌上 | 方案 E:屏幕常亮 | 低(但耗电) |
  342 +
  343 +---
  344 +
  345 +## 七、待确认问题(需要用户回答)
  346 +
  347 +1. **你的 Pixel 10 Fold 有设置锁屏密码/PIN/指纹吗?**
  348 + - 如果有,能否开启 Smart Lock?
  349 +
  350 +2. **手机通常是放在桌上插电,还是随身携带?**
  351 + - 这影响"屏幕常亮"方案的可行性
  352 +
  353 +3. **对回复延迟的容忍度如何?**
  354 + - 是否接受息屏时延迟到下次解锁才发送?
  355 +
  356 +4. **是否愿意安装 Termux:API 并保持其前台服务运行?**
  357 + - 这可以绕过部分后台限制
  358 +
  359 +根据你的回答,我可以给出最精准的实施方案。
docs/wechat-screen-off-send-attempts.md 0 → 100644
  1 +# WeChat 息屏自动发送方案汇总
  2 +
  3 +> 设备:Pixel 10 Pro Fold (Android 15)
  4 +> 微信:8.0.72
  5 +> 目标:息屏状态下成功发送消息给 yibooo
  6 +> 时间:2026-05-23
  7 +
  8 +---
  9 +
  10 +## 一、整体架构(当前部署)
  11 +
  12 +```
  13 +息屏检测 消息生成 消息发送
  14 +----------> 三种方案并行尝试 ---------->
  15 +
  16 +1. sqlcipher DB 轮询 ←── 检测新消息 ──→ hermes API ←── 生成 AI 回复
  17 +2. termux-notification-list(息屏兜底唤醒)
  18 +3. LSPosed hook(WCDB insert 监控)
  19 +```
  20 +
  21 +**当前部署路径**:`/data/data/com.termux/files/home/hermes-wechat/`
  22 +
  23 +---
  24 +
  25 +## 二、已尝试的发送方案(5 种)
  26 +
  27 +### 方案 1:LSPosed Xposed 模块 + `am broadcast`
  28 +
  29 +**原理**:
  30 +- 安装 LSPosed 模块到 WeChat 进程
  31 +- 模块注册 BroadcastReceiver,监听 `com.hermes.wechat.SEND_MSG`
  32 +- 收到广播后,反射调用微信内部 API:`u01.r1.a(talker) → q1.e(content) → q1.a() → m1.a()`
  33 +- 将 NetScene 提交到主线程执行
  34 +
  35 +**代码位置**:
  36 +- Java 源码:`wechat_plain/scripts/lsposed-wechat-sendmsg/app/src/main/java/com/hermes/wechat/xposed/HermesWeChatModule.java`
  37 +- Smali(已编译):`/tmp/lsposed-mod/smali/com/hermes/wechat/xposed/HermesWeChatModule.smali`
  38 +- 签名 APK:`/tmp/lsposed-mod/dist/lsposed-wechat-sendmsg.apk`
  39 +
  40 +**已做的 bypass**:
  41 +- `ExtContentProviderBase.h()` → return 1(auth bypass)
  42 +- `ExtContentProviderBase.b()` → return true(login bypass)
  43 +- `AESUtil.b()` / `AESUtil.a()` 跳过加密
  44 +- NetScene submit 改为 `Handler(Looper.getMainLooper()).post()` 主线程执行
  45 +
  46 +**测试结果**:
  47 +- 亮屏:`am broadcast` 可以执行,但消息未成功写入 DB(怀疑模块未正确 hook 或 `m1.a()` 不是真正的发送方法)
  48 +- 息屏:`am broadcast` 可能被系统拦截,未验证
  49 +- **状态:❌ 未成功**
  50 +
  51 +**失败原因分析**:
  52 +1. 模块重新签名安装后,LSPosed 数据库中路径未更新,可能未正确加载
  53 +2. `u01.m1.a()` 返回 true 但无 DB 写入,说明 `a()` 可能不是真正的提交方法
  54 +3. Android 15 后台广播限制可能阻止息屏时的 `am broadcast`
  55 +
  56 +---
  57 +
  58 +### 方案 2:Frida 直接调用微信内部 API
  59 +
  60 +**原理**:
  61 +- Frida attach 到 WeChat 主进程,直接执行 JavaScript 调用微信内部类
  62 +- 调用链:`u01.r1.a("yibooo") → q1.e("content") → q1.a() → m1.a()`
  63 +- 使用 `Java.scheduleOnMainThread()` 确保在主线程执行
  64 +
  65 +**Frida 脚本**:
  66 +- `/tmp/send_msg3.js` — 基本发送脚本(主线程提交)
  67 +- `/tmp/send_msg4.js` — 测试 `q1.b()` 作为替代发送方法(尚未运行)
  68 +- `/tmp/inspect_all.js` — 类方法枚举工具
  69 +
  70 +**已发现的关键类签名**:
  71 +```
  72 +u01.r1.a(java.lang.String) → u01.q1 # 创建 builder
  73 +u01.q1.e(java.lang.String) → u01.q1 # 设置消息内容(链式)
  74 +u01.q1.g(java.lang.String) → u01.q1 # 可能是设置 talker
  75 +u01.q1.h(int) → u01.q1 # 可能是设置 type
  76 +u01.q1.a() → u01.m1 # build NetScene
  77 +u01.q1.b() → void # ??? 可能是真正发送
  78 +u01.q1.d(u01.q1, qw5.l, int, Object) → void # 静态方法,可能是 dispatch
  79 +u01.m1.a() → boolean # 返回 true 但无实际效果
  80 +```
  81 +
  82 +**测试结果**:
  83 +- `m1.a()` 在主线程执行,返回 `true`,但 DB 中没有新的 `isSend=1` 记录
  84 +- msgId 846 之后无新消息写入
  85 +- **状态:❌ 亮屏也未成功**
  86 +
  87 +**关键疑点**:
  88 +`u01.m1.a()` 可能不是 `doScene()`/`dispatch()`,而是:
  89 +- 预检查/校验方法
  90 +- 状态查询方法
  91 +- 需要配合其他参数才能触发真正的发送
  92 +
  93 +**下一步待验证**:
  94 +1. `u01.q1.b()` — 返回 void,更像执行方法
  95 +2. `u01.q1.d()` — 静态方法,参数 `(q1, l, int, Object)` 可能是 dispatcher
  96 +3. hook `u01.m1.a()` 内部实现,追踪它到底调用了什么
  97 +4. hook WCDB `insert()`,确认调用 API 后是否有任何 DB 写入尝试
  98 +
  99 +---
  100 +
  101 +### 方案 3:`input text` + `input keyevent 66` 模拟键盘输入
  102 +
  103 +**原理**:
  104 +- 使用 Android `input text "消息内容"` 向当前焦点窗口输入文本
  105 +- `input keyevent 66` 模拟回车发送
  106 +
  107 +**测试结果**:
  108 +- 亮屏:简单英文数字可用,某些字符导致 `NullPointerException`
  109 +- 息屏:无法输入到没有焦点的前台窗口
  110 +- **状态:❌ 不适用**
  111 +
  112 +**失败原因**:
  113 +1. 微信聊天窗口需要是前台焦点才能接收 `input text`
  114 +2. 息屏状态下系统不会分发输入事件到应用
  115 +3. 特殊字符(中文、标点)容易崩溃
  116 +
  117 +---
  118 +
  119 +### 方案 4:直接启动 ChattingUI (`am start -n`)
  120 +
  121 +**原理**:
  122 +- `am start -n com.tencent.mm/.ui.chatting.ChattingUI` 直接打开聊天窗口
  123 +- 然后配合 `input text` 发送
  124 +
  125 +**测试结果**:
  126 +- `am start` 抛出 `SecurityException: Not exported`
  127 +- ChattingUI 未在 manifest 中声明 exported=true
  128 +- **状态:❌ 无法启动**
  129 +
  130 +---
  131 +
  132 +### 方案 5:`content insert` → ContentProvider
  133 +
  134 +**原理**:
  135 +- 通过 `content insert --uri content://com.hermes.wechat.xposed.provider/send` 发送
  136 +- 绕过了广播,直接走 ContentProvider
  137 +
  138 +**代码位置**:`android-wechat-sender.js::sendTextMessage()`
  139 +
  140 +**测试结果**:
  141 +- 未实际测试(依赖 LSPosed 模块的 ContentProvider)
  142 +- 模块的 Provider 可能需要同样 bypass auth
  143 +- **状态:❓ 未测试**
  144 +
  145 +---
  146 +
  147 +## 三、消息检测方案(2 种,均可用)
  148 +
  149 +### 检测 1:sqlcipher DB 轮询 ✅
  150 +
  151 +**原理**:
  152 +- 直接用 sqlcipher 打开 `EnMicroMsg.db`(key: `e03ce81`)
  153 +- 轮询 `message` 表,查 `msgId > lastMsgId AND isSend = 0`
  154 +
  155 +**代码位置**:`android-wechat-tracker-sqlcipher.js`
  156 +
  157 +**已修复的问题**:
  158 +- temp 文件路径从 `/tmp/` 改为 `/data/data/com.termux/files/usr/tmp/`(Termux 无 `/tmp` 权限)
  159 +
  160 +**状态**:✅ **工作正常**,可检测新消息
  161 +
  162 +### 检测 2:termux-notification-list 息屏兜底 ✅
  163 +
  164 +**原理**:
  165 +- 每 3 秒执行 `termux-notification-list`
  166 +- 过滤 `packageName === 'com.tencent.mm'` 的通知
  167 +- 检测到通知后 → `input keyevent 26` 点亮屏幕 → `am force-stop` + `am start` 重启微信 → 等待前台确认
  168 +
  169 +**代码位置**:`auto-reply.js::pollNotifications()`
  170 +
  171 +**已修复的问题**:
  172 +- `pkgName` → `packageName` 字段名修正
  173 +- 添加 `input keyevent 26` 唤醒屏幕(息屏时 `am start` 不执行)
  174 +- 添加 `ensureWechatForeground()` 轮询确认前台 + 模拟按键保持活跃
  175 +
  176 +**状态**:✅ **检测+唤醒工作正常**,但发送卡住了
  177 +
  178 +---
  179 +
  180 +## 四、当前代码文件状态
  181 +
  182 +| 文件 | 位置 | 状态 | 说明 |
  183 +|------|------|------|------|
  184 +| `auto-reply.js` | `server/services/wechat/` | ✅ 已部署 | 主逻辑,通知兜底+sqlcipher轮询+hermes调用,但发送调用的是 `sendTextMessageBroadcast()` |
  185 +| `android-wechat-tracker-sqlcipher.js` | `server/services/wechat/` | ✅ 已部署 | sqlcipher DB 轮询,检测新消息正常 |
  186 +| `android-wechat-sender.js` | `server/services/wechat/` | ✅ 已部署 | 提供 `sendTextMessageBroadcast()` 和 `sendTextMessage()`,但底层依赖 LSPosed 模块 |
  187 +| `HermesWeChatModule.java` | `wechat_plain/scripts/lsposed-wechat-sendmsg/...` | ✅ 已修改 | 主线程提交 NetScene,但 APK 重新签名后 LSPosed 可能未正确加载 |
  188 +| `lsposed-wechat-sendmsg.apk` | `/tmp/lsposed-mod/dist/` | ⚠️ 已编译 | 新签名,LSPosed DB 中路径可能未更新 |
  189 +| Frida 脚本 | `/tmp/send_msg*.js` | ✅ 可用 | `send_msg3.js` 亮屏测试未成功,`send_msg4.js` 待测试 `q1.b()` |
  190 +
  191 +---
  192 +
  193 +## 五、核心卡点总结
  194 +
  195 +### 卡点 1:为什么 `u01.m1.a()` 返回 true 但消息没发出去?
  196 +
  197 +**现象**:
  198 +- Frida 调用 `netScene.a()` 返回 `true`
  199 +- DB 中没有新的 `isSend=1` 记录
  200 +- 没有异常抛出
  201 +
  202 +**假设**:
  203 +1. `a()` 不是 `doScene()`,而是其他方法(如 `check()` / `preSend()`)
  204 +2. 真正的发送方法是 `u01.q1.b()` 或 `u01.q1.d()`
  205 +3. NetScene 需要额外的初始化(如 `MsgInfo` 的某些字段未设置)
  206 +4. 微信在息屏/后台时 NetSceneDispatcher 被冻结,即使调用也不执行
  207 +
  208 +**待验证**:
  209 +- [ ] 测试 `u01.q1.b()` 是否能触发 DB 写入
  210 +- [ ] 测试 `u01.q1.d()` 静态方法
  211 +- [ ] hook `u01.m1.a()` 内部,看其调用链
  212 +- [ ] hook WCDB insert,确认 API 调用后是否有任何 DB 操作
  213 +
  214 +### 卡点 2:息屏状态下后台执行限制
  215 +
  216 +**现象**:
  217 +- Android 15 对后台 Activity 启动、后台广播有严格限制
  218 +- `am start` 在息屏锁定时不会立即执行
  219 +- 需要先 `input keyevent 26` 点亮屏幕
  220 +
  221 +**当前 workaround**:
  222 +- 检测到通知 → 唤醒屏幕 → force-stop + start 微信 → 轮询确认前台
  223 +- 这个流程已验证可以工作
  224 +
  225 +**剩余风险**:
  226 +- 即使微信在前台,如果 NetSceneDispatcher 在息屏时被系统挂起,消息仍可能无法发送
  227 +- 需要确认亮屏后微信网络层是否完全恢复
  228 +
  229 +### 卡点 3:LSPosed 模块加载问题
  230 +
  231 +**现象**:
  232 +- 重新编译签名 APK 后安装
  233 +- LSPosed 管理器中显示已启用,但日志中没有模块 hook 的输出
  234 +- 可能原因:新签名导致 LSPosed 数据库中路径/签名不匹配
  235 +
  236 +**已尝试**:
  237 +- 直接修改 `/data/adb/lspd/config/modules_config.db` 更新路径
  238 +- 重启 Zygote (`setprop ctl.restart zygote`) 和 lspd
  239 +
  240 +**状态**:⚠️ 不确定模块是否真正加载
  241 +
  242 +---
  243 +
  244 +## 六、下一步建议(优先级排序)
  245 +
  246 +### P0:确认真正发送方法(Frida 验证)
  247 +
  248 +用 Frida 在亮屏状态下测试以下方法,找到真正能触发 DB 写入的调用:
  249 +
  250 +```javascript
  251 +// 测试 1:q1.b()
  252 +var builder = r1.a("yibooo");
  253 +builder.e("test msg");
  254 +builder.b(); // 返回 void,可能是真正发送
  255 +
  256 +// 测试 2:q1.d() 静态方法
  257 +q1.d(builder, null, 0, null); // 参数猜测
  258 +
  259 +// 测试 3:hook m1.a() 内部
  260 +// 看 m1.a() 到底调用了什么,追踪其调用链
  261 +```
  262 +
  263 +### P1:验证亮屏发送
  264 +
  265 +只有在亮屏状态下能稳定发送,才有意义测试息屏。
  266 +当前亮屏都无法发送,说明调用链不正确。
  267 +
  268 +### P2:息屏发送验证
  269 +
  270 +亮屏发送成功后:
  271 +1. 息屏
  272 +2. 等待 5 秒
  273 +3. Frida 执行相同脚本
  274 +4. 检查 DB 是否有写入
  275 +
  276 +### P3:修复 LSPosed 模块(备用方案)
  277 +
  278 +如果 Frida 方案因息屏限制不可行,回到 LSPosed 模块:
  279 +1. 确认模块正确加载(查看 LSPosed 日志)
  280 +2. 修正 `m1.a()` 为正确的方法调用
  281 +3. 验证息屏时广播是否能被接收
  282 +
  283 +### P4:集成到 auto-reply.js
  284 +
  285 +发送方法确定后:
  286 +1. 修改 `auto-reply.js` 中的发送调用
  287 +2. 确保息屏唤醒流程与发送流程衔接
  288 +3. 测试完整闭环:收到消息 → AI 回复 → 成功发送
  289 +
  290 +---
  291 +
  292 +## 七、附录:Frida 调试命令速查
  293 +
  294 +```bash
  295 +# 连接设备
  296 +adb connect 192.168.x.x:5555
  297 +
  298 +# 启动微信并 Frida 注入
  299 +frida -U -f com.tencent.mm -l /tmp/send_msg3.js --no-pause
  300 +
  301 +# attach 到已运行的微信
  302 +frida -U -n "com.tencent.mm" -l /tmp/send_msg3.js
  303 +
  304 +# 查看微信进程 PID
  305 +adb shell ps | grep com.tencent.mm
  306 +
  307 +# 息屏/亮屏
  308 +adb shell input keyevent 26
  309 +
  310 +# 查询 DB 最新发送消息
  311 +adb shell su -c "sqlcipher /data/data/.../EnMicroMsg.db 'PRAGMA key=e03ce81; SELECT msgId, talker, content FROM message WHERE isSend=1 ORDER BY msgId DESC LIMIT 3;'"
  312 +```
  313 +
  314 +---
  315 +
  316 +## 八、关键发现记录
  317 +
  318 +| 发现 | 时间 | 影响 |
  319 +|------|------|------|
  320 +| WeChat 8.0.72 使用 u01.r1/q1/m1 作为发送 API | 2026-05-23 | 确认了调用链 |
  321 +| `m1.a()` 返回 true 但无 DB 写入 | 2026-05-23 | 怀疑 `a()` 不是真正发送方法 |
  322 +| `termux-notification-list` 字段是 `packageName` 非 `pkgName` | 2026-05-23 | 修复了息屏检测 |
  323 +| Termux 无法写 `/tmp`,需用 `/data/data/com.termux/files/usr/tmp/` | 2026-05-23 | 修复了 sqlcipher 查询 |
  324 +| `input keyevent 26` 可在息屏时点亮屏幕 | 2026-05-23 | 实现了息屏唤醒 |
  325 +| ChattingUI 未 exported,无法 `am start` | 2026-05-23 | 排除了直接启动聊天窗口方案 |
  326 +| `u01.q1.b()` 和 `d()` 可能是真正发送方法 | 2026-05-23 | 待验证 |
server/services/wechat/android-wechat-sender-frida.js 0 → 100644
  1 +/**
  2 + * android-wechat-sender-frida.js — 基于 Frida Daemon 的微信消息发送
  3 + *
  4 + * 原理:写入队列文件 /data/local/tmp/frida_daemon/queue.jsonl
  5 + * 由常驻微信进程的 Frida daemon 读取并发送
  6 + *
  7 + * 前提:
  8 + * 1. frida-server 运行中
  9 + * 2. frida_daemon.js 已注入微信进程(通过 Mac 端 frida CLI)
  10 + * 3. 队列目录 /data/local/tmp/frida_daemon/ 存在且可写
  11 + *
  12 + * 注意:发送为 fire-and-forget,daemon 会异步处理队列。
  13 + * 如需确认送达,可通过 sqlcipher 查询 message 表。
  14 + */
  15 +
  16 +const fs = require('fs');
  17 +
  18 +const QUEUE_DIR = '/data/local/tmp/frida_daemon';
  19 +const QUEUE_FILE = QUEUE_DIR + '/queue.jsonl';
  20 +
  21 +function ensureDir() {
  22 + try {
  23 + if (!fs.existsSync(QUEUE_DIR)) {
  24 + fs.mkdirSync(QUEUE_DIR, { recursive: true });
  25 + fs.chmodSync(QUEUE_DIR, 0o777);
  26 + }
  27 + } catch (e) {
  28 + // ignore
  29 + }
  30 +}
  31 +
  32 +/**
  33 + * 使用 Frida daemon 发送文本消息(fire-and-forget)
  34 + * @param {string} toUser — wxid / 群聊ID
  35 + * @param {string} content — 消息内容
  36 + * @returns {Promise<{ok: boolean, error?: string}>}
  37 + */
  38 +async function sendTextMessageFrida(toUser, content) {
  39 + if (!toUser || !content) {
  40 + return { ok: false, error: 'toUser and content are required' };
  41 + }
  42 +
  43 + ensureDir();
  44 +
  45 + const id = 'msg_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
  46 + const payload = JSON.stringify({ talker: toUser, content, id }) + '\n';
  47 +
  48 + try {
  49 + fs.writeFileSync(QUEUE_FILE, payload);
  50 + fs.chmodSync(QUEUE_FILE, 0o666);
  51 + } catch (e) {
  52 + return { ok: false, error: `Failed to write queue: ${e.message}` };
  53 + }
  54 +
  55 + // Fire-and-forget: daemon will process asynchronously
  56 + // 给 daemon 一点时间处理(通常 < 1s)
  57 + await new Promise(r => setTimeout(r, 500));
  58 + return { ok: true };
  59 +}
  60 +
  61 +module.exports = { sendTextMessageFrida };
server/services/wechat/android-wechat-tracker-sqlcipher.js 0 → 100644
  1 +/**
  2 + * android-wechat-tracker-sqlcipher.js — 通过 sqlcipher 轮询 WeChat DB 检测新消息
  3 + *
  4 + * 不依赖 LSPosed HOOK,直接查询 EnMicroMsg.db 的 message 表。
  5 + * 需要 root 权限(su -c)。
  6 + */
  7 +
  8 +const { spawn } = require('child_process');
  9 +const EventEmitter = require('events');
  10 +const fs = require('fs');
  11 +
  12 +const DB_PATH = '/data/data/com.tencent.mm/MicroMsg/653cdc2320abb089e149a3f7a7683fcf/EnMicroMsg.db';
  13 +const SQLCIPHER_KEY = 'e03ce81';
  14 +const SQLCIPHER_BIN = '/data/data/com.termux/files/usr/bin/sqlcipher';
  15 +
  16 +function hasRoot() {
  17 + return process.getuid && process.getuid() === 0;
  18 +}
  19 +
  20 +function wrapCmd(cmd) {
  21 + if (hasRoot()) return cmd;
  22 + return `su -c "${cmd.replace(/"/g, '\\"')}"`;
  23 +}
  24 +
  25 +function shell(cmd, timeoutMs = 15000) {
  26 + const fullCmd = wrapCmd(cmd);
  27 + const args = ['-c', fullCmd];
  28 + const shellBin = process.env.SHELL || '/system/bin/sh';
  29 +
  30 + return new Promise((resolve, reject) => {
  31 + const proc = spawn(shellBin, args, { timeout: timeoutMs });
  32 + let stdout = '';
  33 + let stderr = '';
  34 + proc.stdout.on('data', d => stdout += d.toString());
  35 + proc.stderr.on('data', d => stderr += d.toString());
  36 + proc.on('close', code => {
  37 + if (code !== 0) {
  38 + reject(new Error(`exit ${code}: ${stderr.trim() || stdout.trim()}`));
  39 + } else {
  40 + resolve(stdout.trim());
  41 + }
  42 + });
  43 + proc.on('error', reject);
  44 + });
  45 +}
  46 +
  47 +class AndroidWechatTrackerSQLCipher extends EventEmitter {
  48 + constructor() {
  49 + super();
  50 + this.polling = false;
  51 + this.lastMsgId = 0;
  52 + this.dbPath = DB_PATH;
  53 + }
  54 +
  55 + async init() {
  56 + const out = await this._query(`SELECT MAX(msgId) AS max_id FROM message;`, 5000);
  57 + const val = this._extractValue(out, 'max_id');
  58 + this.lastMsgId = parseInt(val, 10) || 0;
  59 + console.log(`[TrackerSQLCipher] DB: ${this.dbPath}, lastMsgId: ${this.lastMsgId}`);
  60 + }
  61 +
  62 + async _query(sql, timeoutMs = 10000) {
  63 + // 使用 .mode line 和 .headers off 获取干净输出
  64 + const script = `.headers off\n.mode line\nPRAGMA key="${SQLCIPHER_KEY}";\nPRAGMA cipher_compatibility=1;\nPRAGMA cipher_page_size=1024;\n${sql}\n`;
  65 + // 写入临时文件避免命令行转义问题
  66 + const tmpFile = `/data/data/com.termux/files/usr/tmp/sqlcipher_query_${Date.now()}.sql`;
  67 + fs.writeFileSync(tmpFile, script);
  68 + const cmd = `${SQLCIPHER_BIN} "${this.dbPath}" < ${tmpFile}`;
  69 + const out = await shell(cmd, timeoutMs);
  70 + // 清理临时文件
  71 + try { fs.unlinkSync(tmpFile); } catch (_) {}
  72 + return out;
  73 + }
  74 +
  75 + _extractValue(out, columnName) {
  76 + if (!out) return '';
  77 + const lines = out.trim().split('\n');
  78 + // .mode line 输出格式: columnName = value 或 columnName: value
  79 + for (const line of lines) {
  80 + const trimmed = line.trim();
  81 + const eqPrefix = `${columnName} = `;
  82 + const colonPrefix = `${columnName}: `;
  83 + if (trimmed.startsWith(eqPrefix)) {
  84 + return trimmed.substring(eqPrefix.length);
  85 + }
  86 + if (trimmed.startsWith(colonPrefix)) {
  87 + return trimmed.substring(colonPrefix.length);
  88 + }
  89 + }
  90 + return '';
  91 + }
  92 +
  93 + /**
  94 + * 获取新消息(msgId > lastMsgId)
  95 + */
  96 + async fetchNew() {
  97 + // 只查询文本消息 (type=1) 和撤回通知 (type=10002),排除自己发送的
  98 + const sql = `SELECT msgId, type, isSend, talker, content, createTime FROM message WHERE msgId > ${this.lastMsgId} AND isSend = 0 AND type IN (1, 10002) ORDER BY msgId ASC;`;
  99 + const out = await this._query(sql, 10000);
  100 + const msgs = this._parseRows(out);
  101 + if (msgs.length > 0) {
  102 + this.lastMsgId = Math.max(...msgs.map(m => m.msgId));
  103 + }
  104 + return msgs;
  105 + }
  106 +
  107 + startPolling(intervalMs = 3000) {
  108 + if (this.polling) return;
  109 + this.polling = true;
  110 +
  111 + const tick = async () => {
  112 + if (!this.polling) return;
  113 + try {
  114 + const msgs = await this.fetchNew();
  115 + for (const msg of msgs) {
  116 + this.emit('message', msg);
  117 + }
  118 + } catch (e) {
  119 + // 静默失败,防止日志刷屏
  120 + if (!e.message.includes('database is locked')) {
  121 + this.emit('error', e);
  122 + }
  123 + }
  124 + setTimeout(tick, intervalMs);
  125 + };
  126 +
  127 + tick();
  128 + }
  129 +
  130 + stopPolling() {
  131 + this.polling = false;
  132 + }
  133 +
  134 + _parseRows(text) {
  135 + const messages = [];
  136 + if (!text) return messages;
  137 +
  138 + // .mode line 输出格式:每行一个 "column = value",记录之间用空行分隔
  139 + const records = [];
  140 + let current = {};
  141 + for (const line of text.split('\n')) {
  142 + const trimmed = line.trim();
  143 + if (!trimmed) {
  144 + if (Object.keys(current).length > 0) {
  145 + records.push(current);
  146 + current = {};
  147 + }
  148 + continue;
  149 + }
  150 + // sqlcipher .mode line 输出格式: "key: value" 或 "key = value"
  151 + let sepIdx = trimmed.indexOf(': ');
  152 + if (sepIdx < 0) sepIdx = trimmed.indexOf(' = ');
  153 + if (sepIdx > 0) {
  154 + const key = trimmed.substring(0, sepIdx).trim();
  155 + const val = trimmed.substring(sepIdx + 2).trim();
  156 + current[key] = val;
  157 + }
  158 + }
  159 + if (Object.keys(current).length > 0) {
  160 + records.push(current);
  161 + }
  162 +
  163 + for (const rec of records) {
  164 + if (!rec.msgId) continue;
  165 + const isSendVal = parseInt(rec.isSend, 10);
  166 + messages.push({
  167 + msgId: parseInt(rec.msgId, 10),
  168 + msgType: parseInt(rec.type, 10),
  169 + isSend: isSendVal,
  170 + talker: rec.talker || '',
  171 + content: rec.content || '',
  172 + createTime: parseInt(rec.createTime, 10),
  173 + type: isSendVal === 1 ? 'send' : 'recv',
  174 + timestamp: Date.now(),
  175 + raw: JSON.stringify(rec),
  176 + });
  177 + }
  178 + return messages;
  179 + }
  180 +}
  181 +
  182 +module.exports = { AndroidWechatTrackerSQLCipher, shell };
server/services/wechat/auto-reply.js
@@ -2,10 +2,10 @@ @@ -2,10 +2,10 @@
2 * auto-reply.js — Pixel 手机微信自动回复 Plugin(接入 hermes API) 2 * auto-reply.js — Pixel 手机微信自动回复 Plugin(接入 hermes API)
3 * 3 *
4 * 架构: 4 * 架构:
5 - * AndroidWechatTracker (tail -f LSPosed 日志) ──→ 实时消息检测 5 + * AndroidWechatTrackerSQLCipher (sqlcipher DB 轮询) ──→ 消息检测
6 * termux-notification-list (兜底) ──→ 息屏时检测通知并唤醒微信 6 * termux-notification-list (兜底) ──→ 息屏时检测通知并唤醒微信
7 * hermes API (POST /api/master/chat) ──→ AI 回复生成 7 * hermes API (POST /api/master/chat) ──→ AI 回复生成
8 - * android-wechat-sender (am broadcast) ──→ 发送回复 8 + * android-wechat-sender-frida (frida-inject 调用微信内部 API) ──→ 发送回复
9 * 9 *
10 * 运行方式: 10 * 运行方式:
11 * cd /data/data/com.termux/files/home/hermes-wechat 11 * cd /data/data/com.termux/files/home/hermes-wechat
@@ -17,7 +17,9 @@ @@ -17,7 +17,9 @@
17 */ 17 */
18 18
19 const { AndroidWechatTracker } = require('./android-wechat-tracker'); 19 const { AndroidWechatTracker } = require('./android-wechat-tracker');
  20 +const { AndroidWechatTrackerSQLCipher } = require('./android-wechat-tracker-sqlcipher');
20 const { sendTextMessageBroadcast } = require('./android-wechat-sender'); 21 const { sendTextMessageBroadcast } = require('./android-wechat-sender');
  22 +const { sendTextMessageFrida } = require('./android-wechat-sender-frida');
21 const { shell } = require('./android-wechat-sender'); 23 const { shell } = require('./android-wechat-sender');
22 const fs = require('fs'); 24 const fs = require('fs');
23 const path = require('path'); 25 const path = require('path');
@@ -32,9 +34,9 @@ const CONFIG = { @@ -32,9 +34,9 @@ const CONFIG = {
32 myWxid: process.env.MY_WXID || '', 34 myWxid: process.env.MY_WXID || '',
33 myNickname: process.env.MY_NICKNAME || '', 35 myNickname: process.env.MY_NICKNAME || '',
34 36
35 - // hermes API  
36 - hermesHost: process.env.HERMES_HOST || 'localhost',  
37 - hermesPort: parseInt(process.env.HERMES_PORT, 10) || 3010, 37 + // hermes-agent API (OpenAI-compatible, localhost:8642)
  38 + hermesHost: process.env.HERMES_HOST || '127.0.0.1',
  39 + hermesPort: parseInt(process.env.HERMES_PORT, 10) || 8642,
38 hermesTimeoutMs: parseInt(process.env.HERMES_TIMEOUT, 10) || 120000, 40 hermesTimeoutMs: parseInt(process.env.HERMES_TIMEOUT, 10) || 120000,
39 41
40 // 回复行为 42 // 回复行为
@@ -127,19 +129,23 @@ function log(...args) { @@ -127,19 +129,23 @@ function log(...args) {
127 // ═══════════════════════════════════════════════════════════════ 129 // ═══════════════════════════════════════════════════════════════
128 130
129 /** 131 /**
130 - * 调用 hermes /api/master/chat 生成回复 132 + * 调用 hermes-agent API (OpenAI-compatible) 生成回复
131 * 133 *
132 - * @param {string} message - 构造好的 prompt(包含上下文、系统提示)  
133 - * @param {string} sessionId - 会话 ID(每个 talker 独立) 134 + * @param {Array<{role:string,content:string}>} messages - OpenAI messages 格式
  135 + * @param {string} sessionId - 会话 ID(每个 talker 独立,作为 X-Hermes-Session-Id)
134 * @returns {Promise<string|null>} 回复文本 136 * @returns {Promise<string|null>} 回复文本
135 */ 137 */
136 -async function callHermesApi(message, sessionId) {  
137 - return new Promise((resolve, reject) => {  
138 - const payload = JSON.stringify({ message, sessionId }); 138 +async function callHermesApi(messages, sessionId) {
  139 + return new Promise((resolve) => {
  140 + const payload = JSON.stringify({
  141 + model: 'hermes-agent',
  142 + messages,
  143 + stream: false,
  144 + });
139 const req = http.request({ 145 const req = http.request({
140 hostname: CONFIG.hermesHost, 146 hostname: CONFIG.hermesHost,
141 port: CONFIG.hermesPort, 147 port: CONFIG.hermesPort,
142 - path: '/api/master/chat', 148 + path: '/v1/chat/completions',
143 method: 'POST', 149 method: 'POST',
144 headers: { 150 headers: {
145 'Content-Type': 'application/json', 151 'Content-Type': 'application/json',
@@ -150,11 +156,18 @@ async function callHermesApi(message, sessionId) { @@ -150,11 +156,18 @@ async function callHermesApi(message, sessionId) {
150 let buffer = ''; 156 let buffer = '';
151 res.on('data', chunk => { buffer += chunk; }); 157 res.on('data', chunk => { buffer += chunk; });
152 res.on('end', () => { 158 res.on('end', () => {
  159 + log('[HERMES] HTTP status:', res.statusCode, 'body:', buffer.substring(0, 300));
153 try { 160 try {
154 - const result = parseSSE(buffer);  
155 - resolve(result); 161 + const data = JSON.parse(buffer);
  162 + if (data.error) {
  163 + log('[HERMES] API error:', JSON.stringify(data.error));
  164 + resolve(null);
  165 + return;
  166 + }
  167 + const content = data.choices?.[0]?.message?.content;
  168 + resolve(content || null);
156 } catch (e) { 169 } catch (e) {
157 - log('[HERMES] SSE parse error:', e.message); 170 + log('[HERMES] JSON parse error:', e.message);
158 resolve(null); 171 resolve(null);
159 } 172 }
160 }); 173 });
@@ -175,85 +188,43 @@ async function callHermesApi(message, sessionId) { @@ -175,85 +188,43 @@ async function callHermesApi(message, sessionId) {
175 }); 188 });
176 } 189 }
177 190
178 -/**  
179 - * 解析 SSE 响应,提取 result.output  
180 - */  
181 -function parseSSE(buffer) {  
182 - const lines = buffer.split('\n');  
183 - const events = [];  
184 - let currentEvent = null;  
185 - let currentData = '';  
186 -  
187 - for (const line of lines) {  
188 - const trimmed = line.trim();  
189 - if (trimmed.startsWith('event:')) {  
190 - if (currentEvent !== null) {  
191 - events.push({ event: currentEvent, data: currentData });  
192 - }  
193 - currentEvent = trimmed.slice(6).trim();  
194 - currentData = '';  
195 - } else if (trimmed.startsWith('data:')) {  
196 - currentData += trimmed.slice(5).trim();  
197 - } else if (trimmed === '' && currentEvent !== null) {  
198 - events.push({ event: currentEvent, data: currentData });  
199 - currentEvent = null;  
200 - currentData = '';  
201 - }  
202 - }  
203 - if (currentEvent !== null) {  
204 - events.push({ event: currentEvent, data: currentData });  
205 - }  
206 -  
207 - // 找 result 事件  
208 - for (const ev of events) {  
209 - if (ev.event === 'result') {  
210 - try {  
211 - const data = JSON.parse(ev.data);  
212 - return data.output || null;  
213 - } catch {  
214 - return ev.data || null;  
215 - }  
216 - }  
217 - }  
218 -  
219 - return null;  
220 -}  
221 -  
222 // ═══════════════════════════════════════════════════════════════ 191 // ═══════════════════════════════════════════════════════════════
223 // 回复生成器 — 接入 hermes 192 // 回复生成器 — 接入 hermes
224 // ═══════════════════════════════════════════════════════════════ 193 // ═══════════════════════════════════════════════════════════════
225 194
226 /** 195 /**
227 - * 构造发给 hermes 的 prompt 196 + * 构造 OpenAI messages 数组发给 hermes-agent
228 */ 197 */
229 -function buildHermesPrompt(ctx) { 198 +function buildHermesMessages(ctx) {
230 const { type, talker, content, senderName } = ctx; 199 const { type, talker, content, senderName } = ctx;
231 200
232 - let prompt = '';  
233 - 201 + let userContent = '';
234 if (type === 'private') { 202 if (type === 'private') {
235 - prompt = `收到微信私聊消息(来自 ${talker}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; 203 + userContent = `收到微信私聊消息(来自 ${talker}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`;
236 } else if (type === 'group-at') { 204 } else if (type === 'group-at') {
237 - prompt = `收到微信群聊消息,有人 @ 我(来自群 ${talker},发送者 ${senderName}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; 205 + userContent = `收到微信群聊消息,有人 @ 我(来自群 ${talker},发送者 ${senderName}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`;
238 } else if (type === 'group-quote') { 206 } else if (type === 'group-quote') {
239 - prompt = `收到微信群聊消息,有人引用了我的消息(来自群 ${talker},发送者 ${senderName}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; 207 + userContent = `收到微信群聊消息,有人引用了我的消息(来自群 ${talker},发送者 ${senderName}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`;
240 } else { 208 } else {
241 - prompt = `收到微信消息(来自 ${talker}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; 209 + userContent = `收到微信消息(来自 ${talker}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`;
242 } 210 }
243 211
244 - return prompt; 212 + return [
  213 + { role: 'system', content: '你是 lemonAI 助手,一个智能、友好、简短的 AI 助手。' },
  214 + { role: 'user', content: userContent },
  215 + ];
245 } 216 }
246 217
247 /** 218 /**
248 - * 接入 hermes 的回复生成器 219 + * 接入 hermes-agent 的回复生成器
249 */ 220 */
250 async function hermesReplyGenerator(ctx) { 221 async function hermesReplyGenerator(ctx) {
251 const { talker } = ctx; 222 const { talker } = ctx;
252 const sessionId = `wx-${talker}`; 223 const sessionId = `wx-${talker}`;
253 - const prompt = buildHermesPrompt(ctx); 224 + const messages = buildHermesMessages(ctx);
254 225
255 log('[HERMES] Calling API for', talker, 'session:', sessionId); 226 log('[HERMES] Calling API for', talker, 'session:', sessionId);
256 - const reply = await callHermesApi(prompt, sessionId); 227 + const reply = await callHermesApi(messages, sessionId);
257 228
258 if (reply) { 229 if (reply) {
259 log('[HERMES] Reply:', reply.substring(0, 80)); 230 log('[HERMES] Reply:', reply.substring(0, 80));
@@ -291,29 +262,105 @@ function escapeRegex(str) { @@ -291,29 +262,105 @@ function escapeRegex(str) {
291 } 262 }
292 263
293 // ═══════════════════════════════════════════════════════════════ 264 // ═══════════════════════════════════════════════════════════════
294 -// 微信前台检测与唤醒 265 +// 屏幕状态检测与微信唤醒(息屏专用)
295 // ═══════════════════════════════════════════════════════════════ 266 // ═══════════════════════════════════════════════════════════════
296 267
  268 +async function isScreenOn() {
  269 + try {
  270 + const out = await shell('dumpsys power | grep "mWakefulness"', 3000);
  271 + return out.includes('mWakefulness=Awake');
  272 + } catch {
  273 + return false;
  274 + }
  275 +}
  276 +
  277 +async function isKeyguardLocked() {
  278 + try {
  279 + const out = await shell('dumpsys window | grep "mInputRestricted"', 3000);
  280 + return out.includes('mInputRestricted=true');
  281 + } catch {
  282 + return false;
  283 + }
  284 +}
  285 +
297 async function isWechatForeground() { 286 async function isWechatForeground() {
298 try { 287 try {
299 - const out = await shell('dumpsys activity activities | grep "mResumedActivity"', 5000); 288 + // Android 15 上 mCurrentFocus 已废弃,改用 mFocusedApp
  289 + const out = await shell('dumpsys activity activities | grep "mFocusedApp"', 5000);
300 return out.includes('com.tencent.mm'); 290 return out.includes('com.tencent.mm');
301 } catch { 291 } catch {
302 return false; 292 return false;
303 } 293 }
304 } 294 }
305 295
  296 +/**
  297 + * 息屏唤醒完整流程:点亮 → 解锁(无密码时滑动) → 启动微信 → 确认前台
  298 + */
306 async function wakeWechat() { 299 async function wakeWechat() {
307 try { 300 try {
  301 + // 1. 如果屏幕是黑的,用 KEYCODE_WAKEUP(224) 点亮,不会 toggle
  302 + const screenOn = await isScreenOn();
  303 + if (!screenOn) {
  304 + await shell('input keyevent 224', 3000);
  305 + log('[WAKE] Screen woke up');
  306 + await sleep(800);
  307 + }
  308 +
  309 + // 2. 无密码时,wm dismiss-keyguard 可直接解除锁屏,比滑动更可靠
  310 + try {
  311 + await shell('wm dismiss-keyguard', 3000);
  312 + log('[WAKE] Keyguard dismissed');
  313 + } catch (e) {
  314 + log('[WAKE] dismiss-keyguard failed (may already unlocked):', e.message);
  315 + }
  316 + await sleep(500);
  317 +
  318 + // 3. 启动微信到前台
308 await shell('/system/bin/am start -n com.tencent.mm/.ui.LauncherUI', 5000); 319 await shell('/system/bin/am start -n com.tencent.mm/.ui.LauncherUI', 5000);
309 - log('[WECHAT] Woken up'); 320 + log('[WAKE] WeChat LauncherUI started');
  321 + await sleep(1000);
  322 +
310 return true; 323 return true;
311 } catch (e) { 324 } catch (e) {
312 - log('[WECHAT] Failed to wake:', e.message); 325 + log('[WAKE] Failed:', e.message);
313 return false; 326 return false;
314 } 327 }
315 } 328 }
316 329
  330 +/**
  331 + * 确保微信在前台且屏幕已解锁,轮询最多 20 秒
  332 + */
  333 +async function ensureWechatForeground(maxWaitMs = 20000) {
  334 + const start = Date.now();
  335 + while (Date.now() - start < maxWaitMs) {
  336 + const screenOn = await isScreenOn();
  337 + const locked = await isKeyguardLocked();
  338 + const fg = await isWechatForeground();
  339 +
  340 + if (screenOn && !locked && fg) {
  341 + // 模拟一次点击保持活跃
  342 + try { await shell('input keyevent 62', 1000); } catch (_) {}
  343 + log('[WAKE] Confirmed: screen on + unlocked + WeChat foreground');
  344 + return true;
  345 + }
  346 +
  347 + if (!screenOn) {
  348 + log('[WAKE] Screen still off, retrying keyevent 224...');
  349 + try { await shell('input keyevent 224', 3000); } catch (_) {}
  350 + } else if (locked) {
  351 + log('[WAKE] Still locked, retrying dismiss-keyguard...');
  352 + try { await shell('wm dismiss-keyguard', 3000); } catch (_) {}
  353 + } else if (!fg) {
  354 + log('[WAKE] WeChat not foreground, retrying start...');
  355 + try { await shell('am start -n com.tencent.mm/.ui.LauncherUI', 5000); } catch (_) {}
  356 + }
  357 +
  358 + await sleep(1500);
  359 + }
  360 + log('[WAKE] Foreground check timeout');
  361 + return false;
  362 +}
  363 +
317 // ═══════════════════════════════════════════════════════════════ 364 // ═══════════════════════════════════════════════════════════════
318 // 通知监听(息屏兜底) 365 // 通知监听(息屏兜底)
319 // ═══════════════════════════════════════════════════════════════ 366 // ═══════════════════════════════════════════════════════════════
@@ -333,7 +380,7 @@ async function pollNotifications(bot) { @@ -333,7 +380,7 @@ async function pollNotifications(bot) {
333 try { 380 try {
334 const out = await shell(termuxBin, 10000); 381 const out = await shell(termuxBin, 10000);
335 const notifs = JSON.parse(out); 382 const notifs = JSON.parse(out);
336 - const wechatNotifs = notifs.filter(n => n.pkgName === 'com.tencent.mm'); 383 + const wechatNotifs = notifs.filter(n => n.packageName === 'com.tencent.mm');
337 384
338 for (const n of wechatNotifs) { 385 for (const n of wechatNotifs) {
339 const key = `${n.title}:${n.content}`; 386 const key = `${n.title}:${n.content}`;
@@ -349,15 +396,17 @@ async function pollNotifications(bot) { @@ -349,15 +396,17 @@ async function pollNotifications(bot) {
349 396
350 log('[NOTIF] WeChat notification:', n.title, n.content.substring(0, 60)); 397 log('[NOTIF] WeChat notification:', n.title, n.content.substring(0, 60));
351 398
352 - // 息屏时微信可能不在前台,先唤醒  
353 - const fg = await isWechatForeground();  
354 - if (!fg) {  
355 - await wakeWechat();  
356 - await sleep(3000); // 等待微信同步 DB、LSPosed 写日志 399 + // 息屏时微信可能不在前台,先点亮+启动微信
  400 + // 剩下的检测和发送交给 sqlcipher 轮询 + handleMessage
  401 + const screenOn = await isScreenOn();
  402 + if (!screenOn) {
  403 + log('[NOTIF] Screen off, waking WeChat...');
  404 + const ok = await wakeWechat();
  405 + if (ok) {
  406 + await ensureWechatForeground(20000);
  407 + log('[NOTIF] WeChat woken up, tracker will catch the message');
  408 + }
357 } 409 }
358 -  
359 - // 通知 tracker 立即检查(tracker 的 tail -f 会捕获新日志)  
360 - // 如果 tracker 已经停止,这里无法恢复它  
361 } 410 }
362 } catch (e) { 411 } catch (e) {
363 // notification-list 可能返回空或失败,忽略 412 // notification-list 可能返回空或失败,忽略
@@ -377,7 +426,8 @@ function sleep(ms) { @@ -377,7 +426,8 @@ function sleep(ms) {
377 426
378 class AutoReplyBot { 427 class AutoReplyBot {
379 constructor(replyGenerator = hermesReplyGenerator) { 428 constructor(replyGenerator = hermesReplyGenerator) {
380 - this.tracker = new AndroidWechatTracker(); 429 + // 优先使用 sqlcipher DB 轮询(LSPosed hook 在 WeChat 8.0.72 上不稳定)
  430 + this.tracker = new AndroidWechatTrackerSQLCipher();
381 this.state = new ReplyState(CONFIG.stateFile); 431 this.state = new ReplyState(CONFIG.stateFile);
382 this.replyGenerator = replyGenerator; 432 this.replyGenerator = replyGenerator;
383 this.running = false; 433 this.running = false;
@@ -416,11 +466,17 @@ class AutoReplyBot { @@ -416,11 +466,17 @@ class AutoReplyBot {
416 466
417 log(`[${replyType.toUpperCase()}] ${talker}: ${content.substring(0, 80)}`); 467 log(`[${replyType.toUpperCase()}] ${talker}: ${content.substring(0, 80)}`);
418 468
419 - // 发送前检查微信前台 469 + // 发送前确保微信在前台(息屏时通知监听会先唤醒)
420 const fg = await isWechatForeground(); 470 const fg = await isWechatForeground();
421 if (!fg) { 471 if (!fg) {
  472 + log('[WECHAT] Not foreground, waking...');
422 await wakeWechat(); 473 await wakeWechat();
423 - await sleep(2000); 474 + const ok = await ensureWechatForeground();
  475 + if (!ok) {
  476 + log('[WECHAT] Could not bring WeChat to foreground, skip sending');
  477 + return;
  478 + }
  479 + await sleep(2000); // 额外等待网络初始化
424 } 480 }
425 481
426 let replyText; 482 let replyText;
@@ -447,7 +503,7 @@ class AutoReplyBot { @@ -447,7 +503,7 @@ class AutoReplyBot {
447 } 503 }
448 504
449 try { 505 try {
450 - const result = await sendTextMessageBroadcast(talker, replyText); 506 + const result = await sendTextMessageFrida(talker, replyText);
451 if (result.ok) { 507 if (result.ok) {
452 log('[SENT]', talker, replyText.substring(0, 60)); 508 log('[SENT]', talker, replyText.substring(0, 60));
453 this.state.markReplied(msgId, talker); 509 this.state.markReplied(msgId, talker);
@@ -474,13 +530,14 @@ class AutoReplyBot { @@ -474,13 +530,14 @@ class AutoReplyBot {
474 notificationEnabled: CONFIG.notificationEnabled, 530 notificationEnabled: CONFIG.notificationEnabled,
475 })); 531 }));
476 532
477 - // 启动 tracker(流式 tail -f,最实时) 533 + // 启动 tracker(sqlcipher DB 轮询,比 LSPosed hook 更可靠)
478 this.tracker.on('message', msg => this.handleMessage(msg)); 534 this.tracker.on('message', msg => this.handleMessage(msg));
479 this.tracker.on('error', err => log('[TRACKER ERROR]', err.message)); 535 this.tracker.on('error', err => log('[TRACKER ERROR]', err.message));
480 - this.tracker.startStream();  
481 - log('[TRACKER] Stream started (tail -f)'); 536 + await this.tracker.init();
  537 + this.tracker.startPolling(3000);
  538 + log('[TRACKER] DB polling started (sqlcipher)');
482 539
483 - // 启动通知监听(息屏兜底) 540 + // 启动通知监听(息屏兜底:检测到通知时点亮屏幕+启动微信)
484 if (CONFIG.notificationEnabled) { 541 if (CONFIG.notificationEnabled) {
485 this.notifLoop = pollNotifications(this); 542 this.notifLoop = pollNotifications(this);
486 } 543 }
@@ -492,7 +549,6 @@ class AutoReplyBot { @@ -492,7 +549,6 @@ class AutoReplyBot {
492 stop() { 549 stop() {
493 if (!this.running) return; 550 if (!this.running) return;
494 this.running = false; 551 this.running = false;
495 - this.tracker.stopStream();  
496 this.tracker.stopPolling(); 552 this.tracker.stopPolling();
497 log('=== Auto-Reply Bot Stopped ==='); 553 log('=== Auto-Reply Bot Stopped ===');
498 process.exit(0); 554 process.exit(0);
wechat_plain/scripts/local-testkit/docs/README.md
@@ -158,9 +158,18 @@ cd ~/local-testkit @@ -158,9 +158,18 @@ cd ~/local-testkit
158 158
159 ### 3. 启动 hermes server(如果还没运行) 159 ### 3. 启动 hermes server(如果还没运行)
160 160
  161 +**必须在 proot Ubuntu 内以 ubuntu 用户运行**(Claude CLI 安全机制拒绝 root + `--dangerously-skip-permissions`):
  162 +
  163 +```bash
  164 +proot-distro login ubuntu --user ubuntu -- bash -c \
  165 + "cd /data/data/com.termux/files/home/hermes-wechat && . .env && node server/index.js"
  166 +```
  167 +
  168 +或后台运行:
161 ```bash 169 ```bash
162 -cd ~/hermes-wechat  
163 -node server/index.js 170 +nohup proot-distro login ubuntu --user ubuntu -- bash -c \
  171 + "cd /data/data/com.termux/files/home/hermes-wechat && . .env && node server/index.js" \
  172 + > hermes-server.log 2>&1 &
164 ``` 173 ```
165 174
166 ### 4. 启动 auto-reply plugin(推荐主流程) 175 ### 4. 启动 auto-reply plugin(推荐主流程)
@@ -240,6 +249,7 @@ python3 hermes_wechat_daemon.py --daemon @@ -240,6 +249,7 @@ python3 hermes_wechat_daemon.py --daemon
240 | 息屏后完全失效 | 确认电池优化已关闭;检查 `keepalive.sh` 是否运行 | 249 | 息屏后完全失效 | 确认电池优化已关闭;检查 `keepalive.sh` 是否运行 |
241 | 发送失败 | 检查微信是否在前台;检查 LSPosed 模块是否启用 | 250 | 发送失败 | 检查微信是否在前台;检查 LSPosed 模块是否启用 |
242 | Python daemon 查询不到新消息 | 检查微信是否在前台;检查 `sqlcipher_key`;确认 WAL 已复制 | 251 | Python daemon 查询不到新消息 | 检查微信是否在前台;检查 `sqlcipher_key`;确认 WAL 已复制 |
  252 +| Claude CLI 报错 `root/sudo privileges` | hermes server 必须以 proot 内 **ubuntu 用户** 运行,不能是 root |
243 253
244 --- 254 ---
245 255
wechat_plain/scripts/local-testkit/keepalive.sh
@@ -20,6 +20,9 @@ @@ -20,6 +20,9 @@
20 20
21 set -e 21 set -e
22 22
  23 +# 确保 Termux 工具在 PATH 中(su 环境下可能缺失)
  24 +export PATH="/data/data/com.termux/files/usr/bin:$PATH"
  25 +
23 HERMES_DIR="${HERMES_DIR:-/data/data/com.termux/files/home/hermes-wechat}" 26 HERMES_DIR="${HERMES_DIR:-/data/data/com.termux/files/home/hermes-wechat}"
24 HERMES_HOST="${HERMES_HOST:-localhost}" 27 HERMES_HOST="${HERMES_HOST:-localhost}"
25 HERMES_PORT="${HERMES_PORT:-3010}" 28 HERMES_PORT="${HERMES_PORT:-3010}"
@@ -37,26 +40,26 @@ log &quot;Wake lock acquired&quot; @@ -37,26 +40,26 @@ log &quot;Wake lock acquired&quot;
37 40
38 # ── 2. 检查前置条件 ────────────────────────────────────────────── 41 # ── 2. 检查前置条件 ──────────────────────────────────────────────
39 check_prerequisites() { 42 check_prerequisites() {
40 - local ok=true 43 + _ok=0
41 44
42 if ! command -v termux-notification-list >/dev/null 2>&1; then 45 if ! command -v termux-notification-list >/dev/null 2>&1; then
43 log "[WARN] termux-notification-list not found. Install: pkg install termux-api" 46 log "[WARN] termux-notification-list not found. Install: pkg install termux-api"
44 - ok=false 47 + _ok=1
45 fi 48 fi
46 49
47 if [ ! -d "$HERMES_DIR" ]; then 50 if [ ! -d "$HERMES_DIR" ]; then
48 log "[WARN] HERMES_DIR not found: $HERMES_DIR" 51 log "[WARN] HERMES_DIR not found: $HERMES_DIR"
49 - ok=false 52 + _ok=1
50 fi 53 fi
51 54
52 if [ -z "$MY_WXID" ] || [ -z "$MY_NICKNAME" ]; then 55 if [ -z "$MY_WXID" ] || [ -z "$MY_NICKNAME" ]; then
53 log "[WARN] MY_WXID or MY_NICKNAME not set" 56 log "[WARN] MY_WXID or MY_NICKNAME not set"
54 log " export MY_WXID=wxid_xxxxxx" 57 log " export MY_WXID=wxid_xxxxxx"
55 log " export MY_NICKNAME=你的昵称" 58 log " export MY_NICKNAME=你的昵称"
56 - ok=false 59 + _ok=1
57 fi 60 fi
58 61
59 - $ok 62 + return $_ok
60 } 63 }
61 64
62 # ── 3. 检查 hermes server 是否运行 ─────────────────────────────── 65 # ── 3. 检查 hermes server 是否运行 ───────────────────────────────
@@ -65,15 +68,18 @@ is_hermes_running() { @@ -65,15 +68,18 @@ is_hermes_running() {
65 } 68 }
66 69
67 # ── 4. 启动 hermes server ──────────────────────────────────────── 70 # ── 4. 启动 hermes server ────────────────────────────────────────
  71 +# 必须在 proot Ubuntu 内以 ubuntu 用户运行(Claude CLI 拒绝 root + --dangerously-skip-permissions)
68 start_hermes() { 72 start_hermes() {
69 - log "Starting hermes server..." 73 + log "Starting hermes server in proot Ubuntu user=ubuntu..."
70 cd "$HERMES_DIR" 74 cd "$HERMES_DIR"
71 - nohup node server/index.js > hermes-server.log 2>&1 &  
72 - sleep 3 75 + nohup proot-distro login ubuntu --user ubuntu -- bash -c \
  76 + "cd $HERMES_DIR && . .env && node server/index.js" \
  77 + > hermes-server.log 2>&1 &
  78 + sleep 5
73 if is_hermes_running; then 79 if is_hermes_running; then
74 - log "Hermes server started (PID=$!)" 80 + log "Hermes server started PID=$!"
75 else 81 else
76 - log "[WARN] Hermes server may have failed to start" 82 + log "[WARN] Hermes server may have failed to start, check hermes-server.log"
77 fi 83 fi
78 } 84 }
79 85
@@ -88,14 +94,14 @@ start_auto_reply() { @@ -88,14 +94,14 @@ start_auto_reply() {
88 cd "$HERMES_DIR/server/services/wechat" 94 cd "$HERMES_DIR/server/services/wechat"
89 env MY_WXID="$MY_WXID" MY_NICKNAME="$MY_NICKNAME" \ 95 env MY_WXID="$MY_WXID" MY_NICKNAME="$MY_NICKNAME" \
90 nohup node auto-reply.js > auto-reply.log 2>&1 & 96 nohup node auto-reply.js > auto-reply.log 2>&1 &
91 - log "Auto-reply started (PID=$!)" 97 + log "Auto-reply started PID=$!"
92 } 98 }
93 99
94 # ── 7. 主循环 ──────────────────────────────────────────────────── 100 # ── 7. 主循环 ────────────────────────────────────────────────────
95 log "=== Keepalive started ===" 101 log "=== Keepalive started ==="
96 log "HERMES_DIR=$HERMES_DIR" 102 log "HERMES_DIR=$HERMES_DIR"
97 -log "MY_WXID=${MY_WXID:-(not set)}"  
98 -log "MY_NICKNAME=${MY_NICKNAME:-(not set)}" 103 +log "MY_WXID=${MY_WXID:-not-set}"
  104 +log "MY_NICKNAME=${MY_NICKNAME:-not-set}"
99 105
100 if ! check_prerequisites; then 106 if ! check_prerequisites; then
101 log "[ERROR] Prerequisites check failed, exiting" 107 log "[ERROR] Prerequisites check failed, exiting"
wechat_plain/scripts/lsposed-wechat-sendmsg/app/src/main/java/com/hermes/wechat/xposed/HermesWeChatModule.java
@@ -337,18 +337,20 @@ public class HermesWeChatModule implements IXposedHookLoadPackage { @@ -337,18 +337,20 @@ public class HermesWeChatModule implements IXposedHookLoadPackage {
337 return false; 337 return false;
338 } 338 }
339 339
340 - // Submit to NetScene queue on a background thread.  
341 - // u01.m1.a() may throw a non-fatal NPE during internal cleanup after the  
342 - // message has already been queued; we swallow it and treat as success. 340 + // Submit to NetScene queue on the MAIN thread.
  341 + // WeChat's NetScene dispatcher requires main-looper execution; calling from
  342 + // a background thread silently drops the message on some devices / ROMs.
343 final java.lang.reflect.Method submit = result.getClass().getMethod("a"); 343 final java.lang.reflect.Method submit = result.getClass().getMethod("a");
344 final Object m1Result = result; 344 final Object m1Result = result;
345 - new Thread(() -> { 345 + new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
346 try { 346 try {
347 submit.invoke(m1Result); 347 submit.invoke(m1Result);
348 - } catch (Exception ignored) {  
349 - // NPE here is harmless; message was already submitted. 348 + XposedBridge.log("[" + TAG + "] NetScene submitted on main thread");
  349 + } catch (Exception e) {
  350 + XposedBridge.log("[" + TAG + "] NetScene submit on main thread failed: " + e.getMessage());
  351 + XposedBridge.log(e);
350 } 352 }
351 - }).start(); 353 + });
352 354
353 XposedBridge.log("[" + TAG + "] Message submitted to NetScene queue"); 355 XposedBridge.log("[" + TAG + "] Message submitted to NetScene queue");
354 return true; 356 return true;