Commit d7b8a7e5a3b5b4a0bde4e8bbea7b27a5732275bb
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>
Showing
9 changed files
with
1122 additions
and
115 deletions
.gitignore
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 | 2 | * auto-reply.js — Pixel 手机微信自动回复 Plugin(接入 hermes API) |
| 3 | 3 | * |
| 4 | 4 | * 架构: |
| 5 | - * AndroidWechatTracker (tail -f LSPosed 日志) ──→ 实时消息检测 | |
| 5 | + * AndroidWechatTrackerSQLCipher (sqlcipher DB 轮询) ──→ 消息检测 | |
| 6 | 6 | * termux-notification-list (兜底) ──→ 息屏时检测通知并唤醒微信 |
| 7 | 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 | 11 | * cd /data/data/com.termux/files/home/hermes-wechat |
| ... | ... | @@ -17,7 +17,9 @@ |
| 17 | 17 | */ |
| 18 | 18 | |
| 19 | 19 | const { AndroidWechatTracker } = require('./android-wechat-tracker'); |
| 20 | +const { AndroidWechatTrackerSQLCipher } = require('./android-wechat-tracker-sqlcipher'); | |
| 20 | 21 | const { sendTextMessageBroadcast } = require('./android-wechat-sender'); |
| 22 | +const { sendTextMessageFrida } = require('./android-wechat-sender-frida'); | |
| 21 | 23 | const { shell } = require('./android-wechat-sender'); |
| 22 | 24 | const fs = require('fs'); |
| 23 | 25 | const path = require('path'); |
| ... | ... | @@ -32,9 +34,9 @@ const CONFIG = { |
| 32 | 34 | myWxid: process.env.MY_WXID || '', |
| 33 | 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 | 40 | hermesTimeoutMs: parseInt(process.env.HERMES_TIMEOUT, 10) || 120000, |
| 39 | 41 | |
| 40 | 42 | // 回复行为 |
| ... | ... | @@ -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 | 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 | 145 | const req = http.request({ |
| 140 | 146 | hostname: CONFIG.hermesHost, |
| 141 | 147 | port: CONFIG.hermesPort, |
| 142 | - path: '/api/master/chat', | |
| 148 | + path: '/v1/chat/completions', | |
| 143 | 149 | method: 'POST', |
| 144 | 150 | headers: { |
| 145 | 151 | 'Content-Type': 'application/json', |
| ... | ... | @@ -150,11 +156,18 @@ async function callHermesApi(message, sessionId) { |
| 150 | 156 | let buffer = ''; |
| 151 | 157 | res.on('data', chunk => { buffer += chunk; }); |
| 152 | 158 | res.on('end', () => { |
| 159 | + log('[HERMES] HTTP status:', res.statusCode, 'body:', buffer.substring(0, 300)); | |
| 153 | 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 | 169 | } catch (e) { |
| 157 | - log('[HERMES] SSE parse error:', e.message); | |
| 170 | + log('[HERMES] JSON parse error:', e.message); | |
| 158 | 171 | resolve(null); |
| 159 | 172 | } |
| 160 | 173 | }); |
| ... | ... | @@ -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 | 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 | 199 | const { type, talker, content, senderName } = ctx; |
| 231 | 200 | |
| 232 | - let prompt = ''; | |
| 233 | - | |
| 201 | + let userContent = ''; | |
| 234 | 202 | if (type === 'private') { |
| 235 | - prompt = `收到微信私聊消息(来自 ${talker}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; | |
| 203 | + userContent = `收到微信私聊消息(来自 ${talker}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; | |
| 236 | 204 | } else if (type === 'group-at') { |
| 237 | - prompt = `收到微信群聊消息,有人 @ 我(来自群 ${talker},发送者 ${senderName}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; | |
| 205 | + userContent = `收到微信群聊消息,有人 @ 我(来自群 ${talker},发送者 ${senderName}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; | |
| 238 | 206 | } else if (type === 'group-quote') { |
| 239 | - prompt = `收到微信群聊消息,有人引用了我的消息(来自群 ${talker},发送者 ${senderName}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; | |
| 207 | + userContent = `收到微信群聊消息,有人引用了我的消息(来自群 ${talker},发送者 ${senderName}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; | |
| 240 | 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 | 221 | async function hermesReplyGenerator(ctx) { |
| 251 | 222 | const { talker } = ctx; |
| 252 | 223 | const sessionId = `wx-${talker}`; |
| 253 | - const prompt = buildHermesPrompt(ctx); | |
| 224 | + const messages = buildHermesMessages(ctx); | |
| 254 | 225 | |
| 255 | 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 | 229 | if (reply) { |
| 259 | 230 | log('[HERMES] Reply:', reply.substring(0, 80)); |
| ... | ... | @@ -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 | 286 | async function isWechatForeground() { |
| 298 | 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 | 290 | return out.includes('com.tencent.mm'); |
| 301 | 291 | } catch { |
| 302 | 292 | return false; |
| 303 | 293 | } |
| 304 | 294 | } |
| 305 | 295 | |
| 296 | +/** | |
| 297 | + * 息屏唤醒完整流程:点亮 → 解锁(无密码时滑动) → 启动微信 → 确认前台 | |
| 298 | + */ | |
| 306 | 299 | async function wakeWechat() { |
| 307 | 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 | 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 | 323 | return true; |
| 311 | 324 | } catch (e) { |
| 312 | - log('[WECHAT] Failed to wake:', e.message); | |
| 325 | + log('[WAKE] Failed:', e.message); | |
| 313 | 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 | 380 | try { |
| 334 | 381 | const out = await shell(termuxBin, 10000); |
| 335 | 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 | 385 | for (const n of wechatNotifs) { |
| 339 | 386 | const key = `${n.title}:${n.content}`; |
| ... | ... | @@ -349,15 +396,17 @@ async function pollNotifications(bot) { |
| 349 | 396 | |
| 350 | 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 | 411 | } catch (e) { |
| 363 | 412 | // notification-list 可能返回空或失败,忽略 |
| ... | ... | @@ -377,7 +426,8 @@ function sleep(ms) { |
| 377 | 426 | |
| 378 | 427 | class AutoReplyBot { |
| 379 | 428 | constructor(replyGenerator = hermesReplyGenerator) { |
| 380 | - this.tracker = new AndroidWechatTracker(); | |
| 429 | + // 优先使用 sqlcipher DB 轮询(LSPosed hook 在 WeChat 8.0.72 上不稳定) | |
| 430 | + this.tracker = new AndroidWechatTrackerSQLCipher(); | |
| 381 | 431 | this.state = new ReplyState(CONFIG.stateFile); |
| 382 | 432 | this.replyGenerator = replyGenerator; |
| 383 | 433 | this.running = false; |
| ... | ... | @@ -416,11 +466,17 @@ class AutoReplyBot { |
| 416 | 466 | |
| 417 | 467 | log(`[${replyType.toUpperCase()}] ${talker}: ${content.substring(0, 80)}`); |
| 418 | 468 | |
| 419 | - // 发送前检查微信前台 | |
| 469 | + // 发送前确保微信在前台(息屏时通知监听会先唤醒) | |
| 420 | 470 | const fg = await isWechatForeground(); |
| 421 | 471 | if (!fg) { |
| 472 | + log('[WECHAT] Not foreground, waking...'); | |
| 422 | 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 | 482 | let replyText; |
| ... | ... | @@ -447,7 +503,7 @@ class AutoReplyBot { |
| 447 | 503 | } |
| 448 | 504 | |
| 449 | 505 | try { |
| 450 | - const result = await sendTextMessageBroadcast(talker, replyText); | |
| 506 | + const result = await sendTextMessageFrida(talker, replyText); | |
| 451 | 507 | if (result.ok) { |
| 452 | 508 | log('[SENT]', talker, replyText.substring(0, 60)); |
| 453 | 509 | this.state.markReplied(msgId, talker); |
| ... | ... | @@ -474,13 +530,14 @@ class AutoReplyBot { |
| 474 | 530 | notificationEnabled: CONFIG.notificationEnabled, |
| 475 | 531 | })); |
| 476 | 532 | |
| 477 | - // 启动 tracker(流式 tail -f,最实时) | |
| 533 | + // 启动 tracker(sqlcipher DB 轮询,比 LSPosed hook 更可靠) | |
| 478 | 534 | this.tracker.on('message', msg => this.handleMessage(msg)); |
| 479 | 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 | 541 | if (CONFIG.notificationEnabled) { |
| 485 | 542 | this.notifLoop = pollNotifications(this); |
| 486 | 543 | } |
| ... | ... | @@ -492,7 +549,6 @@ class AutoReplyBot { |
| 492 | 549 | stop() { |
| 493 | 550 | if (!this.running) return; |
| 494 | 551 | this.running = false; |
| 495 | - this.tracker.stopStream(); | |
| 496 | 552 | this.tracker.stopPolling(); |
| 497 | 553 | log('=== Auto-Reply Bot Stopped ==='); |
| 498 | 554 | process.exit(0); | ... | ... |
wechat_plain/scripts/local-testkit/docs/README.md
| ... | ... | @@ -158,9 +158,18 @@ cd ~/local-testkit |
| 158 | 158 | |
| 159 | 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 | 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 | 175 | ### 4. 启动 auto-reply plugin(推荐主流程) |
| ... | ... | @@ -240,6 +249,7 @@ python3 hermes_wechat_daemon.py --daemon |
| 240 | 249 | | 息屏后完全失效 | 确认电池优化已关闭;检查 `keepalive.sh` 是否运行 | |
| 241 | 250 | | 发送失败 | 检查微信是否在前台;检查 LSPosed 模块是否启用 | |
| 242 | 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 | 20 | |
| 21 | 21 | set -e |
| 22 | 22 | |
| 23 | +# 确保 Termux 工具在 PATH 中(su 环境下可能缺失) | |
| 24 | +export PATH="/data/data/com.termux/files/usr/bin:$PATH" | |
| 25 | + | |
| 23 | 26 | HERMES_DIR="${HERMES_DIR:-/data/data/com.termux/files/home/hermes-wechat}" |
| 24 | 27 | HERMES_HOST="${HERMES_HOST:-localhost}" |
| 25 | 28 | HERMES_PORT="${HERMES_PORT:-3010}" |
| ... | ... | @@ -37,26 +40,26 @@ log "Wake lock acquired" |
| 37 | 40 | |
| 38 | 41 | # ── 2. 检查前置条件 ────────────────────────────────────────────── |
| 39 | 42 | check_prerequisites() { |
| 40 | - local ok=true | |
| 43 | + _ok=0 | |
| 41 | 44 | |
| 42 | 45 | if ! command -v termux-notification-list >/dev/null 2>&1; then |
| 43 | 46 | log "[WARN] termux-notification-list not found. Install: pkg install termux-api" |
| 44 | - ok=false | |
| 47 | + _ok=1 | |
| 45 | 48 | fi |
| 46 | 49 | |
| 47 | 50 | if [ ! -d "$HERMES_DIR" ]; then |
| 48 | 51 | log "[WARN] HERMES_DIR not found: $HERMES_DIR" |
| 49 | - ok=false | |
| 52 | + _ok=1 | |
| 50 | 53 | fi |
| 51 | 54 | |
| 52 | 55 | if [ -z "$MY_WXID" ] || [ -z "$MY_NICKNAME" ]; then |
| 53 | 56 | log "[WARN] MY_WXID or MY_NICKNAME not set" |
| 54 | 57 | log " export MY_WXID=wxid_xxxxxx" |
| 55 | 58 | log " export MY_NICKNAME=你的昵称" |
| 56 | - ok=false | |
| 59 | + _ok=1 | |
| 57 | 60 | fi |
| 58 | 61 | |
| 59 | - $ok | |
| 62 | + return $_ok | |
| 60 | 63 | } |
| 61 | 64 | |
| 62 | 65 | # ── 3. 检查 hermes server 是否运行 ─────────────────────────────── |
| ... | ... | @@ -65,15 +68,18 @@ is_hermes_running() { |
| 65 | 68 | } |
| 66 | 69 | |
| 67 | 70 | # ── 4. 启动 hermes server ──────────────────────────────────────── |
| 71 | +# 必须在 proot Ubuntu 内以 ubuntu 用户运行(Claude CLI 拒绝 root + --dangerously-skip-permissions) | |
| 68 | 72 | start_hermes() { |
| 69 | - log "Starting hermes server..." | |
| 73 | + log "Starting hermes server in proot Ubuntu user=ubuntu..." | |
| 70 | 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 | 79 | if is_hermes_running; then |
| 74 | - log "Hermes server started (PID=$!)" | |
| 80 | + log "Hermes server started PID=$!" | |
| 75 | 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 | 83 | fi |
| 78 | 84 | } |
| 79 | 85 | |
| ... | ... | @@ -88,14 +94,14 @@ start_auto_reply() { |
| 88 | 94 | cd "$HERMES_DIR/server/services/wechat" |
| 89 | 95 | env MY_WXID="$MY_WXID" MY_NICKNAME="$MY_NICKNAME" \ |
| 90 | 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 | 100 | # ── 7. 主循环 ──────────────────────────────────────────────────── |
| 95 | 101 | log "=== Keepalive started ===" |
| 96 | 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 | 106 | if ! check_prerequisites; then |
| 101 | 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 | 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 | 343 | final java.lang.reflect.Method submit = result.getClass().getMethod("a"); |
| 344 | 344 | final Object m1Result = result; |
| 345 | - new Thread(() -> { | |
| 345 | + new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> { | |
| 346 | 346 | try { |
| 347 | 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 | 355 | XposedBridge.log("[" + TAG + "] Message submitted to NetScene queue"); |
| 354 | 356 | return true; | ... | ... |