const http = require("http"); const https = require("https"); const fs = require("fs"); const path = require("path"); const { app, ipcMain, dialog } = require("electron"); var configFilePath = ""; var pluginDataDir = path.join(LiteLoader.path.data, "anti_recall"); const { Level } = require("level"); var db = null; var sampleConfig = { mainColor: "#ff6d6d", saveDb: false, enableShadow: true, enableTip: true, isAntiRecallSelfMsg: false, maxMsgSaveLimit: 10000, deleteMsgCountPerTime: 500, }; var nowConfig = {}; function initConfig() { fs.writeFileSync( configFilePath, JSON.stringify(sampleConfig, null, 2), "utf-8" ); } function loadConfig() { if (!fs.existsSync(configFilePath)) { initConfig(); return sampleConfig; } else { return JSON.parse(fs.readFileSync(configFilePath, "utf-8")); } } onLoad(); async function onLoad() { if (!fs.existsSync(pluginDataDir)) { fs.mkdirSync(pluginDataDir, { recursive: true }); } configFilePath = path.join(pluginDataDir, "config.json"); nowConfig = loadConfig(); if (nowConfig.mainColor == null) { nowConfig.mainColor = "#ff6d6d"; } if (nowConfig.enableShadow == null) { nowConfig.enableShadow = true; } if (nowConfig.enableTip == null) { nowConfig.enableTip = true; } fs.writeFileSync(configFilePath, JSON.stringify(nowConfig, null, 2), "utf-8"); ipcMain.handle( "LiteLoader.anti_recall.getNowConfig", async (event, message) => { return nowConfig; } ); ipcMain.handle("LiteLoader.anti_recall.saveConfig", async (event, config) => { nowConfig = config; sendChatWindowsMessage("LiteLoader.anti_recall.mainWindow.repatchCss"); fs.writeFileSync(configFilePath, JSON.stringify(config, null, 2), "utf-8"); }); if (nowConfig.saveDb) { db = new Level(path.join(pluginDataDir, "qq-recalled-db"), { valueEncoding: "json", }); db.open((e) => { if (e !== undefined && e !== null) { app.whenReady().then(() => { dialog .showMessageBox({ type: "warning", title: "警告", message: "打开反撤回数据库失败,可能是上次QQ进程未完全退出或同时开启了多个QQ(插件不支持QQ多开,如需多开请关闭保存到数据库功能)。建议关闭QQ并彻底结束QQ进程,再重启QQ,否则反撤回消息无法正常保存(即时反撤回仍生效,只是重启QQ后会丢失)。", buttons: ["继续打开QQ", "关闭QQ"], }) .then((r) => { if (r.response == 1) { app.exit(); } }); }); output( "打开数据库失败,可能是QQ进程未完全退出。请查看下面详细错误信息中的cause部分:", e ); } }); } ipcMain.handle("LiteLoader.anti_recall.clearDb", async (event, message) => { dialog .showMessageBox({ type: "warning", title: "警告", message: "清空所有已储存的撤回消息后不可恢复,是否确认清空?", buttons: ["确定", "取消"], cancelId: 1, }) .then(async (idx) => { //第一个按钮 if (idx.response == 0) { await db.clear(); dialog.showMessageBox({ type: "info", title: "提示", message: "清空完毕,之前保存的所有已撤回消息均被删除,重启QQ后就能看见效果。", buttons: ["确定"], }); } }); }); // 老方法,废弃 // setTimeout(async () => { // await loadDb() // .catch(async (e) => { // output( // "Error while loading recalled msgs from db: " + // e.toString(), // ", retrying..." // ); // await db.open(); // await loadDb(); // }) // .catch((e) => { // output( // "Error while loading recalled msgs from db: " + // e.toString(), // ", stop load." // ); // }); // }, 100); app.on("quit", async () => { output("Closing db..."); await db.close(); }); } // 废弃 // async function loadDb() { // if (nowConfig.saveDb) { // output("Loading recalled msgs from db..."); // var counter = 0; // for await (const value of db.values()) { // counter++; // recalledMsg.push(value); // if (value.msg == null) continue; // for (item of value.msg.elements) { // if (item.picElement != null) { // item.picElement.thumbPath = new Map([ // [ // 0, // item.picElement.sourcePath // .replace("Ori", "Thumb") // .replace( // item.picElement.md5HexStr, // item.picElement.md5HexStr + "_0" // ) // ], // [ // 198, // item.picElement.sourcePath // .replace("Ori", "Thumb") // .replace( // item.picElement.md5HexStr, // item.picElement.md5HexStr + "_198" // ) // ], // [ // 720, // item.picElement.sourcePath // .replace("Ori", "Thumb") // .replace( // item.picElement.md5HexStr, // item.picElement.md5HexStr + "_720" // ) // ] // ]); // } // } // } // output(`Loaded ${counter} msgs.`); // } else { // output("Db saving is disabled, continue."); // } // } var msgFlow = []; var recalledMsg = []; function request(url) { return new Promise((resolve, reject) => { const protocol = url.startsWith("https") ? https : http; const req = protocol.get(url); req.on("error", (error) => reject(error)); req.on("response", (res) => { // 发生跳转就继续请求 if (res.statusCode >= 300 && res.statusCode <= 399) { return resolve(request(res.headers.location)); } const chunks = []; res.on("error", (error) => reject(error)); res.on("data", (chunk) => chunks.push(chunk)); res.on("end", () => resolve(Buffer.concat(chunks))); }); }); } // 下载被撤回的图片(抄自Lite-Tools) async function processPic(msgItem) { msgItem?.elements?.forEach(async (el) => { if (el?.picElement) { const pic = el.picElement; const picName = pic.md5HexStr.toUpperCase(); const picUrl = `https://gchat.qpic.cn/gchatpic_new/0/0-0-${picName}/`; output("Download lost pic(s)... url=", picUrl, "msgId=", msgItem.msgId); if (!fs.existsSync(pic.sourcePath)) { output("Download pic:", `${picUrl}0`); const body = await request(`${picUrl}0`); fs.mkdirSync(path.dirname(pic.sourcePath), { recursive: true }); fs.writeFileSync(pic.sourcePath, body); } else { output("Pic already existed, skip.", pic.sourcePath); } // 修复本地数据中的错误 if ( pic?.thumbPath && (pic.thumbPath instanceof Array || pic.thumbPath instanceof Object) ) { pic.thumbPath = new Map([ [ 0, pic.sourcePath .replace("Ori", "Thumb") .replace(pic.md5HexStr, pic.md5HexStr + "_0"), ], [ 198, pic.sourcePath .replace("Ori", "Thumb") .replace(pic.md5HexStr, pic.md5HexStr + "_198"), ], [ 720, pic.sourcePath .replace("Ori", "Thumb") .replace(pic.md5HexStr, pic.md5HexStr + "_720"), ], ]); } if ( pic?.thumbPath && (pic.thumbPath instanceof Array || pic.thumbPath instanceof Map) ) { pic.thumbPath.forEach(async (el, key) => { if (!fs.existsSync(el)) { output("Download thumbs:", `${picUrl}${key}`); const body = await request(`${picUrl}${key}`); fs.mkdirSync(path.dirname(el), { recursive: true }); fs.writeFileSync(el, body); } }); } } }); } //废弃(老方法) // async function downloadPic(msgList) { // if (msgList == null) return; // for (item of msgList.elements) { // if (item.picElement) { // var pic = item.picElement; // var picBasePath = pic.md5HexStr.toUpperCase(); // var urlBase = `https://gchat.qpic.cn/gchatpic_new/0/0-0-${picBasePath}/`; // output( // `[${pic.md5HexStr // .toUpperCase() // .substring(0, 5)}]Downloading lost pic(s)... ${urlBase}` // ); // if (!fs.existsSync(pic.sourcePath)) { // const body = await request(`${urlBase}0`); // fs.mkdirSync(path.dirname(pic.sourcePath), { recursive: true }); // fs.writeFileSync(pic.sourcePath, body); // } else { // output( // `[${pic.md5HexStr.toUpperCase().substring(0, 5)}] ${ // pic.sourcePath // } Pic(s) already existed, skip.` // ); // } // if (pic.thumbPath instanceof Array) { // pic.thumbPath.forEach(async (value, key) => { // if (!fs.existsSync(value)) { // const body = await request(`${urlBase}${key}`); // fs.mkdirSync(path.dirname(value), { recursive: true }); // fs.writeFileSync(value, body); // } // }); // } // output( // `[${pic.md5HexStr // .toUpperCase() // .substring(0, 5)}]Download completed!` // ); // } // } // } function insertDb(msg) { if (db != null) { db.get(msg.id, (error, value) => { if (error.status == 404) { db.put(msg.id, msg, (err) => { if (err) throw err; }); } else { if (error) throw error; } }); } } async function getMsgById(id) { if (db != null) { try { return await db.get(id); } catch (e) { if (e.status != 404) { output(e); } return null; } } else { output("Warning: Db is null, no previous msg available."); } return null; } function sendChatWindowsMessage(message) { for (var window of mainWindowObjs) { if (window.isDestroyed()) continue; window.webContents.send(message); } } var mainWindowObjs = []; function onBrowserWindowCreated(window) { window.webContents.on("did-stop-loading", () => { //只针对主界面和独立聊天界面生效 if ( window.webContents.getURL().indexOf("#/main/message") != -1 || window.webContents.getURL().indexOf("#/chat") != -1 ) { mainWindowObjs.push(window); // const proxyEvents = new Proxy( // window.webContents._events["-ipc-message"], // { // // 拦截函数调用 // apply(target, thisArg, argumentsList) { // if ( // argumentsList[3][0]["eventName"] && // !argumentsList[3][0]["eventName"].includes( // "ns-Logger" // ) // ) { // output(JSON.stringify(argumentsList)); // } // return target.apply(thisArg, argumentsList); // } // } // ); // window.webContents._events["-ipc-message"] = proxyEvents; //补充知识: //撤回的原理是,你先发了一条消息,这条消息有一个msgId,然后又撤回了他,那腾讯就会发一条一样msgId的撤回消息包来替换,这样你以后拉取的话,这个msgId只会对应一条撤回提示了; //本插件的原理是,先在内存中临时储存所有消息(1000条上限),然后如果有撤回发生,则将撤回的提示替换为之前保存的消息。 const original_send = (window.webContents.__qqntim_original_object && window.webContents.__qqntim_original_object.send) || window.webContents.send; //var myUid = ""; const patched_send = async function (channel, ...args) { // output(channel, JSON.stringify(args)); // if (db != null) { // db.put("a", { x: 123 }, function (err) { // if (err) throw err; // db.get("a", function (err, value) { // console.log(value); // { x: 123 } // }); // }); // } try { if (args.length >= 2) { //MessageList IPC 中能看到消息全量更新内容,其中包含撤回的提示,但并不包含被撤回的消息(被撤回的已经被替换掉了),需要替换撤回提示为之前保存的消息内容 if ( args.some( (item) => item && item.hasOwnProperty("msgList") && item.msgList != null && item.msgList instanceof Array && item.msgList.length > 0 ) ) { var currentMsgPeer = ""; //撤回提示所在的msgList下标数组,在后面需要一个个替换为真实的消息 var needUpdateIdx = []; for (let idx in args[1].msgList) { var item = args[1].msgList[idx]; currentMsgPeer = item.peerUid; if (item.msgType == 5 && item.subMsgType == 4) { if ( item.elements[0].grayTipElement != null && item.elements[0].grayTipElement.revokeElement != null && (nowConfig.isAntiRecallSelfMsg || !item.elements[0].grayTipElement.revokeElement .isSelfOperate) ) { needUpdateIdx.push(idx); } } // console.log( // item.recallTime, // "<====>", // item.elements, // "<====>", // item.elements[0].grayTipElement.revokeElement // ); } needUpdateIdx.sort((a, b) => b - a); for (var i of needUpdateIdx) { var currMsgId = args[1].msgList[i].msgId; //如果之前存了消息 var olderMsg = msgFlow.find((i) => i.id == currMsgId); var olderMsgFromRecalledMsg = recalledMsg.find( (i) => i.id == currMsgId ); var dbMsg = await getMsgById(currMsgId); //优先从已保存的撤回的消息中获取 if (olderMsgFromRecalledMsg != null) { // original_send.call( // window.webContents, // channel, // { // type: "request", // eventName: "ns-ntApi-2" // }, // [ // { // cmdName: // "nodeIKernelMsgListener/onRecvMsg", // cmdType: "event", // payload: { // msgList: [ // olderMsgFromRecalledMsg.msg // ] // } // } // ] // ); await processPic(olderMsgFromRecalledMsg.msg); args[1].msgList[i] = olderMsgFromRecalledMsg.msg; output( "Detected recall, intercepted and recovered from old msg" ); } //如果没有存过,则说明他在消息流里 else if (olderMsg != null) { args[1].msgList[i] = olderMsg.msg; //没专门存过这条消息到专门的反撤回数组中,就存一下 if (olderMsgFromRecalledMsg == null) { recalledMsg.push(olderMsg); } await processPic(olderMsg.msg); output( "Detected recall, intercepted and recovered from msgFlow" ); } else if (dbMsg != null) { args[1].msgList[i] = dbMsg.msg; //没专门存过这条消息到专门的反撤回数组中,就存一下 if (olderMsgFromRecalledMsg == null) { recalledMsg.push(dbMsg); } await processPic(dbMsg.msg); output( "Detected recall, intercepted and recovered from dbMsg" ); } args[1].msgList[i].isOnlineMsg = true; } original_send.call( window.webContents, "LiteLoader.anti_recall.mainWindow.recallTipList", recalledMsg .filter( (i) => i.sender == currentMsgPeer || i?.sender == null ) .map((i) => i.id) ); } //增量更新 IPC if ( args.some( (item) => item instanceof Array && item.length > 0 && item[0] && item[0].cmdName != null ) ) { var args1 = args[1][0]; if (args1 == null) return; //方法一:获取个人信息的IPC,用来获取个人UID,避免防撤回自己的消息 // if (args1.cmdName.indexOf("onProfileDetailInfoChanged") != -1) { // myUid = args1.payload.info.uid; // } else //目前采用方法二,直接获取撤回消息中的参数 //拦截撤回IPC if ( args1.cmdName != null && (args1.cmdName.indexOf("onMsgInfoListUpdate") != -1 || args1.cmdName.indexOf("onActiveMsgInfoUpdate") != -1) && args1.payload != null && args1.payload.msgList instanceof Array && args1.payload.msgList[0].msgType == 5 && args1.payload.msgList[0].subMsgType == 4 ) { var msgList = args1.payload.msgList[0]; //不是自己撤回的,才拦截 if ( msgList.elements[0].grayTipElement != null && msgList.elements[0].grayTipElement.revokeElement != null && (nowConfig.isAntiRecallSelfMsg || !msgList.elements[0].grayTipElement.revokeElement .isSelfOperate) ) { original_send.call( window.webContents, "LiteLoader.anti_recall.mainWindow.recallTip", msgList.msgId ); //如果之前存了消息 var olderMsg = msgFlow.find((i) => i.id == msgList.msgId); var olderMsgFromRecalledMsg = recalledMsg.find( (i) => i.id == msgList.msgId ); //之前存了消息,但是还没有存入专门的反撤回数组中 if (olderMsg != null && olderMsgFromRecalledMsg == null) { recalledMsg.push(olderMsg); if (nowConfig.saveDb) { insertDb(olderMsg); } } await processPic(olderMsg?.msg); await processPic(olderMsgFromRecalledMsg?.msg); args[1][0].cmdName = "none"; args[1][0].payload.msgList.pop(); // console.log(args1.payload); output("Detected recall, intercepted"); } } //接到消息 else if ( (args1.cmdName != null && args1.payload != null && (args1.cmdName.indexOf("onRecvMsg") != -1 || args1.cmdName.indexOf("onRecvActiveMsg") != -1) && args1.payload.msgList instanceof Array) || (args1.cmdName.indexOf("onAddSendMsg") != -1 && args1.payload.msgRecord != null) || (args1.cmdName.indexOf("onMsgInfoListUpdate") != -1 && args1.payload.msgList instanceof Array) ) { var msgList = args1.payload.msgList instanceof Array ? args1.payload.msgList : [args1.payload.msgRecord]; for (msg of msgList) { var msgId = msg.msgId; var olderMsgIdx = msgFlow.findIndex((i) => i.id == msgId); if (olderMsgIdx == -1) { msgFlow.push({}); olderMsgIdx = msgFlow.length - 1; } msgFlow[olderMsgIdx] = { id: msgId, sender: msg.peerUid, msg: msg, }; if (nowConfig.maxMsgSaveLimit == null) { nowConfig.maxMsgSaveLimit = 10000; } if (nowConfig.deleteMsgCountPerTime == null) { nowConfig.deleteMsgCountPerTime = 500; } if (msgFlow.length > nowConfig.maxMsgSaveLimit) { msgFlow.splice(0, nowConfig.deleteMsgCountPerTime); } } } } } } catch (e) { output( "NTQQ Anti-Recall Error: ", e, "Please report this to https://github.com/xh321/LiteLoaderQQNT-Anti-Recall/issues, thank you" ); } return original_send.call(window.webContents, channel, ...args); }; if (window.webContents.__qqntim_original_object) window.webContents.__qqntim_original_object.send = patched_send; else window.webContents.send = patched_send; output( "NTQQ Anti-Recall loaded for window: " + window.webContents.getURL() ); } }); } function output(...args) { console.log("\x1b[32m%s\x1b[0m", "Anti-Recall:", ...args); } module.exports = { onBrowserWindowCreated, };