/** * auto-reply.js — Pixel 手机微信自动回复 Plugin(接入 hermes API) * * 架构: * AndroidWechatTrackerSQLCipher (sqlcipher DB 轮询) ──→ 消息检测 * termux-notification-list (兜底) ──→ 息屏时检测通知并唤醒微信 * hermes API (POST /api/master/chat) ──→ AI 回复生成 * android-wechat-sender-frida (frida-inject 调用微信内部 API) ──→ 发送回复 * * 运行方式: * cd /data/data/com.termux/files/home/hermes-wechat * nohup node auto-reply.js & * * 终止: * ps aux | grep auto-reply * kill */ const { AndroidWechatTracker } = require('./android-wechat-tracker'); const { AndroidWechatTrackerSQLCipher } = require('./android-wechat-tracker-sqlcipher'); const { sendTextMessageBroadcast } = require('./android-wechat-sender'); const { sendTextMessageFrida } = require('./android-wechat-sender-frida'); const { 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 || '', // 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, // 通知监听(息屏兜底) 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; } } // ═══════════════════════════════════════════════════════════════ // 日志 // ═══════════════════════════════════════════════════════════════ function log(...args) { const line = `[${new Date().toISOString()}] ${args.join(' ')}`; console.log(line); try { fs.appendFileSync(CONFIG.logFile, line + '\n'); } catch {} } // ═══════════════════════════════════════════════════════════════ // Hermes API 调用 // ═══════════════════════════════════════════════════════════════ /** * 调用 hermes-agent API (OpenAI-compatible) 生成回复 * * @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 */ function buildHermesMessages(ctx) { const { type, talker, content, senderName } = ctx; let userContent = ''; if (type === 'private') { userContent = `收到微信私聊消息(来自 ${talker}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; } else if (type === 'group-at') { userContent = `收到微信群聊消息,有人 @ 我(来自群 ${talker},发送者 ${senderName}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; } else if (type === 'group-quote') { userContent = `收到微信群聊消息,有人引用了我的消息(来自群 ${talker},发送者 ${senderName}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; } else { userContent = `收到微信消息(来自 ${talker}):\n${content}\n\n请给出自然、简短、得体的中文回复。只输出回复内容,不要加任何解释。`; } return [ { role: 'system', content: '你是 lemonAI 助手,一个智能、友好、简短的 AI 助手。' }, { role: 'user', content: userContent }, ]; } /** * 接入 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); 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, '\\$&'); } // ═══════════════════════════════════════════════════════════════ // 屏幕状态检测与微信唤醒(息屏专用) // ═══════════════════════════════════════════════════════════════ 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.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; log(`[${replyType.toUpperCase()}] ${talker}: ${content.substring(0, 80)}`); // 发送前确保微信在前台(息屏时通知监听会先唤醒) 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); // 额外等待网络初始化 } let replyText; try { replyText = await this.replyGenerator({ type: replyType, talker, content, senderName, raw: msg.raw, }); } catch (e) { log('[REPLY GEN ERROR]', e.message); return; } if (!replyText) { log('[NO REPLY] Generator returned null'); return; } if (replyText.length > CONFIG.maxReplyLength) { replyText = replyText.substring(0, CONFIG.maxReplyLength) + '...'; } try { const result = await sendTextMessageFrida(talker, replyText); if (result.ok) { log('[SENT]', talker, replyText.substring(0, 60)); this.state.markReplied(msgId, talker); } else { log('[SEND FAILED]', talker, result.error); } } catch (e) { log('[SEND ERROR]', e.message); } } 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, notificationEnabled: CONFIG.notificationEnabled, })); // 启动 tracker(sqlcipher DB 轮询,比 LSPosed hook 更可靠) 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) { 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 };