更新:拿到创建频道的资格了,进行了详细测试,以下方法证实用处不大。
此方法仅能实现目前浏览的子频道 中的消息防撤回,因为其余子频道的消息没有进行下载,仅仅更新了消息预览。
且消息并没有保存下来,切换子频道后会触发重新加载子频道消息,获取消息的逻辑还是在QQ中(ipcRenderer.invoke('[gpro-invoke][0]','getChannelMsg')
),导致防撤回的消息丢失。
如有兴趣防撤回当前子频道的消息,请继续阅读。
效果:
0x0 背景
QQ近期公(内?)测了QQ频道,即TX版的Discord。有幸受群友邀请进入频道玩耍。本来以为PCQQ需要挺久才会支持,没想到目前已经可以使用了,只是功能相比手机端没有那么完善,且BUG不少,但至少聊天是够用了。经群友提醒PC版的QQ频道是Electron应用,缝合进QQ提供一个入口。既然是Electron,那么就可以简单搞点事情了,试试防撤回?
0x1 开启DevTools
Windows任务管理器可以看到QQ频道的程序名为QQGuild
,没有打开文件位置的选项。用Everything搜一下QQGuild
即可发现其位于$HOME/AppData/Local/Tencent/QQGuild/1.x/
。打卡即可看到熟悉的Electron应用结构,从resources/app/package.json
可得知程序主入口为background.js
格式化后在最下方可以看到加载方法:
class y {
constructor() {
this.initGpro(), this.initInvoker();
}
initGpro() {
const e = p.a,
t = new m.NodeIGProSdkListener(e);
this.wrapper = new m.NodeIGProSdkWrapper(t);
const n = new v();
(n.dbPath = "gpro.db"),
(n.clientVer = a.app.getVersion() + "-330c67c4"),
(n.pSqlite3 = 0),
(n.platform = f.a),
(n.platVer = f.b),
(n.a2 = f.c ? _.a.a2 : ""),
(n.d2 = f.c ? _.a.d2 : ""),
(n.d2Key = f.c ? _.a.d2Key : ""),
(n.userDataDir = d.a),
(n.machineId = c),
(n.argv = process.argv),
(n.isProduction = !f.c),
(p.a.uin = String(f.c ? _.a.uin : "")),
(p.a.wrapper = this.wrapper),
this.wrapper.start(_.a.uin, n),
h.a.info("initGpro call wrapper.start");
}
initInvoker() {
Object(g.a)(this.wrapper);
}
}
class v {}
var M = n(14);
a.protocol.registerSchemesAsPrivileged([
{ scheme: "app", privileges: { secure: !0, standard: !0 } },
]),
h.a.info("main process start"),
f.d &&
(a.app.commandLine.appendSwitch("--ignore-certificate-errors", "true"),
f.c || a.app.dock.hide()),
a.app.on("window-all-closed", () => {
h.a.info("[app window-all-closed]"), f.c && a.app.quit();
}),
a.app.on("activate", () => {
var e;
f.d && (null === (e = M.a.win) || void 0 === e || e.show());
}),
......
......
process.on("unhandledRejection", (e) => {
h.a.error(null == e ? void 0 : e.toString());
}),
process.on("uncaughtException", (e) => {
h.a.error(null == e ? void 0 : e.toString());
});
},
]);
在h.a.info("main process start"),
前添加
a.app.commandLine.appendSwitch('remote-debugging-port', '8315');
即可在加载后通过http://127.0.0.1:8315
调试,来自https://stackoverflow.com/a/30638448。
多个教程中均包含这条a.app.commandLine.appendSwitch('host-rules', 'MAP * 127.0.0.1');
。其实际作用是强制所有主机名映射到 127.0.0.1
,这个配置会导致无法从腾讯服务器上下载资源,如用户头像,频道头图等。实测不加此条照样可以进行调试,这里选择不添加 。
0x2 防撤回
打开Console之后就可看到打印的日志,观察下就可发现info级别的日志已经打印出需要的信息了,包括onRecvMsg
、onAddSendMsg
等。尝试撤回消息后发现打印了一个onMsgInfoListUpdate
,定位到代码后如下:
bindMsgInfoListUpdate({ rootState: e, commit: t }) {
o["a"].on("onMsgInfoListUpdate", (n) => {
r["a"].info("onMsgInfoListUpdate: ", n);
const { msgList: i } = e.chat,
A = [...i],
{ msgList: s } = n;
s.forEach((e) => {
const { msgId: t } = e,
n = i.findIndex((e) => e.msgId === t);
-1 !== n && (A[n] = e);
}),
t("UPDATE_MSG_LIST", A);
});
},
结合打印出的内容:
{
"msgList": [
{
"msgId": "xxxxxxxxx",
"msgRandom": "xxxxxxxxx",
"msgSeq": "xxx",
"cntSeq": "xxx",
"chatType": 4,
"msgType": 5,
"subMsgType": 4,
"sendType": 2,
"senderUid": "xxxxxx",
"peerUid": "xxxxxx",
"channelId": "xxxxxx",
"guildId": "xxxxxx",
"guildCode": "xxxxxx",
"fromUid": "0",
"fromAppid": "0",
"msgTime": "xxxxxx",
"msgMeta": "0x",
"sendStatus": 2,
"sendMemberName": "",
"sendNickName": "xxxxxx",
"guildName": "",
"channelName": "",
"elements": [
{
"elementType": 8,
"elementId": "xxxxxx",
"textElement": {
"content": "",
"atType": 0,
"atUid": "0",
"atTinyId": "0"
},
"faceElement": {
"faceIndex": 0,
"faceText": ""
},
"replyElement": {
"replayMsgId": "0",
"replayMsgSeq": "0",
"sourceMsgText": "",
"senderUid": "0"
},
"picElement": {
"fileName": "",
"fileSize": "0",
"picWidth": 0,
"picHeight": 0,
"original": false,
"md5": "",
"sourcePath": "",
"thumbPath": {}
},
"videoElement": {
"fileName": "",
"videoMd5": "",
"thumbMd5": "",
"fileTime": 0,
"thumbSize": 0,
"fileFormat": 0,
"fileSize": 0,
"thumbWidth": 0,
"thumbHeight": 0,
"busiType": 0
},
"grayTipElement": {
"subElementType": 1,
"revokeElement": {
"operatorTinyId": "xxxxxx",
"operatorRole": "0"
},
"proclamationElement": {
"isSetProclamation": 0
}
},
"arkElement": {
"bytesData": ""
},
"fileElement": {
"fileMd5": "",
"fileName": "",
"filePath": "",
"fileSize": "0",
"picHeight": 0,
"picWidth": 0,
"picThumbPath": {},
"expireTime": "0"
}
}
],
"emojiLikesList": [],
"commentCnt": "0"
}
]
}
可以发现这个方法是接收服务器下发的消息内容更新,对于撤回来说将内容清空了。之前的防撤回方法相对粗暴,直接将此方法注释,可能会影响消息的编辑、贴表情(PC尚未支持)等。现在有空闲时间可以继续研究一下如何精确拦截防撤回。
用revoke
当关键词搜索并阅读代码可以发现grayTipElement.subElementType
的取值是1
时为消息撤回。
那么就可以在上述bindMsgInfoListUpdate
方法的循环中添加判断是否为消息撤回。既然定位到消息更新的方法了,那么我们也可以对消息进行编辑实现撤回提示。最终修改完成的代码如下:
bindMsgInfoListUpdate({ rootState: e, commit: t }) {
o["a"].on("onMsgInfoListUpdate", (n) => {
r["a"].info("onMsgInfoListUpdate: ", JSON.stringify(n));
const { msgList: i } = e.chat,
A = [...i],
{ msgList: s } = n;
s.forEach((e) => {
const { msgId: t } = e,
n = i.findIndex((e) => e.msgId === t);
if (-1 !== n) {
if (e.elements.some(el => el.grayTipElement.subElementType == 1)) {
e = A[n];
e.elements.push(
{
"elementType": 1,
"elementId": "114514",
"textElement": {
"content": "\r↑拦截到此消息撤回↑",
"atType": 0,
"atUid": "0",
"atTinyId": "0"
},
"faceElement": {
"faceIndex": 0,
"faceText": ""
},
"replyElement": {
"replayMsgId": "0",
"replayMsgSeq": "0",
"sourceMsgText": "",
"senderUid": "0"
},
"picElement": {
"fileName": "",
"fileSize": "0",
"picWidth": 0,
"picHeight": 0,
"original": false,
"md5": "",
"sourcePath": "",
"thumbPath": {}
},
"videoElement": {
"fileName": "",
"videoMd5": "",
"thumbMd5": "",
"fileTime": 0,
"thumbSize": 0,
"fileFormat": 0,
"fileSize": 0,
"thumbWidth": 0,
"thumbHeight": 0,
"busiType": 0
},
"grayTipElement": {
"subElementType": 0,
"revokeElement": {
"operatorTinyId": "0",
"operatorRole": "0"
},
"proclamationElement": {
"isSetProclamation": 0
}
},
"arkElement": {
"bytesData": ""
},
"fileElement": {
"fileMd5": "",
"fileName": "",
"filePath": "",
"fileSize": "0",
"picHeight": 0,
"picWidth": 0,
"picThumbPath": {},
"expireTime": "0"
}
}
);
}
A[n] = e;
}
}),
t("UPDATE_MSG_LIST", A);
});
},