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/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/HERMES_WECHAT_ARCHITECTURE.md b/wechat_plain/scripts/local-testkit/docs/HERMES_WECHAT_ARCHITECTURE.md index 5b39c39..5b39c39 100644 --- a/wechat_plain/scripts/local-testkit/HERMES_WECHAT_ARCHITECTURE.md +++ b/wechat_plain/scripts/local-testkit/docs/HERMES_WECHAT_ARCHITECTURE.md diff --git a/wechat_plain/scripts/local-testkit/NOTIFICATION_STRATEGY.md b/wechat_plain/scripts/local-testkit/docs/NOTIFICATION_STRATEGY.md index a6e3f50..a6e3f50 100644 --- a/wechat_plain/scripts/local-testkit/NOTIFICATION_STRATEGY.md +++ b/wechat_plain/scripts/local-testkit/docs/NOTIFICATION_STRATEGY.md 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/README_LOCAL.md b/wechat_plain/scripts/local-testkit/docs/README_LOCAL.md index 3c27727..3c27727 100644 --- a/wechat_plain/scripts/local-testkit/README_LOCAL.md +++ b/wechat_plain/scripts/local-testkit/docs/README_LOCAL.md diff --git a/wechat_plain/scripts/local-testkit/REVERSE_ENGINEERING_LOG.md b/wechat_plain/scripts/local-testkit/docs/REVERSE_ENGINEERING_LOG.md index 4759b0d..4759b0d 100644 --- a/wechat_plain/scripts/local-testkit/REVERSE_ENGINEERING_LOG.md +++ b/wechat_plain/scripts/local-testkit/docs/REVERSE_ENGINEERING_LOG.md diff --git a/wechat_plain/scripts/local-testkit/plan.md b/wechat_plain/scripts/local-testkit/docs/plan.md index 4917c9a..4917c9a 100644 --- a/wechat_plain/scripts/local-testkit/plan.md +++ b/wechat_plain/scripts/local-testkit/docs/plan.md 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