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 | 11 | * cd /data/data/com.termux/files/home/hermes-wechat |
| ... | ... | @@ -17,25 +18,36 @@ |
| 17 | 18 | |
| 18 | 19 | const { AndroidWechatTracker } = require('./android-wechat-tracker'); |
| 19 | 20 | const { sendTextMessageBroadcast } = require('./android-wechat-sender'); |
| 21 | +const { shell } = require('./android-wechat-sender'); | |
| 20 | 22 | const fs = require('fs'); |
| 21 | 23 | const path = require('path'); |
| 24 | +const http = require('http'); | |
| 22 | 25 | |
| 23 | 26 | // ═══════════════════════════════════════════════════════════════ |
| 24 | -// 配置区 — 请根据实际情况修改 | |
| 27 | +// 配置区 | |
| 25 | 28 | // ═══════════════════════════════════════════════════════════════ |
| 26 | 29 | |
| 27 | 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 | 53 | logFile: path.join(__dirname, 'auto-reply.log'), |
| ... | ... | @@ -50,7 +62,7 @@ class ReplyState { |
| 50 | 62 | constructor(filePath) { |
| 51 | 63 | this.filePath = filePath; |
| 52 | 64 | this.repliedMsgIds = new Set(); |
| 53 | - this.lastReplyTime = {}; // talker -> timestamp | |
| 65 | + this.lastReplyTime = {}; | |
| 54 | 66 | this.load(); |
| 55 | 67 | } |
| 56 | 68 | |
| ... | ... | @@ -84,7 +96,6 @@ class ReplyState { |
| 84 | 96 | markReplied(msgId, talker) { |
| 85 | 97 | this.repliedMsgIds.add(msgId); |
| 86 | 98 | this.lastReplyTime[talker] = Date.now(); |
| 87 | - // 清理旧记录,只保留最近 1000 条 | |
| 88 | 99 | if (this.repliedMsgIds.size > 1000) { |
| 89 | 100 | const arr = Array.from(this.repliedMsgIds); |
| 90 | 101 | this.repliedMsgIds = new Set(arr.slice(-500)); |
| ... | ... | @@ -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 | 275 | function isAtMe(content, nickname) { |
| 130 | 276 | if (!nickname) return false; |
| 131 | - // 匹配 @昵称 后跟空格或换行 | |
| 132 | 277 | const pattern = new RegExp('@' + escapeRegex(nickname) + '(\\s|\\n|$)'); |
| 133 | 278 | return pattern.test(content); |
| 134 | 279 | } |
| 135 | 280 | |
| 136 | -/** | |
| 137 | - * 判断群聊消息是否引用我 | |
| 138 | - * 引用消息 content 中包含 <refermsg> XML | |
| 139 | - */ | |
| 140 | 281 | function isQuoteMe(content, nickname) { |
| 141 | 282 | if (!nickname) return false; |
| 142 | 283 | if (!content.includes('<refermsg')) return false; |
| 143 | - // 提取 displayname | |
| 144 | 284 | const match = content.match(/<displayname>([^<]+)<\/displayname>/); |
| 145 | - if (match) { | |
| 146 | - return match[1] === nickname; | |
| 147 | - } | |
| 285 | + if (match) return match[1] === nickname; | |
| 148 | 286 | return false; |
| 149 | 287 | } |
| 150 | 288 | |
| ... | ... | @@ -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 | 376 | // ═══════════════════════════════════════════════════════════════ |
| 216 | 377 | |
| 217 | 378 | class AutoReplyBot { |
| 218 | - constructor(replyGenerator = defaultReplyGenerator) { | |
| 379 | + constructor(replyGenerator = hermesReplyGenerator) { | |
| 219 | 380 | this.tracker = new AndroidWechatTracker(); |
| 220 | 381 | this.state = new ReplyState(CONFIG.stateFile); |
| 221 | 382 | this.replyGenerator = replyGenerator; |
| ... | ... | @@ -223,36 +384,24 @@ class AutoReplyBot { |
| 223 | 384 | } |
| 224 | 385 | |
| 225 | 386 | async handleMessage(msg) { |
| 226 | - // 只处理收到的消息 | |
| 227 | 387 | if (msg.type !== 'recv') return; |
| 228 | 388 | |
| 229 | 389 | const { talker, content, msgId } = msg; |
| 230 | 390 | if (!talker || !content) return; |
| 231 | 391 | |
| 232 | - // 忽略自己 | |
| 233 | 392 | if (CONFIG.ignoreSelf && talker === CONFIG.myWxid) return; |
| 234 | - | |
| 235 | - // 已回复过 | |
| 236 | 393 | if (this.state.hasReplied(msgId)) return; |
| 237 | - | |
| 238 | - // 冷却检查 | |
| 239 | 394 | if (this.state.isCooldown(talker)) { |
| 240 | - log(`[COOLDOWN] Skip ${talker}`); | |
| 395 | + log('[COOLDOWN] Skip', talker); | |
| 241 | 396 | return; |
| 242 | 397 | } |
| 243 | 398 | |
| 244 | 399 | let replyType = null; |
| 245 | 400 | let senderName = ''; |
| 246 | 401 | |
| 247 | - // 判断消息类型 | |
| 248 | 402 | if (!isGroupChat(talker)) { |
| 249 | - // 单聊 | |
| 250 | - if (CONFIG.replyInPrivateChat) { | |
| 251 | - replyType = 'private'; | |
| 252 | - } | |
| 403 | + if (CONFIG.replyInPrivateChat) replyType = 'private'; | |
| 253 | 404 | } else { |
| 254 | - // 群聊 | |
| 255 | - // 提取发送者昵称(群聊消息格式:"昵称:\n内容" 或 XML) | |
| 256 | 405 | const nameMatch = content.match(/^([^:\n]+):\n/); |
| 257 | 406 | if (nameMatch) senderName = nameMatch[1]; |
| 258 | 407 | |
| ... | ... | @@ -267,7 +416,13 @@ class AutoReplyBot { |
| 267 | 416 | |
| 268 | 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 | 426 | let replyText; |
| 272 | 427 | try { |
| 273 | 428 | replyText = await this.replyGenerator({ |
| ... | ... | @@ -287,12 +442,10 @@ class AutoReplyBot { |
| 287 | 442 | return; |
| 288 | 443 | } |
| 289 | 444 | |
| 290 | - // 截断 | |
| 291 | 445 | if (replyText.length > CONFIG.maxReplyLength) { |
| 292 | 446 | replyText = replyText.substring(0, CONFIG.maxReplyLength) + '...'; |
| 293 | 447 | } |
| 294 | 448 | |
| 295 | - // 发送回复 | |
| 296 | 449 | try { |
| 297 | 450 | const result = await sendTextMessageBroadcast(talker, replyText); |
| 298 | 451 | if (result.ok) { |
| ... | ... | @@ -306,24 +459,32 @@ class AutoReplyBot { |
| 306 | 459 | } |
| 307 | 460 | } |
| 308 | 461 | |
| 309 | - start() { | |
| 462 | + async start() { | |
| 310 | 463 | if (this.running) return; |
| 311 | 464 | this.running = true; |
| 312 | 465 | |
| 313 | 466 | log('=== Auto-Reply Bot Started ==='); |
| 467 | + log('Hermes:', `${CONFIG.hermesHost}:${CONFIG.hermesPort}`); | |
| 314 | 468 | log('Config:', JSON.stringify({ |
| 315 | 469 | myWxid: CONFIG.myWxid ? '***' : '(not set)', |
| 316 | 470 | myNickname: CONFIG.myNickname || '(not set)', |
| 317 | 471 | replyInPrivateChat: CONFIG.replyInPrivateChat, |
| 318 | 472 | replyAtInGroup: CONFIG.replyAtInGroup, |
| 319 | 473 | replyQuoteInGroup: CONFIG.replyQuoteInGroup, |
| 474 | + notificationEnabled: CONFIG.notificationEnabled, | |
| 320 | 475 | })); |
| 321 | 476 | |
| 477 | + // 启动 tracker(流式 tail -f,最实时) | |
| 322 | 478 | this.tracker.on('message', msg => this.handleMessage(msg)); |
| 323 | 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 | 488 | process.on('SIGINT', () => this.stop()); |
| 328 | 489 | process.on('SIGTERM', () => this.stop()); |
| 329 | 490 | } |
| ... | ... | @@ -331,8 +492,8 @@ class AutoReplyBot { |
| 331 | 492 | stop() { |
| 332 | 493 | if (!this.running) return; |
| 333 | 494 | this.running = false; |
| 334 | - this.tracker.stopPolling(); | |
| 335 | 495 | this.tracker.stopStream(); |
| 496 | + this.tracker.stopPolling(); | |
| 336 | 497 | log('=== Auto-Reply Bot Stopped ==='); |
| 337 | 498 | process.exit(0); |
| 338 | 499 | } |
| ... | ... | @@ -343,7 +504,6 @@ class AutoReplyBot { |
| 343 | 504 | // ═══════════════════════════════════════════════════════════════ |
| 344 | 505 | |
| 345 | 506 | if (require.main === module) { |
| 346 | - // 从环境变量读取配置 | |
| 347 | 507 | if (process.env.MY_WXID) CONFIG.myWxid = process.env.MY_WXID; |
| 348 | 508 | if (process.env.MY_NICKNAME) CONFIG.myNickname = process.env.MY_NICKNAME; |
| 349 | 509 | |
| ... | ... | @@ -352,7 +512,7 @@ if (require.main === module) { |
| 352 | 512 | console.error('ERROR: 请设置 MY_WXID 和 MY_NICKNAME 环境变量'); |
| 353 | 513 | console.error(''); |
| 354 | 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 | 516 | console.error(''); |
| 357 | 517 | process.exit(1); |
| 358 | 518 | } |
| ... | ... | @@ -361,4 +521,4 @@ if (require.main === module) { |
| 361 | 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 | 14 | "termux_bin_path": "/data/data/com.termux/files/usr/bin/termux-notification-list" |
| 15 | 15 | }, |
| 16 | 16 | "hermes": { |
| 17 | - "api_url": "http://localhost:8642/v1/chat/completions", | |
| 17 | + "host": "localhost", | |
| 18 | + "port": 3010, | |
| 18 | 19 | "timeout_sec": 120, |
| 19 | 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 | 27 | "auto_reply": { |
| 23 | 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 | 210 | logging.debug(f"Context query failed: {e}") |
| 211 | 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 | 247 | messages = [] |
| 232 | 248 | if system_hint: |
| 233 | 249 | messages.append({"role": "system", "content": system_hint}) |
| ... | ... | @@ -238,8 +254,21 @@ def call_hermes_api(talker, content, context_msgs=None): |
| 238 | 254 | ctx_lines.append(f"{who}: {m.get('content', '')[:100]}") |
| 239 | 255 | messages.append({"role": "system", "content": "最近对话上下文:\n" + "\n".join(ctx_lines)}) |
| 240 | 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 | 272 | headers = {"Content-Type": "application/json"} |
| 244 | 273 | if provider == "anthropic": |
| 245 | 274 | if not api_url: |
| ... | ... | @@ -252,7 +281,6 @@ def call_hermes_api(talker, content, context_msgs=None): |
| 252 | 281 | "model": model, |
| 253 | 282 | "max_tokens": 1024, |
| 254 | 283 | "messages": messages, |
| 255 | - "metadata": {"session_id": session_id} | |
| 256 | 284 | }).encode("utf-8") |
| 257 | 285 | elif provider in ("openai", "azure", "doubao"): |
| 258 | 286 | headers["Authorization"] = f"Bearer {api_key}" |
| ... | ... | @@ -267,39 +295,74 @@ def call_hermes_api(talker, content, context_msgs=None): |
| 267 | 295 | return None |
| 268 | 296 | |
| 269 | 297 | req = urllib.request.Request(api_url, data=payload, headers=headers, method="POST") |
| 270 | - | |
| 271 | 298 | try: |
| 272 | - print(f"[LLM] Calling {provider} API for {talker}...", flush=True) | |
| 273 | 299 | with urllib.request.urlopen(req, timeout=timeout) as resp: |
| 274 | - print(f"[LLM] API connected (status={resp.status})", flush=True) | |
| 275 | 300 | data = json.loads(resp.read().decode("utf-8")) |
| 276 | - | |
| 277 | - # Parse response | |
| 278 | - result_text = None | |
| 279 | 301 | if provider == "anthropic": |
| 280 | - result_text = data.get("content", [{}])[0].get("text", "") | |
| 302 | + return data.get("content", [{}])[0].get("text", "") | |
| 281 | 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 | 352 | except urllib.error.HTTPError as e: |
| 291 | 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 | 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 | 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 | 367 | # ── Send Message ────────────────────────────────────────────────────────────── |
| 305 | 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 | ... | ... |