/** * auto-reply.js — Pixel 手机微信自动回复守护进程 * * 功能: * 1. 单聊消息 → 自动回复 * 2. 群聊 @我 → 自动回复 * 3. 群聊引用我的消息 → 自动回复 * * 运行方式: * 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 { sendTextMessageBroadcast } = require('./android-wechat-sender'); const fs = require('fs'); const path = require('path'); // ═══════════════════════════════════════════════════════════════ // 配置区 — 请根据实际情况修改 // ═══════════════════════════════════════════════════════════════ const CONFIG = { // 你的微信信息(用于判断群聊 @/引用) myWxid: process.env.MY_WXID || '', // 如: wxid_xxxxxx myNickname: process.env.MY_NICKNAME || '', // 如: 小明 // 回复行为 replyInPrivateChat: true, // 单聊自动回复 replyAtInGroup: true, // 群聊 @我 时回复 replyQuoteInGroup: true, // 群聊引用我时回复 maxReplyLength: 500, // 单条回复最大长度 cooldownMs: 3000, // 同一人冷却时间(毫秒) ignoreSelf: true, // 忽略自己发送的消息 // 日志 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 = {}; // talker -> timestamp 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(); // 清理旧记录,只保留最近 1000 条 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 {} } // ═══════════════════════════════════════════════════════════════ // 消息判断 // ═══════════════════════════════════════════════════════════════ /** * 判断是否为群聊 */ function isGroupChat(talker) { return talker && talker.endsWith('@chatroom'); } /** * 判断群聊消息是否 @我 * 微信 @ 消息格式:"@昵称 消息内容" 或 "@昵称\n消息内容" */ function isAtMe(content, nickname) { if (!nickname) return false; // 匹配 @昵称 后跟空格或换行 const pattern = new RegExp('@' + escapeRegex(nickname) + '(\\s|\\n|$)'); return pattern.test(content); } /** * 判断群聊消息是否引用我 * 引用消息 content 中包含 XML */ 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, '\\$&'); } // ═══════════════════════════════════════════════════════════════ // 回复生成 — 默认实现(可替换) // ═══════════════════════════════════════════════════════════════ /** * 默认回复生成器 — 简单示例 * 实际使用时,应接入 hermes / Claude CLI 生成智能回复 * * @param {Object} ctx — 消息上下文 * ctx.type: 'private' | 'group-at' | 'group-quote' * ctx.talker: 对方 wxid / 群聊 ID * ctx.content: 消息内容 * ctx.senderName: 发送者昵称(如能从 content 解析) * @returns {Promise} 回复内容,null 表示不回复 */ async function defaultReplyGenerator(ctx) { // 示例:接入 Claude CLI 生成回复 // 请根据实际环境修改此函数 const { type, talker, content, senderName } = ctx; // 方案 A:调用本地 Claude CLI(如果手机上安装了) // const reply = await callClaudeCli(content); // 方案 B:调用 hermes agent API(如果手机上有 HTTP 服务) // const reply = await callHermesApi(content); // 方案 C:简单兜底回复 if (type === 'private') { return `收到你的消息:"${content.substring(0, 50)}",稍后回复~`; } if (type === 'group-at') { return `@${senderName || ''} 收到,稍后处理~`; } if (type === 'group-quote') { return `看到引用了我的消息,稍后回复~`; } return null; } /** * 调用 Claude CLI 生成回复(需要手机上安装 claude CLI) */ async function callClaudeCli(prompt) { return new Promise((resolve, reject) => { const { spawn } = require('child_process'); const proc = spawn('claude', ['-p', prompt], { timeout: 30000 }); let stdout = ''; proc.stdout.on('data', d => stdout += d.toString()); proc.on('close', code => { if (code !== 0) reject(new Error('claude exit ' + code)); else resolve(stdout.trim()); }); proc.on('error', reject); }); } // ═══════════════════════════════════════════════════════════════ // 主逻辑 // ═══════════════════════════════════════════════════════════════ class AutoReplyBot { constructor(replyGenerator = defaultReplyGenerator) { this.tracker = new AndroidWechatTracker(); 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 { // 群聊 // 提取发送者昵称(群聊消息格式:"昵称:\n内容" 或 XML) 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)}`); // 生成回复 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 sendTextMessageBroadcast(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); } } start() { if (this.running) return; this.running = true; log('=== Auto-Reply Bot Started ==='); log('Config:', JSON.stringify({ myWxid: CONFIG.myWxid ? '***' : '(not set)', myNickname: CONFIG.myNickname || '(not set)', replyInPrivateChat: CONFIG.replyInPrivateChat, replyAtInGroup: CONFIG.replyAtInGroup, replyQuoteInGroup: CONFIG.replyQuoteInGroup, })); this.tracker.on('message', msg => this.handleMessage(msg)); this.tracker.on('error', err => log('[TRACKER ERROR]', err.message)); this.tracker.startPolling(2000); // 优雅退出 process.on('SIGINT', () => this.stop()); process.on('SIGTERM', () => this.stop()); } stop() { if (!this.running) return; this.running = false; this.tracker.stopPolling(); this.tracker.stopStream(); 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, defaultReplyGenerator, CONFIG };