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