android-wechat-tracker.js 4.56 KB
/**
 * android-wechat-tracker.js — 手机端实时微信消息跟踪(无 adb,直接 su)
 *
 * 运行环境:Pixel 10 Pro Fold 的 Termux / Debian chroot
 * 前提:已 root,LSPosed/Vector 日志已启用
 *
 * 直接读取 /data/adb/lspd/log/verbose_*.log,解析 [HermesWX] 标签。
 */

const { spawn } = require('child_process');
const EventEmitter = require('events');

const LOG_DIR = '/data/adb/lspd/log';
const LOG_PATTERN = /\[HermesWX\].*?MSG\s+(SEND|RECV)\s+talker=([^\s]+)\s+type=(\d+)\s+msgId=(\d+)\s+content=(.+)/;
const SEND_RESULT_PATTERN = /\[HermesWX\].*?Send result:\s+(true|false)/;
const BROADCAST_PATTERN = /\[HermesWX\].*?Broadcast received:\s+to=(.+)/;

function hasRoot() {
  return process.getuid && process.getuid() === 0;
}

function wrapCmd(cmd) {
  if (hasRoot()) return cmd;
  return `su -c "${cmd.replace(/"/g, '\\"')}"`;
}

function shell(cmd, timeoutMs = 15000) {
  const fullCmd = wrapCmd(cmd);
  const args = ['-c', fullCmd];
  const shellBin = process.env.SHELL || '/system/bin/sh';

  return new Promise((resolve, reject) => {
    const proc = spawn(shellBin, args, { timeout: timeoutMs });
    let stdout = '';
    let stderr = '';
    proc.stdout.on('data', d => stdout += d.toString());
    proc.stderr.on('data', d => stderr += d.toString());
    proc.on('close', code => {
      if (code !== 0) {
        reject(new Error(`exit ${code}: ${stderr.trim() || stdout.trim()}`));
      } else {
        resolve(stdout.trim());
      }
    });
    proc.on('error', reject);
  });
}

class AndroidWechatTracker extends EventEmitter {
  constructor() {
    super();
    this.polling = false;
    this.lastLogTime = null;
    this.streamProc = null;
  }

  /**
   * 一次性拉取最近 N 条消息
   */
  async fetchRecent(n = 50) {
    const cmd = `cat ${LOG_DIR}/verbose_*.log 2>/dev/null | grep 'HermesWX' | tail -${n}`;
    try {
      const out = await shell(cmd, 10000);
      return this._parseLines(out);
    } catch (e) {
      return [];
    }
  }

  /**
   * 开始轮询监听
   */
  startPolling(intervalMs = 3000) {
    if (this.polling) return;
    this.polling = true;

    const tick = async () => {
      if (!this.polling) return;
      try {
        const msgs = await this.fetchRecent(20);
        for (const msg of msgs) {
          if (this.lastLogTime && msg.timestamp <= this.lastLogTime) continue;
          this.emit('message', msg);
          this.lastLogTime = msg.timestamp;
        }
      } catch (e) {
        this.emit('error', e);
      }
      setTimeout(tick, intervalMs);
    };

    tick();
  }

  stopPolling() {
    this.polling = false;
  }

  /**
   * 开始流式监听(直接 tail -f 日志文件,更实时)
   */
  startStream() {
    if (this.streamProc) return;

    const tailCmd = `tail -f ${LOG_DIR}/verbose_*.log`;
    const fullCmd = wrapCmd(tailCmd);
    const args = ['-c', fullCmd];
    const shellBin = process.env.SHELL || '/system/bin/sh';

    this.streamProc = spawn(shellBin, args);

    const rl = require('readline').createInterface({ input: this.streamProc.stdout });
    rl.on('line', line => {
      const msgs = this._parseLines(line);
      for (const msg of msgs) {
        this.emit('message', msg);
      }
      const sendResult = this._parseSendResult(line);
      if (sendResult) this.emit('sendResult', sendResult);
      const broadcast = this._parseBroadcast(line);
      if (broadcast) this.emit('sendRequest', broadcast);
    });

    this.streamProc.stderr.on('data', d => {
      const err = d.toString().trim();
      if (err) this.emit('error', new Error(err));
    });

    this.streamProc.on('close', () => {
      this.streamProc = null;
      this.emit('close');
    });
  }

  stopStream() {
    if (this.streamProc) {
      this.streamProc.kill();
      this.streamProc = null;
    }
  }

  _parseLines(text) {
    const messages = [];
    if (!text) return messages;
    for (const line of text.split('\n')) {
      const m = line.match(LOG_PATTERN);
      if (m) {
        messages.push({
          type: m[1].toLowerCase(),
          talker: m[2],
          msgType: parseInt(m[3], 10),
          msgId: parseInt(m[4], 10),
          content: m[5],
          raw: line.trim(),
          timestamp: Date.now(),
        });
      }
    }
    return messages;
  }

  _parseSendResult(line) {
    const m = line.match(SEND_RESULT_PATTERN);
    if (m) return { success: m[1] === 'true', raw: line.trim() };
    return null;
  }

  _parseBroadcast(line) {
    const m = line.match(BROADCAST_PATTERN);
    if (m) return { toUser: m[1], raw: line.trim() };
    return null;
  }
}

module.exports = { AndroidWechatTracker, shell };