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,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 "Wake lock acquired" | @@ -37,26 +40,26 @@ log "Wake lock acquired" | ||
| 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; |