android-wechat-tracker-sqlcipher.js 9.13 KB
/**
 * android-wechat-tracker-sqlcipher.js — 通过 sqlcipher 轮询 WeChat DB 检测新消息
 *
 * 不依赖 LSPosed HOOK,直接查询 EnMicroMsg.db 的 message 表。
 * 需要 root 权限(su -c)。
 */

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

const DB_PATH = '/data/data/com.tencent.mm/MicroMsg/653cdc2320abb089e149a3f7a7683fcf/EnMicroMsg.db';
const SQLCIPHER_KEY = 'e03ce81';
const SQLCIPHER_BIN = '/data/data/com.termux/files/usr/bin/sqlcipher';

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 AndroidWechatTrackerSQLCipher extends EventEmitter {
  constructor() {
    super();
    this.polling = false;
    this.lastMsgId = 0;
    this.dbPath = DB_PATH;
  }

  async init() {
    const out = await this._query(`SELECT MAX(msgId) AS max_id FROM message;`, 5000);
    const val = this._extractValue(out, 'max_id');
    this.lastMsgId = parseInt(val, 10) || 0;
    console.log(`[TrackerSQLCipher] DB: ${this.dbPath}, lastMsgId: ${this.lastMsgId}`);
  }

  async _query(sql, timeoutMs = 10000) {
    // 使用 .mode line 和 .headers off 获取干净输出
    const script = `.headers off\n.mode line\nPRAGMA key="${SQLCIPHER_KEY}";\nPRAGMA cipher_compatibility=1;\nPRAGMA cipher_page_size=1024;\n${sql}\n`;
    // 写入临时文件避免命令行转义问题
    const tmpFile = `/data/data/com.termux/files/usr/tmp/sqlcipher_query_${Date.now()}.sql`;
    fs.writeFileSync(tmpFile, script);
    const cmd = `${SQLCIPHER_BIN} "${this.dbPath}" < ${tmpFile}`;
    const out = await shell(cmd, timeoutMs);
    // 清理临时文件
    try { fs.unlinkSync(tmpFile); } catch (_) {}
    return out;
  }

  _extractValue(out, columnName) {
    if (!out) return '';
    const lines = out.trim().split('\n');
    // .mode line 输出格式: columnName = value 或 columnName: value
    for (const line of lines) {
      const trimmed = line.trim();
      const eqPrefix = `${columnName} = `;
      const colonPrefix = `${columnName}: `;
      if (trimmed.startsWith(eqPrefix)) {
        return trimmed.substring(eqPrefix.length);
      }
      if (trimmed.startsWith(colonPrefix)) {
        return trimmed.substring(colonPrefix.length);
      }
    }
    return '';
  }

  /**
   * 获取新消息(msgId > lastMsgId)
   */
  async fetchNew() {
    // type=1 纯文本,type=10002 撤回通知,type=822083633 引用回复(appmsg)
    // 引用回复的 message.type 是这个大整数(0x31000031),@小火子 在 <appmsg><title> 里
    const sql = `SELECT msgId, type, isSend, talker, content, createTime FROM message WHERE msgId > ${this.lastMsgId} AND isSend = 0 AND type IN (1, 10002, 822083633) ORDER BY msgId ASC;`;
    const out = await this._query(sql, 10000);
    const msgs = this._parseRows(out);
    if (msgs.length > 0) {
      this.lastMsgId = Math.max(...msgs.map(m => m.msgId));
    }
    return msgs;
  }

  /**
   * 查询 talker 最近 limit 条文本消息(按时间正序),用于喂给 hermes 做上下文。
   * 返回 [{ msgId, isSend, content, createTime }, ...]
   */
  async fetchHistory(talker, limit = 10) {
    const safeTalker = String(talker).replace(/'/g, "''");
    const sql = `SELECT msgId, isSend, content, createTime FROM message `
      + `WHERE talker='${safeTalker}' AND type=1 `
      + `ORDER BY msgId DESC LIMIT ${parseInt(limit, 10) || 10};`;
    const out = await this._query(sql, 10000);
    const rows = this._parseRows(out);
    // _parseRows 期望 msgId/talker/content 等字段,这里 talker 没查所以补一下
    return rows
      .map(r => ({
        msgId: r.msgId,
        isSend: r.isSend,
        content: r.content,
        createTime: r.createTime,
      }))
      .reverse(); // 正序:旧 → 新
  }

  /**
   * 根据 wxid 查询联系人的显示名(用于 A11y 搜索框输入)。
   * 优先级:conRemark(备注名)→ nickname → 内置 helper 映射 → wxid 本身
   *
   * @param {string} wxid
   * @returns {Promise<string>}
   */
  async getContactNickname(wxid) {
    if (!wxid) return '';

    // 微信内置 helper 没有 rcontact 记录,直接硬编码
    const builtin = {
      filehelper: '文件传输助手',
      weixin: '微信团队',
      newsapp: '腾讯新闻',
    };
    if (builtin[wxid]) return builtin[wxid];

    // 群(xxxx@chatroom)和私聊都用 rcontact 表 — nickname 就是群名 / 个人昵称
    const safeWxid = String(wxid).replace(/'/g, "''");
    const sql = `SELECT conRemark, nickname FROM rcontact WHERE username='${safeWxid}' LIMIT 1;`;
    try {
      const out = await this._query(sql, 5000);
      const conRemark = this._extractValue(out, 'conRemark');
      const nickname = this._extractValue(out, 'nickname');
      if (conRemark || nickname) return conRemark || nickname;
    } catch (_) {}

    // 群聊兜底:用户没设群名时,rcontact.nickname 为空,从 chatroom.displayname
    // 拿微信前端默认拼出来的名字(如 "丁鹏、🍋火星人、火星人")
    if (wxid.endsWith('@chatroom')) {
      try {
        const out2 = await this._query(
          `SELECT displayname FROM chatroom WHERE chatroomname='${safeWxid}' LIMIT 1;`, 5000);
        const dn = this._extractValue(out2, 'displayname');
        if (dn) return dn;
      } catch (_) {}
    }

    return wxid;
  }

  startPolling(intervalMs = 3000) {
    if (this.polling) return;
    this.polling = true;

    const tick = async () => {
      if (!this.polling) return;
      try {
        const msgs = await this.fetchNew();
        for (const msg of msgs) {
          this.emit('message', msg);
        }
      } catch (e) {
        // 静默失败,防止日志刷屏
        if (!e.message.includes('database is locked')) {
          this.emit('error', e);
        }
      }
      setTimeout(tick, intervalMs);
    };

    tick();
  }

  stopPolling() {
    this.polling = false;
  }

  _parseRows(text) {
    const messages = [];
    if (!text) return messages;

    // .mode line 输出格式:
    //   key1 = value1
    //   key2 = value2_line1
    //          value2_line2     <-- 续行(带前导空格对齐,不含 ":"/"=")
    //   key3 = value3
    //   (空行分隔下一条记录)
    //
    // 注意:多行 TEXT 字段(如 message.content)的换行在输出里保留,
    //       续行没有 ": " / " = ",必须并入上一个 key 的 value,否则会
    //       丢内容并导致 @ / 引用等检测失败。
    const records = [];
    let current = {};
    let lastKey = null;
    // 已知所有合法 column 名(用于判断一行是不是新 key=value),避免
    // 把内容里碰巧带 "xxx = yyy" 的文本误识别为新字段
    const KNOWN_COLS = new Set(['msgId', 'type', 'isSend', 'talker', 'content', 'createTime']);

    for (const rawLine of text.split('\n')) {
      if (!rawLine.trim()) {
        if (Object.keys(current).length > 0) {
          records.push(current);
          current = {};
          lastKey = null;
        }
        continue;
      }

      // 试图解析为 "key = value" 或 "key: value";只在 key 是已知列名时才当作新字段
      let sepIdx = rawLine.indexOf(' = ');
      let sepLen = 3;
      if (sepIdx < 0) { sepIdx = rawLine.indexOf(': '); sepLen = 2; }
      let isNewField = false;
      if (sepIdx > 0) {
        const key = rawLine.substring(0, sepIdx).trim();
        if (KNOWN_COLS.has(key)) {
          current[key] = rawLine.substring(sepIdx + sepLen);
          lastKey = key;
          isNewField = true;
        }
      }
      if (!isNewField && lastKey) {
        // 续行:sqlcipher 给续行加了和首行 value 等宽的前导空白对齐,
        // 去掉与上一字段名等长 + " = " 的前缀长度即可;保守起见用 trimStart
        current[lastKey] += '\n' + rawLine.replace(/^\s+/, '');
      }
    }
    if (Object.keys(current).length > 0) {
      records.push(current);
    }

    for (const rec of records) {
      if (!rec.msgId) continue;
      const isSendVal = parseInt(rec.isSend, 10);
      messages.push({
        msgId: parseInt(rec.msgId, 10),
        msgType: parseInt(rec.type, 10),
        isSend: isSendVal,
        talker: rec.talker || '',
        content: rec.content || '',
        createTime: parseInt(rec.createTime, 10),
        type: isSendVal === 1 ? 'send' : 'recv',
        timestamp: Date.now(),
        raw: JSON.stringify(rec),
      });
    }
    return messages;
  }
}

module.exports = { AndroidWechatTrackerSQLCipher, shell };