From 47826cd039b0b626910f4f16b88c2a1d02a7be0d Mon Sep 17 00:00:00 2001 From: yibo Date: Sat, 23 May 2026 10:43:29 +0800 Subject: [PATCH] feat: wechat auto-reply plugin with hermes API integration + screen-off detection --- .gitignore | 1 + server/services/wechat/auto-reply.js | 362 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------------------------------------------------------------- wechat_plain/scripts/local-testkit/HERMES_WECHAT_ARCHITECTURE.md | 534 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ wechat_plain/scripts/local-testkit/NOTIFICATION_STRATEGY.md | 79 ------------------------------------------------------------------------------- wechat_plain/scripts/local-testkit/README_LOCAL.md | 208 ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- wechat_plain/scripts/local-testkit/REVERSE_ENGINEERING_LOG.md | 408 ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ wechat_plain/scripts/local-testkit/config.json | 9 +++++++-- wechat_plain/scripts/local-testkit/docs/HERMES_WECHAT_ARCHITECTURE.md | 534 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/local-testkit/docs/NOTIFICATION_STRATEGY.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/local-testkit/docs/README.md | 269 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/local-testkit/docs/README_LOCAL.md | 208 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/local-testkit/docs/REVERSE_ENGINEERING_LOG.md | 408 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/local-testkit/docs/plan.md | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/local-testkit/hermes_wechat_daemon.py | 145 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------- wechat_plain/scripts/local-testkit/keepalive.sh | 119 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wechat_plain/scripts/local-testkit/plan.md | 96 ------------------------------------------------------------------------------------------------ 16 files changed, 2086 insertions(+), 1469 deletions(-) delete mode 100644 wechat_plain/scripts/local-testkit/HERMES_WECHAT_ARCHITECTURE.md delete mode 100644 wechat_plain/scripts/local-testkit/NOTIFICATION_STRATEGY.md delete mode 100644 wechat_plain/scripts/local-testkit/README_LOCAL.md delete mode 100644 wechat_plain/scripts/local-testkit/REVERSE_ENGINEERING_LOG.md create mode 100644 wechat_plain/scripts/local-testkit/docs/HERMES_WECHAT_ARCHITECTURE.md create mode 100644 wechat_plain/scripts/local-testkit/docs/NOTIFICATION_STRATEGY.md create mode 100644 wechat_plain/scripts/local-testkit/docs/README.md create mode 100644 wechat_plain/scripts/local-testkit/docs/README_LOCAL.md create mode 100644 wechat_plain/scripts/local-testkit/docs/REVERSE_ENGINEERING_LOG.md create mode 100644 wechat_plain/scripts/local-testkit/docs/plan.md create mode 100755 wechat_plain/scripts/local-testkit/keepalive.sh delete mode 100644 wechat_plain/scripts/local-testkit/plan.md diff --git a/.gitignore b/.gitignore index 9c51693..e9d2c72 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ system/agents/*/memory/ # SQLite databases *.sqlite +server/db/ # Build output dist/ diff --git a/server/services/wechat/auto-reply.js b/server/services/wechat/auto-reply.js index 54cf725..f108550 100644 --- a/server/services/wechat/auto-reply.js +++ b/server/services/wechat/auto-reply.js @@ -1,10 +1,11 @@ /** - * auto-reply.js — Pixel 手机微信自动回复守护进程 + * auto-reply.js — Pixel 手机微信自动回复 Plugin(接入 hermes API) * - * 功能: - * 1. 单聊消息 → 自动回复 - * 2. 群聊 @我 → 自动回复 - * 3. 群聊引用我的消息 → 自动回复 + * 架构: + * AndroidWechatTracker (tail -f LSPosed 日志) ──→ 实时消息检测 + * termux-notification-list (兜底) ──→ 息屏时检测通知并唤醒微信 + * hermes API (POST /api/master/chat) ──→ AI 回复生成 + * android-wechat-sender (am broadcast) ──→ 发送回复 * * 运行方式: * cd /data/data/com.termux/files/home/hermes-wechat @@ -17,25 +18,36 @@ const { AndroidWechatTracker } = require('./android-wechat-tracker'); const { sendTextMessageBroadcast } = require('./android-wechat-sender'); +const { shell } = require('./android-wechat-sender'); const fs = require('fs'); const path = require('path'); +const http = require('http'); // ═══════════════════════════════════════════════════════════════ -// 配置区 — 请根据实际情况修改 +// 配置区 // ═══════════════════════════════════════════════════════════════ const CONFIG = { // 你的微信信息(用于判断群聊 @/引用) - myWxid: process.env.MY_WXID || '', // 如: wxid_xxxxxx - myNickname: process.env.MY_NICKNAME || '', // 如: 小明 + myWxid: process.env.MY_WXID || '', + myNickname: process.env.MY_NICKNAME || '', + + // hermes API + hermesHost: process.env.HERMES_HOST || 'localhost', + hermesPort: parseInt(process.env.HERMES_PORT, 10) || 3010, + hermesTimeoutMs: parseInt(process.env.HERMES_TIMEOUT, 10) || 120000, // 回复行为 - replyInPrivateChat: true, // 单聊自动回复 - replyAtInGroup: true, // 群聊 @我 时回复 - replyQuoteInGroup: true, // 群聊引用我时回复 - maxReplyLength: 500, // 单条回复最大长度 - cooldownMs: 3000, // 同一人冷却时间(毫秒) - ignoreSelf: true, // 忽略自己发送的消息 + replyInPrivateChat: true, + replyAtInGroup: true, + replyQuoteInGroup: true, + maxReplyLength: 500, + cooldownMs: 3000, + ignoreSelf: true, + + // 通知监听(息屏兜底) + notificationPollIntervalMs: 3000, + notificationEnabled: process.env.NOTIFICATION_ENABLED !== 'false', // 日志 logFile: path.join(__dirname, 'auto-reply.log'), @@ -50,7 +62,7 @@ class ReplyState { constructor(filePath) { this.filePath = filePath; this.repliedMsgIds = new Set(); - this.lastReplyTime = {}; // talker -> timestamp + this.lastReplyTime = {}; this.load(); } @@ -84,7 +96,6 @@ class ReplyState { markReplied(msgId, talker) { this.repliedMsgIds.add(msgId); this.lastReplyTime[talker] = Date.now(); - // 清理旧记录,只保留最近 1000 条 if (this.repliedMsgIds.size > 1000) { const arr = Array.from(this.repliedMsgIds); this.repliedMsgIds = new Set(arr.slice(-500)); @@ -112,39 +123,166 @@ function log(...args) { } // ═══════════════════════════════════════════════════════════════ -// 消息判断 +// Hermes API 调用 // ═══════════════════════════════════════════════════════════════ /** - * 判断是否为群聊 + * 调用 hermes /api/master/chat 生成回复 + * + * @param {string} message - 构造好的 prompt(包含上下文、系统提示) + * @param {string} sessionId - 会话 ID(每个 talker 独立) + * @returns {Promise} 回复文本 */ -function isGroupChat(talker) { - return talker && talker.endsWith('@chatroom'); +async function callHermesApi(message, sessionId) { + return new Promise((resolve, reject) => { + const payload = JSON.stringify({ message, sessionId }); + const req = http.request({ + hostname: CONFIG.hermesHost, + port: CONFIG.hermesPort, + path: '/api/master/chat', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }, + timeout: CONFIG.hermesTimeoutMs, + }, (res) => { + let buffer = ''; + res.on('data', chunk => { buffer += chunk; }); + res.on('end', () => { + try { + const result = parseSSE(buffer); + resolve(result); + } catch (e) { + log('[HERMES] SSE parse error:', e.message); + resolve(null); + } + }); + }); + + req.on('error', (err) => { + log('[HERMES] HTTP error:', err.message); + resolve(null); + }); + req.on('timeout', () => { + req.destroy(); + log('[HERMES] API timeout'); + resolve(null); + }); + + req.write(payload); + req.end(); + }); } /** - * 判断群聊消息是否 @我 - * 微信 @ 消息格式:"@昵称 消息内容" 或 "@昵称\n消息内容" + * 解析 SSE 响应,提取 result.output */ +function parseSSE(buffer) { + const lines = buffer.split('\n'); + const events = []; + let currentEvent = null; + let currentData = ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('event:')) { + if (currentEvent !== null) { + events.push({ event: currentEvent, data: currentData }); + } + currentEvent = trimmed.slice(6).trim(); + currentData = ''; + } else if (trimmed.startsWith('data:')) { + currentData += trimmed.slice(5).trim(); + } else if (trimmed === '' && currentEvent !== null) { + events.push({ event: currentEvent, data: currentData }); + currentEvent = null; + currentData = ''; + } + } + if (currentEvent !== null) { + events.push({ event: currentEvent, data: currentData }); + } + + // 找 result 事件 + for (const ev of events) { + if (ev.event === 'result') { + try { + const data = JSON.parse(ev.data); + return data.output || null; + } catch { + return ev.data || null; + } + } + } + + return null; +} + +// ═══════════════════════════════════════════════════════════════ +// 回复生成器 — 接入 hermes +// ═══════════════════════════════════════════════════════════════ + +/** + * 构造发给 hermes 的 prompt + */ +function buildHermesPrompt(ctx) { + const { type, talker, content, senderName } = ctx; + + let prompt = ''; + + if (type === 'private') { + prompt = `收到微信私聊消息(来自 ${talker}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; + } else if (type === 'group-at') { + prompt = `收到微信群聊消息,有人 @ 我(来自群 ${talker},发送者 ${senderName}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; + } else if (type === 'group-quote') { + prompt = `收到微信群聊消息,有人引用了我的消息(来自群 ${talker},发送者 ${senderName}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; + } else { + prompt = `收到微信消息(来自 ${talker}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; + } + + return prompt; +} + +/** + * 接入 hermes 的回复生成器 + */ +async function hermesReplyGenerator(ctx) { + const { talker } = ctx; + const sessionId = `wx-${talker}`; + const prompt = buildHermesPrompt(ctx); + + log('[HERMES] Calling API for', talker, 'session:', sessionId); + const reply = await callHermesApi(prompt, sessionId); + + if (reply) { + log('[HERMES] Reply:', reply.substring(0, 80)); + } else { + log('[HERMES] No reply generated'); + } + + return reply; +} + +// ═══════════════════════════════════════════════════════════════ +// 消息判断 +// ═══════════════════════════════════════════════════════════════ + +function isGroupChat(talker) { + return talker && talker.endsWith('@chatroom'); +} + function isAtMe(content, nickname) { if (!nickname) return false; - // 匹配 @昵称 后跟空格或换行 const pattern = new RegExp('@' + escapeRegex(nickname) + '(\\s|\\n|$)'); return pattern.test(content); } -/** - * 判断群聊消息是否引用我 - * 引用消息 content 中包含 XML - */ function isQuoteMe(content, nickname) { if (!nickname) return false; if (!content.includes('([^<]+)<\/displayname>/); - if (match) { - return match[1] === nickname; - } + if (match) return match[1] === nickname; return false; } @@ -153,61 +291,84 @@ function escapeRegex(str) { } // ═══════════════════════════════════════════════════════════════ -// 回复生成 — 默认实现(可替换) +// 微信前台检测与唤醒 // ═══════════════════════════════════════════════════════════════ -/** - * 默认回复生成器 — 简单示例 - * 实际使用时,应接入 hermes / Claude CLI 生成智能回复 - * - * @param {Object} ctx — 消息上下文 - * ctx.type: 'private' | 'group-at' | 'group-quote' - * ctx.talker: 对方 wxid / 群聊 ID - * ctx.content: 消息内容 - * ctx.senderName: 发送者昵称(如能从 content 解析) - * @returns {Promise} 回复内容,null 表示不回复 - */ -async function defaultReplyGenerator(ctx) { - // 示例:接入 Claude CLI 生成回复 - // 请根据实际环境修改此函数 +async function isWechatForeground() { + try { + const out = await shell('dumpsys activity activities | grep "mResumedActivity"', 5000); + return out.includes('com.tencent.mm'); + } catch { + return false; + } +} - const { type, talker, content, senderName } = ctx; +async function wakeWechat() { + try { + await shell('/system/bin/am start -n com.tencent.mm/.ui.LauncherUI', 5000); + log('[WECHAT] Woken up'); + return true; + } catch (e) { + log('[WECHAT] Failed to wake:', e.message); + return false; + } +} - // 方案 A:调用本地 Claude CLI(如果手机上安装了) - // const reply = await callClaudeCli(content); +// ═══════════════════════════════════════════════════════════════ +// 通知监听(息屏兜底) +// ═══════════════════════════════════════════════════════════════ - // 方案 B:调用 hermes agent API(如果手机上有 HTTP 服务) - // const reply = await callHermesApi(content); +async function pollNotifications(bot) { + const termuxBin = '/data/data/com.termux/files/usr/bin/termux-notification-list'; - // 方案 C:简单兜底回复 - if (type === 'private') { - return `收到你的消息:"${content.substring(0, 50)}",稍后回复~`; - } - if (type === 'group-at') { - return `@${senderName || ''} 收到,稍后处理~`; - } - if (type === 'group-quote') { - return `看到引用了我的消息,稍后回复~`; + if (!fs.existsSync(termuxBin)) { + log('[NOTIF] termux-notification-list not found, notification watcher disabled'); + return; } - return null; + log('[NOTIF] Notification watcher started'); + const knownKeys = new Set(); + + while (bot.running) { + try { + const out = await shell(termuxBin, 10000); + const notifs = JSON.parse(out); + const wechatNotifs = notifs.filter(n => n.pkgName === 'com.tencent.mm'); + + for (const n of wechatNotifs) { + const key = `${n.title}:${n.content}`; + if (knownKeys.has(key)) continue; + knownKeys.add(key); + + // 限制 set 大小 + if (knownKeys.size > 200) { + const arr = Array.from(knownKeys); + knownKeys.clear(); + arr.slice(-100).forEach(k => knownKeys.add(k)); + } + + log('[NOTIF] WeChat notification:', n.title, n.content.substring(0, 60)); + + // 息屏时微信可能不在前台,先唤醒 + const fg = await isWechatForeground(); + if (!fg) { + await wakeWechat(); + await sleep(3000); // 等待微信同步 DB、LSPosed 写日志 + } + + // 通知 tracker 立即检查(tracker 的 tail -f 会捕获新日志) + // 如果 tracker 已经停止,这里无法恢复它 + } + } catch (e) { + // notification-list 可能返回空或失败,忽略 + } + + await sleep(CONFIG.notificationPollIntervalMs); + } } -/** - * 调用 Claude CLI 生成回复(需要手机上安装 claude CLI) - */ -async function callClaudeCli(prompt) { - return new Promise((resolve, reject) => { - const { spawn } = require('child_process'); - const proc = spawn('claude', ['-p', prompt], { timeout: 30000 }); - let stdout = ''; - proc.stdout.on('data', d => stdout += d.toString()); - proc.on('close', code => { - if (code !== 0) reject(new Error('claude exit ' + code)); - else resolve(stdout.trim()); - }); - proc.on('error', reject); - }); +function sleep(ms) { + return new Promise(r => setTimeout(r, ms)); } // ═══════════════════════════════════════════════════════════════ @@ -215,7 +376,7 @@ async function callClaudeCli(prompt) { // ═══════════════════════════════════════════════════════════════ class AutoReplyBot { - constructor(replyGenerator = defaultReplyGenerator) { + constructor(replyGenerator = hermesReplyGenerator) { this.tracker = new AndroidWechatTracker(); this.state = new ReplyState(CONFIG.stateFile); this.replyGenerator = replyGenerator; @@ -223,36 +384,24 @@ class AutoReplyBot { } async handleMessage(msg) { - // 只处理收到的消息 if (msg.type !== 'recv') return; const { talker, content, msgId } = msg; if (!talker || !content) return; - // 忽略自己 if (CONFIG.ignoreSelf && talker === CONFIG.myWxid) return; - - // 已回复过 if (this.state.hasReplied(msgId)) return; - - // 冷却检查 if (this.state.isCooldown(talker)) { - log(`[COOLDOWN] Skip ${talker}`); + log('[COOLDOWN] Skip', talker); return; } let replyType = null; let senderName = ''; - // 判断消息类型 if (!isGroupChat(talker)) { - // 单聊 - if (CONFIG.replyInPrivateChat) { - replyType = 'private'; - } + if (CONFIG.replyInPrivateChat) replyType = 'private'; } else { - // 群聊 - // 提取发送者昵称(群聊消息格式:"昵称:\n内容" 或 XML) const nameMatch = content.match(/^([^:\n]+):\n/); if (nameMatch) senderName = nameMatch[1]; @@ -267,7 +416,13 @@ class AutoReplyBot { log(`[${replyType.toUpperCase()}] ${talker}: ${content.substring(0, 80)}`); - // 生成回复 + // 发送前检查微信前台 + const fg = await isWechatForeground(); + if (!fg) { + await wakeWechat(); + await sleep(2000); + } + let replyText; try { replyText = await this.replyGenerator({ @@ -287,12 +442,10 @@ class AutoReplyBot { return; } - // 截断 if (replyText.length > CONFIG.maxReplyLength) { replyText = replyText.substring(0, CONFIG.maxReplyLength) + '...'; } - // 发送回复 try { const result = await sendTextMessageBroadcast(talker, replyText); if (result.ok) { @@ -306,24 +459,32 @@ class AutoReplyBot { } } - start() { + async start() { if (this.running) return; this.running = true; log('=== Auto-Reply Bot Started ==='); + log('Hermes:', `${CONFIG.hermesHost}:${CONFIG.hermesPort}`); log('Config:', JSON.stringify({ myWxid: CONFIG.myWxid ? '***' : '(not set)', myNickname: CONFIG.myNickname || '(not set)', replyInPrivateChat: CONFIG.replyInPrivateChat, replyAtInGroup: CONFIG.replyAtInGroup, replyQuoteInGroup: CONFIG.replyQuoteInGroup, + notificationEnabled: CONFIG.notificationEnabled, })); + // 启动 tracker(流式 tail -f,最实时) this.tracker.on('message', msg => this.handleMessage(msg)); this.tracker.on('error', err => log('[TRACKER ERROR]', err.message)); - this.tracker.startPolling(2000); + this.tracker.startStream(); + log('[TRACKER] Stream started (tail -f)'); + + // 启动通知监听(息屏兜底) + if (CONFIG.notificationEnabled) { + this.notifLoop = pollNotifications(this); + } - // 优雅退出 process.on('SIGINT', () => this.stop()); process.on('SIGTERM', () => this.stop()); } @@ -331,8 +492,8 @@ class AutoReplyBot { stop() { if (!this.running) return; this.running = false; - this.tracker.stopPolling(); this.tracker.stopStream(); + this.tracker.stopPolling(); log('=== Auto-Reply Bot Stopped ==='); process.exit(0); } @@ -343,7 +504,6 @@ class AutoReplyBot { // ═══════════════════════════════════════════════════════════════ if (require.main === module) { - // 从环境变量读取配置 if (process.env.MY_WXID) CONFIG.myWxid = process.env.MY_WXID; if (process.env.MY_NICKNAME) CONFIG.myNickname = process.env.MY_NICKNAME; @@ -352,7 +512,7 @@ if (require.main === module) { console.error('ERROR: 请设置 MY_WXID 和 MY_NICKNAME 环境变量'); console.error(''); console.error('用法示例:'); - console.error(' MY_WXID=wxid_xxxxxx MY_NICKNAME=小明 nohup node auto-reply.js \&'); + console.error(' MY_WXID=wxid_xxxxxx MY_NICKNAME=小明 nohup node auto-reply.js &'); console.error(''); process.exit(1); } @@ -361,4 +521,4 @@ if (require.main === module) { bot.start(); } -module.exports = { AutoReplyBot, defaultReplyGenerator, CONFIG }; +module.exports = { AutoReplyBot, hermesReplyGenerator, callHermesApi, CONFIG }; diff --git a/wechat_plain/scripts/local-testkit/HERMES_WECHAT_ARCHITECTURE.md b/wechat_plain/scripts/local-testkit/HERMES_WECHAT_ARCHITECTURE.md deleted file mode 100644 index 5b39c39..0000000 --- a/wechat_plain/scripts/local-testkit/HERMES_WECHAT_ARCHITECTURE.md +++ /dev/null @@ -1,534 +0,0 @@ -# Hermes WeChat 本地闭环架构方案 - -> 版本: 2.0 (HTTP API 模式) -> 日期: 2026-05-22 -> 目标设备: Pixel 10 Fold (Android) -> 运行环境: Termux + Ubuntu proot-distro -> 微信版本: 8.0.72 - ---- - -## 1. 目标 - -在 Pixel 手机本地实现微信全自动闭环: - -``` -收到消息 → 读取内容 → HTTP 调用 hermes → 生成回复 → 发送回复 -``` - -**不依赖 Mac 远程 adb**,所有组件运行在 Android 本地。 - -**核心设计原则**: -- **daemon** 是常驻后台进程,只负责数据读写和消息收发 -- **hermes** (lemonPCRM) 是独立的 AI 服务,负责 LLM 推理和回复生成 -- 两者通过 HTTP API (`/api/master/chat`) 通信 - ---- - -## 2. 架构总览 - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ Pixel 手机本地运行环境 │ -│ │ -│ ┌───────────────────────────────────────────────────────────────────────┐ │ -│ │ Ubuntu proot-distro 容器 │ │ -│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ -│ │ │ hermes (lemonPCRM) │ │ │ -│ │ │ ┌──────────────┐ ┌──────────┐ ┌──────────┐ │ │ │ -│ │ │ │ Node.js │ │ Master │ │ Claude │ │ │ │ -│ │ │ │ Express │ │ Session │ │ CLI │ │ │ │ -│ │ │ │ :3010 │ │ Manager │ │ Protocol │ │ │ │ -│ │ │ └──────────────┘ └──────────┘ └──────────┘ │ │ │ -│ │ │ ↑ │ │ │ -│ │ │ │ POST /api/master/chat (SSE) │ │ │ -│ │ │ │ {message, sessionId} │ │ │ -│ │ └─────────┼────────────────────────────────────────────────────────┘ │ │ -│ │ │ │ │ -│ │ ┌─────────┴─────────────────────────────────────────────────────────┐ │ │ -│ │ │ daemon (Python) │ │ │ -│ │ │ ┌──────────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │ │ -│ │ │ │ DB 轮询/ │→ │ 新消息 │→ │ HTTP │→ │ am broadcast │ │ │ │ -│ │ │ │ 通知触发 │ │ 检测 │ │ hermes │ │ 发送回复 │ │ │ │ -│ │ │ └──────────────┘ └──────────┘ └──────────┘ └──────────────┘ │ │ │ -│ │ │ ↑ │ │ │ │ -│ │ │ │ │ │ │ │ -│ │ │ ┌──────────────┐ ┌──────────┴─────┐ │ │ │ -│ │ │ │ sqlcipher │ │ 回复内容 │ │ │ │ -│ │ │ │ 解密读取 │ │ (SSE result) │ │ │ │ -│ │ │ └──────────────┘ └────────────────┘ │ │ │ -│ │ └──────────────────────────────────────────────────────────────────┘ │ │ -│ │ ↑ │ │ -│ │ │ │ │ -│ │ ┌──────────────┐ ┌──────────┴──┐│ -│ │ │ su -c cp DB │ │ LSPosed ││ -│ │ │ + WAL + SHM │ │ WeChatHook ││ -│ │ └──────────────┘ └─────────────┘│ -│ └───────────────────────────────────────────────────────────────────────────┘ │ -│ ↑ │ │ -│ │ ▼ │ -│ ┌───────────────────────────────────────────────────────────────────────┐ │ -│ │ Termux 环境 (与 proot 共享 home) │ │ -│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ -│ │ │ 通知监听线程 (辅助通道) │ │ │ -│ │ │ termux-notification-list 每 3 秒轮询 │ │ │ -│ │ │ 发现微信通知 → 触发紧急 DB 轮询 │ │ │ -│ │ └─────────────────────────────────────────────────────────────────┘ │ │ -│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ -│ │ │ Termux:Boot 开机自启 │ │ │ -│ │ │ ~/.termux/boot/start-hermes.sh │ │ │ -│ │ └─────────────────────────────────────────────────────────────────┘ │ │ -│ └───────────────────────────────────────────────────────────────────────┘ │ -│ ↑ │ │ -│ │ ▼ │ -│ ┌───────────────────────────────────────────────────────────────────────┐ │ -│ │ Android 系统层 │ │ -│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ -│ │ │ 微信 APK │ │ SQLCipher DB │ │ 通知栏 │ │ │ -│ │ │ (已 root) │ │ EnMicroMsg │ │ (微信通知) │ │ │ -│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ -│ └───────────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## 3. 数据流详解 - -### 3.1 完整消息处理流程 - -``` -1. 通知触发(或定时轮询) - │ - ▼ -2. daemon 复制 EnMicroMsg.db + WAL + SHM 到 /sdcard/hermes/dbs/ - │ - ▼ -3. daemon 用 sqlcipher 解密 DB - │ - ▼ -4. daemon 查询 message 表:SELECT * WHERE msgId > last_msg_id - │ - ▼ -5. daemon 提取消息内容 + 最近 5 条上下文 - │ - ▼ -6. daemon 构造 prompt,HTTP POST 到 hermes /api/master/chat - │ { - │ "message": "收到消息(来自 xxx):...\n上下文: ...", - │ "sessionId": "wx-xxx" - │ } - │ - ▼ -7. hermes (lemonPCRM) 调用 Claude CLI 处理消息,返回 SSE 流 - │ events: session → progress → agent_output → result - │ - ▼ -8. daemon 解析 SSE,提取 result.output 作为回复文本 - │ - ▼ -9. daemon 执行 am broadcast 发送回复到微信 - │ - ▼ -10. LSPosed 模块拦截广播,调用微信内部 API 发送消息 -``` - -### 3.2 Session 管理 - -每个微信联系人 (`talker`) 对应一个独立的 hermes session: - -| talker | sessionId | 作用 | -|--------|-----------|------| -| `filehelper` | `wx-filehelper` | 文件传输助手 | -| `wxid_abc123` | `wx-wxid_abc123` | 个人联系人 | -| `123456789@chatroom` | `wx-123456789@chatroom` | 群聊 | - -**好处**: -- hermes 自动维护每个对话的上下文记忆 -- 不同联系人的对话互不干扰 -- session 10 分钟空闲后自动清理 - ---- - -## 4. 消息监听双通道 - -### 4.1 通道 A: DB 轮询(主推) - -**原理**: 定期复制微信加密 DB → sqlcipher 解密 → 查询 message 表 → 检测 msgId 变化 - -**流程**: -```python -while True: - su -c "cp /data/data/com.tencent.mm/MicroMsg//EnMicroMsg.db /sdcard/hermes/dbs/" - su -c "cp /data/data/com.tencent.mm/MicroMsg//EnMicroMsg.db-wal /sdcard/hermes/dbs/" - - sqlcipher EnMicroMsg.db "PRAGMA key='e03ce81'; ..." - - SELECT * FROM message WHERE msgId > last_msg_id ORDER BY msgId - UPDATE last_msg_id = max(msgId) - sleep(10) -``` - -**关键参数**: -| 参数 | 值 | 说明 | -|------|-----|------| -| SQLCipher key | `e03ce81` | 7 字符字符串密码 | -| cipher_compatibility | `1` | WCDB 兼容模式 | -| cipher_page_size | `1024` | 页大小 | -| 轮询间隔 | `10s` | 可配置 | -| WAL 文件 | **必须复制** | 否则丢失最新数据 | - -### 4.2 通道 B: 通知监听(辅助) - -**原理**: 通过 Termux:API 的 `termux-notification-list` 读取系统通知栏,发现微信通知后立即触发一次 DB 轮询。 - -**流程**: -```python -while True: - notifs = termux-notification-list - wechat_notifs = [n for n in notifs if n.pkgName == "com.tencent.mm"] - for n in wechat_notifs: - if is_new(n): - trigger_urgent_db_poll() # 不等 10 秒周期 - sleep(3) -``` - -**权限要求**: -1. 安装 Termux:API 应用 -2. 设置 → 应用 → Termux:API → 通知访问权限 → 允许 -3. 关闭 Termux 和 Termux:API 的电池优化 - -### 4.3 双通道协同 - -``` -正常状态: DB 轮询每 10 秒执行一次 - -收到新消息: - 通知栏产生通知 ──→ termux-notification-list 在 3 秒内发现 - │ - ▼ - 触发紧急 DB 轮询(不等 10 秒周期) - │ - ▼ - 获取完整消息内容 ──→ HTTP POST hermes ──→ 发送回复 -``` - ---- - -## 5. Hermes HTTP API 调用 - -### 5.1 API 端点 - -| 端点 | 方法 | 说明 | -|------|------|------| -| `POST /api/master/chat` | SSE | 发送消息,流式返回处理结果 | -| `GET /api/health` | HTTP | 健康检查 | - -### 5.2 请求格式 - -```json -POST /api/master/chat -Content-Type: application/json - -{ - "message": "收到消息(来自 filehelper):你好\n请回复:", - "sessionId": "wx-filehelper" -} -``` - -### 5.3 SSE 响应格式 - -``` -event: session -data: {"sessionId": "wx-filehelper"} - -event: progress -data: {"step": "initializing", "message": "🤖 Master Agent 启动中..."} - -event: agent_output -data: {"agent": "master", "message": "你好!很高兴收到你的消息。"} - -event: result -data: {"sessionId": "wx-filehelper", "type": "text", "output": "你好!很高兴收到你的消息。"} -``` - -daemon 提取 `result` 事件的 `output` 字段作为最终回复。 - -### 5.4 Prompt 构造 - -daemon 发送给 hermes 的消息格式: - -``` -你是一个微信智能助手。根据收到的消息,给出简短、自然、得体的中文回复。 - -最近对话上下文: - 对方: 在吗? - 我: 在的,有什么可以帮你的? - -收到新消息(来自 filehelper):帮我查一下明天的天气 - -请直接给出回复内容,不要加任何解释或格式标记。 -``` - ---- - -## 6. 消息发送 - -### 6.1 发送机制 - -LSPosed 模块 `HermesWeChatModule` 注入微信进程,暴露 BroadcastReceiver: - -```bash -/system/bin/am broadcast \ - -a com.hermes.wechat.SEND_MSG \ - --es toUser "filehelper" \ - --es content "回复内容" -``` - -### 6.2 发送限制 - -| 限制 | 说明 | -|------|------| -| 仅文本消息 | 当前模块只支持文本 | -| 主线程调用 | NetScene 提交必须在主线程 | -| 微信前台 | 建议保持微信在前台或后台运行 | - ---- - -## 7. 开机自启(Termux:Boot) - -### 7.1 启动脚本 - -文件: `~/.termux/boot/start-hermes.sh` - -```bash -#!/bin/bash - -# 启动 frida-server(可选) -if [ -f "/data/local/tmp/frida-server" ]; then - su -c "/data/local/tmp/frida-server &" -fi - -# 启动微信(如果未运行) -if ! su -c "pgrep -x com.tencent.mm" > /dev/null 2>&1; then - su -c "am start -n com.tencent.mm/.ui.LauncherUI" - sleep 5 -fi - -# 启动 hermes (lemonPCRM) — 在 proot 内 -export PATH=/data/data/com.termux/files/usr/bin:$PATH -proot-distro login --shared-home ubuntu -- bash -c \ - "cd /root/aipcrm && npm start &" - -sleep 5 - -# 启动守护进程 -proot-distro login --shared-home ubuntu -- bash -c \ - "cd /root/local-testkit && python3 hermes_wechat_daemon.py --daemon" - -# 发送启动通知 -termux-notification \ - --title "Hermes WeChat" \ - --content "守护进程已启动" \ - --priority low -``` - -### 7.2 安装步骤 - -```bash -mkdir -p ~/.termux/boot -cp setup_termux_boot.sh ~/.termux/boot/start-hermes.sh -chmod +x ~/.termux/boot/start-hermes.sh -``` - ---- - -## 8. 目录结构 - -``` -~/local-testkit/ # daemon 目录 -├── hermes_wechat_daemon.py # 主守护进程 -├── config.json # 运行时配置 -├── test_env.py # 环境预检 -├── test_db_poll.py # DB 轮询测试 -├── test_send_msg.py # 发送消息测试 -├── test_termux_api.py # Termux API 测试 -├── test_hermes_api.py # hermes HTTP API 测试 -├── run_all_tests.sh # 一键测试 -├── setup_termux_boot.sh # 开机自启脚本 -└── HERMES_WECHAT_ARCHITECTURE.md # 本方案文档 - -~/aipcrm/ # hermes (lemonPCRM) 目录 -├── server/ # Express 后端 -├── packages/ # ACP 协议 + Agent 运行时 -├── package.json -└── ... - -/sdcard/hermes/ # 运行时数据 -├── dbs/ # DB 副本 -│ ├── EnMicroMsg.db -│ ├── EnMicroMsg.db-wal -│ └── EnMicroMsg.db.decrypted.db -├── wechat_msgs.jsonl # 消息日志 -└── daemon.log # 守护进程日志 -``` - ---- - -## 9. 配置说明 - -`config.json`: - -```json -{ - "db_src_path": "", - "db_copy_dir": "/sdcard/hermes/dbs", - "sqlcipher_key": "e03ce81", - "cipher_compatibility": 1, - "cipher_page_size": 1024, - "poll_interval_sec": 10, - "max_msgs_per_poll": 50, - "hermes": { - "api_url": "http://localhost:3010/api/master/chat", - "timeout_sec": 120, - "default_session_prefix": "wx-", - "system_hint": "你是一个微信智能助手。根据收到的消息,给出简短、自然、得体的中文回复。" - }, - "auto_reply": { - "enabled": false, - "contacts": ["filehelper"], - "exclude_types": [10000], - "cooldown_sec": 5 - }, - "send": { - "method": "am_broadcast", - "broadcast_action": "com.hermes.wechat.SEND_MSG", - "am_path": "/system/bin/am" - }, - "logging": { - "level": "INFO", - "file": "/sdcard/hermes/daemon.log" - } -} -``` - -**注意**:daemon 不配置 LLM API key,所有 LLM 配置在 hermes (lemonPCRM) 中。 - ---- - -## 10. 部署步骤 - -### Step 1: 环境准备 - -```bash -# Pixel Termux 中 -pkg update -pkg install python sqlcipher termux-api - -# 安装 Termux:Boot 和 Termux:API 应用(F-Droid 或 GitHub) -``` - -### Step 2: 部署 hermes (lemonPCRM) - -```bash -# 在 proot Ubuntu 中 -cd ~/aipcrm -npm install -# 配置 .env 中的 Claude API key -npm start # 或 npm run dev(热重载) -``` - -确认 hermes 监听 `localhost:3010`。 - -### Step 3: 部署 daemon - -```bash -cd ~/local-testkit -``` - -### Step 4: 运行测试 - -```bash -chmod +x run_all_tests.sh -./run_all_tests.sh -``` - -预期结果: -- `test_env.py`: 全部 [OK] -- `test_db_poll.py`: DB 复制、解密、查询通过 -- `test_send_msg.py`: am broadcast 发送成功 -- `test_termux_api.py`: 通知读取成功 -- `test_hermes_api.py`: hermes API 调用成功,收到回复 - -### Step 5: 启动守护进程 - -```bash -# 单次测试 -python3 hermes_wechat_daemon.py --once - -# 正常运行(前台) -python3 hermes_wechat_daemon.py - -# 后台运行 -python3 hermes_wechat_daemon.py --daemon -``` - -### Step 6: 设置开机自启 - -```bash -cp setup_termux_boot.sh ~/.termux/boot/start-hermes.sh -chmod +x ~/.termux/boot/start-hermes.sh -``` - ---- - -## 11. 故障排查 - -### Q: DB 解密失败 -**A**: -1. 确认 key 正确 -2. 确认 WAL 文件已复制:`ls -la /sdcard/hermes/dbs/EnMicroMsg.db*` -3. 微信运行时 DB 被锁定,必须通过 `su -c cp` 复制后再解密 - -### Q: hermes API 调用超时 -**A**: -1. 确认 hermes 已启动:`curl http://localhost:3010/api/health` -2. 检查 hermes 日志是否有 Claude CLI 调用错误 -3. 增加 `hermes.timeout_sec`(Claude 推理可能需要 30-60 秒) - -### Q: hermes 返回空回复 -**A**: -1. 检查 hermes 的 `system/agents/` 目录是否有微信相关 Agent -2. 查看 hermes 日志中的 SSE 事件流 -3. 确认 Claude CLI 进程正常运行(非 mock 模式) - -### Q: am broadcast 发送成功但微信没收到 -**A**: -1. 检查 LSPosed 模块是否启用且作用域包含微信 -2. 检查日志:`su -c "cat /data/adb/lspd/log/modules_*.log | grep HermesWX | tail -20"` -3. 确认微信在主进程运行 - -### Q: termux-notification-list 返回空 -**A**: -1. 确认 Termux:API 应用已安装 -2. 确认已授予通知访问权限 -3. 发送一条测试消息,等待通知出现 - ---- - -## 12. 安全与风险提示 - -1. **微信封号风险**: 自动化操作存在被检测风险,建议使用测试小号 -2. **Root 权限**: 需要 Magisk root -3. **LSPosed 模块**: 仅作用于微信进程 -4. **API Key**: Claude API key 仅在 hermes 中配置,daemon 不持有 - ---- - -## 13. 扩展方向 - -- [ ] 支持群聊 @识别和回复 -- [ ] 支持图片/语音消息处理 -- [ ] 本地 LLM 部署(减少 API 延迟) -- [ ] Web UI 管理界面 -- [ ] 多账号支持 diff --git a/wechat_plain/scripts/local-testkit/NOTIFICATION_STRATEGY.md b/wechat_plain/scripts/local-testkit/NOTIFICATION_STRATEGY.md deleted file mode 100644 index a6e3f50..0000000 --- a/wechat_plain/scripts/local-testkit/NOTIFICATION_STRATEGY.md +++ /dev/null @@ -1,79 +0,0 @@ -# Termux API 通知监听策略 - -## 核心发现 - -Termux API 的 `termux-notification-list` 可以读取系统通知栏,包括微信消息通知。这为消息监听提供了**第二通道**。 - -## 双通道监听架构 - -``` -通道 A: DB 轮询(主推) - ├─ 优点: 完整消息内容、历史消息可追溯 - ├─ 缺点: 10秒延迟、IO开销 - └─ 适用: 所有消息类型 - -通道 B: 通知监听(辅助) - ├─ 优点: 实时性高(通知即时触发)、零IO - ├─ 缺点: 内容可能被截断、锁屏/免打扰时缺失、无群聊完整信息 - └─ 适用: 新消息触发器(收到通知后触发一次DB轮询) -``` - -## 混合策略实现 - -```python -# 在 hermes_wechat_daemon.py 中增加通知轮询线程 - -def poll_notifications(): - """Poll notifications via termux-notification-list.""" - known_keys = set() # (pkgName, title, content) hash - - while True: - ok, stdout, _ = run_termux_api(["termux-notification-list"]) - if ok: - notifs = json.loads(stdout) - wechat_notifs = [n for n in notifs if n.get("pkgName") == "com.tencent.mm"] - - for n in wechat_notifs: - key = hash((n.get("title"), n.get("content"))) - if key not in known_keys: - known_keys.add(key) - # 触发一次紧急DB轮询(不等待常规轮询周期) - trigger_urgent_poll() - - time.sleep(3) # 通知轮询更频繁 -``` - -## Termux:API 权限要求 - -1. **通知访问权限**: 设置 → 应用 → Termux:API → 通知访问 → 允许 -2. **电池优化**: 关闭 Termux 和 Termux:API 的电池优化 -3. **自启动**: 允许 Termux 后台运行 - -## 与现有 LSPosed 模块的配合 - -| 功能 | LSPosed 模块 | Termux API | DB 轮询 | -|------|-------------|-----------|---------| -| 发送消息 | ✅ Broadcast | ❌ | ❌ | -| 接收消息(完整) | ❌(hook未触发) | ⚠️(可能截断) | ✅ | -| 接收触发(实时) | ❌ | ✅ | ❌(10s延迟) | -| 历史消息 | ❌ | ❌ | ✅ | -| 群聊 @识别 | ❌ | ⚠️ | ✅ | - -**最优方案**: 通知作为触发器 → 立即执行 DB 轮询获取完整内容 → LLM 处理 → LSPosed 发送回复 - -## Termux X11 的作用 - -Termux X11 提供 X11 服务器,可在 proot 中运行图形应用(如 Firefox、VS Code)。 - -对微信自动化的**直接帮助有限**: -- 微信是 Android 原生应用,不是 X11 应用 -- 但可以在 X11 中运行图形化调试工具(如 sqlitebrowser 查看解密后的 DB) -- 可运行 Python GUI 脚本进行可视化监控 - -## Termux Boot 的作用 - -确保手机重启后自动恢复全部服务: -1. 启动 frida-server -2. 启动微信 -3. 启动守护进程 -4. 发送启动确认通知 diff --git a/wechat_plain/scripts/local-testkit/README_LOCAL.md b/wechat_plain/scripts/local-testkit/README_LOCAL.md deleted file mode 100644 index 3c27727..0000000 --- a/wechat_plain/scripts/local-testkit/README_LOCAL.md +++ /dev/null @@ -1,208 +0,0 @@ -# WeChat 本地闭环测试套件 — Pixel Termux 版 - -> 目标:在 Pixel 手机本地(Termux / Ubuntu proot)实现微信消息的监控-响应-发送全流程,**不依赖 Mac 远程 adb**。 - ---- - -## 文件清单 - -| 文件 | 用途 | 执行环境 | -|------|------|----------| -| `hermes_wechat_daemon.py` | 主守护进程:DB 轮询 → 新消息检测 → LLM 处理 → 发送回复 | Ubuntu proot | -| `test_env.py` | 环境预检:验证所有依赖(su, sqlcipher, sqlite3, am, frida) | Ubuntu proot | -| `test_db_poll.py` | DB 轮询测试:验证复制+解密+增量读取 | Ubuntu proot | -| `test_send_msg.py` | 发送消息测试:验证 `am broadcast` 本地触发 LSPosed 模块 | Ubuntu proot | -| `test_frida_local.py` | Frida 本地 attach 测试:验证 `get_local_device()` 捕获 key | Ubuntu proot | -| `run_all_tests.sh` | 一键运行全部测试 | Ubuntu proot | -| `test_termux_api.py` | Termux API 测试:通知读取、剪贴板、电量 | Termux / proot | -| `setup_termux_boot.sh` | Termux Boot 启动脚本(开机自启) | Termux | -| `NOTIFICATION_STRATEGY.md` | 通知监听策略说明 | - | -| `config.json` | 运行时配置(DB 路径、密钥、轮询间隔等) | Ubuntu proot | - ---- - -## 快速开始 - -### 1. 部署到 Pixel - -在 Mac 上: -```bash -# 方式 A:adb push 到 Termux home -cd /Users/yibo/Documents/aipcrm/wechat_plain/scripts -adb push local-testkit /sdcard/hermes/local-testkit - -# 然后在 Pixel 的 Termux 中: -cp -r /sdcard/hermes/local-testkit ~/local-testkit -cd ~/local-testkit -``` - -或者方式 B(直接 zip 上传): -```bash -cd /Users/yibo/Documents/aipcrm/wechat_plain/scripts -zip -r local-testkit.zip local-testkit -adb push local-testkit.zip /sdcard/Download/ -# 在 Termux 中 unzip -``` - -### 2. 运行环境预检 - -```bash -cd ~/local-testkit -python3 test_env.py -``` - -预期输出所有 `[OK]`,如果有 `[FAIL]` 按提示修复。 - -### 3. 运行全部测试 - -```bash -chmod +x run_all_tests.sh -./run_all_tests.sh -``` - -### 4. 启动守护进程(测试全部通过后) - -```bash -# 编辑配置 -nano config.json - -# 启动 daemon -python3 hermes_wechat_daemon.py -``` - ---- - -## 架构说明 - -### 消息监控:DB 轮询(主推方案) - -``` -┌─────────────────┐ ┌──────────────┐ ┌──────────────┐ -│ 微信 EnMicroMsg │────→│ su -c cp DB │────→│ sqlcipher 解密│ -│ (SQLCipher) │ │ + WAL + SHM │ │ │ -└─────────────────┘ └──────────────┘ └──────────────┘ - │ - ▼ -┌─────────────────┐ ┌──────────────┐ ┌──────────────┐ -│ LLM 处理回复 │←────│ 新消息检测 │←────│ sqlite3 查询 │ -│ (Kimi/Claude) │ │ (msgId > last)│ │ message 表 │ -└─────────────────┘ └──────────────┘ └──────────────┘ - │ - ▼ -┌─────────────────┐ ┌──────────────┐ ┌──────────────┐ -│ /system/bin/am │────→│ LSPosed 模块 │────→│ NetSceneSendMsg│ -│ broadcast │ │ Broadcast RX │ │ 发送消息 │ -└─────────────────┘ └──────────────┘ └──────────────┘ -``` - -轮询间隔默认 10 秒,可配置。 - -### 消息发送:am broadcast → LSPosed - -已有 LSPosed 模块 `HermesWeChatModule` 在运行,只需本地触发 broadcast: - -```bash -/system/bin/am broadcast \ - -a com.hermes.wechat.SEND_MSG \ - --es toUser "filehelper" \ - --es content "测试消息" -``` - -### Key 更新:Frida 本地 attach - -微信重启后 key 可能变化,daemon 内置 key 失效检测和自动重捕获: - -```python -device = frida.get_local_device() # 不是 get_usb_device() -pid = device.spawn(["com.tencent.mm"]) # 或 attach 已有进程 -``` - ---- - -## 配置说明(config.json) - -```json -{ - "db_src_path": "/data/data/com.tencent.mm/MicroMsg/xxxxxxxx/EnMicroMsg.db", - "db_copy_dir": "/sdcard/hermes/dbs", - "sqlcipher_key": "e03ce81", - "cipher_compatibility": 1, - "cipher_page_size": 1024, - "poll_interval_sec": 10, - "llm_provider": "kimi-coding", - "llm_base_url": "https://api.kimi.com/coding", - "llm_api_key": "sk-...", - "llm_model": "kimi-for-coding", - "auto_reply_enabled": true, - "auto_reply_contacts": ["filehelper"], - "log_level": "INFO" -} -``` - ---- - -## 故障排查 - -### Q: `su` 命令找不到或权限不足? -A: 确保 Magisk 已安装,并在 Magisk Manager → 设置 → SuList 中授予 Termux/proot root 权限。 - -### Q: `am broadcast` 执行成功但微信没收到? -A: 检查 LSPosed 模块是否启用且作用域包含微信。查看日志:`su -c "cat /data/adb/lspd/log/verbose_*.log | grep HermesWX | tail -20"` - -### Q: sqlcipher 解密失败? -A: 确认 key 正确(运行 test_frida_local.py 重新捕获),并检查 WAL 文件是否复制。微信运行时 DB 被锁定,必须通过 `cp` 复制后再解密。 - -### Q: DB 复制成功但 sqlite3 查询为空? -A: 可能 WAL 文件未复制或未及时 checkpoint。检查 `EnMicroMsg.db-wal` 是否存在。 - -### Q: Termux API 命令找不到? -A: 在 Termux 中运行 `pkg install termux-api`,并安装 Termux:API 应用。在 proot 容器内可能需要通过 `/data/data/com.termux/files/usr/bin/termux-*` 完整路径调用。 - ---- - -## Termux API 双通道监听(补充方案) - -除了 DB 轮询,还可以利用 **Termux:API** 的通知读取能力实现实时消息触发: - -```bash -# 读取通知栏 -/data/data/com.termux/files/usr/bin/termux-notification-list -``` - -**混合策略**: -1. `termux-notification-list` 每 3 秒轮询一次 -2. 发现微信新通知 → 立即触发一次 DB 轮询(获取完整内容) -3. LLM 处理 → `am broadcast` 发送回复 - -详见 `NOTIFICATION_STRATEGY.md`。 - ---- - -## 开机自启(Termux:Boot) - -复制启动脚本到 Termux boot 目录: -```bash -cp setup_termux_boot.sh ~/.termux/boot/start-hermes-wechat.sh -chmod +x ~/.termux/boot/start-hermes-wechat.sh -``` - -重启手机后自动:启动 frida-server → 启动微信 → 启动守护进程。 - ---- - -## 测试报告模板 - -运行 `run_all_tests.sh` 后,会在 `test_report.json` 生成结构化报告: - -```json -{ - "timestamp": "2026-05-22T18:00:00", - "env": {"su": true, "sqlcipher": true, "sqlite3": true, "am": true, "frida": true}, - "db_poll": {"last_msg_id": 12345, "new_messages": 3, "ok": true}, - "send_msg": {"sent": true, "method": "am_broadcast"}, - "frida": {"key_captured": true, "key_hex": "65303363653831"}, - "overall": "PASS" -} -``` - -请将此报告内容贴回给 Claude,用于进一步分析和优化。 diff --git a/wechat_plain/scripts/local-testkit/REVERSE_ENGINEERING_LOG.md b/wechat_plain/scripts/local-testkit/REVERSE_ENGINEERING_LOG.md deleted file mode 100644 index 4759b0d..0000000 --- a/wechat_plain/scripts/local-testkit/REVERSE_ENGINEERING_LOG.md +++ /dev/null @@ -1,408 +0,0 @@ -# WeChat 8.0.72 发消息 API 逆向工程记录 - -> 记录时间:2026-05-22 -> 目标:找到 WeChat 8.0.72 在 Android 15 (Pixel 10 Fold) 上发送文本消息的可靠 API -> 工具:LSPosed 模块 + Frida 17.9.10 + adb - ---- - -## 一、已验证的工作流程(接收侧) - -自动回复闭环的接收部分已完全跑通: - -1. Termux API 检测微信通知 -2. `am start -n com.tencent.mm/.ui.LauncherUI` 唤醒微信到前台 -3. `copy_db()` 复制 EnMicroMsg.db + WAL + SHM 到 Termux home -4. `sqlcipher` 解密(key=`e03ce81`, compat=1, page_size=1024) -5. `query_new_messages()` 读取 `msgId > last_msg_id` -6. 调用 hermes-agent SSE API 生成回复 -7. **发送侧:目前阻塞中**(见下方) - ---- - -## 二、LSPosed 模块编译与安装 - -### 2.1 Mac 本地编译环境搭建 - -```bash -# Java -brew install openjdk@17 -export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home -export PATH=$JAVA_HOME/bin:$PATH - -# Android SDK -mkdir -p ~/android-sdk/cmdline-tools -cd ~/android-sdk/cmdline-tools -curl -L "https://dl.google.com/android/repository/commandlinetools-mac-11076708_latest.zip" -o tools.zip -unzip -q tools.zip && mv cmdline-tools latest && rm tools.zip -export ANDROID_HOME=~/android-sdk -export PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$PATH -yes | sdkmanager --licenses -sdkmanager "platforms;android-34" "build-tools;34.0.0" - -# 编译 -cd lsposed-wechat-sendmsg -export ANDROID_HOME=~/android-sdk -./gradlew assembleRelease - -# 签名 -$ANDROID_HOME/build-tools/34.0.0/apksigner sign \ - --ks hermes-debug.keystore --ks-pass pass:hermes123 --key-pass pass:hermes123 \ - --in app/build/outputs/apk/release/app-release-unsigned.apk \ - --out app/build/outputs/apk/release/app-release.apk -``` - -### 2.2 安装到手机 - -```bash -# 注意:/sdcard/ 是 FUSE 挂载,system_server 无权限读取 -# 必须复制到 /data/local/tmp/ 再安装 -adb push app/build/outputs/apk/release/app-release.apk /sdcard/Download/ -adb shell "cp /sdcard/Download/app-release.apk /data/local/tmp/" -adb shell pm install -r /data/local/tmp/app-release.apk - -# LSPosed Manager 中启用模块,作用域选微信,强行停止微信后重新打开 -``` - -### 2.3 查看日志 - -```bash -# LSPosed verbose 日志(最可靠) -adb shell su -c "cat /data/adb/lspd/log/verbose_*.log | grep HermesWX | tail -30" - -# 或 logcat -adb logcat -s LSPosed-Bridge:I | grep HermesWX -``` - ---- - -## 三、ContentProvider 路径(已失败) - -### 3.1 尝试的 URI - -```java -// query 路径 -Uri uri = Uri.parse("content://com.tencent.mm.plugin.ext.message/sendTextMsg" - + "?appid=wx00000000000000&source=openapi"); -Cursor cursor = resolver.query(uri, null, null, selectionArgs, null); -// → cursor == null - -// insert 路径 -Uri uri = Uri.parse("content://com.tencent.mm.plugin.ext.message/sendTextMsg"); -ContentValues values = new ContentValues(); -values.put("talker", toUser); -values.put("content", content); -Uri result = resolver.insert(uri, values); -// → result == null -``` - -### 3.2 已绕过的权限检查 - -LSPosed 已 hook `ExtContentProviderBase`: -- `h()` → return 1(绕过 UID/签名校验) -- `b()` → return true(绕过登录检查) -- `i(Context)` → return true(备用校验) - -Provider 确实收到了请求(日志显示 `Provider query: uri=... args=[filehelper, ...]`),但**内部返回 null**。可能原因: -- appid 不正确 -- Provider 内部有其他业务逻辑校验 -- URI 路径或参数格式错误 - ---- - -## 四、`u01.r1 → u01.q1 → u01.m1` 路径(已验证有效 ✅) - -### 4.1 调用链 - -``` -u01.r1.a(String toUser) → u01.q1 Builder - Builder.e(String content) → 设置文本 - Builder.h(int type) → 设置类型 (0=普通) - Builder.a() → u01.m1 Result - m1.a() → 提交到 NetScene 队列 -``` - -### 4.2 关键发现(LSPosed 模块验证) - -**Frida 测试时失败的真正原因:缺少两个关键字段赋值。** - -Frida 脚本只调用了: -```javascript -builder.e(content) -builder.h(0) -result = builder.a() -result.a() -``` - -但 LSPosed 模块中通过 `XposedHelpers.setIntField` 补充了: -```java -XposedHelpers.setIntField(builder, "f", 0); // 关键! -XposedHelpers.setIntField(builder, "i", 4); // 关键! -``` - -**加上这两个字段后,消息成功发送并出现在聊天记录中。** - -### 4.3 完整有效调用代码 - -```java -Class factoryClass = XposedHelpers.findClass("u01.r1", classLoader); -Object builder = factoryClass.getMethod("a", String.class).invoke(null, toUser); -Class builderClass = builder.getClass(); - -builderClass.getMethod("e", String.class).invoke(builder, content); -builderClass.getMethod("h", int.class).invoke(builder, 0); -XposedHelpers.setIntField(builder, "f", 0); -XposedHelpers.setIntField(builder, "i", 4); - -Object result = builderClass.getMethod("a").invoke(builder); - -// 必须在主线程执行 m1.a() -new Handler(Looper.getMainLooper()).post(() -> { - result.getClass().getMethod("a").invoke(result); -}); -``` - -### 4.4 为什么 Frida 没有检测到 WCDB INSERT - -- `u01.r1` 创建的消息先进入 **NetScene 同步队列**(`u01.g2.b` LinkedList),不是立即写入 DB -- `u01.f2` SyncProcessor 每几秒运行一次,从队列取消息 ID 再写入 `storage.h9` -- Frida 的 hook 窗口(单次调用)捕获不到这个延迟写入 -- LSPosed 模块持续运行,所以能观察到完整的 WCDB INSERT 流程 - ---- - -## 五、手动发送的真实路径(已确认) - -### 5.1 WCDB INSERT 调用栈 - -``` -dalvik.system.VMStack.getThreadStackTrace(Native Method) -java.lang.Thread.getStackTrace(Thread.java:2842) -com.tencent.wcdb.database.SQLiteDatabase.insert(Unknown Source) -q75.a0$$e.a(Unknown Source:34) -q75.a0.y(Unknown Source:38) -com.tencent.mm.storage.h9.ta(Unknown Source:352) -com.tencent.mm.storage.h9.na(Unknown Source:1) -az0.w9.x(Unknown Source:82) -az0.l.l(Unknown Source:266) -com.tencent.mm.plugin.messenger.foundation.a2.b(Unknown Source:660) -com.tencent.mm.plugin.messenger.foundation.a2.c(Unknown Source:9) -com.tencent.mm.plugin.messenger.foundation.a2.a(Unknown Source:551) -com.tencent.mm.plugin.messenger.foundation.z2.a(Unknown Source:32) -com.tencent.mm.plugin.zero.a1.d(Unknown Source:119) -u01.f2.run(Unknown Source:298) -java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:520) -java.util.concurrent.FutureTask.run(FutureTask.java:317) -is5.l.run(Unknown Source:242) -js5.v.run(Unknown Source:8) -java.util.concurrent.ThreadPoolExecutor.runWorker(...) -java.lang.Thread.run(Thread.java:1563) -``` - -### 5.2 路径分析 - -| 层级 | 类/方法 | 作用 | -|------|--------|------| -| 入口 | `u01.f2.run()` | Runnable,在线程池中执行 | -| 调度 | `com.tencent.mm.plugin.zero.a1.d()` | zero 插件层 | -| 中介 | `com.tencent.mm.plugin.messenger.foundation.z2.a()` | messenger foundation | -| 核心 | `com.tencent.mm.plugin.messenger.foundation.a2.a/b/c()` | 消息处理核心 | -| 中间层 | `az0.l.l()` / `az0.w9.x()` | 消息构建/序列化 | -| 存储 | `com.tencent.mm.storage.h9.na()/ta()` | 消息存储写入 | -| DB | `q75.a0$$e.a()` / `q75.a0.y()` | WCDB 封装层 | -| DB | `WCDB.insert(message, isSend=1)` | 最终写入 | - -### 5.3 `u01.f2` 的本质 - -- `u01.f2` 是**长期存在的后台 SyncProcessor**,不是为单条消息创建的 -- `u01.f2` 只有一个字段 `d`,类型是 `u01.g2` -- `u01.g2` 的字段: - - `a = u01.e2 [u01.t]` — 处理器 - - `b = LinkedList` — 消息 ID 队列(如 `128927674_1779461490188`) - - `c = HashMap` — 同步状态映射(如 `{261975436=NetSync[261975436]}`) - - `d = Object` — 锁对象 - - `e = js5.b` — 线程池标签 - - `f = String` — `SyncProcessorThreadTag@0` - -`u01.f2` 每几秒运行一次,从 `LinkedList` 中取出消息 ID,调用 `storage.h9` 写入 DB,然后发送到服务器。 - -### 5.4 `a2` 类方法签名 - -``` -com.tencent.mm.plugin.messenger.foundation.a2: - a(Object, f35.r40, int, byte[], boolean, f35.pu4, ef3.z4) → void - b(com.tencent.mm.modelbase.p0, f35.pu4, ef3.z4) → com.tencent.mm.modelbase.q0 - c(f35.i4, Object, int, f35.pu4, ef3.z4) → void -``` - -`b()` 方法的返回值 `com.tencent.mm.modelbase.q0` 和参数 `com.tencent.mm.modelbase.p0` 表明 `a2` 是 NetScene 框架的封装层。 - -### 5.5 关键洞察 - -真正的发送入口**不是直接创建 `u01.f2`**,而是: -1. 将消息添加到 `u01.g2.b`(LinkedList)队列 -2. 或调用 `u01.e2` 的某个方法 -3. 或直接调用 `a2.a/b/c()` 方法 - -但这些方法的参数涉及大量微信内部类型(`f35.og6`, `f35.i4`, `f35.pu4`, `ef3.z4` 等),通过反射构造几乎不可能。 - ---- - -## 六、Frida 脚本汇总 - -### 6.1 v1 — 基础搜索(`debug_wechat_send.js`) - -- 搜索 sendmsg 相关类 -- Hook `u01/v01/w01` 工厂方法 `a(String)` -- Hook WCDB insert -- Hook ContentProvider -- 提供 `rpc.exports.sendTestMsg()` - -### 6.2 v2 — 多调用链测试(`debug_wechat_send2.js`) - -测试 4 种 builder 调用组合,全部无 WCDB INSERT。 - -### 6.3 v3/v5 — WCDB 调用栈(`debug_wechat_send3.js` / `debug_wechat_send5.js`) - -v3 使用 `Exception.$new()` 获取调用栈(失败,栈为空)。 -v5 使用 `Thread.currentThread().getStackTrace()`(成功,获取到完整 Java 调用栈)。 - -> **教训**:Frida 中获取 Java 调用栈必须用 `Thread.currentThread().getStackTrace()`,不能用 `Exception.$new()`。 - -### 6.4 v4 — 全面 WCDB hook(`debug_wechat_send4.js`) - -Hook 了 `insert`, `insertWithOnConflict`, `replace`, `execSQL`, `rawQuery`, `SQLiteStatement.execute`。 - -### 6.5 v6 — 深度 inspect `u01.g2`(`debug_wechat_send6.js`) - -打印 `u01.f2` 和 `u01.g2` 的所有字段。 - -### 6.6 v7 — `u01.f2` 构造函数(`debug_wechat_send7.js`) - -- `u01.f2 constructor: (u01.g2,)` -- `u01.g2 constructor: (u01.t,)` -- `u01.e2` 方法: - - `a(f35.og6, u01.f)` → void - - `b(f35.og6, u01.u)` → void - -### 6.7 v8 — 追踪创建者(`debug_wechat_send8.js`) - -Hook `u01.f2` 构造函数、`u01.g2` 构造函数、`LinkedList.add`,打印调用栈。 -**问题**:`LinkedList.add` 被频繁调用(各种微信事件),调用栈获取困难。`u01.f2` 只在启动时创建一次。 - -### 6.8 v9 — 全链路对比(`debug_wechat_send9.js`) - -同时 hook `u01.r1.a`, `u01.q1.e`, `u01.q1.a`, `u01.m1.a`, `a2.b`, `a2.c`, WCDB。 -**导致微信崩溃**(hook 太多方法)。 - -### 6.9 v10 — 轻量 WCDB + f2(`debug_wechat_send10.js`) - -只 hook WCDB insert 和 `u01.f2.run`,避免崩溃。 - -### 6.10 v11 — 终极验证(`debug_wechat_send11.js`) - -只 hook `u01.r1.a(String)` + WCDB。 -**关键结论**:手动发送时 `u01.r1.a(String)` 完全没有被调用。 - ---- - -## 七、已知问题与限制 - -1. **Frida hook 导致微信崩溃**:hook 过多方法时,微信进程会崩溃重启。建议最多 hook 2-3 个方法。 -2. **logcat 缓冲区刷新**:LSPosed 日志在 logcat 中会被快速刷新,需及时抓取或使用 `adb logcat -d`。 -3. **FUSE /sdcard 限制**:root/system_server 无法读取 /sdcard/ 上的文件,安装 APK 需先 `cp` 到 `/data/local/tmp/`。 -4. **WAL 复制问题**:复制 WAL 到 Termux home 可解决 sqlcipher 解密问题。 - ---- - -## 八、最终方案:u01.r1 反射路径(已采用 ✅) - -### 方案对比 - -| 方案 | 状态 | 说明 | -|------|------|------| -| u01.r1 反射 | ✅ **已采用** | 通过 LSPosed 模块直接调用,无需微信前台 | -| AccessibilityService | ✅ 已实现备用 | 需要微信前台,作为 fallback | -| ContentProvider | ❌ 已放弃 | WeChat 8.0.72 所有变体均返回 null | -| storage.h9 直接插入 | ❌ 未尝试 | 过于底层,风险高 | - -### 当前架构(2026-05-23 已验证 ✅) - -``` -hermes_wechat_daemon.py - ↓ am broadcast (com.hermes.wechat.SEND_MSG) -LSPosed 模块 (HermesWeChatModule) - ↓ u01.r1.a(toUser) → builder (toUser 用显示名称,如"文件传输助手") - ↓ builder.e(content) + setIntField("f", 0) + setIntField("i", 4) - ↓ builder.a() → m1 - ↓ new Thread(() -> m1.a()).start() // 后台线程提交,忽略 NPE -WeChat NetScene 队列 - ↓ u01.f2 SyncProcessor 定时消费 -WCDB message 表 INSERT - ↓ 网络发送 -``` - -### 关键发现(2026-05-23 最终验证) - -1. **`m1.a()` 必须在后台线程执行**:在主线程调用会立即 NPE。 -2. **NPE 无害**:`u01.m1.a()` 内部 `Objects.requireNonNull` 在发送逻辑完成后抛出,消息已成功入队。 -3. **toUser 用显示名称**:`u01.r1.a("文件传输助手")` 有效。`filehelper`(wxid)无法成功。 -4. **无需 Provider init**:`ExtControlProviderMsg` 的初始化对 `u01.r1` 路径无影响。 - -### 待优化 - -- [x] 文本消息发送(2026-05-23 已验证 ✅) -- [ ] 支持发送图片/语音/文件等多媒体消息 -- [ ] 处理发送失败重试 -- [ ] 支持群聊 @ 某人 -- [ ] 微信版本升级后的兼容性(u01.r1 可能改名) - ---- - -## 九、文件位置 - -| 文件 | 路径 | -|------|------| -| LSPosed 模块源码 | `lsposed-wechat-sendmsg/app/src/main/java/.../HermesWeChatModule.java` | -| Daemon 主脚本 | `local-testkit/hermes_wechat_daemon.py` | -| Frida 脚本 v1-v11 | `local-testkit/debug_wechat_send*.js` | -| 开发计划 | `local-testkit/plan.md` | -| 本记录 | `local-testkit/REVERSE_ENGINEERING_LOG.md` | - ---- - -## 十、环境信息 - -- 设备:Pixel 10 Fold -- Android:15 -- WeChat:8.0.72 -- Termux + Termux:API -- LSPosed (Zygisk) -- Frida 17.9.10 (client + server) -- sqlcipher 3.x - ---- - -## 十一、AccessibilityService 迭代记录 - -### 11.1 初始实现(v1) - -- 实现 `HermesAccessibilityService` 接收 `com.hermes.wechat.SEND_MSG` 广播 -- Flow: 启动微信 → 检测是否已在聊天界面 → 搜索联系人 → 输入消息 → 点击发送 -- 问题:`getRootInActiveWindow()` 在微信未前台时返回桌面 launcher -- 问题:搜索使用 wxid(如 `filehelper`),但微信搜索框需要显示名称(如 `文件助手`) - -### 11.2 显示名称支持(v2) - -- 广播 intent 新增 `toUserDisplayName` 字段 -- `HermesAccessibilityService` 优先使用 `toUserDisplayName` 进行搜索和点击结果匹配 -- `hermes_wechat_daemon.py` 新增 `contact_display_names` 配置映射表 -- `test_send_msg.py` 同步支持 `toUserDisplayName` -- APK 重新编译并安装成功 - -### 11.3 待验证 - -- [ ] 使用 `toUserDisplayName="文件助手"` 搜索并发送消息到 filehelper -- [ ] 确认消息实际出现在聊天界面(需要手动检查微信) -- [ ] 处理搜索无结果时的重试逻辑 -- [ ] 测试非 filehelper 联系人的发送流程 diff --git a/wechat_plain/scripts/local-testkit/config.json b/wechat_plain/scripts/local-testkit/config.json index cf7cd7c..5012b3b 100644 --- a/wechat_plain/scripts/local-testkit/config.json +++ b/wechat_plain/scripts/local-testkit/config.json @@ -14,10 +14,15 @@ "termux_bin_path": "/data/data/com.termux/files/usr/bin/termux-notification-list" }, "hermes": { - "api_url": "http://localhost:8642/v1/chat/completions", + "host": "localhost", + "port": 3010, "timeout_sec": 120, "default_session_prefix": "wx-", - "system_hint": "你是一个微信智能助手。根据收到的消息,给出简短、自然、得体的中文回复。群聊中被@时要友好回应。" + "system_hint": "你是一个微信智能助手。根据收到的消息,给出简短、自然、得体的中文回复。群聊中被@时要友好回应。", + "api_url": "", + "api_key": "", + "provider": "anthropic", + "model": "" }, "auto_reply": { "enabled": true, diff --git a/wechat_plain/scripts/local-testkit/docs/HERMES_WECHAT_ARCHITECTURE.md b/wechat_plain/scripts/local-testkit/docs/HERMES_WECHAT_ARCHITECTURE.md new file mode 100644 index 0000000..5b39c39 --- /dev/null +++ b/wechat_plain/scripts/local-testkit/docs/HERMES_WECHAT_ARCHITECTURE.md @@ -0,0 +1,534 @@ +# Hermes WeChat 本地闭环架构方案 + +> 版本: 2.0 (HTTP API 模式) +> 日期: 2026-05-22 +> 目标设备: Pixel 10 Fold (Android) +> 运行环境: Termux + Ubuntu proot-distro +> 微信版本: 8.0.72 + +--- + +## 1. 目标 + +在 Pixel 手机本地实现微信全自动闭环: + +``` +收到消息 → 读取内容 → HTTP 调用 hermes → 生成回复 → 发送回复 +``` + +**不依赖 Mac 远程 adb**,所有组件运行在 Android 本地。 + +**核心设计原则**: +- **daemon** 是常驻后台进程,只负责数据读写和消息收发 +- **hermes** (lemonPCRM) 是独立的 AI 服务,负责 LLM 推理和回复生成 +- 两者通过 HTTP API (`/api/master/chat`) 通信 + +--- + +## 2. 架构总览 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Pixel 手机本地运行环境 │ +│ │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ Ubuntu proot-distro 容器 │ │ +│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ hermes (lemonPCRM) │ │ │ +│ │ │ ┌──────────────┐ ┌──────────┐ ┌──────────┐ │ │ │ +│ │ │ │ Node.js │ │ Master │ │ Claude │ │ │ │ +│ │ │ │ Express │ │ Session │ │ CLI │ │ │ │ +│ │ │ │ :3010 │ │ Manager │ │ Protocol │ │ │ │ +│ │ │ └──────────────┘ └──────────┘ └──────────┘ │ │ │ +│ │ │ ↑ │ │ │ +│ │ │ │ POST /api/master/chat (SSE) │ │ │ +│ │ │ │ {message, sessionId} │ │ │ +│ │ └─────────┼────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ │ +│ │ ┌─────────┴─────────────────────────────────────────────────────────┐ │ │ +│ │ │ daemon (Python) │ │ │ +│ │ │ ┌──────────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │ │ +│ │ │ │ DB 轮询/ │→ │ 新消息 │→ │ HTTP │→ │ am broadcast │ │ │ │ +│ │ │ │ 通知触发 │ │ 检测 │ │ hermes │ │ 发送回复 │ │ │ │ +│ │ │ └──────────────┘ └──────────┘ └──────────┘ └──────────────┘ │ │ │ +│ │ │ ↑ │ │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ ┌──────────────┐ ┌──────────┴─────┐ │ │ │ +│ │ │ │ sqlcipher │ │ 回复内容 │ │ │ │ +│ │ │ │ 解密读取 │ │ (SSE result) │ │ │ │ +│ │ │ └──────────────┘ └────────────────┘ │ │ │ +│ │ └──────────────────────────────────────────────────────────────────┘ │ │ +│ │ ↑ │ │ +│ │ │ │ │ +│ │ ┌──────────────┐ ┌──────────┴──┐│ +│ │ │ su -c cp DB │ │ LSPosed ││ +│ │ │ + WAL + SHM │ │ WeChatHook ││ +│ │ └──────────────┘ └─────────────┘│ +│ └───────────────────────────────────────────────────────────────────────────┘ │ +│ ↑ │ │ +│ │ ▼ │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ Termux 环境 (与 proot 共享 home) │ │ +│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ 通知监听线程 (辅助通道) │ │ │ +│ │ │ termux-notification-list 每 3 秒轮询 │ │ │ +│ │ │ 发现微信通知 → 触发紧急 DB 轮询 │ │ │ +│ │ └─────────────────────────────────────────────────────────────────┘ │ │ +│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ +│ │ │ Termux:Boot 开机自启 │ │ │ +│ │ │ ~/.termux/boot/start-hermes.sh │ │ │ +│ │ └─────────────────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +│ ↑ │ │ +│ │ ▼ │ +│ ┌───────────────────────────────────────────────────────────────────────┐ │ +│ │ Android 系统层 │ │ +│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ +│ │ │ 微信 APK │ │ SQLCipher DB │ │ 通知栏 │ │ │ +│ │ │ (已 root) │ │ EnMicroMsg │ │ (微信通知) │ │ │ +│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. 数据流详解 + +### 3.1 完整消息处理流程 + +``` +1. 通知触发(或定时轮询) + │ + ▼ +2. daemon 复制 EnMicroMsg.db + WAL + SHM 到 /sdcard/hermes/dbs/ + │ + ▼ +3. daemon 用 sqlcipher 解密 DB + │ + ▼ +4. daemon 查询 message 表:SELECT * WHERE msgId > last_msg_id + │ + ▼ +5. daemon 提取消息内容 + 最近 5 条上下文 + │ + ▼ +6. daemon 构造 prompt,HTTP POST 到 hermes /api/master/chat + │ { + │ "message": "收到消息(来自 xxx):...\n上下文: ...", + │ "sessionId": "wx-xxx" + │ } + │ + ▼ +7. hermes (lemonPCRM) 调用 Claude CLI 处理消息,返回 SSE 流 + │ events: session → progress → agent_output → result + │ + ▼ +8. daemon 解析 SSE,提取 result.output 作为回复文本 + │ + ▼ +9. daemon 执行 am broadcast 发送回复到微信 + │ + ▼ +10. LSPosed 模块拦截广播,调用微信内部 API 发送消息 +``` + +### 3.2 Session 管理 + +每个微信联系人 (`talker`) 对应一个独立的 hermes session: + +| talker | sessionId | 作用 | +|--------|-----------|------| +| `filehelper` | `wx-filehelper` | 文件传输助手 | +| `wxid_abc123` | `wx-wxid_abc123` | 个人联系人 | +| `123456789@chatroom` | `wx-123456789@chatroom` | 群聊 | + +**好处**: +- hermes 自动维护每个对话的上下文记忆 +- 不同联系人的对话互不干扰 +- session 10 分钟空闲后自动清理 + +--- + +## 4. 消息监听双通道 + +### 4.1 通道 A: DB 轮询(主推) + +**原理**: 定期复制微信加密 DB → sqlcipher 解密 → 查询 message 表 → 检测 msgId 变化 + +**流程**: +```python +while True: + su -c "cp /data/data/com.tencent.mm/MicroMsg//EnMicroMsg.db /sdcard/hermes/dbs/" + su -c "cp /data/data/com.tencent.mm/MicroMsg//EnMicroMsg.db-wal /sdcard/hermes/dbs/" + + sqlcipher EnMicroMsg.db "PRAGMA key='e03ce81'; ..." + + SELECT * FROM message WHERE msgId > last_msg_id ORDER BY msgId + UPDATE last_msg_id = max(msgId) + sleep(10) +``` + +**关键参数**: +| 参数 | 值 | 说明 | +|------|-----|------| +| SQLCipher key | `e03ce81` | 7 字符字符串密码 | +| cipher_compatibility | `1` | WCDB 兼容模式 | +| cipher_page_size | `1024` | 页大小 | +| 轮询间隔 | `10s` | 可配置 | +| WAL 文件 | **必须复制** | 否则丢失最新数据 | + +### 4.2 通道 B: 通知监听(辅助) + +**原理**: 通过 Termux:API 的 `termux-notification-list` 读取系统通知栏,发现微信通知后立即触发一次 DB 轮询。 + +**流程**: +```python +while True: + notifs = termux-notification-list + wechat_notifs = [n for n in notifs if n.pkgName == "com.tencent.mm"] + for n in wechat_notifs: + if is_new(n): + trigger_urgent_db_poll() # 不等 10 秒周期 + sleep(3) +``` + +**权限要求**: +1. 安装 Termux:API 应用 +2. 设置 → 应用 → Termux:API → 通知访问权限 → 允许 +3. 关闭 Termux 和 Termux:API 的电池优化 + +### 4.3 双通道协同 + +``` +正常状态: DB 轮询每 10 秒执行一次 + +收到新消息: + 通知栏产生通知 ──→ termux-notification-list 在 3 秒内发现 + │ + ▼ + 触发紧急 DB 轮询(不等 10 秒周期) + │ + ▼ + 获取完整消息内容 ──→ HTTP POST hermes ──→ 发送回复 +``` + +--- + +## 5. Hermes HTTP API 调用 + +### 5.1 API 端点 + +| 端点 | 方法 | 说明 | +|------|------|------| +| `POST /api/master/chat` | SSE | 发送消息,流式返回处理结果 | +| `GET /api/health` | HTTP | 健康检查 | + +### 5.2 请求格式 + +```json +POST /api/master/chat +Content-Type: application/json + +{ + "message": "收到消息(来自 filehelper):你好\n请回复:", + "sessionId": "wx-filehelper" +} +``` + +### 5.3 SSE 响应格式 + +``` +event: session +data: {"sessionId": "wx-filehelper"} + +event: progress +data: {"step": "initializing", "message": "🤖 Master Agent 启动中..."} + +event: agent_output +data: {"agent": "master", "message": "你好!很高兴收到你的消息。"} + +event: result +data: {"sessionId": "wx-filehelper", "type": "text", "output": "你好!很高兴收到你的消息。"} +``` + +daemon 提取 `result` 事件的 `output` 字段作为最终回复。 + +### 5.4 Prompt 构造 + +daemon 发送给 hermes 的消息格式: + +``` +你是一个微信智能助手。根据收到的消息,给出简短、自然、得体的中文回复。 + +最近对话上下文: + 对方: 在吗? + 我: 在的,有什么可以帮你的? + +收到新消息(来自 filehelper):帮我查一下明天的天气 + +请直接给出回复内容,不要加任何解释或格式标记。 +``` + +--- + +## 6. 消息发送 + +### 6.1 发送机制 + +LSPosed 模块 `HermesWeChatModule` 注入微信进程,暴露 BroadcastReceiver: + +```bash +/system/bin/am broadcast \ + -a com.hermes.wechat.SEND_MSG \ + --es toUser "filehelper" \ + --es content "回复内容" +``` + +### 6.2 发送限制 + +| 限制 | 说明 | +|------|------| +| 仅文本消息 | 当前模块只支持文本 | +| 主线程调用 | NetScene 提交必须在主线程 | +| 微信前台 | 建议保持微信在前台或后台运行 | + +--- + +## 7. 开机自启(Termux:Boot) + +### 7.1 启动脚本 + +文件: `~/.termux/boot/start-hermes.sh` + +```bash +#!/bin/bash + +# 启动 frida-server(可选) +if [ -f "/data/local/tmp/frida-server" ]; then + su -c "/data/local/tmp/frida-server &" +fi + +# 启动微信(如果未运行) +if ! su -c "pgrep -x com.tencent.mm" > /dev/null 2>&1; then + su -c "am start -n com.tencent.mm/.ui.LauncherUI" + sleep 5 +fi + +# 启动 hermes (lemonPCRM) — 在 proot 内 +export PATH=/data/data/com.termux/files/usr/bin:$PATH +proot-distro login --shared-home ubuntu -- bash -c \ + "cd /root/aipcrm && npm start &" + +sleep 5 + +# 启动守护进程 +proot-distro login --shared-home ubuntu -- bash -c \ + "cd /root/local-testkit && python3 hermes_wechat_daemon.py --daemon" + +# 发送启动通知 +termux-notification \ + --title "Hermes WeChat" \ + --content "守护进程已启动" \ + --priority low +``` + +### 7.2 安装步骤 + +```bash +mkdir -p ~/.termux/boot +cp setup_termux_boot.sh ~/.termux/boot/start-hermes.sh +chmod +x ~/.termux/boot/start-hermes.sh +``` + +--- + +## 8. 目录结构 + +``` +~/local-testkit/ # daemon 目录 +├── hermes_wechat_daemon.py # 主守护进程 +├── config.json # 运行时配置 +├── test_env.py # 环境预检 +├── test_db_poll.py # DB 轮询测试 +├── test_send_msg.py # 发送消息测试 +├── test_termux_api.py # Termux API 测试 +├── test_hermes_api.py # hermes HTTP API 测试 +├── run_all_tests.sh # 一键测试 +├── setup_termux_boot.sh # 开机自启脚本 +└── HERMES_WECHAT_ARCHITECTURE.md # 本方案文档 + +~/aipcrm/ # hermes (lemonPCRM) 目录 +├── server/ # Express 后端 +├── packages/ # ACP 协议 + Agent 运行时 +├── package.json +└── ... + +/sdcard/hermes/ # 运行时数据 +├── dbs/ # DB 副本 +│ ├── EnMicroMsg.db +│ ├── EnMicroMsg.db-wal +│ └── EnMicroMsg.db.decrypted.db +├── wechat_msgs.jsonl # 消息日志 +└── daemon.log # 守护进程日志 +``` + +--- + +## 9. 配置说明 + +`config.json`: + +```json +{ + "db_src_path": "", + "db_copy_dir": "/sdcard/hermes/dbs", + "sqlcipher_key": "e03ce81", + "cipher_compatibility": 1, + "cipher_page_size": 1024, + "poll_interval_sec": 10, + "max_msgs_per_poll": 50, + "hermes": { + "api_url": "http://localhost:3010/api/master/chat", + "timeout_sec": 120, + "default_session_prefix": "wx-", + "system_hint": "你是一个微信智能助手。根据收到的消息,给出简短、自然、得体的中文回复。" + }, + "auto_reply": { + "enabled": false, + "contacts": ["filehelper"], + "exclude_types": [10000], + "cooldown_sec": 5 + }, + "send": { + "method": "am_broadcast", + "broadcast_action": "com.hermes.wechat.SEND_MSG", + "am_path": "/system/bin/am" + }, + "logging": { + "level": "INFO", + "file": "/sdcard/hermes/daemon.log" + } +} +``` + +**注意**:daemon 不配置 LLM API key,所有 LLM 配置在 hermes (lemonPCRM) 中。 + +--- + +## 10. 部署步骤 + +### Step 1: 环境准备 + +```bash +# Pixel Termux 中 +pkg update +pkg install python sqlcipher termux-api + +# 安装 Termux:Boot 和 Termux:API 应用(F-Droid 或 GitHub) +``` + +### Step 2: 部署 hermes (lemonPCRM) + +```bash +# 在 proot Ubuntu 中 +cd ~/aipcrm +npm install +# 配置 .env 中的 Claude API key +npm start # 或 npm run dev(热重载) +``` + +确认 hermes 监听 `localhost:3010`。 + +### Step 3: 部署 daemon + +```bash +cd ~/local-testkit +``` + +### Step 4: 运行测试 + +```bash +chmod +x run_all_tests.sh +./run_all_tests.sh +``` + +预期结果: +- `test_env.py`: 全部 [OK] +- `test_db_poll.py`: DB 复制、解密、查询通过 +- `test_send_msg.py`: am broadcast 发送成功 +- `test_termux_api.py`: 通知读取成功 +- `test_hermes_api.py`: hermes API 调用成功,收到回复 + +### Step 5: 启动守护进程 + +```bash +# 单次测试 +python3 hermes_wechat_daemon.py --once + +# 正常运行(前台) +python3 hermes_wechat_daemon.py + +# 后台运行 +python3 hermes_wechat_daemon.py --daemon +``` + +### Step 6: 设置开机自启 + +```bash +cp setup_termux_boot.sh ~/.termux/boot/start-hermes.sh +chmod +x ~/.termux/boot/start-hermes.sh +``` + +--- + +## 11. 故障排查 + +### Q: DB 解密失败 +**A**: +1. 确认 key 正确 +2. 确认 WAL 文件已复制:`ls -la /sdcard/hermes/dbs/EnMicroMsg.db*` +3. 微信运行时 DB 被锁定,必须通过 `su -c cp` 复制后再解密 + +### Q: hermes API 调用超时 +**A**: +1. 确认 hermes 已启动:`curl http://localhost:3010/api/health` +2. 检查 hermes 日志是否有 Claude CLI 调用错误 +3. 增加 `hermes.timeout_sec`(Claude 推理可能需要 30-60 秒) + +### Q: hermes 返回空回复 +**A**: +1. 检查 hermes 的 `system/agents/` 目录是否有微信相关 Agent +2. 查看 hermes 日志中的 SSE 事件流 +3. 确认 Claude CLI 进程正常运行(非 mock 模式) + +### Q: am broadcast 发送成功但微信没收到 +**A**: +1. 检查 LSPosed 模块是否启用且作用域包含微信 +2. 检查日志:`su -c "cat /data/adb/lspd/log/modules_*.log | grep HermesWX | tail -20"` +3. 确认微信在主进程运行 + +### Q: termux-notification-list 返回空 +**A**: +1. 确认 Termux:API 应用已安装 +2. 确认已授予通知访问权限 +3. 发送一条测试消息,等待通知出现 + +--- + +## 12. 安全与风险提示 + +1. **微信封号风险**: 自动化操作存在被检测风险,建议使用测试小号 +2. **Root 权限**: 需要 Magisk root +3. **LSPosed 模块**: 仅作用于微信进程 +4. **API Key**: Claude API key 仅在 hermes 中配置,daemon 不持有 + +--- + +## 13. 扩展方向 + +- [ ] 支持群聊 @识别和回复 +- [ ] 支持图片/语音消息处理 +- [ ] 本地 LLM 部署(减少 API 延迟) +- [ ] Web UI 管理界面 +- [ ] 多账号支持 diff --git a/wechat_plain/scripts/local-testkit/docs/NOTIFICATION_STRATEGY.md b/wechat_plain/scripts/local-testkit/docs/NOTIFICATION_STRATEGY.md new file mode 100644 index 0000000..a6e3f50 --- /dev/null +++ b/wechat_plain/scripts/local-testkit/docs/NOTIFICATION_STRATEGY.md @@ -0,0 +1,79 @@ +# Termux API 通知监听策略 + +## 核心发现 + +Termux API 的 `termux-notification-list` 可以读取系统通知栏,包括微信消息通知。这为消息监听提供了**第二通道**。 + +## 双通道监听架构 + +``` +通道 A: DB 轮询(主推) + ├─ 优点: 完整消息内容、历史消息可追溯 + ├─ 缺点: 10秒延迟、IO开销 + └─ 适用: 所有消息类型 + +通道 B: 通知监听(辅助) + ├─ 优点: 实时性高(通知即时触发)、零IO + ├─ 缺点: 内容可能被截断、锁屏/免打扰时缺失、无群聊完整信息 + └─ 适用: 新消息触发器(收到通知后触发一次DB轮询) +``` + +## 混合策略实现 + +```python +# 在 hermes_wechat_daemon.py 中增加通知轮询线程 + +def poll_notifications(): + """Poll notifications via termux-notification-list.""" + known_keys = set() # (pkgName, title, content) hash + + while True: + ok, stdout, _ = run_termux_api(["termux-notification-list"]) + if ok: + notifs = json.loads(stdout) + wechat_notifs = [n for n in notifs if n.get("pkgName") == "com.tencent.mm"] + + for n in wechat_notifs: + key = hash((n.get("title"), n.get("content"))) + if key not in known_keys: + known_keys.add(key) + # 触发一次紧急DB轮询(不等待常规轮询周期) + trigger_urgent_poll() + + time.sleep(3) # 通知轮询更频繁 +``` + +## Termux:API 权限要求 + +1. **通知访问权限**: 设置 → 应用 → Termux:API → 通知访问 → 允许 +2. **电池优化**: 关闭 Termux 和 Termux:API 的电池优化 +3. **自启动**: 允许 Termux 后台运行 + +## 与现有 LSPosed 模块的配合 + +| 功能 | LSPosed 模块 | Termux API | DB 轮询 | +|------|-------------|-----------|---------| +| 发送消息 | ✅ Broadcast | ❌ | ❌ | +| 接收消息(完整) | ❌(hook未触发) | ⚠️(可能截断) | ✅ | +| 接收触发(实时) | ❌ | ✅ | ❌(10s延迟) | +| 历史消息 | ❌ | ❌ | ✅ | +| 群聊 @识别 | ❌ | ⚠️ | ✅ | + +**最优方案**: 通知作为触发器 → 立即执行 DB 轮询获取完整内容 → LLM 处理 → LSPosed 发送回复 + +## Termux X11 的作用 + +Termux X11 提供 X11 服务器,可在 proot 中运行图形应用(如 Firefox、VS Code)。 + +对微信自动化的**直接帮助有限**: +- 微信是 Android 原生应用,不是 X11 应用 +- 但可以在 X11 中运行图形化调试工具(如 sqlitebrowser 查看解密后的 DB) +- 可运行 Python GUI 脚本进行可视化监控 + +## Termux Boot 的作用 + +确保手机重启后自动恢复全部服务: +1. 启动 frida-server +2. 启动微信 +3. 启动守护进程 +4. 发送启动确认通知 diff --git a/wechat_plain/scripts/local-testkit/docs/README.md b/wechat_plain/scripts/local-testkit/docs/README.md new file mode 100644 index 0000000..1e1924b --- /dev/null +++ b/wechat_plain/scripts/local-testkit/docs/README.md @@ -0,0 +1,269 @@ +# Pixel WeChat Auto-Reply — Plugin for Hermes + +> 在 Pixel 手机本地实现微信全自动闭环,**通过 hermes API 生成回复**,息屏时通过通知监听兜底。 +> +> 运行环境:Pixel 10 Fold / Android 15 / WeChat 8.0.72 + +--- + +## 架构(双轨制) + +### 主流程:Node.js Plugin(推荐) + +``` +LSPosed 模块 hook WCDB insert + │ + ▼ +┌─────────────────────────┐ +│ XposedBridge.log() │────→ /data/adb/lspd/log/verbose_*.log +│ writeMsgToFile() │────→ /sdcard/hermes/wechat_msgs.jsonl +└─────────────────────────┘ + │ + ▼ +┌─────────────────────────┐ ┌─────────────────────────┐ +│ AndroidWechatTracker │────→│ tail -f 日志 │ +│ (Node.js EventEmitter) │ │ 实时事件驱动 │ +└─────────────────────────┘ └─────────────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ auto-reply.js │ +│ handleMessage() │ +│ - 判断单聊/群聊@/引用 │ +│ - 检查 cooldown │ +└─────────────────────────┘ + │ + ▼ +┌─────────────────────────┐ ┌─────────────────────────┐ +│ callHermesApi() │────→│ POST localhost:3010 │ +│ buildHermesPrompt() │ │ /api/master/chat │ +│ 构造 prompt + session │ │ SSE 流式响应 │ +└─────────────────────────┘ └─────────────────────────┘ + │ │ + │ ▼ + │ ┌─────────────────────────┐ + │ │ hermes (lemonPCRM) │ + │ │ MasterSession │ + │ │ → Claude CLI / Agents │ + │ └─────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────┐ ┌─────────────────────────┐ +│ parseSSE() 提取 output │────→│ 回复文本 │ +└─────────────────────────┘ └─────────────────────────┘ + │ + ▼ +┌─────────────────────────┐ ┌─────────────────────────┐ +│ 微信在前台? │ No →│ am start 唤醒微信 │ +└─────────────────────────┘ │ 等待 2s │ + │Yes └─────────────────────────┘ + ▼ +┌─────────────────────────┐ +│ sendTextMessageBroadcast │────→ am broadcast +│ (android-wechat-sender) │ action: com.hermes.wechat.SEND_MSG +└─────────────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ LSPosed 模块 │────→ u01.r1.a() → builder → m1.a() +│ HermesWeChatModule │ setIntField f=0, i=4 +└─────────────────────────┘ +``` + +**优势**: +- 事件驱动(tail -f),比 DB 轮询实时性高 +- 不需要 sqlcipher 解密,不需要复制 WAL +- 通过 hermes API 享受完整的 Agent 编排、工具调用、记忆能力 +- hermes 不可用时自动 fallback 到直接 LLM API + +### 备用流程:Python Daemon + +`hermes_wechat_daemon.py` 作为备用通道,当 Node.js plugin 不可用时运行: +- DB 轮询检测 +- 优先调用 hermes API,fallback 到直接 LLM API +- 同样支持通知监听兜底 + +### 息屏兜底:通知监听 + Termux 保活 + +``` +息屏后微信/Termux 被冻结 + │ + ▼ +微信消息到来 → 系统通知栏产生通知 + │ + ▼ +┌─────────────────────────┐ +│ termux-notification-list │────→ 每 3 秒轮询(独立线程) +│ (Node.js plugin 内) │ +└─────────────────────────┘ + │ + ▼ +检测到微信通知 + │ + ▼ +isWechatForeground()? No → wakeWechat() → sleep 3s + │ + ▼ +微信唤醒 → LSPosed 恢复写日志 → tail -f 捕获 → 正常处理 +``` + +**保活措施**: +1. `termux-wake-lock` — 防止息屏后 CPU 被冻结 +2. 关闭电池优化(Termux / Termux:API / 微信) +3. `keepalive.sh` 进程守护 — hermes / auto-reply 死了自动重启 + +--- + +## 文件清单 + +| 文件 | 用途 | 环境 | +|------|------|------| +| `../../server/services/wechat/auto-reply.js` | **主 plugin**:tracker → hermes API → sender | proot Ubuntu | +| `../../server/services/wechat/android-wechat-tracker.js` | 手机端消息跟踪(tail -f LSPosed 日志) | proot Ubuntu | +| `../../server/services/wechat/android-wechat-sender.js` | 手机端消息发送(am broadcast) | proot Ubuntu | +| `hermes_wechat_daemon.py` | **备用 daemon**:DB 轮询 → hermes/LLM API → sender | Termux / proot | +| `config.json` | 运行时配置 | Termux / proot | +| `keepalive.sh` | **保活脚本**:wake-lock + 进程守护 | Termux | +| `test_env.py` | 环境预检 | Termux / proot | +| `test_send_msg.py` | 发送消息测试 | Termux / proot | +| `run_all_tests.sh` | 一键运行全部测试 | Termux / proot | + +--- + +## 快速开始 + +### 1. 前置设置(必须手动) + +Android 设置中关闭电池优化: +- 设置 → 应用 → Termux → 电池优化 → **不优化** +- 设置 → 应用 → Termux:API → 电池优化 → **不优化** +- 设置 → 应用 → 微信 → 电池优化 → **不优化** +- 设置 → 应用 → Termux → 允许自启动 / 后台运行 + +Termux:API 权限: +- 安装 Termux:API 应用 +- 设置 → 应用 → Termux:API → 通知访问权限 → **允许** + +### 2. 部署 + +```bash +# Mac 上推送 +adb push server/services/wechat /data/data/com.termux/files/home/hermes-wechat +adb push wechat_plain/scripts/local-testkit /data/data/com.termux/files/home/local-testkit + +# Pixel 上 +cp -r /sdcard/hermes/local-testkit ~/local-testkit +cd ~/local-testkit +``` + +### 3. 启动 hermes server(如果还没运行) + +```bash +cd ~/hermes-wechat +node server/index.js +``` + +### 4. 启动 auto-reply plugin(推荐主流程) + +```bash +cd ~/hermes-wechat/server/services/wechat +export MY_WXID=wxid_xxxxxx +export MY_NICKNAME=你的昵称 +export HERMES_HOST=localhost +export HERMES_PORT=3010 + +nohup node auto-reply.js > auto-reply.log 2>&1 & +``` + +### 5. 启动保活脚本(推荐) + +```bash +cd ~/local-testkit +export MY_WXID=wxid_xxxxxx +export MY_NICKNAME=你的昵称 +export HERMES_DIR=/data/data/com.termux/files/home/hermes-wechat + +chmod +x keepalive.sh +nohup ./keepalive.sh > keepalive.log 2>&1 & +``` + +### 6. 备用:启动 Python daemon + +```bash +cd ~/local-testkit +python3 hermes_wechat_daemon.py --daemon +``` + +--- + +## 配置说明 + +### auto-reply.js 环境变量 + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `MY_WXID` | — | 你的微信 wxid(必填) | +| `MY_NICKNAME` | — | 你的微信昵称(必填,用于判断群聊@) | +| `HERMES_HOST` | `localhost` | hermes server 地址 | +| `HERMES_PORT` | `3010` | hermes server 端口 | +| `NOTIFICATION_ENABLED` | `true` | 是否启用通知监听兜底 | + +### Python daemon config.json + +```json +{ + "hermes": { + "host": "localhost", + "port": 3010, + "timeout_sec": 120, + "default_session_prefix": "wx-", + "system_hint": "你是一个微信智能助手...", + "api_url": "", + "api_key": "", + "provider": "anthropic", + "model": "" + } +} +``` + +- `host`/`port`:本地 hermes API 地址 +- `api_url`/`api_key`/`provider`/`model`:hermes 不可用时 fallback 到直接 LLM API 的配置 + +--- + +## 故障排查 + +| 问题 | 排查步骤 | +|------|---------| +| auto-reply 收不到消息 | 检查 LSPosed 模块日志:`su -c "cat /data/adb/lspd/log/verbose_*.log \| grep HermesWX \| tail -20"` | +| hermes API 返回空 | 检查 hermes server 是否运行:`curl http://localhost:3010/api/health` | +| 息屏后完全失效 | 确认电池优化已关闭;检查 `keepalive.sh` 是否运行 | +| 发送失败 | 检查微信是否在前台;检查 LSPosed 模块是否启用 | +| Python daemon 查询不到新消息 | 检查微信是否在前台;检查 `sqlcipher_key`;确认 WAL 已复制 | + +--- + +## 目录结构 + +``` +local-testkit/ +├── hermes_wechat_daemon.py # 备用守护进程(DB 轮询) +├── config.json # 运行时配置 +├── keepalive.sh # 保活脚本 +├── test_env.py # 环境预检 +├── test_send_msg.py # 发送消息测试 +├── run_all_tests.sh # 一键测试 +└── docs/ + ├── README.md # 本文档 + ├── HERMES_WECHAT_ARCHITECTURE.md + ├── REVERSE_ENGINEERING_LOG.md + ├── NOTIFICATION_STRATEGY.md + └── plan.md + +server/services/wechat/ # 主 plugin(Node.js) +├── auto-reply.js # 主入口:tracker → hermes → sender +├── android-wechat-tracker.js # 消息跟踪 +├── android-wechat-sender.js # 消息发送 +├── index.js # 统一入口 +└── README.md # plugin 文档 +``` diff --git a/wechat_plain/scripts/local-testkit/docs/README_LOCAL.md b/wechat_plain/scripts/local-testkit/docs/README_LOCAL.md new file mode 100644 index 0000000..3c27727 --- /dev/null +++ b/wechat_plain/scripts/local-testkit/docs/README_LOCAL.md @@ -0,0 +1,208 @@ +# WeChat 本地闭环测试套件 — Pixel Termux 版 + +> 目标:在 Pixel 手机本地(Termux / Ubuntu proot)实现微信消息的监控-响应-发送全流程,**不依赖 Mac 远程 adb**。 + +--- + +## 文件清单 + +| 文件 | 用途 | 执行环境 | +|------|------|----------| +| `hermes_wechat_daemon.py` | 主守护进程:DB 轮询 → 新消息检测 → LLM 处理 → 发送回复 | Ubuntu proot | +| `test_env.py` | 环境预检:验证所有依赖(su, sqlcipher, sqlite3, am, frida) | Ubuntu proot | +| `test_db_poll.py` | DB 轮询测试:验证复制+解密+增量读取 | Ubuntu proot | +| `test_send_msg.py` | 发送消息测试:验证 `am broadcast` 本地触发 LSPosed 模块 | Ubuntu proot | +| `test_frida_local.py` | Frida 本地 attach 测试:验证 `get_local_device()` 捕获 key | Ubuntu proot | +| `run_all_tests.sh` | 一键运行全部测试 | Ubuntu proot | +| `test_termux_api.py` | Termux API 测试:通知读取、剪贴板、电量 | Termux / proot | +| `setup_termux_boot.sh` | Termux Boot 启动脚本(开机自启) | Termux | +| `NOTIFICATION_STRATEGY.md` | 通知监听策略说明 | - | +| `config.json` | 运行时配置(DB 路径、密钥、轮询间隔等) | Ubuntu proot | + +--- + +## 快速开始 + +### 1. 部署到 Pixel + +在 Mac 上: +```bash +# 方式 A:adb push 到 Termux home +cd /Users/yibo/Documents/aipcrm/wechat_plain/scripts +adb push local-testkit /sdcard/hermes/local-testkit + +# 然后在 Pixel 的 Termux 中: +cp -r /sdcard/hermes/local-testkit ~/local-testkit +cd ~/local-testkit +``` + +或者方式 B(直接 zip 上传): +```bash +cd /Users/yibo/Documents/aipcrm/wechat_plain/scripts +zip -r local-testkit.zip local-testkit +adb push local-testkit.zip /sdcard/Download/ +# 在 Termux 中 unzip +``` + +### 2. 运行环境预检 + +```bash +cd ~/local-testkit +python3 test_env.py +``` + +预期输出所有 `[OK]`,如果有 `[FAIL]` 按提示修复。 + +### 3. 运行全部测试 + +```bash +chmod +x run_all_tests.sh +./run_all_tests.sh +``` + +### 4. 启动守护进程(测试全部通过后) + +```bash +# 编辑配置 +nano config.json + +# 启动 daemon +python3 hermes_wechat_daemon.py +``` + +--- + +## 架构说明 + +### 消息监控:DB 轮询(主推方案) + +``` +┌─────────────────┐ ┌──────────────┐ ┌──────────────┐ +│ 微信 EnMicroMsg │────→│ su -c cp DB │────→│ sqlcipher 解密│ +│ (SQLCipher) │ │ + WAL + SHM │ │ │ +└─────────────────┘ └──────────────┘ └──────────────┘ + │ + ▼ +┌─────────────────┐ ┌──────────────┐ ┌──────────────┐ +│ LLM 处理回复 │←────│ 新消息检测 │←────│ sqlite3 查询 │ +│ (Kimi/Claude) │ │ (msgId > last)│ │ message 表 │ +└─────────────────┘ └──────────────┘ └──────────────┘ + │ + ▼ +┌─────────────────┐ ┌──────────────┐ ┌──────────────┐ +│ /system/bin/am │────→│ LSPosed 模块 │────→│ NetSceneSendMsg│ +│ broadcast │ │ Broadcast RX │ │ 发送消息 │ +└─────────────────┘ └──────────────┘ └──────────────┘ +``` + +轮询间隔默认 10 秒,可配置。 + +### 消息发送:am broadcast → LSPosed + +已有 LSPosed 模块 `HermesWeChatModule` 在运行,只需本地触发 broadcast: + +```bash +/system/bin/am broadcast \ + -a com.hermes.wechat.SEND_MSG \ + --es toUser "filehelper" \ + --es content "测试消息" +``` + +### Key 更新:Frida 本地 attach + +微信重启后 key 可能变化,daemon 内置 key 失效检测和自动重捕获: + +```python +device = frida.get_local_device() # 不是 get_usb_device() +pid = device.spawn(["com.tencent.mm"]) # 或 attach 已有进程 +``` + +--- + +## 配置说明(config.json) + +```json +{ + "db_src_path": "/data/data/com.tencent.mm/MicroMsg/xxxxxxxx/EnMicroMsg.db", + "db_copy_dir": "/sdcard/hermes/dbs", + "sqlcipher_key": "e03ce81", + "cipher_compatibility": 1, + "cipher_page_size": 1024, + "poll_interval_sec": 10, + "llm_provider": "kimi-coding", + "llm_base_url": "https://api.kimi.com/coding", + "llm_api_key": "sk-...", + "llm_model": "kimi-for-coding", + "auto_reply_enabled": true, + "auto_reply_contacts": ["filehelper"], + "log_level": "INFO" +} +``` + +--- + +## 故障排查 + +### Q: `su` 命令找不到或权限不足? +A: 确保 Magisk 已安装,并在 Magisk Manager → 设置 → SuList 中授予 Termux/proot root 权限。 + +### Q: `am broadcast` 执行成功但微信没收到? +A: 检查 LSPosed 模块是否启用且作用域包含微信。查看日志:`su -c "cat /data/adb/lspd/log/verbose_*.log | grep HermesWX | tail -20"` + +### Q: sqlcipher 解密失败? +A: 确认 key 正确(运行 test_frida_local.py 重新捕获),并检查 WAL 文件是否复制。微信运行时 DB 被锁定,必须通过 `cp` 复制后再解密。 + +### Q: DB 复制成功但 sqlite3 查询为空? +A: 可能 WAL 文件未复制或未及时 checkpoint。检查 `EnMicroMsg.db-wal` 是否存在。 + +### Q: Termux API 命令找不到? +A: 在 Termux 中运行 `pkg install termux-api`,并安装 Termux:API 应用。在 proot 容器内可能需要通过 `/data/data/com.termux/files/usr/bin/termux-*` 完整路径调用。 + +--- + +## Termux API 双通道监听(补充方案) + +除了 DB 轮询,还可以利用 **Termux:API** 的通知读取能力实现实时消息触发: + +```bash +# 读取通知栏 +/data/data/com.termux/files/usr/bin/termux-notification-list +``` + +**混合策略**: +1. `termux-notification-list` 每 3 秒轮询一次 +2. 发现微信新通知 → 立即触发一次 DB 轮询(获取完整内容) +3. LLM 处理 → `am broadcast` 发送回复 + +详见 `NOTIFICATION_STRATEGY.md`。 + +--- + +## 开机自启(Termux:Boot) + +复制启动脚本到 Termux boot 目录: +```bash +cp setup_termux_boot.sh ~/.termux/boot/start-hermes-wechat.sh +chmod +x ~/.termux/boot/start-hermes-wechat.sh +``` + +重启手机后自动:启动 frida-server → 启动微信 → 启动守护进程。 + +--- + +## 测试报告模板 + +运行 `run_all_tests.sh` 后,会在 `test_report.json` 生成结构化报告: + +```json +{ + "timestamp": "2026-05-22T18:00:00", + "env": {"su": true, "sqlcipher": true, "sqlite3": true, "am": true, "frida": true}, + "db_poll": {"last_msg_id": 12345, "new_messages": 3, "ok": true}, + "send_msg": {"sent": true, "method": "am_broadcast"}, + "frida": {"key_captured": true, "key_hex": "65303363653831"}, + "overall": "PASS" +} +``` + +请将此报告内容贴回给 Claude,用于进一步分析和优化。 diff --git a/wechat_plain/scripts/local-testkit/docs/REVERSE_ENGINEERING_LOG.md b/wechat_plain/scripts/local-testkit/docs/REVERSE_ENGINEERING_LOG.md new file mode 100644 index 0000000..4759b0d --- /dev/null +++ b/wechat_plain/scripts/local-testkit/docs/REVERSE_ENGINEERING_LOG.md @@ -0,0 +1,408 @@ +# WeChat 8.0.72 发消息 API 逆向工程记录 + +> 记录时间:2026-05-22 +> 目标:找到 WeChat 8.0.72 在 Android 15 (Pixel 10 Fold) 上发送文本消息的可靠 API +> 工具:LSPosed 模块 + Frida 17.9.10 + adb + +--- + +## 一、已验证的工作流程(接收侧) + +自动回复闭环的接收部分已完全跑通: + +1. Termux API 检测微信通知 +2. `am start -n com.tencent.mm/.ui.LauncherUI` 唤醒微信到前台 +3. `copy_db()` 复制 EnMicroMsg.db + WAL + SHM 到 Termux home +4. `sqlcipher` 解密(key=`e03ce81`, compat=1, page_size=1024) +5. `query_new_messages()` 读取 `msgId > last_msg_id` +6. 调用 hermes-agent SSE API 生成回复 +7. **发送侧:目前阻塞中**(见下方) + +--- + +## 二、LSPosed 模块编译与安装 + +### 2.1 Mac 本地编译环境搭建 + +```bash +# Java +brew install openjdk@17 +export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home +export PATH=$JAVA_HOME/bin:$PATH + +# Android SDK +mkdir -p ~/android-sdk/cmdline-tools +cd ~/android-sdk/cmdline-tools +curl -L "https://dl.google.com/android/repository/commandlinetools-mac-11076708_latest.zip" -o tools.zip +unzip -q tools.zip && mv cmdline-tools latest && rm tools.zip +export ANDROID_HOME=~/android-sdk +export PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$PATH +yes | sdkmanager --licenses +sdkmanager "platforms;android-34" "build-tools;34.0.0" + +# 编译 +cd lsposed-wechat-sendmsg +export ANDROID_HOME=~/android-sdk +./gradlew assembleRelease + +# 签名 +$ANDROID_HOME/build-tools/34.0.0/apksigner sign \ + --ks hermes-debug.keystore --ks-pass pass:hermes123 --key-pass pass:hermes123 \ + --in app/build/outputs/apk/release/app-release-unsigned.apk \ + --out app/build/outputs/apk/release/app-release.apk +``` + +### 2.2 安装到手机 + +```bash +# 注意:/sdcard/ 是 FUSE 挂载,system_server 无权限读取 +# 必须复制到 /data/local/tmp/ 再安装 +adb push app/build/outputs/apk/release/app-release.apk /sdcard/Download/ +adb shell "cp /sdcard/Download/app-release.apk /data/local/tmp/" +adb shell pm install -r /data/local/tmp/app-release.apk + +# LSPosed Manager 中启用模块,作用域选微信,强行停止微信后重新打开 +``` + +### 2.3 查看日志 + +```bash +# LSPosed verbose 日志(最可靠) +adb shell su -c "cat /data/adb/lspd/log/verbose_*.log | grep HermesWX | tail -30" + +# 或 logcat +adb logcat -s LSPosed-Bridge:I | grep HermesWX +``` + +--- + +## 三、ContentProvider 路径(已失败) + +### 3.1 尝试的 URI + +```java +// query 路径 +Uri uri = Uri.parse("content://com.tencent.mm.plugin.ext.message/sendTextMsg" + + "?appid=wx00000000000000&source=openapi"); +Cursor cursor = resolver.query(uri, null, null, selectionArgs, null); +// → cursor == null + +// insert 路径 +Uri uri = Uri.parse("content://com.tencent.mm.plugin.ext.message/sendTextMsg"); +ContentValues values = new ContentValues(); +values.put("talker", toUser); +values.put("content", content); +Uri result = resolver.insert(uri, values); +// → result == null +``` + +### 3.2 已绕过的权限检查 + +LSPosed 已 hook `ExtContentProviderBase`: +- `h()` → return 1(绕过 UID/签名校验) +- `b()` → return true(绕过登录检查) +- `i(Context)` → return true(备用校验) + +Provider 确实收到了请求(日志显示 `Provider query: uri=... args=[filehelper, ...]`),但**内部返回 null**。可能原因: +- appid 不正确 +- Provider 内部有其他业务逻辑校验 +- URI 路径或参数格式错误 + +--- + +## 四、`u01.r1 → u01.q1 → u01.m1` 路径(已验证有效 ✅) + +### 4.1 调用链 + +``` +u01.r1.a(String toUser) → u01.q1 Builder + Builder.e(String content) → 设置文本 + Builder.h(int type) → 设置类型 (0=普通) + Builder.a() → u01.m1 Result + m1.a() → 提交到 NetScene 队列 +``` + +### 4.2 关键发现(LSPosed 模块验证) + +**Frida 测试时失败的真正原因:缺少两个关键字段赋值。** + +Frida 脚本只调用了: +```javascript +builder.e(content) +builder.h(0) +result = builder.a() +result.a() +``` + +但 LSPosed 模块中通过 `XposedHelpers.setIntField` 补充了: +```java +XposedHelpers.setIntField(builder, "f", 0); // 关键! +XposedHelpers.setIntField(builder, "i", 4); // 关键! +``` + +**加上这两个字段后,消息成功发送并出现在聊天记录中。** + +### 4.3 完整有效调用代码 + +```java +Class factoryClass = XposedHelpers.findClass("u01.r1", classLoader); +Object builder = factoryClass.getMethod("a", String.class).invoke(null, toUser); +Class builderClass = builder.getClass(); + +builderClass.getMethod("e", String.class).invoke(builder, content); +builderClass.getMethod("h", int.class).invoke(builder, 0); +XposedHelpers.setIntField(builder, "f", 0); +XposedHelpers.setIntField(builder, "i", 4); + +Object result = builderClass.getMethod("a").invoke(builder); + +// 必须在主线程执行 m1.a() +new Handler(Looper.getMainLooper()).post(() -> { + result.getClass().getMethod("a").invoke(result); +}); +``` + +### 4.4 为什么 Frida 没有检测到 WCDB INSERT + +- `u01.r1` 创建的消息先进入 **NetScene 同步队列**(`u01.g2.b` LinkedList),不是立即写入 DB +- `u01.f2` SyncProcessor 每几秒运行一次,从队列取消息 ID 再写入 `storage.h9` +- Frida 的 hook 窗口(单次调用)捕获不到这个延迟写入 +- LSPosed 模块持续运行,所以能观察到完整的 WCDB INSERT 流程 + +--- + +## 五、手动发送的真实路径(已确认) + +### 5.1 WCDB INSERT 调用栈 + +``` +dalvik.system.VMStack.getThreadStackTrace(Native Method) +java.lang.Thread.getStackTrace(Thread.java:2842) +com.tencent.wcdb.database.SQLiteDatabase.insert(Unknown Source) +q75.a0$$e.a(Unknown Source:34) +q75.a0.y(Unknown Source:38) +com.tencent.mm.storage.h9.ta(Unknown Source:352) +com.tencent.mm.storage.h9.na(Unknown Source:1) +az0.w9.x(Unknown Source:82) +az0.l.l(Unknown Source:266) +com.tencent.mm.plugin.messenger.foundation.a2.b(Unknown Source:660) +com.tencent.mm.plugin.messenger.foundation.a2.c(Unknown Source:9) +com.tencent.mm.plugin.messenger.foundation.a2.a(Unknown Source:551) +com.tencent.mm.plugin.messenger.foundation.z2.a(Unknown Source:32) +com.tencent.mm.plugin.zero.a1.d(Unknown Source:119) +u01.f2.run(Unknown Source:298) +java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:520) +java.util.concurrent.FutureTask.run(FutureTask.java:317) +is5.l.run(Unknown Source:242) +js5.v.run(Unknown Source:8) +java.util.concurrent.ThreadPoolExecutor.runWorker(...) +java.lang.Thread.run(Thread.java:1563) +``` + +### 5.2 路径分析 + +| 层级 | 类/方法 | 作用 | +|------|--------|------| +| 入口 | `u01.f2.run()` | Runnable,在线程池中执行 | +| 调度 | `com.tencent.mm.plugin.zero.a1.d()` | zero 插件层 | +| 中介 | `com.tencent.mm.plugin.messenger.foundation.z2.a()` | messenger foundation | +| 核心 | `com.tencent.mm.plugin.messenger.foundation.a2.a/b/c()` | 消息处理核心 | +| 中间层 | `az0.l.l()` / `az0.w9.x()` | 消息构建/序列化 | +| 存储 | `com.tencent.mm.storage.h9.na()/ta()` | 消息存储写入 | +| DB | `q75.a0$$e.a()` / `q75.a0.y()` | WCDB 封装层 | +| DB | `WCDB.insert(message, isSend=1)` | 最终写入 | + +### 5.3 `u01.f2` 的本质 + +- `u01.f2` 是**长期存在的后台 SyncProcessor**,不是为单条消息创建的 +- `u01.f2` 只有一个字段 `d`,类型是 `u01.g2` +- `u01.g2` 的字段: + - `a = u01.e2 [u01.t]` — 处理器 + - `b = LinkedList` — 消息 ID 队列(如 `128927674_1779461490188`) + - `c = HashMap` — 同步状态映射(如 `{261975436=NetSync[261975436]}`) + - `d = Object` — 锁对象 + - `e = js5.b` — 线程池标签 + - `f = String` — `SyncProcessorThreadTag@0` + +`u01.f2` 每几秒运行一次,从 `LinkedList` 中取出消息 ID,调用 `storage.h9` 写入 DB,然后发送到服务器。 + +### 5.4 `a2` 类方法签名 + +``` +com.tencent.mm.plugin.messenger.foundation.a2: + a(Object, f35.r40, int, byte[], boolean, f35.pu4, ef3.z4) → void + b(com.tencent.mm.modelbase.p0, f35.pu4, ef3.z4) → com.tencent.mm.modelbase.q0 + c(f35.i4, Object, int, f35.pu4, ef3.z4) → void +``` + +`b()` 方法的返回值 `com.tencent.mm.modelbase.q0` 和参数 `com.tencent.mm.modelbase.p0` 表明 `a2` 是 NetScene 框架的封装层。 + +### 5.5 关键洞察 + +真正的发送入口**不是直接创建 `u01.f2`**,而是: +1. 将消息添加到 `u01.g2.b`(LinkedList)队列 +2. 或调用 `u01.e2` 的某个方法 +3. 或直接调用 `a2.a/b/c()` 方法 + +但这些方法的参数涉及大量微信内部类型(`f35.og6`, `f35.i4`, `f35.pu4`, `ef3.z4` 等),通过反射构造几乎不可能。 + +--- + +## 六、Frida 脚本汇总 + +### 6.1 v1 — 基础搜索(`debug_wechat_send.js`) + +- 搜索 sendmsg 相关类 +- Hook `u01/v01/w01` 工厂方法 `a(String)` +- Hook WCDB insert +- Hook ContentProvider +- 提供 `rpc.exports.sendTestMsg()` + +### 6.2 v2 — 多调用链测试(`debug_wechat_send2.js`) + +测试 4 种 builder 调用组合,全部无 WCDB INSERT。 + +### 6.3 v3/v5 — WCDB 调用栈(`debug_wechat_send3.js` / `debug_wechat_send5.js`) + +v3 使用 `Exception.$new()` 获取调用栈(失败,栈为空)。 +v5 使用 `Thread.currentThread().getStackTrace()`(成功,获取到完整 Java 调用栈)。 + +> **教训**:Frida 中获取 Java 调用栈必须用 `Thread.currentThread().getStackTrace()`,不能用 `Exception.$new()`。 + +### 6.4 v4 — 全面 WCDB hook(`debug_wechat_send4.js`) + +Hook 了 `insert`, `insertWithOnConflict`, `replace`, `execSQL`, `rawQuery`, `SQLiteStatement.execute`。 + +### 6.5 v6 — 深度 inspect `u01.g2`(`debug_wechat_send6.js`) + +打印 `u01.f2` 和 `u01.g2` 的所有字段。 + +### 6.6 v7 — `u01.f2` 构造函数(`debug_wechat_send7.js`) + +- `u01.f2 constructor: (u01.g2,)` +- `u01.g2 constructor: (u01.t,)` +- `u01.e2` 方法: + - `a(f35.og6, u01.f)` → void + - `b(f35.og6, u01.u)` → void + +### 6.7 v8 — 追踪创建者(`debug_wechat_send8.js`) + +Hook `u01.f2` 构造函数、`u01.g2` 构造函数、`LinkedList.add`,打印调用栈。 +**问题**:`LinkedList.add` 被频繁调用(各种微信事件),调用栈获取困难。`u01.f2` 只在启动时创建一次。 + +### 6.8 v9 — 全链路对比(`debug_wechat_send9.js`) + +同时 hook `u01.r1.a`, `u01.q1.e`, `u01.q1.a`, `u01.m1.a`, `a2.b`, `a2.c`, WCDB。 +**导致微信崩溃**(hook 太多方法)。 + +### 6.9 v10 — 轻量 WCDB + f2(`debug_wechat_send10.js`) + +只 hook WCDB insert 和 `u01.f2.run`,避免崩溃。 + +### 6.10 v11 — 终极验证(`debug_wechat_send11.js`) + +只 hook `u01.r1.a(String)` + WCDB。 +**关键结论**:手动发送时 `u01.r1.a(String)` 完全没有被调用。 + +--- + +## 七、已知问题与限制 + +1. **Frida hook 导致微信崩溃**:hook 过多方法时,微信进程会崩溃重启。建议最多 hook 2-3 个方法。 +2. **logcat 缓冲区刷新**:LSPosed 日志在 logcat 中会被快速刷新,需及时抓取或使用 `adb logcat -d`。 +3. **FUSE /sdcard 限制**:root/system_server 无法读取 /sdcard/ 上的文件,安装 APK 需先 `cp` 到 `/data/local/tmp/`。 +4. **WAL 复制问题**:复制 WAL 到 Termux home 可解决 sqlcipher 解密问题。 + +--- + +## 八、最终方案:u01.r1 反射路径(已采用 ✅) + +### 方案对比 + +| 方案 | 状态 | 说明 | +|------|------|------| +| u01.r1 反射 | ✅ **已采用** | 通过 LSPosed 模块直接调用,无需微信前台 | +| AccessibilityService | ✅ 已实现备用 | 需要微信前台,作为 fallback | +| ContentProvider | ❌ 已放弃 | WeChat 8.0.72 所有变体均返回 null | +| storage.h9 直接插入 | ❌ 未尝试 | 过于底层,风险高 | + +### 当前架构(2026-05-23 已验证 ✅) + +``` +hermes_wechat_daemon.py + ↓ am broadcast (com.hermes.wechat.SEND_MSG) +LSPosed 模块 (HermesWeChatModule) + ↓ u01.r1.a(toUser) → builder (toUser 用显示名称,如"文件传输助手") + ↓ builder.e(content) + setIntField("f", 0) + setIntField("i", 4) + ↓ builder.a() → m1 + ↓ new Thread(() -> m1.a()).start() // 后台线程提交,忽略 NPE +WeChat NetScene 队列 + ↓ u01.f2 SyncProcessor 定时消费 +WCDB message 表 INSERT + ↓ 网络发送 +``` + +### 关键发现(2026-05-23 最终验证) + +1. **`m1.a()` 必须在后台线程执行**:在主线程调用会立即 NPE。 +2. **NPE 无害**:`u01.m1.a()` 内部 `Objects.requireNonNull` 在发送逻辑完成后抛出,消息已成功入队。 +3. **toUser 用显示名称**:`u01.r1.a("文件传输助手")` 有效。`filehelper`(wxid)无法成功。 +4. **无需 Provider init**:`ExtControlProviderMsg` 的初始化对 `u01.r1` 路径无影响。 + +### 待优化 + +- [x] 文本消息发送(2026-05-23 已验证 ✅) +- [ ] 支持发送图片/语音/文件等多媒体消息 +- [ ] 处理发送失败重试 +- [ ] 支持群聊 @ 某人 +- [ ] 微信版本升级后的兼容性(u01.r1 可能改名) + +--- + +## 九、文件位置 + +| 文件 | 路径 | +|------|------| +| LSPosed 模块源码 | `lsposed-wechat-sendmsg/app/src/main/java/.../HermesWeChatModule.java` | +| Daemon 主脚本 | `local-testkit/hermes_wechat_daemon.py` | +| Frida 脚本 v1-v11 | `local-testkit/debug_wechat_send*.js` | +| 开发计划 | `local-testkit/plan.md` | +| 本记录 | `local-testkit/REVERSE_ENGINEERING_LOG.md` | + +--- + +## 十、环境信息 + +- 设备:Pixel 10 Fold +- Android:15 +- WeChat:8.0.72 +- Termux + Termux:API +- LSPosed (Zygisk) +- Frida 17.9.10 (client + server) +- sqlcipher 3.x + +--- + +## 十一、AccessibilityService 迭代记录 + +### 11.1 初始实现(v1) + +- 实现 `HermesAccessibilityService` 接收 `com.hermes.wechat.SEND_MSG` 广播 +- Flow: 启动微信 → 检测是否已在聊天界面 → 搜索联系人 → 输入消息 → 点击发送 +- 问题:`getRootInActiveWindow()` 在微信未前台时返回桌面 launcher +- 问题:搜索使用 wxid(如 `filehelper`),但微信搜索框需要显示名称(如 `文件助手`) + +### 11.2 显示名称支持(v2) + +- 广播 intent 新增 `toUserDisplayName` 字段 +- `HermesAccessibilityService` 优先使用 `toUserDisplayName` 进行搜索和点击结果匹配 +- `hermes_wechat_daemon.py` 新增 `contact_display_names` 配置映射表 +- `test_send_msg.py` 同步支持 `toUserDisplayName` +- APK 重新编译并安装成功 + +### 11.3 待验证 + +- [ ] 使用 `toUserDisplayName="文件助手"` 搜索并发送消息到 filehelper +- [ ] 确认消息实际出现在聊天界面(需要手动检查微信) +- [ ] 处理搜索无结果时的重试逻辑 +- [ ] 测试非 filehelper 联系人的发送流程 diff --git a/wechat_plain/scripts/local-testkit/docs/plan.md b/wechat_plain/scripts/local-testkit/docs/plan.md new file mode 100644 index 0000000..4917c9a --- /dev/null +++ b/wechat_plain/scripts/local-testkit/docs/plan.md @@ -0,0 +1,96 @@ +# WeChat Daemon 开发计划 + +## 当前状态(2026-05-22) + +完整自动回复闭环已验证成功: + +1. Termux API 检测微信通知 +2. 复制 `EnMicroMsg.db` + WAL + SHM 到工作目录 +3. SQLCipher 解密(key=`e03ce81`, compat=1, page_size=1024) +4. 查询最新消息(`ORDER BY msgId DESC`) +5. 调用 hermes `api_server` OpenAI-compatible API(SSE 流式) +6. 通过 `am broadcast` 发送回复到微信 + +## 已知问题 + +### P1 — `last_msg_id` 无持久化 +- `--once` 模式每次从 0 开始,会重复处理历史消息 +- `run_daemon()` 前台运行时 `last_msg_id` 仅在内存中,进程重启后丢失 +- **影响**:重复回复、API 浪费、用户体验差 + +### P2 — 后台 daemon 未充分验证 +- 通知驱动 + `wake_wechat()` + 3s 延迟 + DB 轮询的实时流程未完整测试 +- 长时间运行稳定性未知(内存泄漏、句柄泄漏) +- **影响**:无法部署为 7x24 服务 + +### P3 — WAL 复制偶发不稳定 +- `copy_db` 使用 `su -c cp` 复制 WAL,在 root/Termux 权限边界上偶尔行为不一致 +- 已观察到:不带 WAL 时 sqlcipher 解密成功,带 WAL 时某些环境下失败 +- **影响**:最新消息丢失(WAL 中未 checkpoint 的数据) + +### P4 — 通知内容无法直接用于回复 +- Termux `termux-notification-list` 只能拿到通知标题/摘要 +- 微信通知内容通常被截断,拿不到完整消息文本 +- **影响**:必须依赖 DB 轮询,通知只能作为"触发器" + +### P5 — 微信唤醒延迟硬编码 +- `poll_notifications()` 中 `time.sleep(3)` 固定等待微信同步 DB +- 网络差时 3 秒不够,网络好时浪费 +- **影响**:回复延迟不稳定或过度延迟 + +### P6 — 群聊消息处理不完善 +- 当前仅通过 `contacts` 白名单过滤 talker +- 群聊消息 talker 是 `@chatroom`,没有解析群成员昵称和 @ 信息 +- **影响**:群聊中被 @ 时无法正确识别和回复 + +## 下一步开发目标 + +### 1. 状态持久化(高优先级) +- 将 `last_msg_id` 和 `last_reply_time` 持久化到 `/data/data/com.termux/files/home/.hermes_daemon_state.json` +- `poll_cycle()` 启动时读取,结束时写入 +- 支持 `last_msg_id` 初始化为当前最大 msgId(首次运行不处理历史消息) + +### 2. 后台 daemon 稳定运行(高优先级) +- 测试 `run_daemon()` 前台模式 30 分钟以上 +- 验证通知 → 唤醒微信 → DB 同步 → 自动回复的完整实时流 +- 添加进程守护(崩溃自动重启)或 systemd/termux-services 集成 + +### 3. 通知内容直接提取(中优先级) +- 研究 Termux API 或 Android AccessibilityService 能否拿到完整通知内容 +- 如果能拿到,实现"通知内容直接回复"分支,跳过 DB 轮询 +- 作为 DB 轮询的补充/降级方案 + +### 4. 配置热重载(中优先级) +- 监听 `config.json` 修改时间戳,变化时重新加载 +- 支持运行时修改 `contacts`、`system_hint`、`cooldown_sec` 等 + +### 5. 日志系统修复(中优先级) +- su 环境下 `/sdcard/` 的 FUSE 限制导致 `FileHandler` 可能失败 +- 将日志写入 Termux home 目录,或完全依赖 `print(..., flush=True)` +- 添加日志轮转,避免单文件过大 + +### 6. 群聊消息支持(低优先级) +- 解析 `@chatroom` 消息中的 `fromusername` 字段 +- 识别被 @ 的场景(检查消息内容是否包含当前用户昵称) +- 群聊回复时引用原消息上下文 + +### 7. 消息类型扩展(低优先级) +- 图片(type=3)、语音(type=34)、链接(type=49)的识别 +- 对非文本消息回复固定提示(如"收到图片/语音,暂不支持查看") +- 链接类型消息提取标题和 URL 传给 hermes API + +## 技术债务 + +- [ ] `decrypt_db` 中的 `.quit` 命令在 sqlcipher stdin 模式下是否必要? +- [ ] `copy_db` 使用 `su -c cp` 在 su 环境下嵌套调用,考虑直接用 `shutil.copy` +- [ ] `poll_interval_sec` 和 `cooldown_sec` 的默认值需要调优 +- [ ] 缺少单元测试框架 + +## 已验证环境 + +- Pixel 10 Fold +- Android 15 +- Termux + Termux:API +- WeChat 8.0.72 +- sqlcipher 3.x +- hermes-agent api_server @ localhost:8642 diff --git a/wechat_plain/scripts/local-testkit/hermes_wechat_daemon.py b/wechat_plain/scripts/local-testkit/hermes_wechat_daemon.py index 9dd7421..0a9c9de 100644 --- a/wechat_plain/scripts/local-testkit/hermes_wechat_daemon.py +++ b/wechat_plain/scripts/local-testkit/hermes_wechat_daemon.py @@ -210,24 +210,40 @@ def get_recent_context(db_path, talker, before_msg_id, limit=5): logging.debug(f"Context query failed: {e}") return [] -# ── LLM API Call ────────────────────────────────────────────────────────────── -def call_hermes_api(talker, content, context_msgs=None): - """ - Call configured LLM API (Anthropic / OpenAI / Azure / local proxy). - Returns reply text or None. - """ - hermes_cfg = config.get("hermes", {}) - api_url = hermes_cfg.get("api_url", "") - api_key = hermes_cfg.get("api_key", "") - provider = hermes_cfg.get("provider", "anthropic") - model = hermes_cfg.get("model", "") - timeout = hermes_cfg.get("timeout_sec", 120) - prefix = hermes_cfg.get("default_session_prefix", "wx-") - system_hint = hermes_cfg.get("system_hint", "") +# ── Hermes API Call (primary) + LLM API Fallback ────────────────────────────── + +def _parse_sse(buffer): + """Parse SSE response buffer, extract result.output.""" + lines = buffer.decode("utf-8").split("\n") + events = [] + current_event = None + current_data = "" + for line in lines: + line = line.strip() + if line.startswith("event:"): + if current_event is not None: + events.append((current_event, current_data)) + current_event = line[6:].strip() + current_data = "" + elif line.startswith("data:"): + current_data += line[5:].strip() + elif line == "" and current_event is not None: + events.append((current_event, current_data)) + current_event = None + current_data = "" + if current_event is not None: + events.append((current_event, current_data)) + for ev, data in events: + if ev == "result": + try: + return json.loads(data).get("output", "") + except Exception: + return data + return "" - session_id = f"{prefix}{talker}" - # Build messages +def _build_llm_messages(talker, content, context_msgs, system_hint): + """Build messages array for direct LLM API call.""" messages = [] if system_hint: messages.append({"role": "system", "content": system_hint}) @@ -238,8 +254,21 @@ def call_hermes_api(talker, content, context_msgs=None): ctx_lines.append(f"{who}: {m.get('content', '')[:100]}") messages.append({"role": "system", "content": "最近对话上下文:\n" + "\n".join(ctx_lines)}) messages.append({"role": "user", "content": f"收到新消息(来自 {talker}):{content}\n请直接给出回复内容,不要加任何解释或格式标记。"}) + return messages + + +def _call_llm_api(talker, content, context_msgs=None): + """Direct LLM API call (fallback when hermes is unavailable).""" + hermes_cfg = config.get("hermes", {}) + api_url = hermes_cfg.get("api_url", "") + api_key = hermes_cfg.get("api_key", "") + provider = hermes_cfg.get("provider", "anthropic") + model = hermes_cfg.get("model", "") + timeout = hermes_cfg.get("timeout_sec", 120) + system_hint = hermes_cfg.get("system_hint", "") + + messages = _build_llm_messages(talker, content, context_msgs, system_hint) - # Provider-specific request building headers = {"Content-Type": "application/json"} if provider == "anthropic": if not api_url: @@ -252,7 +281,6 @@ def call_hermes_api(talker, content, context_msgs=None): "model": model, "max_tokens": 1024, "messages": messages, - "metadata": {"session_id": session_id} }).encode("utf-8") elif provider in ("openai", "azure", "doubao"): headers["Authorization"] = f"Bearer {api_key}" @@ -267,39 +295,74 @@ def call_hermes_api(talker, content, context_msgs=None): return None req = urllib.request.Request(api_url, data=payload, headers=headers, method="POST") - try: - print(f"[LLM] Calling {provider} API for {talker}...", flush=True) with urllib.request.urlopen(req, timeout=timeout) as resp: - print(f"[LLM] API connected (status={resp.status})", flush=True) data = json.loads(resp.read().decode("utf-8")) - - # Parse response - result_text = None if provider == "anthropic": - result_text = data.get("content", [{}])[0].get("text", "") + return data.get("content", [{}])[0].get("text", "") elif provider in ("openai", "azure", "doubao"): - result_text = data.get("choices", [{}])[0].get("message", {}).get("content", "") + return data.get("choices", [{}])[0].get("message", {}).get("content", "") + except Exception as e: + logging.error(f"[LLM] Fallback API failed: {e}") + return None - if result_text: - print(f"[LLM] Reply for {talker}: {result_text[:80]}", flush=True) - logging.info(f"[LLM] Reply for {talker}: {result_text[:60]}") - else: - print(f"[LLM] Empty reply for {talker}", flush=True) - return result_text if result_text else None + +def call_hermes_api(talker, content, context_msgs=None): + """ + Generate reply via hermes API (primary) or direct LLM API (fallback). + Returns reply text or None. + """ + hermes_cfg = config.get("hermes", {}) + hermes_host = hermes_cfg.get("host", "localhost") + hermes_port = hermes_cfg.get("port", 3010) + timeout = hermes_cfg.get("timeout_sec", 120) + prefix = hermes_cfg.get("default_session_prefix", "wx-") + system_hint = hermes_cfg.get("system_hint", "") + session_id = f"{prefix}{talker}" + + # Build prompt + prompt_parts = [] + if system_hint: + prompt_parts.append(system_hint) + if context_msgs: + ctx_lines = [] + for m in context_msgs: + who = "对方" if m.get("isSend") == 0 else "我" + ctx_lines.append(f"{who}: {m.get('content', '')[:100]}") + prompt_parts.append("最近对话上下文:\n" + "\n".join(ctx_lines)) + prompt_parts.append(f"收到新消息(来自 {talker}):{content}\n请直接给出回复内容,不要加任何解释或格式标记。") + full_prompt = "\n\n".join(prompt_parts) + + # ── Primary: call hermes /api/master/chat ── + hermes_url = f"http://{hermes_host}:{hermes_port}/api/master/chat" + payload = json.dumps({"message": full_prompt, "sessionId": session_id}).encode("utf-8") + headers = {"Content-Type": "application/json", "Content-Length": str(len(payload))} + req = urllib.request.Request(hermes_url, data=payload, headers=headers, method="POST") + + try: + print(f"[HERMES] Calling {hermes_url} for {talker}...", flush=True) + with urllib.request.urlopen(req, timeout=timeout) as resp: + sse_buffer = resp.read() + reply = _parse_sse(sse_buffer) + if reply: + print(f"[HERMES] Reply for {talker}: {reply[:80]}", flush=True) + logging.info(f"[HERMES] Reply for {talker}: {reply[:60]}") + return reply + print(f"[HERMES] Empty reply for {talker}, trying fallback...", flush=True) except urllib.error.HTTPError as e: body = e.read().decode("utf-8", errors="replace")[:200] - print(f"[LLM] HTTP {e.code}: {body}", flush=True) - logging.error(f"[LLM] HTTP {e.code}: {body}") - return None + print(f"[HERMES] HTTP {e.code}: {body}, falling back to LLM API...", flush=True) except urllib.error.URLError as e: - print(f"[LLM] Connection failed: {e}", flush=True) - logging.error(f"[LLM] Connection failed: {e}") - return None + print(f"[HERMES] Connection failed: {e}, falling back to LLM API...", flush=True) except Exception as e: - print(f"[LLM] API call failed: {e}", flush=True) - logging.error(f"[LLM] API call failed: {e}") - return None + print(f"[HERMES] API call failed: {e}, falling back to LLM API...", flush=True) + + # ── Fallback: direct LLM API ── + print(f"[LLM] Fallback direct API for {talker}...", flush=True) + reply = _call_llm_api(talker, content, context_msgs) + if reply: + print(f"[LLM] Fallback reply for {talker}: {reply[:80]}", flush=True) + return reply # ── Send Message ────────────────────────────────────────────────────────────── def send_message(to_user, content, to_user_display_name=None): diff --git a/wechat_plain/scripts/local-testkit/keepalive.sh b/wechat_plain/scripts/local-testkit/keepalive.sh new file mode 100755 index 0000000..21e26c9 --- /dev/null +++ b/wechat_plain/scripts/local-testkit/keepalive.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# keepalive.sh — Termux 保活 + 进程守护脚本 +# +# 功能: +# 1. 获取 wake-lock,防止息屏后 CPU 被冻结 +# 2. 检查并启动 hermes server +# 3. 检查并启动 wechat auto-reply plugin +# 4. 进程死了自动重启 +# +# 用法: +# chmod +x keepalive.sh +# nohup ./keepalive.sh > keepalive.log 2>&1 & +# +# 前置条件(必须手动设置): +# 1. Android 设置 → 应用 → Termux → 电池优化 → 不优化 +# 2. Android 设置 → 应用 → Termux:API → 电池优化 → 不优化 +# 3. Android 设置 → 应用 → 微信 → 电池优化 → 不优化 +# 4. Android 设置 → 应用 → Termux → 允许自启动 +# 5. Termux:API 已安装,并已授予通知访问权限 + +set -e + +HERMES_DIR="${HERMES_DIR:-/data/data/com.termux/files/home/hermes-wechat}" +HERMES_HOST="${HERMES_HOST:-localhost}" +HERMES_PORT="${HERMES_PORT:-3010}" +MY_WXID="${MY_WXID:-}" +MY_NICKNAME="${MY_NICKNAME:-}" +CHECK_INTERVAL_SEC="${CHECK_INTERVAL_SEC:-10}" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" +} + +# ── 1. Wake lock ───────────────────────────────────────────────── +termux-wake-lock 2>/dev/null || true +log "Wake lock acquired" + +# ── 2. 检查前置条件 ────────────────────────────────────────────── +check_prerequisites() { + local ok=true + + if ! command -v termux-notification-list >/dev/null 2>&1; then + log "[WARN] termux-notification-list not found. Install: pkg install termux-api" + ok=false + fi + + if [ ! -d "$HERMES_DIR" ]; then + log "[WARN] HERMES_DIR not found: $HERMES_DIR" + ok=false + fi + + if [ -z "$MY_WXID" ] || [ -z "$MY_NICKNAME" ]; then + log "[WARN] MY_WXID or MY_NICKNAME not set" + log " export MY_WXID=wxid_xxxxxx" + log " export MY_NICKNAME=你的昵称" + ok=false + fi + + $ok +} + +# ── 3. 检查 hermes server 是否运行 ─────────────────────────────── +is_hermes_running() { + curl -s "http://${HERMES_HOST}:${HERMES_PORT}/api/health" >/dev/null 2>&1 +} + +# ── 4. 启动 hermes server ──────────────────────────────────────── +start_hermes() { + log "Starting hermes server..." + cd "$HERMES_DIR" + nohup node server/index.js > hermes-server.log 2>&1 & + sleep 3 + if is_hermes_running; then + log "Hermes server started (PID=$!)" + else + log "[WARN] Hermes server may have failed to start" + fi +} + +# ── 5. 检查 auto-reply 是否运行 ────────────────────────────────── +is_auto_reply_running() { + pgrep -f "node auto-reply.js" >/dev/null 2>&1 +} + +# ── 6. 启动 auto-reply ─────────────────────────────────────────── +start_auto_reply() { + log "Starting auto-reply..." + cd "$HERMES_DIR/server/services/wechat" + env MY_WXID="$MY_WXID" MY_NICKNAME="$MY_NICKNAME" \ + nohup node auto-reply.js > auto-reply.log 2>&1 & + log "Auto-reply started (PID=$!)" +} + +# ── 7. 主循环 ──────────────────────────────────────────────────── +log "=== Keepalive started ===" +log "HERMES_DIR=$HERMES_DIR" +log "MY_WXID=${MY_WXID:-(not set)}" +log "MY_NICKNAME=${MY_NICKNAME:-(not set)}" + +if ! check_prerequisites; then + log "[ERROR] Prerequisites check failed, exiting" + exit 1 +fi + +while true; do + # 检查 hermes + if ! is_hermes_running; then + log "Hermes server not responding, restarting..." + start_hermes + fi + + # 检查 auto-reply + if ! is_auto_reply_running; then + log "Auto-reply not running, restarting..." + start_auto_reply + fi + + sleep "$CHECK_INTERVAL_SEC" +done diff --git a/wechat_plain/scripts/local-testkit/plan.md b/wechat_plain/scripts/local-testkit/plan.md deleted file mode 100644 index 4917c9a..0000000 --- a/wechat_plain/scripts/local-testkit/plan.md +++ /dev/null @@ -1,96 +0,0 @@ -# WeChat Daemon 开发计划 - -## 当前状态(2026-05-22) - -完整自动回复闭环已验证成功: - -1. Termux API 检测微信通知 -2. 复制 `EnMicroMsg.db` + WAL + SHM 到工作目录 -3. SQLCipher 解密(key=`e03ce81`, compat=1, page_size=1024) -4. 查询最新消息(`ORDER BY msgId DESC`) -5. 调用 hermes `api_server` OpenAI-compatible API(SSE 流式) -6. 通过 `am broadcast` 发送回复到微信 - -## 已知问题 - -### P1 — `last_msg_id` 无持久化 -- `--once` 模式每次从 0 开始,会重复处理历史消息 -- `run_daemon()` 前台运行时 `last_msg_id` 仅在内存中,进程重启后丢失 -- **影响**:重复回复、API 浪费、用户体验差 - -### P2 — 后台 daemon 未充分验证 -- 通知驱动 + `wake_wechat()` + 3s 延迟 + DB 轮询的实时流程未完整测试 -- 长时间运行稳定性未知(内存泄漏、句柄泄漏) -- **影响**:无法部署为 7x24 服务 - -### P3 — WAL 复制偶发不稳定 -- `copy_db` 使用 `su -c cp` 复制 WAL,在 root/Termux 权限边界上偶尔行为不一致 -- 已观察到:不带 WAL 时 sqlcipher 解密成功,带 WAL 时某些环境下失败 -- **影响**:最新消息丢失(WAL 中未 checkpoint 的数据) - -### P4 — 通知内容无法直接用于回复 -- Termux `termux-notification-list` 只能拿到通知标题/摘要 -- 微信通知内容通常被截断,拿不到完整消息文本 -- **影响**:必须依赖 DB 轮询,通知只能作为"触发器" - -### P5 — 微信唤醒延迟硬编码 -- `poll_notifications()` 中 `time.sleep(3)` 固定等待微信同步 DB -- 网络差时 3 秒不够,网络好时浪费 -- **影响**:回复延迟不稳定或过度延迟 - -### P6 — 群聊消息处理不完善 -- 当前仅通过 `contacts` 白名单过滤 talker -- 群聊消息 talker 是 `@chatroom`,没有解析群成员昵称和 @ 信息 -- **影响**:群聊中被 @ 时无法正确识别和回复 - -## 下一步开发目标 - -### 1. 状态持久化(高优先级) -- 将 `last_msg_id` 和 `last_reply_time` 持久化到 `/data/data/com.termux/files/home/.hermes_daemon_state.json` -- `poll_cycle()` 启动时读取,结束时写入 -- 支持 `last_msg_id` 初始化为当前最大 msgId(首次运行不处理历史消息) - -### 2. 后台 daemon 稳定运行(高优先级) -- 测试 `run_daemon()` 前台模式 30 分钟以上 -- 验证通知 → 唤醒微信 → DB 同步 → 自动回复的完整实时流 -- 添加进程守护(崩溃自动重启)或 systemd/termux-services 集成 - -### 3. 通知内容直接提取(中优先级) -- 研究 Termux API 或 Android AccessibilityService 能否拿到完整通知内容 -- 如果能拿到,实现"通知内容直接回复"分支,跳过 DB 轮询 -- 作为 DB 轮询的补充/降级方案 - -### 4. 配置热重载(中优先级) -- 监听 `config.json` 修改时间戳,变化时重新加载 -- 支持运行时修改 `contacts`、`system_hint`、`cooldown_sec` 等 - -### 5. 日志系统修复(中优先级) -- su 环境下 `/sdcard/` 的 FUSE 限制导致 `FileHandler` 可能失败 -- 将日志写入 Termux home 目录,或完全依赖 `print(..., flush=True)` -- 添加日志轮转,避免单文件过大 - -### 6. 群聊消息支持(低优先级) -- 解析 `@chatroom` 消息中的 `fromusername` 字段 -- 识别被 @ 的场景(检查消息内容是否包含当前用户昵称) -- 群聊回复时引用原消息上下文 - -### 7. 消息类型扩展(低优先级) -- 图片(type=3)、语音(type=34)、链接(type=49)的识别 -- 对非文本消息回复固定提示(如"收到图片/语音,暂不支持查看") -- 链接类型消息提取标题和 URL 传给 hermes API - -## 技术债务 - -- [ ] `decrypt_db` 中的 `.quit` 命令在 sqlcipher stdin 模式下是否必要? -- [ ] `copy_db` 使用 `su -c cp` 在 su 环境下嵌套调用,考虑直接用 `shutil.copy` -- [ ] `poll_interval_sec` 和 `cooldown_sec` 的默认值需要调优 -- [ ] 缺少单元测试框架 - -## 已验证环境 - -- Pixel 10 Fold -- Android 15 -- Termux + Termux:API -- WeChat 8.0.72 -- sqlcipher 3.x -- hermes-agent api_server @ localhost:8642 -- libgit2 0.21.4