/** * 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]; // 群(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 };