/** * 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),排除自己发送的 const sql = `SELECT msgId, type, isSend, talker, content, createTime FROM message WHERE msgId > ${this.lastMsgId} AND isSend = 0 AND type IN (1, 10002) 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} */ async getContactNickname(wxid) { if (!wxid) return ''; // 微信内置 helper 没有 rcontact 记录,直接硬编码 const builtin = { filehelper: '文件传输助手', weixin: '微信团队', newsapp: '腾讯新闻', }; if (builtin[wxid]) return builtin[wxid]; // 群聊 ID 走另一个表(chatroom),先简单返回 wxid 让上层处理 if (wxid.endsWith('@chatroom')) { return wxid; } 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'); return conRemark || nickname || wxid; } 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 输出格式:每行一个 "column = value",记录之间用空行分隔 const records = []; let current = {}; for (const line of text.split('\n')) { const trimmed = line.trim(); if (!trimmed) { if (Object.keys(current).length > 0) { records.push(current); current = {}; } continue; } // sqlcipher .mode line 输出格式: "key: value" 或 "key = value" let sepIdx = trimmed.indexOf(': '); if (sepIdx < 0) sepIdx = trimmed.indexOf(' = '); if (sepIdx > 0) { const key = trimmed.substring(0, sepIdx).trim(); const val = trimmed.substring(sepIdx + 2).trim(); current[key] = val; } } 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 };