/** * 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 || '', // 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, // 通知监听(息屏兜底) 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 * * 把 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, '\\$&'); } // ═══════════════════════════════════════════════════════════════ // 屏幕状态检测与微信唤醒(息屏专用) // ═══════════════════════════════════════════════════════════════ 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); // 额外等待网络初始化 } // 查 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); } let replyText; try { replyText = await this.replyGenerator({ type: replyType, talker, displayName, content, senderName, history, 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 sendTextMessageBroadcast(talker, replyText, { toUserDisplayName: displayName }); if (result.ok) { log('[SENT]', talker, `(as "${displayName}")`, 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, historyContextSize: CONFIG.historyContextSize, notificationEnabled: CONFIG.notificationEnabled, })); // 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) { 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 };