/** * auto-reply.js — Pixel 手机微信自动回复 Plugin(接入 hermes API) * * 完整链路: * 1. termux-notification-list 监听通知(息屏兜底,定时轮询) * 2. 检测到消息后 input keyevent 224 点亮屏幕 + wm dismiss-keyguard 解锁 * 3. am start LauncherUI 把微信切到前台 * 4. AndroidWechatTrackerSQLCipher 轮询 EnMicroMsg.db 拉新消息 + 历史上下文 * 5. callHermesApi POST 到 hermes-agent (127.0.0.1:8642) 生成回复 * 6. sendTextMessageBroadcast → am broadcast com.hermes.wechat.SEND_MSG * → HermesAccessibilityService UI 自动化点击微信完成发送 * * 运行方式: * cd /data/data/com.termux/files/home/hermes-wechat * ./start-ar.sh (内部 nohup node auto-reply.js &) * * 终止: * pkill -f auto-reply.js */ const { AndroidWechatTrackerSQLCipher } = require('./android-wechat-tracker-sqlcipher'); const { sendTextMessageBroadcast, checkAccessibilityServiceEnabled, enableAccessibilityService, shell, } = require('./android-wechat-sender'); const fs = require('fs'); const path = require('path'); const http = require('http'); // ═══════════════════════════════════════════════════════════════ // 配置区 // ═══════════════════════════════════════════════════════════════ const CONFIG = { // 你的微信信息(用于判断群聊 @/引用) myWxid: process.env.MY_WXID || '', myNickname: process.env.MY_NICKNAME || '', // 主人(授权者):非白名单消息会转给 TA 请示是否授权 // ownerWxid 是机器人主人的 wxid(如 yibooo),ownerDisplayName 是 A11y 搜索用的昵称 ownerWxid: process.env.OWNER_WXID || 'yibooo', ownerDisplayName: process.env.OWNER_NICKNAME || '火星人', // 白名单文件:记录已授权的 wxid / 群 + 待授权的 pending 请求 whitelistFile: path.join(__dirname, '.whitelist.json'), // hermes-agent API (OpenAI-compatible, localhost:8642) hermesHost: process.env.HERMES_HOST || '127.0.0.1', hermesPort: parseInt(process.env.HERMES_PORT, 10) || 8642, hermesTimeoutMs: parseInt(process.env.HERMES_TIMEOUT, 10) || 120000, // 回复行为 replyInPrivateChat: true, replyAtInGroup: true, replyQuoteInGroup: true, maxReplyLength: 500, cooldownMs: 3000, ignoreSelf: true, // 上下文长度:每次调 hermes 时回看多少条最近消息(含本次触发的那条) historyContextSize: parseInt(process.env.HISTORY_CONTEXT_SIZE, 10) || 8, // 流式行为:模型沉默 agentic 调用时,是否发"🔧 调用 X"进度消息 // 默认 OFF(避免群里刷屏);开了之后每个 tool.started 都会发一条进度消息 streamToolProgress: process.env.STREAM_TOOL_PROGRESS === 'true', // 是否把 reasoning_content(模型内心独白)作为消息发给用户 // 默认 OFF — reasoning 是模型自言自语("用户要求..." 第三人称), // 直接发给用户极其诡异。开了之后会带 "🧠 " 前缀作为思考进度发。 streamReasoning: process.env.STREAM_REASONING === 'true', // 通知监听(息屏兜底) notificationPollIntervalMs: 3000, notificationEnabled: process.env.NOTIFICATION_ENABLED !== 'false', // 日志 logFile: path.join(__dirname, 'auto-reply.log'), stateFile: path.join(__dirname, '.auto-reply-state.json'), }; // ═══════════════════════════════════════════════════════════════ // 状态管理 // ═══════════════════════════════════════════════════════════════ class ReplyState { constructor(filePath) { this.filePath = filePath; this.repliedMsgIds = new Set(); this.lastReplyTime = {}; this.load(); } load() { try { if (fs.existsSync(this.filePath)) { const data = JSON.parse(fs.readFileSync(this.filePath, 'utf-8')); this.repliedMsgIds = new Set(data.repliedMsgIds || []); this.lastReplyTime = data.lastReplyTime || {}; } } catch (e) { log('State load failed:', e.message); } } save() { try { fs.writeFileSync(this.filePath, JSON.stringify({ repliedMsgIds: Array.from(this.repliedMsgIds), lastReplyTime: this.lastReplyTime, }), 'utf-8'); } catch (e) { log('State save failed:', e.message); } } hasReplied(msgId) { return this.repliedMsgIds.has(msgId); } markReplied(msgId, talker) { this.repliedMsgIds.add(msgId); this.lastReplyTime[talker] = Date.now(); if (this.repliedMsgIds.size > 1000) { const arr = Array.from(this.repliedMsgIds); this.repliedMsgIds = new Set(arr.slice(-500)); } this.save(); } isCooldown(talker) { const last = this.lastReplyTime[talker]; if (!last) return false; return Date.now() - last < CONFIG.cooldownMs; } } // ═══════════════════════════════════════════════════════════════ // 白名单 + 授权 pending(持久化到 .whitelist.json) // ═══════════════════════════════════════════════════════════════ class WhitelistStore { constructor(filePath) { this.filePath = filePath; this.allowed = new Set(); // 已授权的 wxid / 群 chatroom id this.pending = {}; // talker -> { senderName, content, displayName, ts } this.load(); } load() { try { if (fs.existsSync(this.filePath)) { const data = JSON.parse(fs.readFileSync(this.filePath, 'utf-8')); this.allowed = new Set(data.allowed || []); this.pending = data.pending || {}; } } catch (e) { log('[WHITELIST] load failed:', e.message); } } save() { try { fs.writeFileSync(this.filePath, JSON.stringify({ allowed: Array.from(this.allowed), pending: this.pending, }, null, 2), 'utf-8'); } catch (e) { log('[WHITELIST] save failed:', e.message); } } isAllowed(talker) { return this.allowed.has(talker); } allow(talker) { this.allowed.add(talker); delete this.pending[talker]; this.save(); } addPending(talker, info) { this.pending[talker] = info; this.save(); } hasPending(talker) { return Object.prototype.hasOwnProperty.call(this.pending, talker); } removePending(talker) { delete this.pending[talker]; this.save(); } pendingTalkers() { return Object.keys(this.pending); } } // ═══════════════════════════════════════════════════════════════ // 日志 // ═══════════════════════════════════════════════════════════════ function log(...args) { const line = `[${new Date().toISOString()}] ${args.join(' ')}`; // start-ar.sh 已经把 stdout 重定向到 auto-reply.log,console.log 即写入文件。 // 不再额外 fs.appendFileSync — 同步 I/O 在高频流式场景下会卡死 node。 console.log(line); } // ═══════════════════════════════════════════════════════════════ // Hermes API 调用 // ═══════════════════════════════════════════════════════════════ /** * 调用 hermes-agent 的 /v1/runs 流式 API,按 agent loop 的 tool 边界 * 把每一段 assistant 文本作为独立"utterance"通过 onUtterance 回调推出。 * * 事件流(SSE on /v1/runs/{id}/events): * message.delta { delta: "字符片段" } → 累积到 textBuf * tool.started { tool, preview } → flush textBuf 作为一条 utterance * tool.completed → 下一个 turn 准备开始 * reasoning.available → 不转发(hermes 思考过程) * run.completed { output, usage } → 最终 flush * run.failed / run.cancelled → reject * * @param {Array} messages OpenAI 格式(含 system / user / assistant 多轮) * @param {string} sessionId hermes session(每个 talker 独立) * @param {Function} onUtterance async (text, index, isFinal) => Promise * 串行调用;返回的 Promise 完成前不会发下一条 * @returns {Promise<{output?, usage?}>} */ async function callHermesRunStream(messages, sessionId, onUtterance) { const userMsg = messages[messages.length - 1]?.content || ''; const systemMsg = messages.find(m => m.role === 'system'); const history = messages.slice(0, -1) .filter(m => m.role !== 'system') .map(m => ({ role: m.role, content: m.content })); // 1) POST /v1/runs 拿 run_id const startBody = JSON.stringify({ input: userMsg, conversation_history: history, session_id: sessionId, instructions: systemMsg?.content, }); const runId = await new Promise((resolve, reject) => { const req = http.request({ hostname: CONFIG.hermesHost, port: CONFIG.hermesPort, path: '/v1/runs', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(startBody), }, timeout: 15000, }, res => { let buf = ''; res.on('data', c => buf += c.toString()); res.on('end', () => { try { const j = JSON.parse(buf); if (j.run_id) return resolve(j.run_id); reject(new Error('No run_id in response: ' + buf.substring(0, 200))); } catch (e) { reject(e); } }); }); req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error('start timeout')); }); req.write(startBody); req.end(); }); log('[HERMES] Run started:', runId, 'session:', sessionId); // 2) GET /v1/runs/{runId}/events (SSE) return new Promise((resolve, reject) => { const req = http.get({ hostname: CONFIG.hermesHost, port: CONFIG.hermesPort, path: `/v1/runs/${runId}/events`, timeout: CONFIG.hermesTimeoutMs, }, res => { let buf = ''; let textBuf = ''; // 累积 message.delta(assistant content tokens) let reasoningBuf = ''; // 累积 reasoning.delta(Kimi 的 chain-of-thought tokens) let utteranceIdx = 0; let sendChain = Promise.resolve(); // 串行队列:每个 utterance 在前一个完成后执行 let finalResult = {}; // diagnostic counters — message.delta / reasoning.delta 都是高频 token 流, // 必须节流否则 600+ events × sync 文件 IO 会卡死 node const evCount = {}; let lastDiagLogAt = 0; const HIGH_FREQ_EVENTS = new Set(['message.delta', 'reasoning.delta']); const diagLog = (evt) => { evCount[evt] = (evCount[evt] || 0) + 1; const now = Date.now(); const isHighFreq = HIGH_FREQ_EVENTS.has(evt); if (!isHighFreq || now - lastDiagLogAt > 5000) { log('[STREAM EV]', evt, 'counts=', JSON.stringify(evCount), 'textBuf.len=' + textBuf.length, 'reasoningBuf.len=' + reasoningBuf.length); lastDiagLogAt = now; } }; // 跟踪上一次实际发出去的文本,避免 reasoning.available 和 run.completed 重复发同样内容 let lastEmitted = ''; const emit = (text, isFinal) => { const trimmed = (text || '').trim(); if (!trimmed) return; // 简单 dedupe:完全相同 / 完全包含 / 被包含都跳过 if (trimmed === lastEmitted) return; if (lastEmitted && (trimmed.includes(lastEmitted) || lastEmitted.includes(trimmed))) return; lastEmitted = trimmed; const idx = utteranceIdx++; sendChain = sendChain .then(() => onUtterance(trimmed, idx, !!isFinal)) .catch(e => log('[onUtterance error]', e.message)); }; // 重新定义 flush 让它走 emit 通道 const flushTextBuf = (isFinal) => { const t = textBuf.trim(); textBuf = ''; if (t) emit(t, isFinal); }; // 把累积的 reasoning(Kimi chain-of-thought)作为一段独立 utterance flush const flushReasoningBuf = (isFinal) => { const t = reasoningBuf.trim(); reasoningBuf = ''; if (t) emit(t, isFinal); }; res.setEncoding('utf-8'); res.on('data', chunk => { buf += chunk; let sepIdx; while ((sepIdx = buf.indexOf('\n\n')) >= 0) { const block = buf.substring(0, sepIdx); buf = buf.substring(sepIdx + 2); for (const line of block.split('\n')) { if (!line.startsWith('data: ')) continue; let ev; try { ev = JSON.parse(line.substring(6)); } catch (_) { continue; } diagLog(ev.event); switch (ev.event) { case 'message.complete': // 🎯 主路径:interim_assistant_callback(gateway 平台用的事件) // 在每个 turn 末尾、tool 之前 fire,携带模型本轮**给用户看的** // 可见 content(已 strip 块)。这是真正的"多步消息"信号。 // already_streamed=true 表示 content 已通过 message.delta 流过,避免重复 if (!ev.already_streamed) { emit(ev.text, false); // 同 turn 的 message.delta 已被 interim 覆盖,重置 textBuf textBuf = ''; } // reasoning 同 turn 的 chain-of-thought 已落地,清掉避免泄露给用户 reasoningBuf = ''; break; case 'message.delta': // 模型 content tokens — 主要靠 message.complete 拿干净文本, // message.delta 累积仅做兜底(如果 interim 没 fire) textBuf += (ev.delta || ''); break; case 'reasoning.delta': // Kimi/Moonshot 的 chain-of-thought tokens — 模型内心独白, // 默认不发给用户(会出现第三人称"用户要求..."的诡异内容)。 // STREAM_REASONING=true 时累积,flush 时加 "🧠 " 前缀 if (CONFIG.streamReasoning) { reasoningBuf += (ev.text || ''); } break; case 'reasoning.available': // 整段 turn 末尾的 thinking,同样默认不发 if (CONFIG.streamReasoning && !reasoningBuf.trim() && ev.text) { emit('🧠 ' + ev.text, false); } break; case 'tool.started': // turn 边界兜底:interim_assistant_callback 已 emit 过本轮 content, // textBuf 仅在 interim 未触发时用 if (CONFIG.streamReasoning) { const r = reasoningBuf.trim(); reasoningBuf = ''; if (r) emit('🧠 ' + r, false); } flushTextBuf(false); if (CONFIG.streamToolProgress) { const toolName = ev.tool || '?'; const preview = (ev.preview || '').toString().split('\n')[0].substring(0, 60); emit(preview ? `🔧 ${toolName}: ${preview}` : `🔧 ${toolName}`, false); } break; case 'run.completed': // 收尾:emit 最终 output(如果跟 last interim 重复会被 dedupe 吃掉) if (ev.output) { emit(ev.output, true); } else { flushTextBuf(true); } finalResult = { output: ev.output, usage: ev.usage }; sendChain.then(() => resolve(finalResult)); break; case 'run.failed': reject(new Error(ev.error || 'run failed')); break; case 'run.cancelled': reject(new Error('run cancelled')); break; // tool.completed / approval.* 忽略 } } } }); res.on('end', () => { flush(true); sendChain.then(() => resolve(finalResult)); }); res.on('error', reject); }); req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error('events stream timeout')); }); }); } /** * 调用 hermes-agent API (OpenAI-compatible) 生成回复 — 老的非流式同步 API * * @param {Array<{role:string,content:string}>} messages - OpenAI messages 格式 * @param {string} sessionId - 会话 ID(每个 talker 独立,作为 X-Hermes-Session-Id) * @returns {Promise} 回复文本 */ async function callHermesApi(messages, sessionId) { return new Promise((resolve) => { const payload = JSON.stringify({ model: 'hermes-agent', messages, stream: false, }); const req = http.request({ hostname: CONFIG.hermesHost, port: CONFIG.hermesPort, path: '/v1/chat/completions', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), }, timeout: CONFIG.hermesTimeoutMs, }, (res) => { let buffer = ''; res.on('data', chunk => { buffer += chunk; }); res.on('end', () => { log('[HERMES] HTTP status:', res.statusCode, 'body:', buffer.substring(0, 300)); try { const data = JSON.parse(buffer); if (data.error) { log('[HERMES] API error:', JSON.stringify(data.error)); resolve(null); return; } const content = data.choices?.[0]?.message?.content; resolve(content || null); } catch (e) { log('[HERMES] JSON parse error:', e.message); resolve(null); } }); }); req.on('error', (err) => { log('[HERMES] HTTP error:', err.message); resolve(null); }); req.on('timeout', () => { req.destroy(); log('[HERMES] API timeout'); resolve(null); }); req.write(payload); req.end(); }); } // ═══════════════════════════════════════════════════════════════ // 回复生成器 — 接入 hermes // ═══════════════════════════════════════════════════════════════ /** * 构造 OpenAI messages 数组发给 hermes-agent * * 把 talker 的最近 N 条聊天记录作为多轮 messages(自己发的为 assistant、对方发的为 user)。 * 再把本次触发消息作为最后一个 user turn,并在 system 中说明回复要求。 */ function buildHermesMessages(ctx) { const { type, talker, displayName, content, senderName, history } = ctx; let role = ''; if (type === 'private') { role = `这是和「${displayName || talker}」的微信私聊。`; } else if (type === 'group-at') { role = `这是一个微信群聊,刚才有人 @ 我("${senderName}"),群名 ${displayName || talker}。`; } else if (type === 'group-quote') { role = `这是一个微信群聊,有人引用了我的消息(发送者 "${senderName}"),群名 ${displayName || talker}。`; } else { role = `这是来自「${displayName || talker}」的消息。`; } const systemMsg = { role: 'system', content: `你是 lemonAI 助手,正在帮我回复微信消息。${role}\n` + '回复要自然、简短、得体,使用中文。只输出回复内容,不要加任何解释、不要带前缀如"回复:"。', }; // history 是 [{ isSend, content }] 旧→新;本次消息已经在最后一条 const msgs = [systemMsg]; if (Array.isArray(history) && history.length > 0) { for (const h of history) { const r = h.isSend === 1 ? 'assistant' : 'user'; // 群聊里对方发的 content 形如 "wxid_x:\n实际内容",去掉前缀 let txt = h.content || ''; if (r === 'user' && txt.includes(':\n')) { txt = txt.substring(txt.indexOf(':\n') + 2); } msgs.push({ role: r, content: txt }); } } else { // 没有历史时,直接把当前内容作为 user turn msgs.push({ role: 'user', content }); } return msgs; } /** * 接入 hermes-agent 的回复生成器 */ async function hermesReplyGenerator(ctx) { const { talker } = ctx; const sessionId = `wx-${talker}`; const messages = buildHermesMessages(ctx); log('[HERMES] Calling API for', talker, 'session:', sessionId, 'turns=' + messages.length); const reply = await callHermesApi(messages, sessionId); if (reply) { log('[HERMES] Reply:', reply.substring(0, 80)); } else { log('[HERMES] No reply generated'); } return reply; } // ═══════════════════════════════════════════════════════════════ // 消息判断 // ═══════════════════════════════════════════════════════════════ function isGroupChat(talker) { return talker && talker.endsWith('@chatroom'); } function isAtMe(content, nickname) { if (!nickname) return false; const pattern = new RegExp('@' + escapeRegex(nickname) + '(\\s|\\n|$)'); return pattern.test(content); } function isQuoteMe(content, nickname) { if (!nickname) return false; if (!content.includes('([^<]+)<\/displayname>/); if (match) return match[1] === nickname; return false; } function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * 解析 owner 的授权指令。返回 { approve, target } 或 null(不是指令)。 * 例:"同意" → {approve:true, target:''} * "同意 火星人" → {approve:true, target:'火星人'} * "拒绝" → {approve:false, target:''} */ function parseApprovalCommand(content) { if (!content) return null; let replyText = content; let quotedContext = ''; // 引用回复(type 57 appmsg):真正的回复文本在 里, // 被引用的原消息内容在 <refermsg> 里(用来判断"同意哪条") if (content.includes('<appmsg') || content.includes('<refermsg>')) { const tm = content.match(/<title>([\s\S]*?)<\/title>/); if (tm) replyText = tm[1]; const rm = content.match(/<refermsg>([\s\S]*?)<\/refermsg>/); if (rm) quotedContext = rm[1]; } // 群消息前缀剥离(owner 私聊一般没有,保险起见) let t = replyText; const ci = t.indexOf(':\n'); if (ci > 0 && ci < 30) t = t.substring(ci + 2); t = t.trim(); const m = t.match(/^(同意|授权|批准|approve|allow|yes|拒绝|忽略|不同意|deny|no)\s*(.*)$/i); if (!m) return null; const approve = /同意|授权|批准|approve|allow|yes/i.test(m[1]); return { approve, target: (m[2] || '').trim(), quotedContext }; } // ═══════════════════════════════════════════════════════════════ // 屏幕状态检测与微信唤醒(息屏专用) // ═══════════════════════════════════════════════════════════════ async function isScreenOn() { try { const out = await shell('dumpsys power | grep "mWakefulness"', 3000); return out.includes('mWakefulness=Awake'); } catch { return false; } } async function isKeyguardLocked() { try { const out = await shell('dumpsys window | grep "mInputRestricted"', 3000); return out.includes('mInputRestricted=true'); } catch { return false; } } async function isWechatForeground() { try { // Android 15 上 mCurrentFocus 已废弃,改用 mFocusedApp const out = await shell('dumpsys activity activities | grep "mFocusedApp"', 5000); return out.includes('com.tencent.mm'); } catch { return false; } } /** * 息屏唤醒完整流程:点亮 → 解锁(无密码时滑动) → 启动微信 → 确认前台 */ async function wakeWechat() { try { // 1. 如果屏幕是黑的,用 KEYCODE_WAKEUP(224) 点亮,不会 toggle const screenOn = await isScreenOn(); if (!screenOn) { await shell('input keyevent 224', 3000); log('[WAKE] Screen woke up'); await sleep(800); } // 2. 无密码时,wm dismiss-keyguard 可直接解除锁屏,比滑动更可靠 try { await shell('wm dismiss-keyguard', 3000); log('[WAKE] Keyguard dismissed'); } catch (e) { log('[WAKE] dismiss-keyguard failed (may already unlocked):', e.message); } await sleep(500); // 3. 启动微信到前台 await shell('/system/bin/am start -n com.tencent.mm/.ui.LauncherUI', 5000); log('[WAKE] WeChat LauncherUI started'); await sleep(1000); return true; } catch (e) { log('[WAKE] Failed:', e.message); return false; } } /** * 确保微信在前台且屏幕已解锁,轮询最多 20 秒 */ async function ensureWechatForeground(maxWaitMs = 20000) { const start = Date.now(); while (Date.now() - start < maxWaitMs) { const screenOn = await isScreenOn(); const locked = await isKeyguardLocked(); const fg = await isWechatForeground(); if (screenOn && !locked && fg) { // 模拟一次点击保持活跃 try { await shell('input keyevent 62', 1000); } catch (_) {} log('[WAKE] Confirmed: screen on + unlocked + WeChat foreground'); return true; } if (!screenOn) { log('[WAKE] Screen still off, retrying keyevent 224...'); try { await shell('input keyevent 224', 3000); } catch (_) {} } else if (locked) { log('[WAKE] Still locked, retrying dismiss-keyguard...'); try { await shell('wm dismiss-keyguard', 3000); } catch (_) {} } else if (!fg) { log('[WAKE] WeChat not foreground, retrying start...'); try { await shell('am start -n com.tencent.mm/.ui.LauncherUI', 5000); } catch (_) {} } await sleep(1500); } log('[WAKE] Foreground check timeout'); return false; } // ═══════════════════════════════════════════════════════════════ // 通知监听(息屏兜底) // ═══════════════════════════════════════════════════════════════ async function pollNotifications(bot) { const termuxBin = '/data/data/com.termux/files/usr/bin/termux-notification-list'; if (!fs.existsSync(termuxBin)) { log('[NOTIF] termux-notification-list not found, notification watcher disabled'); return; } log('[NOTIF] Notification watcher started'); const knownKeys = new Set(); while (bot.running) { try { const out = await shell(termuxBin, 10000); const notifs = JSON.parse(out); const wechatNotifs = notifs.filter(n => n.packageName === 'com.tencent.mm'); for (const n of wechatNotifs) { const key = `${n.title}:${n.content}`; if (knownKeys.has(key)) continue; knownKeys.add(key); // 限制 set 大小 if (knownKeys.size > 200) { const arr = Array.from(knownKeys); knownKeys.clear(); arr.slice(-100).forEach(k => knownKeys.add(k)); } log('[NOTIF] WeChat notification:', n.title, n.content.substring(0, 60)); // 息屏时微信可能不在前台,先点亮+启动微信 // 剩下的检测和发送交给 sqlcipher 轮询 + handleMessage const screenOn = await isScreenOn(); if (!screenOn) { log('[NOTIF] Screen off, waking WeChat...'); const ok = await wakeWechat(); if (ok) { await ensureWechatForeground(20000); log('[NOTIF] WeChat woken up, tracker will catch the message'); } } } } catch (e) { // notification-list 可能返回空或失败,忽略 } await sleep(CONFIG.notificationPollIntervalMs); } } function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } // ═══════════════════════════════════════════════════════════════ // 主逻辑 // ═══════════════════════════════════════════════════════════════ class AutoReplyBot { constructor(replyGenerator = hermesReplyGenerator) { // 优先使用 sqlcipher DB 轮询(LSPosed hook 在 WeChat 8.0.72 上不稳定) this.tracker = new AndroidWechatTrackerSQLCipher(); this.state = new ReplyState(CONFIG.stateFile); this.whitelist = new WhitelistStore(CONFIG.whitelistFile); // owner 永远在白名单里 if (CONFIG.ownerWxid) this.whitelist.allowed.add(CONFIG.ownerWxid); this.replyGenerator = replyGenerator; this.running = false; } async handleMessage(msg) { if (msg.type !== 'recv') return; const { talker, content, msgId } = msg; if (!talker || !content) return; if (CONFIG.ignoreSelf && talker === CONFIG.myWxid) return; if (this.state.hasReplied(msgId)) return; if (this.state.isCooldown(talker)) { log('[COOLDOWN] Skip', talker); return; } let replyType = null; let senderName = ''; if (!isGroupChat(talker)) { if (CONFIG.replyInPrivateChat) replyType = 'private'; } else { const nameMatch = content.match(/^([^:\n]+):\n/); if (nameMatch) senderName = nameMatch[1]; if (CONFIG.replyAtInGroup && isAtMe(content, CONFIG.myNickname)) { replyType = 'group-at'; } else if (CONFIG.replyQuoteInGroup && isQuoteMe(content, CONFIG.myNickname)) { replyType = 'group-quote'; } } if (!replyType) return; // ═══ 白名单门禁 + owner 授权流程 ═══ // 发送者身份:私聊就是 talker,群聊是 content 前缀里的 senderWxid(即 senderName 变量) const senderWxid = isGroupChat(talker) ? senderName : talker; const isFromOwner = (senderWxid === CONFIG.ownerWxid) || (talker === CONFIG.ownerWxid); if (isFromOwner) { // owner 自己发的(私聊 / 群里 @):先看是不是授权指令 const cmd = parseApprovalCommand(content); if (cmd) { this.state.markReplied(msgId, talker); await this.handleApproval(cmd); return; } // 否则放行(owner 永远白名单) } else if (!this.whitelist.isAllowed(talker)) { // 群 / 私聊不在白名单 → 请示 owner,不直接回复 this.state.markReplied(msgId, talker); await this.requestAuthorization(talker, senderName, content); return; } log(`[${replyType.toUpperCase()}] ${talker}: ${content.substring(0, 80)}`); await this.generateAndSendReply({ talker, content, msgId, replyType, senderName, raw: msg.raw }); } // 生成并发送回复(白名单通过后 / 授权后调用) async generateAndSendReply({ talker, content, msgId, replyType, senderName, raw }) { // 发送前确保微信在前台(息屏时通知监听会先唤醒) const fg = await isWechatForeground(); if (!fg) { log('[WECHAT] Not foreground, waking...'); await wakeWechat(); const ok = await ensureWechatForeground(); if (!ok) { log('[WECHAT] Could not bring WeChat to foreground, skip sending'); return; } await sleep(2000); // 额外等待网络初始化 } // 查 displayName(A11y 用它来在搜索框里搜联系人) let displayName; try { displayName = await this.tracker.getContactNickname(talker); } catch (e) { displayName = talker; } if (!displayName || displayName === talker) { log('[WARN] No displayName resolved for', talker, '— A11y 搜索可能失败'); } // 拉历史消息作为 hermes 上下文 let history = []; try { history = await this.tracker.fetchHistory(talker, CONFIG.historyContextSize); } catch (e) { log('[HISTORY] fetch failed:', e.message); } // 流式 hermes 调用直接在 onUtterance 里发送,不再先生成完整文本 // 群里被 @ 时,第一条回复带 quote + atSender;多步流式回复后续条仅文本 const baseSendOpts = { toUserDisplayName: displayName }; let firstSendOpts = { ...baseSendOpts }; if (replyType === 'group-at' || replyType === 'group-quote') { let bubbleText = content; const colonIdx = bubbleText.indexOf(':\n'); if (colonIdx > 0) bubbleText = bubbleText.substring(colonIdx + 2); const trimmed = bubbleText.replace(/<[^>]+>/g, '').trim(); if (trimmed) firstSendOpts.quoteText = trimmed.substring(0, 30); if (senderName) { try { const senderNick = await this.tracker.getContactNickname(senderName); firstSendOpts.atSender = senderNick || senderName; } catch (_) { firstSendOpts.atSender = senderName; } } log('[GROUP] first-reply quote=' + (firstSendOpts.quoteText || '-') + ' atSender=' + (firstSendOpts.atSender || '-')); } // 走流式:每个 hermes turn → 一条独立微信消息 // 第一条带 quote + atSender,后续条仅文本(避免重复引用 / @) const onUtterance = async (text, idx, isFinal) => { // 单条限长保护 let toSend = text; if (toSend.length > CONFIG.maxReplyLength) { toSend = toSend.substring(0, CONFIG.maxReplyLength) + '...'; } const opts = (idx === 0) ? firstSendOpts : baseSendOpts; log('[UTTER]', idx, isFinal ? '(final)' : '', toSend.substring(0, 60)); try { const r = await sendTextMessageBroadcast(talker, toSend, opts); if (r.ok) { log('[SENT]', talker, `#${idx}`, toSend.substring(0, 60)); } else { log('[SEND FAILED]', talker, r.error); } } catch (e) { log('[SEND ERROR]', e.message); } // 等 A11y 跑完 chat-list-click → quote → @ → type → send 完整一轮 // 不等的话下一条 broadcast 会打断 A11y 当前状态 await sleep(idx === 0 ? 14000 : 9000); // 第一条带 quote+@ 多花时间 }; try { const sessionId = `wx-${talker}`; const messages = buildHermesMessages({ type: replyType, talker, displayName, content, senderName, history, raw, }); log('[HERMES] Streaming for', talker, 'turns=' + messages.length); const result = await callHermesRunStream(messages, sessionId, onUtterance); log('[HERMES] Run done. usage=' + JSON.stringify(result.usage || {})); if (msgId != null) this.state.markReplied(msgId, talker); } catch (e) { log('[STREAM ERROR]', e.message); } } // 给 owner 发一条纯文本消息(授权请求 / 确认),不走 hermes async sendPlainTo(toUser, displayName, text) { const fg = await isWechatForeground(); if (!fg) { await wakeWechat(); await ensureWechatForeground(); await sleep(2000); } try { const r = await sendTextMessageBroadcast(toUser, text, { toUserDisplayName: displayName }); log('[AUTH SEND]', toUser, r.ok ? 'OK' : r.error); await sleep(10000); // 等 A11y 跑完 } catch (e) { log('[AUTH SEND ERROR]', e.message); } } // 非白名单消息 → 请示 owner async requestAuthorization(talker, senderName, content) { if (this.whitelist.hasPending(talker)) { log('[AUTH] Already pending for', talker, '— skip duplicate ask'); return; } let dn = talker; try { dn = await this.tracker.getContactNickname(talker); } catch (_) {} // 群消息 content 形如 "senderWxid:\n实际内容",剥掉前缀 let cleanContent = content; const ci = cleanContent.indexOf(':\n'); if (ci > 0) cleanContent = cleanContent.substring(ci + 2); cleanContent = cleanContent.replace(/<[^>]+>/g, '').trim().substring(0, 100); const who = isGroupChat(talker) ? `群「${dn}」里的 ${senderName || '某人'}` : `${dn}`; this.whitelist.addPending(talker, { senderName, content, displayName: dn, ts: Date.now(), }); log('[AUTH] Pending authorization for', talker, '(' + dn + ')'); const ask = `【授权请求】${who} 给我发消息了:\n「${cleanContent}」\n\n` + `回复「同意」我就自动回复 TA;回复「拒绝」忽略。` + `(多个待授权时用「同意 ${dn}」指定)`; await this.sendPlainTo(CONFIG.ownerWxid, CONFIG.ownerDisplayName, ask); } // 处理 owner 的授权指令 async handleApproval(cmd) { const { approve, target, quotedContext } = cmd; const pendings = this.whitelist.pendingTalkers(); if (pendings.length === 0) { await this.sendPlainTo(CONFIG.ownerWxid, CONFIG.ownerDisplayName, '当前没有待授权的请求。'); return; } // 选定目标,优先级: // 1. 显式 target("同意 宜创AI全员群")按 wxid/昵称/senderName 匹配 // 2. 引用回复的被引用内容里出现哪个 pending 的 displayName("同意"引用了某条授权请求) // 3. 都没有 → 取最近一个 pending let talker = null; if (target) { talker = pendings.find(t => { const p = this.whitelist.pending[t]; return t === target || p.displayName === target || p.senderName === target || (p.displayName && (p.displayName.includes(target) || target.includes(p.displayName))); }); } if (!talker && quotedContext) { talker = pendings.find(t => { const dn = this.whitelist.pending[t].displayName; return dn && quotedContext.includes(dn); }); } if (!talker) talker = pendings[pendings.length - 1]; const p = this.whitelist.pending[talker]; if (approve) { this.whitelist.allow(talker); log('[AUTH] Approved', talker, '(' + p.displayName + ')'); await this.sendPlainTo(CONFIG.ownerWxid, CONFIG.ownerDisplayName, `已授权「${p.displayName}」,正在回复 TA 刚才的消息,之后也会自动回复。`); // 回复 TA 刚才那条 pending 消息 let replyType = 'private'; let senderName = p.senderName || ''; if (isGroupChat(talker)) { replyType = isAtMe(p.content, CONFIG.myNickname) ? 'group-at' : 'group-quote'; } await this.generateAndSendReply({ talker, content: p.content, msgId: null, replyType, senderName, raw: '', }); } else { this.whitelist.removePending(talker); log('[AUTH] Denied', talker, '(' + p.displayName + ')'); await this.sendPlainTo(CONFIG.ownerWxid, CONFIG.ownerDisplayName, `已忽略「${p.displayName}」的请求。`); } } async start() { if (this.running) return; this.running = true; log('=== Auto-Reply Bot Started ==='); log('Hermes:', `${CONFIG.hermesHost}:${CONFIG.hermesPort}`); log('Config:', JSON.stringify({ myWxid: CONFIG.myWxid ? '***' : '(not set)', myNickname: CONFIG.myNickname || '(not set)', replyInPrivateChat: CONFIG.replyInPrivateChat, replyAtInGroup: CONFIG.replyAtInGroup, replyQuoteInGroup: CONFIG.replyQuoteInGroup, historyContextSize: CONFIG.historyContextSize, notificationEnabled: CONFIG.notificationEnabled, ownerWxid: CONFIG.ownerWxid, ownerDisplayName: CONFIG.ownerDisplayName, })); log('[WHITELIST] allowed:', JSON.stringify(Array.from(this.whitelist.allowed)), 'pending:', JSON.stringify(this.whitelist.pendingTalkers())); // A11y service 健康检查 — 发送 broadcast 必须依赖它 const a11y = await checkAccessibilityServiceEnabled(); if (!a11y.enabled) { log('[A11Y] NOT enabled (raw:', a11y.raw, '), trying to enable...'); const en = await enableAccessibilityService(); if (en.ok) { log('[A11Y] Enabled OK'); } else { log('[A11Y] FAILED to enable:', en.error || '(unknown)'); log('[A11Y] Manual fix: adb shell "su -c \'settings put secure enabled_accessibility_services com.hermes.wechat.xposed/com.hermes.wechat.xposed.accessibility.HermesAccessibilityService\'"'); } } else { log('[A11Y] HermesAccessibilityService is enabled'); } // 启动 tracker(sqlcipher DB 轮询,唯一稳定的消息检测方式) this.tracker.on('message', msg => this.handleMessage(msg)); this.tracker.on('error', err => log('[TRACKER ERROR]', err.message)); await this.tracker.init(); this.tracker.startPolling(3000); log('[TRACKER] DB polling started (sqlcipher)'); // 启动通知监听(息屏兜底:检测到通知时点亮屏幕+启动微信) if (CONFIG.notificationEnabled) { this.notifLoop = pollNotifications(this); } process.on('SIGINT', () => this.stop()); process.on('SIGTERM', () => this.stop()); } stop() { if (!this.running) return; this.running = false; this.tracker.stopPolling(); log('=== Auto-Reply Bot Stopped ==='); process.exit(0); } } // ═══════════════════════════════════════════════════════════════ // 入口 // ═══════════════════════════════════════════════════════════════ if (require.main === module) { // 全局兜底:Node 默认遇 unhandledRejection / uncaughtException 会退出进程, // 流式 + sendChain Promise 链路某处漏接 reject 会导致 node 崩 → keepalive 重启 // → 打断正在跑的 hermes run。这里捕获并记日志,保持进程存活。 process.on('unhandledRejection', (reason) => { log('[UNHANDLED REJECTION]', (reason && reason.stack) || String(reason)); }); process.on('uncaughtException', (err) => { log('[UNCAUGHT EXCEPTION]', (err && err.stack) || String(err)); }); if (process.env.MY_WXID) CONFIG.myWxid = process.env.MY_WXID; if (process.env.MY_NICKNAME) CONFIG.myNickname = process.env.MY_NICKNAME; if (!CONFIG.myWxid || !CONFIG.myNickname) { console.error(''); console.error('ERROR: 请设置 MY_WXID 和 MY_NICKNAME 环境变量'); console.error(''); console.error('用法示例:'); console.error(' MY_WXID=wxid_xxxxxx MY_NICKNAME=小明 nohup node auto-reply.js &'); console.error(''); process.exit(1); } const bot = new AutoReplyBot(); bot.start(); } module.exports = { AutoReplyBot, hermesReplyGenerator, callHermesApi, CONFIG };