Commit 47826cd039b0b626910f4f16b88c2a1d02a7be0d

Authored by yibo
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>
.gitignore
... ... @@ -26,6 +26,7 @@ system/agents/*/memory/
26 26  
27 27 # SQLite databases
28 28 *.sqlite
  29 +server/db/
29 30  
30 31 # Build output
31 32 dist/
... ...
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
... ...