Commit 47826cd039b0b626910f4f16b88c2a1d02a7be0d
1 parent
b7cf187c
feat: wechat auto-reply plugin with hermes API integration + screen-off detection
- auto-reply.js: connect to hermes /api/master/chat for AI reply generation - SSE response parsing, session isolation per talker - notification watcher (termux-notification-list) for screen-off fallback - wechat foreground check + wake before send - hermes_wechat_daemon.py: prefer hermes API, fallback to direct LLM API - config.json: add hermes host/port for local API connection - keepalive.sh: termux-wake-lock + process guardian for hermes + auto-reply - Move docs into local-testkit/docs/ directory - .gitignore: exclude server/db/ Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Showing
11 changed files
with
761 additions
and
144 deletions
.gitignore
server/services/wechat/auto-reply.js
| 1 | /** | 1 | /** |
| 2 | - * auto-reply.js — Pixel 手机微信自动回复守护进程 | 2 | + * auto-reply.js — Pixel 手机微信自动回复 Plugin(接入 hermes API) |
| 3 | * | 3 | * |
| 4 | - * 功能: | ||
| 5 | - * 1. 单聊消息 → 自动回复 | ||
| 6 | - * 2. 群聊 @我 → 自动回复 | ||
| 7 | - * 3. 群聊引用我的消息 → 自动回复 | 4 | + * 架构: |
| 5 | + * AndroidWechatTracker (tail -f LSPosed 日志) ──→ 实时消息检测 | ||
| 6 | + * termux-notification-list (兜底) ──→ 息屏时检测通知并唤醒微信 | ||
| 7 | + * hermes API (POST /api/master/chat) ──→ AI 回复生成 | ||
| 8 | + * android-wechat-sender (am broadcast) ──→ 发送回复 | ||
| 8 | * | 9 | * |
| 9 | * 运行方式: | 10 | * 运行方式: |
| 10 | * cd /data/data/com.termux/files/home/hermes-wechat | 11 | * cd /data/data/com.termux/files/home/hermes-wechat |
| @@ -17,25 +18,36 @@ | @@ -17,25 +18,36 @@ | ||
| 17 | 18 | ||
| 18 | const { AndroidWechatTracker } = require('./android-wechat-tracker'); | 19 | const { AndroidWechatTracker } = require('./android-wechat-tracker'); |
| 19 | const { sendTextMessageBroadcast } = require('./android-wechat-sender'); | 20 | const { sendTextMessageBroadcast } = require('./android-wechat-sender'); |
| 21 | +const { shell } = require('./android-wechat-sender'); | ||
| 20 | const fs = require('fs'); | 22 | const fs = require('fs'); |
| 21 | const path = require('path'); | 23 | const path = require('path'); |
| 24 | +const http = require('http'); | ||
| 22 | 25 | ||
| 23 | // ═══════════════════════════════════════════════════════════════ | 26 | // ═══════════════════════════════════════════════════════════════ |
| 24 | -// 配置区 — 请根据实际情况修改 | 27 | +// 配置区 |
| 25 | // ═══════════════════════════════════════════════════════════════ | 28 | // ═══════════════════════════════════════════════════════════════ |
| 26 | 29 | ||
| 27 | const CONFIG = { | 30 | const CONFIG = { |
| 28 | // 你的微信信息(用于判断群聊 @/引用) | 31 | // 你的微信信息(用于判断群聊 @/引用) |
| 29 | - myWxid: process.env.MY_WXID || '', // 如: wxid_xxxxxx | ||
| 30 | - myNickname: process.env.MY_NICKNAME || '', // 如: 小明 | 32 | + myWxid: process.env.MY_WXID || '', |
| 33 | + myNickname: process.env.MY_NICKNAME || '', | ||
| 34 | + | ||
| 35 | + // hermes API | ||
| 36 | + hermesHost: process.env.HERMES_HOST || 'localhost', | ||
| 37 | + hermesPort: parseInt(process.env.HERMES_PORT, 10) || 3010, | ||
| 38 | + hermesTimeoutMs: parseInt(process.env.HERMES_TIMEOUT, 10) || 120000, | ||
| 31 | 39 | ||
| 32 | // 回复行为 | 40 | // 回复行为 |
| 33 | - replyInPrivateChat: true, // 单聊自动回复 | ||
| 34 | - replyAtInGroup: true, // 群聊 @我 时回复 | ||
| 35 | - replyQuoteInGroup: true, // 群聊引用我时回复 | ||
| 36 | - maxReplyLength: 500, // 单条回复最大长度 | ||
| 37 | - cooldownMs: 3000, // 同一人冷却时间(毫秒) | ||
| 38 | - ignoreSelf: true, // 忽略自己发送的消息 | 41 | + replyInPrivateChat: true, |
| 42 | + replyAtInGroup: true, | ||
| 43 | + replyQuoteInGroup: true, | ||
| 44 | + maxReplyLength: 500, | ||
| 45 | + cooldownMs: 3000, | ||
| 46 | + ignoreSelf: true, | ||
| 47 | + | ||
| 48 | + // 通知监听(息屏兜底) | ||
| 49 | + notificationPollIntervalMs: 3000, | ||
| 50 | + notificationEnabled: process.env.NOTIFICATION_ENABLED !== 'false', | ||
| 39 | 51 | ||
| 40 | // 日志 | 52 | // 日志 |
| 41 | logFile: path.join(__dirname, 'auto-reply.log'), | 53 | logFile: path.join(__dirname, 'auto-reply.log'), |
| @@ -50,7 +62,7 @@ class ReplyState { | @@ -50,7 +62,7 @@ class ReplyState { | ||
| 50 | constructor(filePath) { | 62 | constructor(filePath) { |
| 51 | this.filePath = filePath; | 63 | this.filePath = filePath; |
| 52 | this.repliedMsgIds = new Set(); | 64 | this.repliedMsgIds = new Set(); |
| 53 | - this.lastReplyTime = {}; // talker -> timestamp | 65 | + this.lastReplyTime = {}; |
| 54 | this.load(); | 66 | this.load(); |
| 55 | } | 67 | } |
| 56 | 68 | ||
| @@ -84,7 +96,6 @@ class ReplyState { | @@ -84,7 +96,6 @@ class ReplyState { | ||
| 84 | markReplied(msgId, talker) { | 96 | markReplied(msgId, talker) { |
| 85 | this.repliedMsgIds.add(msgId); | 97 | this.repliedMsgIds.add(msgId); |
| 86 | this.lastReplyTime[talker] = Date.now(); | 98 | this.lastReplyTime[talker] = Date.now(); |
| 87 | - // 清理旧记录,只保留最近 1000 条 | ||
| 88 | if (this.repliedMsgIds.size > 1000) { | 99 | if (this.repliedMsgIds.size > 1000) { |
| 89 | const arr = Array.from(this.repliedMsgIds); | 100 | const arr = Array.from(this.repliedMsgIds); |
| 90 | this.repliedMsgIds = new Set(arr.slice(-500)); | 101 | this.repliedMsgIds = new Set(arr.slice(-500)); |
| @@ -112,39 +123,166 @@ function log(...args) { | @@ -112,39 +123,166 @@ function log(...args) { | ||
| 112 | } | 123 | } |
| 113 | 124 | ||
| 114 | // ═══════════════════════════════════════════════════════════════ | 125 | // ═══════════════════════════════════════════════════════════════ |
| 115 | -// 消息判断 | 126 | +// Hermes API 调用 |
| 116 | // ═══════════════════════════════════════════════════════════════ | 127 | // ═══════════════════════════════════════════════════════════════ |
| 117 | 128 | ||
| 118 | /** | 129 | /** |
| 119 | - * 判断是否为群聊 | 130 | + * 调用 hermes /api/master/chat 生成回复 |
| 131 | + * | ||
| 132 | + * @param {string} message - 构造好的 prompt(包含上下文、系统提示) | ||
| 133 | + * @param {string} sessionId - 会话 ID(每个 talker 独立) | ||
| 134 | + * @returns {Promise<string|null>} 回复文本 | ||
| 120 | */ | 135 | */ |
| 121 | -function isGroupChat(talker) { | ||
| 122 | - return talker && talker.endsWith('@chatroom'); | 136 | +async function callHermesApi(message, sessionId) { |
| 137 | + return new Promise((resolve, reject) => { | ||
| 138 | + const payload = JSON.stringify({ message, sessionId }); | ||
| 139 | + const req = http.request({ | ||
| 140 | + hostname: CONFIG.hermesHost, | ||
| 141 | + port: CONFIG.hermesPort, | ||
| 142 | + path: '/api/master/chat', | ||
| 143 | + method: 'POST', | ||
| 144 | + headers: { | ||
| 145 | + 'Content-Type': 'application/json', | ||
| 146 | + 'Content-Length': Buffer.byteLength(payload), | ||
| 147 | + }, | ||
| 148 | + timeout: CONFIG.hermesTimeoutMs, | ||
| 149 | + }, (res) => { | ||
| 150 | + let buffer = ''; | ||
| 151 | + res.on('data', chunk => { buffer += chunk; }); | ||
| 152 | + res.on('end', () => { | ||
| 153 | + try { | ||
| 154 | + const result = parseSSE(buffer); | ||
| 155 | + resolve(result); | ||
| 156 | + } catch (e) { | ||
| 157 | + log('[HERMES] SSE parse error:', e.message); | ||
| 158 | + resolve(null); | ||
| 159 | + } | ||
| 160 | + }); | ||
| 161 | + }); | ||
| 162 | + | ||
| 163 | + req.on('error', (err) => { | ||
| 164 | + log('[HERMES] HTTP error:', err.message); | ||
| 165 | + resolve(null); | ||
| 166 | + }); | ||
| 167 | + req.on('timeout', () => { | ||
| 168 | + req.destroy(); | ||
| 169 | + log('[HERMES] API timeout'); | ||
| 170 | + resolve(null); | ||
| 171 | + }); | ||
| 172 | + | ||
| 173 | + req.write(payload); | ||
| 174 | + req.end(); | ||
| 175 | + }); | ||
| 123 | } | 176 | } |
| 124 | 177 | ||
| 125 | /** | 178 | /** |
| 126 | - * 判断群聊消息是否 @我 | ||
| 127 | - * 微信 @ 消息格式:"@昵称 消息内容" 或 "@昵称\n消息内容" | 179 | + * 解析 SSE 响应,提取 result.output |
| 128 | */ | 180 | */ |
| 181 | +function parseSSE(buffer) { | ||
| 182 | + const lines = buffer.split('\n'); | ||
| 183 | + const events = []; | ||
| 184 | + let currentEvent = null; | ||
| 185 | + let currentData = ''; | ||
| 186 | + | ||
| 187 | + for (const line of lines) { | ||
| 188 | + const trimmed = line.trim(); | ||
| 189 | + if (trimmed.startsWith('event:')) { | ||
| 190 | + if (currentEvent !== null) { | ||
| 191 | + events.push({ event: currentEvent, data: currentData }); | ||
| 192 | + } | ||
| 193 | + currentEvent = trimmed.slice(6).trim(); | ||
| 194 | + currentData = ''; | ||
| 195 | + } else if (trimmed.startsWith('data:')) { | ||
| 196 | + currentData += trimmed.slice(5).trim(); | ||
| 197 | + } else if (trimmed === '' && currentEvent !== null) { | ||
| 198 | + events.push({ event: currentEvent, data: currentData }); | ||
| 199 | + currentEvent = null; | ||
| 200 | + currentData = ''; | ||
| 201 | + } | ||
| 202 | + } | ||
| 203 | + if (currentEvent !== null) { | ||
| 204 | + events.push({ event: currentEvent, data: currentData }); | ||
| 205 | + } | ||
| 206 | + | ||
| 207 | + // 找 result 事件 | ||
| 208 | + for (const ev of events) { | ||
| 209 | + if (ev.event === 'result') { | ||
| 210 | + try { | ||
| 211 | + const data = JSON.parse(ev.data); | ||
| 212 | + return data.output || null; | ||
| 213 | + } catch { | ||
| 214 | + return ev.data || null; | ||
| 215 | + } | ||
| 216 | + } | ||
| 217 | + } | ||
| 218 | + | ||
| 219 | + return null; | ||
| 220 | +} | ||
| 221 | + | ||
| 222 | +// ═══════════════════════════════════════════════════════════════ | ||
| 223 | +// 回复生成器 — 接入 hermes | ||
| 224 | +// ═══════════════════════════════════════════════════════════════ | ||
| 225 | + | ||
| 226 | +/** | ||
| 227 | + * 构造发给 hermes 的 prompt | ||
| 228 | + */ | ||
| 229 | +function buildHermesPrompt(ctx) { | ||
| 230 | + const { type, talker, content, senderName } = ctx; | ||
| 231 | + | ||
| 232 | + let prompt = ''; | ||
| 233 | + | ||
| 234 | + if (type === 'private') { | ||
| 235 | + prompt = `收到微信私聊消息(来自 ${talker}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; | ||
| 236 | + } else if (type === 'group-at') { | ||
| 237 | + prompt = `收到微信群聊消息,有人 @ 我(来自群 ${talker},发送者 ${senderName}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; | ||
| 238 | + } else if (type === 'group-quote') { | ||
| 239 | + prompt = `收到微信群聊消息,有人引用了我的消息(来自群 ${talker},发送者 ${senderName}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; | ||
| 240 | + } else { | ||
| 241 | + prompt = `收到微信消息(来自 ${talker}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; | ||
| 242 | + } | ||
| 243 | + | ||
| 244 | + return prompt; | ||
| 245 | +} | ||
| 246 | + | ||
| 247 | +/** | ||
| 248 | + * 接入 hermes 的回复生成器 | ||
| 249 | + */ | ||
| 250 | +async function hermesReplyGenerator(ctx) { | ||
| 251 | + const { talker } = ctx; | ||
| 252 | + const sessionId = `wx-${talker}`; | ||
| 253 | + const prompt = buildHermesPrompt(ctx); | ||
| 254 | + | ||
| 255 | + log('[HERMES] Calling API for', talker, 'session:', sessionId); | ||
| 256 | + const reply = await callHermesApi(prompt, sessionId); | ||
| 257 | + | ||
| 258 | + if (reply) { | ||
| 259 | + log('[HERMES] Reply:', reply.substring(0, 80)); | ||
| 260 | + } else { | ||
| 261 | + log('[HERMES] No reply generated'); | ||
| 262 | + } | ||
| 263 | + | ||
| 264 | + return reply; | ||
| 265 | +} | ||
| 266 | + | ||
| 267 | +// ═══════════════════════════════════════════════════════════════ | ||
| 268 | +// 消息判断 | ||
| 269 | +// ═══════════════════════════════════════════════════════════════ | ||
| 270 | + | ||
| 271 | +function isGroupChat(talker) { | ||
| 272 | + return talker && talker.endsWith('@chatroom'); | ||
| 273 | +} | ||
| 274 | + | ||
| 129 | function isAtMe(content, nickname) { | 275 | function isAtMe(content, nickname) { |
| 130 | if (!nickname) return false; | 276 | if (!nickname) return false; |
| 131 | - // 匹配 @昵称 后跟空格或换行 | ||
| 132 | const pattern = new RegExp('@' + escapeRegex(nickname) + '(\\s|\\n|$)'); | 277 | const pattern = new RegExp('@' + escapeRegex(nickname) + '(\\s|\\n|$)'); |
| 133 | return pattern.test(content); | 278 | return pattern.test(content); |
| 134 | } | 279 | } |
| 135 | 280 | ||
| 136 | -/** | ||
| 137 | - * 判断群聊消息是否引用我 | ||
| 138 | - * 引用消息 content 中包含 <refermsg> XML | ||
| 139 | - */ | ||
| 140 | function isQuoteMe(content, nickname) { | 281 | function isQuoteMe(content, nickname) { |
| 141 | if (!nickname) return false; | 282 | if (!nickname) return false; |
| 142 | if (!content.includes('<refermsg')) return false; | 283 | if (!content.includes('<refermsg')) return false; |
| 143 | - // 提取 displayname | ||
| 144 | const match = content.match(/<displayname>([^<]+)<\/displayname>/); | 284 | const match = content.match(/<displayname>([^<]+)<\/displayname>/); |
| 145 | - if (match) { | ||
| 146 | - return match[1] === nickname; | ||
| 147 | - } | 285 | + if (match) return match[1] === nickname; |
| 148 | return false; | 286 | return false; |
| 149 | } | 287 | } |
| 150 | 288 | ||
| @@ -153,61 +291,84 @@ function escapeRegex(str) { | @@ -153,61 +291,84 @@ function escapeRegex(str) { | ||
| 153 | } | 291 | } |
| 154 | 292 | ||
| 155 | // ═══════════════════════════════════════════════════════════════ | 293 | // ═══════════════════════════════════════════════════════════════ |
| 156 | -// 回复生成 — 默认实现(可替换) | 294 | +// 微信前台检测与唤醒 |
| 157 | // ═══════════════════════════════════════════════════════════════ | 295 | // ═══════════════════════════════════════════════════════════════ |
| 158 | 296 | ||
| 159 | -/** | ||
| 160 | - * 默认回复生成器 — 简单示例 | ||
| 161 | - * 实际使用时,应接入 hermes / Claude CLI 生成智能回复 | ||
| 162 | - * | ||
| 163 | - * @param {Object} ctx — 消息上下文 | ||
| 164 | - * ctx.type: 'private' | 'group-at' | 'group-quote' | ||
| 165 | - * ctx.talker: 对方 wxid / 群聊 ID | ||
| 166 | - * ctx.content: 消息内容 | ||
| 167 | - * ctx.senderName: 发送者昵称(如能从 content 解析) | ||
| 168 | - * @returns {Promise<string|null>} 回复内容,null 表示不回复 | ||
| 169 | - */ | ||
| 170 | -async function defaultReplyGenerator(ctx) { | ||
| 171 | - // 示例:接入 Claude CLI 生成回复 | ||
| 172 | - // 请根据实际环境修改此函数 | 297 | +async function isWechatForeground() { |
| 298 | + try { | ||
| 299 | + const out = await shell('dumpsys activity activities | grep "mResumedActivity"', 5000); | ||
| 300 | + return out.includes('com.tencent.mm'); | ||
| 301 | + } catch { | ||
| 302 | + return false; | ||
| 303 | + } | ||
| 304 | +} | ||
| 173 | 305 | ||
| 174 | - const { type, talker, content, senderName } = ctx; | 306 | +async function wakeWechat() { |
| 307 | + try { | ||
| 308 | + await shell('/system/bin/am start -n com.tencent.mm/.ui.LauncherUI', 5000); | ||
| 309 | + log('[WECHAT] Woken up'); | ||
| 310 | + return true; | ||
| 311 | + } catch (e) { | ||
| 312 | + log('[WECHAT] Failed to wake:', e.message); | ||
| 313 | + return false; | ||
| 314 | + } | ||
| 315 | +} | ||
| 175 | 316 | ||
| 176 | - // 方案 A:调用本地 Claude CLI(如果手机上安装了) | ||
| 177 | - // const reply = await callClaudeCli(content); | 317 | +// ═══════════════════════════════════════════════════════════════ |
| 318 | +// 通知监听(息屏兜底) | ||
| 319 | +// ═══════════════════════════════════════════════════════════════ | ||
| 178 | 320 | ||
| 179 | - // 方案 B:调用 hermes agent API(如果手机上有 HTTP 服务) | ||
| 180 | - // const reply = await callHermesApi(content); | 321 | +async function pollNotifications(bot) { |
| 322 | + const termuxBin = '/data/data/com.termux/files/usr/bin/termux-notification-list'; | ||
| 181 | 323 | ||
| 182 | - // 方案 C:简单兜底回复 | ||
| 183 | - if (type === 'private') { | ||
| 184 | - return `收到你的消息:"${content.substring(0, 50)}",稍后回复~`; | ||
| 185 | - } | ||
| 186 | - if (type === 'group-at') { | ||
| 187 | - return `@${senderName || ''} 收到,稍后处理~`; | ||
| 188 | - } | ||
| 189 | - if (type === 'group-quote') { | ||
| 190 | - return `看到引用了我的消息,稍后回复~`; | 324 | + if (!fs.existsSync(termuxBin)) { |
| 325 | + log('[NOTIF] termux-notification-list not found, notification watcher disabled'); | ||
| 326 | + return; | ||
| 191 | } | 327 | } |
| 192 | 328 | ||
| 193 | - return null; | 329 | + log('[NOTIF] Notification watcher started'); |
| 330 | + const knownKeys = new Set(); | ||
| 331 | + | ||
| 332 | + while (bot.running) { | ||
| 333 | + try { | ||
| 334 | + const out = await shell(termuxBin, 10000); | ||
| 335 | + const notifs = JSON.parse(out); | ||
| 336 | + const wechatNotifs = notifs.filter(n => n.pkgName === 'com.tencent.mm'); | ||
| 337 | + | ||
| 338 | + for (const n of wechatNotifs) { | ||
| 339 | + const key = `${n.title}:${n.content}`; | ||
| 340 | + if (knownKeys.has(key)) continue; | ||
| 341 | + knownKeys.add(key); | ||
| 342 | + | ||
| 343 | + // 限制 set 大小 | ||
| 344 | + if (knownKeys.size > 200) { | ||
| 345 | + const arr = Array.from(knownKeys); | ||
| 346 | + knownKeys.clear(); | ||
| 347 | + arr.slice(-100).forEach(k => knownKeys.add(k)); | ||
| 348 | + } | ||
| 349 | + | ||
| 350 | + log('[NOTIF] WeChat notification:', n.title, n.content.substring(0, 60)); | ||
| 351 | + | ||
| 352 | + // 息屏时微信可能不在前台,先唤醒 | ||
| 353 | + const fg = await isWechatForeground(); | ||
| 354 | + if (!fg) { | ||
| 355 | + await wakeWechat(); | ||
| 356 | + await sleep(3000); // 等待微信同步 DB、LSPosed 写日志 | ||
| 357 | + } | ||
| 358 | + | ||
| 359 | + // 通知 tracker 立即检查(tracker 的 tail -f 会捕获新日志) | ||
| 360 | + // 如果 tracker 已经停止,这里无法恢复它 | ||
| 361 | + } | ||
| 362 | + } catch (e) { | ||
| 363 | + // notification-list 可能返回空或失败,忽略 | ||
| 364 | + } | ||
| 365 | + | ||
| 366 | + await sleep(CONFIG.notificationPollIntervalMs); | ||
| 367 | + } | ||
| 194 | } | 368 | } |
| 195 | 369 | ||
| 196 | -/** | ||
| 197 | - * 调用 Claude CLI 生成回复(需要手机上安装 claude CLI) | ||
| 198 | - */ | ||
| 199 | -async function callClaudeCli(prompt) { | ||
| 200 | - return new Promise((resolve, reject) => { | ||
| 201 | - const { spawn } = require('child_process'); | ||
| 202 | - const proc = spawn('claude', ['-p', prompt], { timeout: 30000 }); | ||
| 203 | - let stdout = ''; | ||
| 204 | - proc.stdout.on('data', d => stdout += d.toString()); | ||
| 205 | - proc.on('close', code => { | ||
| 206 | - if (code !== 0) reject(new Error('claude exit ' + code)); | ||
| 207 | - else resolve(stdout.trim()); | ||
| 208 | - }); | ||
| 209 | - proc.on('error', reject); | ||
| 210 | - }); | 370 | +function sleep(ms) { |
| 371 | + return new Promise(r => setTimeout(r, ms)); | ||
| 211 | } | 372 | } |
| 212 | 373 | ||
| 213 | // ═══════════════════════════════════════════════════════════════ | 374 | // ═══════════════════════════════════════════════════════════════ |
| @@ -215,7 +376,7 @@ async function callClaudeCli(prompt) { | @@ -215,7 +376,7 @@ async function callClaudeCli(prompt) { | ||
| 215 | // ═══════════════════════════════════════════════════════════════ | 376 | // ═══════════════════════════════════════════════════════════════ |
| 216 | 377 | ||
| 217 | class AutoReplyBot { | 378 | class AutoReplyBot { |
| 218 | - constructor(replyGenerator = defaultReplyGenerator) { | 379 | + constructor(replyGenerator = hermesReplyGenerator) { |
| 219 | this.tracker = new AndroidWechatTracker(); | 380 | this.tracker = new AndroidWechatTracker(); |
| 220 | this.state = new ReplyState(CONFIG.stateFile); | 381 | this.state = new ReplyState(CONFIG.stateFile); |
| 221 | this.replyGenerator = replyGenerator; | 382 | this.replyGenerator = replyGenerator; |
| @@ -223,36 +384,24 @@ class AutoReplyBot { | @@ -223,36 +384,24 @@ class AutoReplyBot { | ||
| 223 | } | 384 | } |
| 224 | 385 | ||
| 225 | async handleMessage(msg) { | 386 | async handleMessage(msg) { |
| 226 | - // 只处理收到的消息 | ||
| 227 | if (msg.type !== 'recv') return; | 387 | if (msg.type !== 'recv') return; |
| 228 | 388 | ||
| 229 | const { talker, content, msgId } = msg; | 389 | const { talker, content, msgId } = msg; |
| 230 | if (!talker || !content) return; | 390 | if (!talker || !content) return; |
| 231 | 391 | ||
| 232 | - // 忽略自己 | ||
| 233 | if (CONFIG.ignoreSelf && talker === CONFIG.myWxid) return; | 392 | if (CONFIG.ignoreSelf && talker === CONFIG.myWxid) return; |
| 234 | - | ||
| 235 | - // 已回复过 | ||
| 236 | if (this.state.hasReplied(msgId)) return; | 393 | if (this.state.hasReplied(msgId)) return; |
| 237 | - | ||
| 238 | - // 冷却检查 | ||
| 239 | if (this.state.isCooldown(talker)) { | 394 | if (this.state.isCooldown(talker)) { |
| 240 | - log(`[COOLDOWN] Skip ${talker}`); | 395 | + log('[COOLDOWN] Skip', talker); |
| 241 | return; | 396 | return; |
| 242 | } | 397 | } |
| 243 | 398 | ||
| 244 | let replyType = null; | 399 | let replyType = null; |
| 245 | let senderName = ''; | 400 | let senderName = ''; |
| 246 | 401 | ||
| 247 | - // 判断消息类型 | ||
| 248 | if (!isGroupChat(talker)) { | 402 | if (!isGroupChat(talker)) { |
| 249 | - // 单聊 | ||
| 250 | - if (CONFIG.replyInPrivateChat) { | ||
| 251 | - replyType = 'private'; | ||
| 252 | - } | 403 | + if (CONFIG.replyInPrivateChat) replyType = 'private'; |
| 253 | } else { | 404 | } else { |
| 254 | - // 群聊 | ||
| 255 | - // 提取发送者昵称(群聊消息格式:"昵称:\n内容" 或 XML) | ||
| 256 | const nameMatch = content.match(/^([^:\n]+):\n/); | 405 | const nameMatch = content.match(/^([^:\n]+):\n/); |
| 257 | if (nameMatch) senderName = nameMatch[1]; | 406 | if (nameMatch) senderName = nameMatch[1]; |
| 258 | 407 | ||
| @@ -267,7 +416,13 @@ class AutoReplyBot { | @@ -267,7 +416,13 @@ class AutoReplyBot { | ||
| 267 | 416 | ||
| 268 | log(`[${replyType.toUpperCase()}] ${talker}: ${content.substring(0, 80)}`); | 417 | log(`[${replyType.toUpperCase()}] ${talker}: ${content.substring(0, 80)}`); |
| 269 | 418 | ||
| 270 | - // 生成回复 | 419 | + // 发送前检查微信前台 |
| 420 | + const fg = await isWechatForeground(); | ||
| 421 | + if (!fg) { | ||
| 422 | + await wakeWechat(); | ||
| 423 | + await sleep(2000); | ||
| 424 | + } | ||
| 425 | + | ||
| 271 | let replyText; | 426 | let replyText; |
| 272 | try { | 427 | try { |
| 273 | replyText = await this.replyGenerator({ | 428 | replyText = await this.replyGenerator({ |
| @@ -287,12 +442,10 @@ class AutoReplyBot { | @@ -287,12 +442,10 @@ class AutoReplyBot { | ||
| 287 | return; | 442 | return; |
| 288 | } | 443 | } |
| 289 | 444 | ||
| 290 | - // 截断 | ||
| 291 | if (replyText.length > CONFIG.maxReplyLength) { | 445 | if (replyText.length > CONFIG.maxReplyLength) { |
| 292 | replyText = replyText.substring(0, CONFIG.maxReplyLength) + '...'; | 446 | replyText = replyText.substring(0, CONFIG.maxReplyLength) + '...'; |
| 293 | } | 447 | } |
| 294 | 448 | ||
| 295 | - // 发送回复 | ||
| 296 | try { | 449 | try { |
| 297 | const result = await sendTextMessageBroadcast(talker, replyText); | 450 | const result = await sendTextMessageBroadcast(talker, replyText); |
| 298 | if (result.ok) { | 451 | if (result.ok) { |
| @@ -306,24 +459,32 @@ class AutoReplyBot { | @@ -306,24 +459,32 @@ class AutoReplyBot { | ||
| 306 | } | 459 | } |
| 307 | } | 460 | } |
| 308 | 461 | ||
| 309 | - start() { | 462 | + async start() { |
| 310 | if (this.running) return; | 463 | if (this.running) return; |
| 311 | this.running = true; | 464 | this.running = true; |
| 312 | 465 | ||
| 313 | log('=== Auto-Reply Bot Started ==='); | 466 | log('=== Auto-Reply Bot Started ==='); |
| 467 | + log('Hermes:', `${CONFIG.hermesHost}:${CONFIG.hermesPort}`); | ||
| 314 | log('Config:', JSON.stringify({ | 468 | log('Config:', JSON.stringify({ |
| 315 | myWxid: CONFIG.myWxid ? '***' : '(not set)', | 469 | myWxid: CONFIG.myWxid ? '***' : '(not set)', |
| 316 | myNickname: CONFIG.myNickname || '(not set)', | 470 | myNickname: CONFIG.myNickname || '(not set)', |
| 317 | replyInPrivateChat: CONFIG.replyInPrivateChat, | 471 | replyInPrivateChat: CONFIG.replyInPrivateChat, |
| 318 | replyAtInGroup: CONFIG.replyAtInGroup, | 472 | replyAtInGroup: CONFIG.replyAtInGroup, |
| 319 | replyQuoteInGroup: CONFIG.replyQuoteInGroup, | 473 | replyQuoteInGroup: CONFIG.replyQuoteInGroup, |
| 474 | + notificationEnabled: CONFIG.notificationEnabled, | ||
| 320 | })); | 475 | })); |
| 321 | 476 | ||
| 477 | + // 启动 tracker(流式 tail -f,最实时) | ||
| 322 | this.tracker.on('message', msg => this.handleMessage(msg)); | 478 | this.tracker.on('message', msg => this.handleMessage(msg)); |
| 323 | this.tracker.on('error', err => log('[TRACKER ERROR]', err.message)); | 479 | this.tracker.on('error', err => log('[TRACKER ERROR]', err.message)); |
| 324 | - this.tracker.startPolling(2000); | 480 | + this.tracker.startStream(); |
| 481 | + log('[TRACKER] Stream started (tail -f)'); | ||
| 482 | + | ||
| 483 | + // 启动通知监听(息屏兜底) | ||
| 484 | + if (CONFIG.notificationEnabled) { | ||
| 485 | + this.notifLoop = pollNotifications(this); | ||
| 486 | + } | ||
| 325 | 487 | ||
| 326 | - // 优雅退出 | ||
| 327 | process.on('SIGINT', () => this.stop()); | 488 | process.on('SIGINT', () => this.stop()); |
| 328 | process.on('SIGTERM', () => this.stop()); | 489 | process.on('SIGTERM', () => this.stop()); |
| 329 | } | 490 | } |
| @@ -331,8 +492,8 @@ class AutoReplyBot { | @@ -331,8 +492,8 @@ class AutoReplyBot { | ||
| 331 | stop() { | 492 | stop() { |
| 332 | if (!this.running) return; | 493 | if (!this.running) return; |
| 333 | this.running = false; | 494 | this.running = false; |
| 334 | - this.tracker.stopPolling(); | ||
| 335 | this.tracker.stopStream(); | 495 | this.tracker.stopStream(); |
| 496 | + this.tracker.stopPolling(); | ||
| 336 | log('=== Auto-Reply Bot Stopped ==='); | 497 | log('=== Auto-Reply Bot Stopped ==='); |
| 337 | process.exit(0); | 498 | process.exit(0); |
| 338 | } | 499 | } |
| @@ -343,7 +504,6 @@ class AutoReplyBot { | @@ -343,7 +504,6 @@ class AutoReplyBot { | ||
| 343 | // ═══════════════════════════════════════════════════════════════ | 504 | // ═══════════════════════════════════════════════════════════════ |
| 344 | 505 | ||
| 345 | if (require.main === module) { | 506 | if (require.main === module) { |
| 346 | - // 从环境变量读取配置 | ||
| 347 | if (process.env.MY_WXID) CONFIG.myWxid = process.env.MY_WXID; | 507 | if (process.env.MY_WXID) CONFIG.myWxid = process.env.MY_WXID; |
| 348 | if (process.env.MY_NICKNAME) CONFIG.myNickname = process.env.MY_NICKNAME; | 508 | if (process.env.MY_NICKNAME) CONFIG.myNickname = process.env.MY_NICKNAME; |
| 349 | 509 | ||
| @@ -352,7 +512,7 @@ if (require.main === module) { | @@ -352,7 +512,7 @@ if (require.main === module) { | ||
| 352 | console.error('ERROR: 请设置 MY_WXID 和 MY_NICKNAME 环境变量'); | 512 | console.error('ERROR: 请设置 MY_WXID 和 MY_NICKNAME 环境变量'); |
| 353 | console.error(''); | 513 | console.error(''); |
| 354 | console.error('用法示例:'); | 514 | console.error('用法示例:'); |
| 355 | - console.error(' MY_WXID=wxid_xxxxxx MY_NICKNAME=小明 nohup node auto-reply.js \&'); | 515 | + console.error(' MY_WXID=wxid_xxxxxx MY_NICKNAME=小明 nohup node auto-reply.js &'); |
| 356 | console.error(''); | 516 | console.error(''); |
| 357 | process.exit(1); | 517 | process.exit(1); |
| 358 | } | 518 | } |
| @@ -361,4 +521,4 @@ if (require.main === module) { | @@ -361,4 +521,4 @@ if (require.main === module) { | ||
| 361 | bot.start(); | 521 | bot.start(); |
| 362 | } | 522 | } |
| 363 | 523 | ||
| 364 | -module.exports = { AutoReplyBot, defaultReplyGenerator, CONFIG }; | 524 | +module.exports = { AutoReplyBot, hermesReplyGenerator, callHermesApi, CONFIG }; |
wechat_plain/scripts/local-testkit/config.json
| @@ -14,10 +14,15 @@ | @@ -14,10 +14,15 @@ | ||
| 14 | "termux_bin_path": "/data/data/com.termux/files/usr/bin/termux-notification-list" | 14 | "termux_bin_path": "/data/data/com.termux/files/usr/bin/termux-notification-list" |
| 15 | }, | 15 | }, |
| 16 | "hermes": { | 16 | "hermes": { |
| 17 | - "api_url": "http://localhost:8642/v1/chat/completions", | 17 | + "host": "localhost", |
| 18 | + "port": 3010, | ||
| 18 | "timeout_sec": 120, | 19 | "timeout_sec": 120, |
| 19 | "default_session_prefix": "wx-", | 20 | "default_session_prefix": "wx-", |
| 20 | - "system_hint": "你是一个微信智能助手。根据收到的消息,给出简短、自然、得体的中文回复。群聊中被@时要友好回应。" | 21 | + "system_hint": "你是一个微信智能助手。根据收到的消息,给出简短、自然、得体的中文回复。群聊中被@时要友好回应。", |
| 22 | + "api_url": "", | ||
| 23 | + "api_key": "", | ||
| 24 | + "provider": "anthropic", | ||
| 25 | + "model": "" | ||
| 21 | }, | 26 | }, |
| 22 | "auto_reply": { | 27 | "auto_reply": { |
| 23 | "enabled": true, | 28 | "enabled": true, |
wechat_plain/scripts/local-testkit/HERMES_WECHAT_ARCHITECTURE.md renamed to wechat_plain/scripts/local-testkit/docs/HERMES_WECHAT_ARCHITECTURE.md
wechat_plain/scripts/local-testkit/NOTIFICATION_STRATEGY.md renamed to wechat_plain/scripts/local-testkit/docs/NOTIFICATION_STRATEGY.md
wechat_plain/scripts/local-testkit/docs/README.md
0 → 100644
| 1 | +# Pixel WeChat Auto-Reply — Plugin for Hermes | ||
| 2 | + | ||
| 3 | +> 在 Pixel 手机本地实现微信全自动闭环,**通过 hermes API 生成回复**,息屏时通过通知监听兜底。 | ||
| 4 | +> | ||
| 5 | +> 运行环境:Pixel 10 Fold / Android 15 / WeChat 8.0.72 | ||
| 6 | + | ||
| 7 | +--- | ||
| 8 | + | ||
| 9 | +## 架构(双轨制) | ||
| 10 | + | ||
| 11 | +### 主流程:Node.js Plugin(推荐) | ||
| 12 | + | ||
| 13 | +``` | ||
| 14 | +LSPosed 模块 hook WCDB insert | ||
| 15 | + │ | ||
| 16 | + ▼ | ||
| 17 | +┌─────────────────────────┐ | ||
| 18 | +│ XposedBridge.log() │────→ /data/adb/lspd/log/verbose_*.log | ||
| 19 | +│ writeMsgToFile() │────→ /sdcard/hermes/wechat_msgs.jsonl | ||
| 20 | +└─────────────────────────┘ | ||
| 21 | + │ | ||
| 22 | + ▼ | ||
| 23 | +┌─────────────────────────┐ ┌─────────────────────────┐ | ||
| 24 | +│ AndroidWechatTracker │────→│ tail -f 日志 │ | ||
| 25 | +│ (Node.js EventEmitter) │ │ 实时事件驱动 │ | ||
| 26 | +└─────────────────────────┘ └─────────────────────────┘ | ||
| 27 | + │ | ||
| 28 | + ▼ | ||
| 29 | +┌─────────────────────────┐ | ||
| 30 | +│ auto-reply.js │ | ||
| 31 | +│ handleMessage() │ | ||
| 32 | +│ - 判断单聊/群聊@/引用 │ | ||
| 33 | +│ - 检查 cooldown │ | ||
| 34 | +└─────────────────────────┘ | ||
| 35 | + │ | ||
| 36 | + ▼ | ||
| 37 | +┌─────────────────────────┐ ┌─────────────────────────┐ | ||
| 38 | +│ callHermesApi() │────→│ POST localhost:3010 │ | ||
| 39 | +│ buildHermesPrompt() │ │ /api/master/chat │ | ||
| 40 | +│ 构造 prompt + session │ │ SSE 流式响应 │ | ||
| 41 | +└─────────────────────────┘ └─────────────────────────┘ | ||
| 42 | + │ │ | ||
| 43 | + │ ▼ | ||
| 44 | + │ ┌─────────────────────────┐ | ||
| 45 | + │ │ hermes (lemonPCRM) │ | ||
| 46 | + │ │ MasterSession │ | ||
| 47 | + │ │ → Claude CLI / Agents │ | ||
| 48 | + │ └─────────────────────────┘ | ||
| 49 | + │ │ | ||
| 50 | + ▼ ▼ | ||
| 51 | +┌─────────────────────────┐ ┌─────────────────────────┐ | ||
| 52 | +│ parseSSE() 提取 output │────→│ 回复文本 │ | ||
| 53 | +└─────────────────────────┘ └─────────────────────────┘ | ||
| 54 | + │ | ||
| 55 | + ▼ | ||
| 56 | +┌─────────────────────────┐ ┌─────────────────────────┐ | ||
| 57 | +│ 微信在前台? │ No →│ am start 唤醒微信 │ | ||
| 58 | +└─────────────────────────┘ │ 等待 2s │ | ||
| 59 | + │Yes └─────────────────────────┘ | ||
| 60 | + ▼ | ||
| 61 | +┌─────────────────────────┐ | ||
| 62 | +│ sendTextMessageBroadcast │────→ am broadcast | ||
| 63 | +│ (android-wechat-sender) │ action: com.hermes.wechat.SEND_MSG | ||
| 64 | +└─────────────────────────┘ | ||
| 65 | + │ | ||
| 66 | + ▼ | ||
| 67 | +┌─────────────────────────┐ | ||
| 68 | +│ LSPosed 模块 │────→ u01.r1.a() → builder → m1.a() | ||
| 69 | +│ HermesWeChatModule │ setIntField f=0, i=4 | ||
| 70 | +└─────────────────────────┘ | ||
| 71 | +``` | ||
| 72 | + | ||
| 73 | +**优势**: | ||
| 74 | +- 事件驱动(tail -f),比 DB 轮询实时性高 | ||
| 75 | +- 不需要 sqlcipher 解密,不需要复制 WAL | ||
| 76 | +- 通过 hermes API 享受完整的 Agent 编排、工具调用、记忆能力 | ||
| 77 | +- hermes 不可用时自动 fallback 到直接 LLM API | ||
| 78 | + | ||
| 79 | +### 备用流程:Python Daemon | ||
| 80 | + | ||
| 81 | +`hermes_wechat_daemon.py` 作为备用通道,当 Node.js plugin 不可用时运行: | ||
| 82 | +- DB 轮询检测 | ||
| 83 | +- 优先调用 hermes API,fallback 到直接 LLM API | ||
| 84 | +- 同样支持通知监听兜底 | ||
| 85 | + | ||
| 86 | +### 息屏兜底:通知监听 + Termux 保活 | ||
| 87 | + | ||
| 88 | +``` | ||
| 89 | +息屏后微信/Termux 被冻结 | ||
| 90 | + │ | ||
| 91 | + ▼ | ||
| 92 | +微信消息到来 → 系统通知栏产生通知 | ||
| 93 | + │ | ||
| 94 | + ▼ | ||
| 95 | +┌─────────────────────────┐ | ||
| 96 | +│ termux-notification-list │────→ 每 3 秒轮询(独立线程) | ||
| 97 | +│ (Node.js plugin 内) │ | ||
| 98 | +└─────────────────────────┘ | ||
| 99 | + │ | ||
| 100 | + ▼ | ||
| 101 | +检测到微信通知 | ||
| 102 | + │ | ||
| 103 | + ▼ | ||
| 104 | +isWechatForeground()? No → wakeWechat() → sleep 3s | ||
| 105 | + │ | ||
| 106 | + ▼ | ||
| 107 | +微信唤醒 → LSPosed 恢复写日志 → tail -f 捕获 → 正常处理 | ||
| 108 | +``` | ||
| 109 | + | ||
| 110 | +**保活措施**: | ||
| 111 | +1. `termux-wake-lock` — 防止息屏后 CPU 被冻结 | ||
| 112 | +2. 关闭电池优化(Termux / Termux:API / 微信) | ||
| 113 | +3. `keepalive.sh` 进程守护 — hermes / auto-reply 死了自动重启 | ||
| 114 | + | ||
| 115 | +--- | ||
| 116 | + | ||
| 117 | +## 文件清单 | ||
| 118 | + | ||
| 119 | +| 文件 | 用途 | 环境 | | ||
| 120 | +|------|------|------| | ||
| 121 | +| `../../server/services/wechat/auto-reply.js` | **主 plugin**:tracker → hermes API → sender | proot Ubuntu | | ||
| 122 | +| `../../server/services/wechat/android-wechat-tracker.js` | 手机端消息跟踪(tail -f LSPosed 日志) | proot Ubuntu | | ||
| 123 | +| `../../server/services/wechat/android-wechat-sender.js` | 手机端消息发送(am broadcast) | proot Ubuntu | | ||
| 124 | +| `hermes_wechat_daemon.py` | **备用 daemon**:DB 轮询 → hermes/LLM API → sender | Termux / proot | | ||
| 125 | +| `config.json` | 运行时配置 | Termux / proot | | ||
| 126 | +| `keepalive.sh` | **保活脚本**:wake-lock + 进程守护 | Termux | | ||
| 127 | +| `test_env.py` | 环境预检 | Termux / proot | | ||
| 128 | +| `test_send_msg.py` | 发送消息测试 | Termux / proot | | ||
| 129 | +| `run_all_tests.sh` | 一键运行全部测试 | Termux / proot | | ||
| 130 | + | ||
| 131 | +--- | ||
| 132 | + | ||
| 133 | +## 快速开始 | ||
| 134 | + | ||
| 135 | +### 1. 前置设置(必须手动) | ||
| 136 | + | ||
| 137 | +Android 设置中关闭电池优化: | ||
| 138 | +- 设置 → 应用 → Termux → 电池优化 → **不优化** | ||
| 139 | +- 设置 → 应用 → Termux:API → 电池优化 → **不优化** | ||
| 140 | +- 设置 → 应用 → 微信 → 电池优化 → **不优化** | ||
| 141 | +- 设置 → 应用 → Termux → 允许自启动 / 后台运行 | ||
| 142 | + | ||
| 143 | +Termux:API 权限: | ||
| 144 | +- 安装 Termux:API 应用 | ||
| 145 | +- 设置 → 应用 → Termux:API → 通知访问权限 → **允许** | ||
| 146 | + | ||
| 147 | +### 2. 部署 | ||
| 148 | + | ||
| 149 | +```bash | ||
| 150 | +# Mac 上推送 | ||
| 151 | +adb push server/services/wechat /data/data/com.termux/files/home/hermes-wechat | ||
| 152 | +adb push wechat_plain/scripts/local-testkit /data/data/com.termux/files/home/local-testkit | ||
| 153 | + | ||
| 154 | +# Pixel 上 | ||
| 155 | +cp -r /sdcard/hermes/local-testkit ~/local-testkit | ||
| 156 | +cd ~/local-testkit | ||
| 157 | +``` | ||
| 158 | + | ||
| 159 | +### 3. 启动 hermes server(如果还没运行) | ||
| 160 | + | ||
| 161 | +```bash | ||
| 162 | +cd ~/hermes-wechat | ||
| 163 | +node server/index.js | ||
| 164 | +``` | ||
| 165 | + | ||
| 166 | +### 4. 启动 auto-reply plugin(推荐主流程) | ||
| 167 | + | ||
| 168 | +```bash | ||
| 169 | +cd ~/hermes-wechat/server/services/wechat | ||
| 170 | +export MY_WXID=wxid_xxxxxx | ||
| 171 | +export MY_NICKNAME=你的昵称 | ||
| 172 | +export HERMES_HOST=localhost | ||
| 173 | +export HERMES_PORT=3010 | ||
| 174 | + | ||
| 175 | +nohup node auto-reply.js > auto-reply.log 2>&1 & | ||
| 176 | +``` | ||
| 177 | + | ||
| 178 | +### 5. 启动保活脚本(推荐) | ||
| 179 | + | ||
| 180 | +```bash | ||
| 181 | +cd ~/local-testkit | ||
| 182 | +export MY_WXID=wxid_xxxxxx | ||
| 183 | +export MY_NICKNAME=你的昵称 | ||
| 184 | +export HERMES_DIR=/data/data/com.termux/files/home/hermes-wechat | ||
| 185 | + | ||
| 186 | +chmod +x keepalive.sh | ||
| 187 | +nohup ./keepalive.sh > keepalive.log 2>&1 & | ||
| 188 | +``` | ||
| 189 | + | ||
| 190 | +### 6. 备用:启动 Python daemon | ||
| 191 | + | ||
| 192 | +```bash | ||
| 193 | +cd ~/local-testkit | ||
| 194 | +python3 hermes_wechat_daemon.py --daemon | ||
| 195 | +``` | ||
| 196 | + | ||
| 197 | +--- | ||
| 198 | + | ||
| 199 | +## 配置说明 | ||
| 200 | + | ||
| 201 | +### auto-reply.js 环境变量 | ||
| 202 | + | ||
| 203 | +| 变量 | 默认值 | 说明 | | ||
| 204 | +|------|--------|------| | ||
| 205 | +| `MY_WXID` | — | 你的微信 wxid(必填) | | ||
| 206 | +| `MY_NICKNAME` | — | 你的微信昵称(必填,用于判断群聊@) | | ||
| 207 | +| `HERMES_HOST` | `localhost` | hermes server 地址 | | ||
| 208 | +| `HERMES_PORT` | `3010` | hermes server 端口 | | ||
| 209 | +| `NOTIFICATION_ENABLED` | `true` | 是否启用通知监听兜底 | | ||
| 210 | + | ||
| 211 | +### Python daemon config.json | ||
| 212 | + | ||
| 213 | +```json | ||
| 214 | +{ | ||
| 215 | + "hermes": { | ||
| 216 | + "host": "localhost", | ||
| 217 | + "port": 3010, | ||
| 218 | + "timeout_sec": 120, | ||
| 219 | + "default_session_prefix": "wx-", | ||
| 220 | + "system_hint": "你是一个微信智能助手...", | ||
| 221 | + "api_url": "", | ||
| 222 | + "api_key": "", | ||
| 223 | + "provider": "anthropic", | ||
| 224 | + "model": "" | ||
| 225 | + } | ||
| 226 | +} | ||
| 227 | +``` | ||
| 228 | + | ||
| 229 | +- `host`/`port`:本地 hermes API 地址 | ||
| 230 | +- `api_url`/`api_key`/`provider`/`model`:hermes 不可用时 fallback 到直接 LLM API 的配置 | ||
| 231 | + | ||
| 232 | +--- | ||
| 233 | + | ||
| 234 | +## 故障排查 | ||
| 235 | + | ||
| 236 | +| 问题 | 排查步骤 | | ||
| 237 | +|------|---------| | ||
| 238 | +| auto-reply 收不到消息 | 检查 LSPosed 模块日志:`su -c "cat /data/adb/lspd/log/verbose_*.log \| grep HermesWX \| tail -20"` | | ||
| 239 | +| hermes API 返回空 | 检查 hermes server 是否运行:`curl http://localhost:3010/api/health` | | ||
| 240 | +| 息屏后完全失效 | 确认电池优化已关闭;检查 `keepalive.sh` 是否运行 | | ||
| 241 | +| 发送失败 | 检查微信是否在前台;检查 LSPosed 模块是否启用 | | ||
| 242 | +| Python daemon 查询不到新消息 | 检查微信是否在前台;检查 `sqlcipher_key`;确认 WAL 已复制 | | ||
| 243 | + | ||
| 244 | +--- | ||
| 245 | + | ||
| 246 | +## 目录结构 | ||
| 247 | + | ||
| 248 | +``` | ||
| 249 | +local-testkit/ | ||
| 250 | +├── hermes_wechat_daemon.py # 备用守护进程(DB 轮询) | ||
| 251 | +├── config.json # 运行时配置 | ||
| 252 | +├── keepalive.sh # 保活脚本 | ||
| 253 | +├── test_env.py # 环境预检 | ||
| 254 | +├── test_send_msg.py # 发送消息测试 | ||
| 255 | +├── run_all_tests.sh # 一键测试 | ||
| 256 | +└── docs/ | ||
| 257 | + ├── README.md # 本文档 | ||
| 258 | + ├── HERMES_WECHAT_ARCHITECTURE.md | ||
| 259 | + ├── REVERSE_ENGINEERING_LOG.md | ||
| 260 | + ├── NOTIFICATION_STRATEGY.md | ||
| 261 | + └── plan.md | ||
| 262 | + | ||
| 263 | +server/services/wechat/ # 主 plugin(Node.js) | ||
| 264 | +├── auto-reply.js # 主入口:tracker → hermes → sender | ||
| 265 | +├── android-wechat-tracker.js # 消息跟踪 | ||
| 266 | +├── android-wechat-sender.js # 消息发送 | ||
| 267 | +├── index.js # 统一入口 | ||
| 268 | +└── README.md # plugin 文档 | ||
| 269 | +``` |
wechat_plain/scripts/local-testkit/README_LOCAL.md renamed to wechat_plain/scripts/local-testkit/docs/README_LOCAL.md
wechat_plain/scripts/local-testkit/REVERSE_ENGINEERING_LOG.md renamed to wechat_plain/scripts/local-testkit/docs/REVERSE_ENGINEERING_LOG.md
wechat_plain/scripts/local-testkit/plan.md renamed to wechat_plain/scripts/local-testkit/docs/plan.md
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): | @@ -210,24 +210,40 @@ def get_recent_context(db_path, talker, before_msg_id, limit=5): | ||
| 210 | logging.debug(f"Context query failed: {e}") | 210 | logging.debug(f"Context query failed: {e}") |
| 211 | return [] | 211 | return [] |
| 212 | 212 | ||
| 213 | -# ── LLM API Call ────────────────────────────────────────────────────────────── | ||
| 214 | -def call_hermes_api(talker, content, context_msgs=None): | ||
| 215 | - """ | ||
| 216 | - Call configured LLM API (Anthropic / OpenAI / Azure / local proxy). | ||
| 217 | - Returns reply text or None. | ||
| 218 | - """ | ||
| 219 | - hermes_cfg = config.get("hermes", {}) | ||
| 220 | - api_url = hermes_cfg.get("api_url", "") | ||
| 221 | - api_key = hermes_cfg.get("api_key", "") | ||
| 222 | - provider = hermes_cfg.get("provider", "anthropic") | ||
| 223 | - model = hermes_cfg.get("model", "") | ||
| 224 | - timeout = hermes_cfg.get("timeout_sec", 120) | ||
| 225 | - prefix = hermes_cfg.get("default_session_prefix", "wx-") | ||
| 226 | - system_hint = hermes_cfg.get("system_hint", "") | 213 | +# ── Hermes API Call (primary) + LLM API Fallback ────────────────────────────── |
| 214 | + | ||
| 215 | +def _parse_sse(buffer): | ||
| 216 | + """Parse SSE response buffer, extract result.output.""" | ||
| 217 | + lines = buffer.decode("utf-8").split("\n") | ||
| 218 | + events = [] | ||
| 219 | + current_event = None | ||
| 220 | + current_data = "" | ||
| 221 | + for line in lines: | ||
| 222 | + line = line.strip() | ||
| 223 | + if line.startswith("event:"): | ||
| 224 | + if current_event is not None: | ||
| 225 | + events.append((current_event, current_data)) | ||
| 226 | + current_event = line[6:].strip() | ||
| 227 | + current_data = "" | ||
| 228 | + elif line.startswith("data:"): | ||
| 229 | + current_data += line[5:].strip() | ||
| 230 | + elif line == "" and current_event is not None: | ||
| 231 | + events.append((current_event, current_data)) | ||
| 232 | + current_event = None | ||
| 233 | + current_data = "" | ||
| 234 | + if current_event is not None: | ||
| 235 | + events.append((current_event, current_data)) | ||
| 236 | + for ev, data in events: | ||
| 237 | + if ev == "result": | ||
| 238 | + try: | ||
| 239 | + return json.loads(data).get("output", "") | ||
| 240 | + except Exception: | ||
| 241 | + return data | ||
| 242 | + return "" | ||
| 227 | 243 | ||
| 228 | - session_id = f"{prefix}{talker}" | ||
| 229 | 244 | ||
| 230 | - # Build messages | 245 | +def _build_llm_messages(talker, content, context_msgs, system_hint): |
| 246 | + """Build messages array for direct LLM API call.""" | ||
| 231 | messages = [] | 247 | messages = [] |
| 232 | if system_hint: | 248 | if system_hint: |
| 233 | messages.append({"role": "system", "content": system_hint}) | 249 | messages.append({"role": "system", "content": system_hint}) |
| @@ -238,8 +254,21 @@ def call_hermes_api(talker, content, context_msgs=None): | @@ -238,8 +254,21 @@ def call_hermes_api(talker, content, context_msgs=None): | ||
| 238 | ctx_lines.append(f"{who}: {m.get('content', '')[:100]}") | 254 | ctx_lines.append(f"{who}: {m.get('content', '')[:100]}") |
| 239 | messages.append({"role": "system", "content": "最近对话上下文:\n" + "\n".join(ctx_lines)}) | 255 | messages.append({"role": "system", "content": "最近对话上下文:\n" + "\n".join(ctx_lines)}) |
| 240 | messages.append({"role": "user", "content": f"收到新消息(来自 {talker}):{content}\n请直接给出回复内容,不要加任何解释或格式标记。"}) | 256 | messages.append({"role": "user", "content": f"收到新消息(来自 {talker}):{content}\n请直接给出回复内容,不要加任何解释或格式标记。"}) |
| 257 | + return messages | ||
| 258 | + | ||
| 259 | + | ||
| 260 | +def _call_llm_api(talker, content, context_msgs=None): | ||
| 261 | + """Direct LLM API call (fallback when hermes is unavailable).""" | ||
| 262 | + hermes_cfg = config.get("hermes", {}) | ||
| 263 | + api_url = hermes_cfg.get("api_url", "") | ||
| 264 | + api_key = hermes_cfg.get("api_key", "") | ||
| 265 | + provider = hermes_cfg.get("provider", "anthropic") | ||
| 266 | + model = hermes_cfg.get("model", "") | ||
| 267 | + timeout = hermes_cfg.get("timeout_sec", 120) | ||
| 268 | + system_hint = hermes_cfg.get("system_hint", "") | ||
| 269 | + | ||
| 270 | + messages = _build_llm_messages(talker, content, context_msgs, system_hint) | ||
| 241 | 271 | ||
| 242 | - # Provider-specific request building | ||
| 243 | headers = {"Content-Type": "application/json"} | 272 | headers = {"Content-Type": "application/json"} |
| 244 | if provider == "anthropic": | 273 | if provider == "anthropic": |
| 245 | if not api_url: | 274 | if not api_url: |
| @@ -252,7 +281,6 @@ def call_hermes_api(talker, content, context_msgs=None): | @@ -252,7 +281,6 @@ def call_hermes_api(talker, content, context_msgs=None): | ||
| 252 | "model": model, | 281 | "model": model, |
| 253 | "max_tokens": 1024, | 282 | "max_tokens": 1024, |
| 254 | "messages": messages, | 283 | "messages": messages, |
| 255 | - "metadata": {"session_id": session_id} | ||
| 256 | }).encode("utf-8") | 284 | }).encode("utf-8") |
| 257 | elif provider in ("openai", "azure", "doubao"): | 285 | elif provider in ("openai", "azure", "doubao"): |
| 258 | headers["Authorization"] = f"Bearer {api_key}" | 286 | headers["Authorization"] = f"Bearer {api_key}" |
| @@ -267,39 +295,74 @@ def call_hermes_api(talker, content, context_msgs=None): | @@ -267,39 +295,74 @@ def call_hermes_api(talker, content, context_msgs=None): | ||
| 267 | return None | 295 | return None |
| 268 | 296 | ||
| 269 | req = urllib.request.Request(api_url, data=payload, headers=headers, method="POST") | 297 | req = urllib.request.Request(api_url, data=payload, headers=headers, method="POST") |
| 270 | - | ||
| 271 | try: | 298 | try: |
| 272 | - print(f"[LLM] Calling {provider} API for {talker}...", flush=True) | ||
| 273 | with urllib.request.urlopen(req, timeout=timeout) as resp: | 299 | with urllib.request.urlopen(req, timeout=timeout) as resp: |
| 274 | - print(f"[LLM] API connected (status={resp.status})", flush=True) | ||
| 275 | data = json.loads(resp.read().decode("utf-8")) | 300 | data = json.loads(resp.read().decode("utf-8")) |
| 276 | - | ||
| 277 | - # Parse response | ||
| 278 | - result_text = None | ||
| 279 | if provider == "anthropic": | 301 | if provider == "anthropic": |
| 280 | - result_text = data.get("content", [{}])[0].get("text", "") | 302 | + return data.get("content", [{}])[0].get("text", "") |
| 281 | elif provider in ("openai", "azure", "doubao"): | 303 | elif provider in ("openai", "azure", "doubao"): |
| 282 | - result_text = data.get("choices", [{}])[0].get("message", {}).get("content", "") | 304 | + return data.get("choices", [{}])[0].get("message", {}).get("content", "") |
| 305 | + except Exception as e: | ||
| 306 | + logging.error(f"[LLM] Fallback API failed: {e}") | ||
| 307 | + return None | ||
| 283 | 308 | ||
| 284 | - if result_text: | ||
| 285 | - print(f"[LLM] Reply for {talker}: {result_text[:80]}", flush=True) | ||
| 286 | - logging.info(f"[LLM] Reply for {talker}: {result_text[:60]}") | ||
| 287 | - else: | ||
| 288 | - print(f"[LLM] Empty reply for {talker}", flush=True) | ||
| 289 | - return result_text if result_text else None | 309 | + |
| 310 | +def call_hermes_api(talker, content, context_msgs=None): | ||
| 311 | + """ | ||
| 312 | + Generate reply via hermes API (primary) or direct LLM API (fallback). | ||
| 313 | + Returns reply text or None. | ||
| 314 | + """ | ||
| 315 | + hermes_cfg = config.get("hermes", {}) | ||
| 316 | + hermes_host = hermes_cfg.get("host", "localhost") | ||
| 317 | + hermes_port = hermes_cfg.get("port", 3010) | ||
| 318 | + timeout = hermes_cfg.get("timeout_sec", 120) | ||
| 319 | + prefix = hermes_cfg.get("default_session_prefix", "wx-") | ||
| 320 | + system_hint = hermes_cfg.get("system_hint", "") | ||
| 321 | + session_id = f"{prefix}{talker}" | ||
| 322 | + | ||
| 323 | + # Build prompt | ||
| 324 | + prompt_parts = [] | ||
| 325 | + if system_hint: | ||
| 326 | + prompt_parts.append(system_hint) | ||
| 327 | + if context_msgs: | ||
| 328 | + ctx_lines = [] | ||
| 329 | + for m in context_msgs: | ||
| 330 | + who = "对方" if m.get("isSend") == 0 else "我" | ||
| 331 | + ctx_lines.append(f"{who}: {m.get('content', '')[:100]}") | ||
| 332 | + prompt_parts.append("最近对话上下文:\n" + "\n".join(ctx_lines)) | ||
| 333 | + prompt_parts.append(f"收到新消息(来自 {talker}):{content}\n请直接给出回复内容,不要加任何解释或格式标记。") | ||
| 334 | + full_prompt = "\n\n".join(prompt_parts) | ||
| 335 | + | ||
| 336 | + # ── Primary: call hermes /api/master/chat ── | ||
| 337 | + hermes_url = f"http://{hermes_host}:{hermes_port}/api/master/chat" | ||
| 338 | + payload = json.dumps({"message": full_prompt, "sessionId": session_id}).encode("utf-8") | ||
| 339 | + headers = {"Content-Type": "application/json", "Content-Length": str(len(payload))} | ||
| 340 | + req = urllib.request.Request(hermes_url, data=payload, headers=headers, method="POST") | ||
| 341 | + | ||
| 342 | + try: | ||
| 343 | + print(f"[HERMES] Calling {hermes_url} for {talker}...", flush=True) | ||
| 344 | + with urllib.request.urlopen(req, timeout=timeout) as resp: | ||
| 345 | + sse_buffer = resp.read() | ||
| 346 | + reply = _parse_sse(sse_buffer) | ||
| 347 | + if reply: | ||
| 348 | + print(f"[HERMES] Reply for {talker}: {reply[:80]}", flush=True) | ||
| 349 | + logging.info(f"[HERMES] Reply for {talker}: {reply[:60]}") | ||
| 350 | + return reply | ||
| 351 | + print(f"[HERMES] Empty reply for {talker}, trying fallback...", flush=True) | ||
| 290 | except urllib.error.HTTPError as e: | 352 | except urllib.error.HTTPError as e: |
| 291 | body = e.read().decode("utf-8", errors="replace")[:200] | 353 | body = e.read().decode("utf-8", errors="replace")[:200] |
| 292 | - print(f"[LLM] HTTP {e.code}: {body}", flush=True) | ||
| 293 | - logging.error(f"[LLM] HTTP {e.code}: {body}") | ||
| 294 | - return None | 354 | + print(f"[HERMES] HTTP {e.code}: {body}, falling back to LLM API...", flush=True) |
| 295 | except urllib.error.URLError as e: | 355 | except urllib.error.URLError as e: |
| 296 | - print(f"[LLM] Connection failed: {e}", flush=True) | ||
| 297 | - logging.error(f"[LLM] Connection failed: {e}") | ||
| 298 | - return None | 356 | + print(f"[HERMES] Connection failed: {e}, falling back to LLM API...", flush=True) |
| 299 | except Exception as e: | 357 | except Exception as e: |
| 300 | - print(f"[LLM] API call failed: {e}", flush=True) | ||
| 301 | - logging.error(f"[LLM] API call failed: {e}") | ||
| 302 | - return None | 358 | + print(f"[HERMES] API call failed: {e}, falling back to LLM API...", flush=True) |
| 359 | + | ||
| 360 | + # ── Fallback: direct LLM API ── | ||
| 361 | + print(f"[LLM] Fallback direct API for {talker}...", flush=True) | ||
| 362 | + reply = _call_llm_api(talker, content, context_msgs) | ||
| 363 | + if reply: | ||
| 364 | + print(f"[LLM] Fallback reply for {talker}: {reply[:80]}", flush=True) | ||
| 365 | + return reply | ||
| 303 | 366 | ||
| 304 | # ── Send Message ────────────────────────────────────────────────────────────── | 367 | # ── Send Message ────────────────────────────────────────────────────────────── |
| 305 | def send_message(to_user, content, to_user_display_name=None): | 368 | def send_message(to_user, content, to_user_display_name=None): |
wechat_plain/scripts/local-testkit/keepalive.sh
0 → 100755
| 1 | +#!/bin/bash | ||
| 2 | +# keepalive.sh — Termux 保活 + 进程守护脚本 | ||
| 3 | +# | ||
| 4 | +# 功能: | ||
| 5 | +# 1. 获取 wake-lock,防止息屏后 CPU 被冻结 | ||
| 6 | +# 2. 检查并启动 hermes server | ||
| 7 | +# 3. 检查并启动 wechat auto-reply plugin | ||
| 8 | +# 4. 进程死了自动重启 | ||
| 9 | +# | ||
| 10 | +# 用法: | ||
| 11 | +# chmod +x keepalive.sh | ||
| 12 | +# nohup ./keepalive.sh > keepalive.log 2>&1 & | ||
| 13 | +# | ||
| 14 | +# 前置条件(必须手动设置): | ||
| 15 | +# 1. Android 设置 → 应用 → Termux → 电池优化 → 不优化 | ||
| 16 | +# 2. Android 设置 → 应用 → Termux:API → 电池优化 → 不优化 | ||
| 17 | +# 3. Android 设置 → 应用 → 微信 → 电池优化 → 不优化 | ||
| 18 | +# 4. Android 设置 → 应用 → Termux → 允许自启动 | ||
| 19 | +# 5. Termux:API 已安装,并已授予通知访问权限 | ||
| 20 | + | ||
| 21 | +set -e | ||
| 22 | + | ||
| 23 | +HERMES_DIR="${HERMES_DIR:-/data/data/com.termux/files/home/hermes-wechat}" | ||
| 24 | +HERMES_HOST="${HERMES_HOST:-localhost}" | ||
| 25 | +HERMES_PORT="${HERMES_PORT:-3010}" | ||
| 26 | +MY_WXID="${MY_WXID:-}" | ||
| 27 | +MY_NICKNAME="${MY_NICKNAME:-}" | ||
| 28 | +CHECK_INTERVAL_SEC="${CHECK_INTERVAL_SEC:-10}" | ||
| 29 | + | ||
| 30 | +log() { | ||
| 31 | + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +# ── 1. Wake lock ───────────────────────────────────────────────── | ||
| 35 | +termux-wake-lock 2>/dev/null || true | ||
| 36 | +log "Wake lock acquired" | ||
| 37 | + | ||
| 38 | +# ── 2. 检查前置条件 ────────────────────────────────────────────── | ||
| 39 | +check_prerequisites() { | ||
| 40 | + local ok=true | ||
| 41 | + | ||
| 42 | + if ! command -v termux-notification-list >/dev/null 2>&1; then | ||
| 43 | + log "[WARN] termux-notification-list not found. Install: pkg install termux-api" | ||
| 44 | + ok=false | ||
| 45 | + fi | ||
| 46 | + | ||
| 47 | + if [ ! -d "$HERMES_DIR" ]; then | ||
| 48 | + log "[WARN] HERMES_DIR not found: $HERMES_DIR" | ||
| 49 | + ok=false | ||
| 50 | + fi | ||
| 51 | + | ||
| 52 | + if [ -z "$MY_WXID" ] || [ -z "$MY_NICKNAME" ]; then | ||
| 53 | + log "[WARN] MY_WXID or MY_NICKNAME not set" | ||
| 54 | + log " export MY_WXID=wxid_xxxxxx" | ||
| 55 | + log " export MY_NICKNAME=你的昵称" | ||
| 56 | + ok=false | ||
| 57 | + fi | ||
| 58 | + | ||
| 59 | + $ok | ||
| 60 | +} | ||
| 61 | + | ||
| 62 | +# ── 3. 检查 hermes server 是否运行 ─────────────────────────────── | ||
| 63 | +is_hermes_running() { | ||
| 64 | + curl -s "http://${HERMES_HOST}:${HERMES_PORT}/api/health" >/dev/null 2>&1 | ||
| 65 | +} | ||
| 66 | + | ||
| 67 | +# ── 4. 启动 hermes server ──────────────────────────────────────── | ||
| 68 | +start_hermes() { | ||
| 69 | + log "Starting hermes server..." | ||
| 70 | + cd "$HERMES_DIR" | ||
| 71 | + nohup node server/index.js > hermes-server.log 2>&1 & | ||
| 72 | + sleep 3 | ||
| 73 | + if is_hermes_running; then | ||
| 74 | + log "Hermes server started (PID=$!)" | ||
| 75 | + else | ||
| 76 | + log "[WARN] Hermes server may have failed to start" | ||
| 77 | + fi | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +# ── 5. 检查 auto-reply 是否运行 ────────────────────────────────── | ||
| 81 | +is_auto_reply_running() { | ||
| 82 | + pgrep -f "node auto-reply.js" >/dev/null 2>&1 | ||
| 83 | +} | ||
| 84 | + | ||
| 85 | +# ── 6. 启动 auto-reply ─────────────────────────────────────────── | ||
| 86 | +start_auto_reply() { | ||
| 87 | + log "Starting auto-reply..." | ||
| 88 | + cd "$HERMES_DIR/server/services/wechat" | ||
| 89 | + env MY_WXID="$MY_WXID" MY_NICKNAME="$MY_NICKNAME" \ | ||
| 90 | + nohup node auto-reply.js > auto-reply.log 2>&1 & | ||
| 91 | + log "Auto-reply started (PID=$!)" | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +# ── 7. 主循环 ──────────────────────────────────────────────────── | ||
| 95 | +log "=== Keepalive started ===" | ||
| 96 | +log "HERMES_DIR=$HERMES_DIR" | ||
| 97 | +log "MY_WXID=${MY_WXID:-(not set)}" | ||
| 98 | +log "MY_NICKNAME=${MY_NICKNAME:-(not set)}" | ||
| 99 | + | ||
| 100 | +if ! check_prerequisites; then | ||
| 101 | + log "[ERROR] Prerequisites check failed, exiting" | ||
| 102 | + exit 1 | ||
| 103 | +fi | ||
| 104 | + | ||
| 105 | +while true; do | ||
| 106 | + # 检查 hermes | ||
| 107 | + if ! is_hermes_running; then | ||
| 108 | + log "Hermes server not responding, restarting..." | ||
| 109 | + start_hermes | ||
| 110 | + fi | ||
| 111 | + | ||
| 112 | + # 检查 auto-reply | ||
| 113 | + if ! is_auto_reply_running; then | ||
| 114 | + log "Auto-reply not running, restarting..." | ||
| 115 | + start_auto_reply | ||
| 116 | + fi | ||
| 117 | + | ||
| 118 | + sleep "$CHECK_INTERVAL_SEC" | ||
| 119 | +done |