auto-reply.js 12 KB
/**
 * 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 <pid>
 */

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 中包含 <refermsg> XML
 */
function isQuoteMe(content, nickname) {
  if (!nickname) return false;
  if (!content.includes('<refermsg')) return false;
  // 提取 displayname
  const match = content.match(/<displayname>([^<]+)<\/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<string|null>} 回复内容,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 };