更新:拿到创建频道的资格了,进行了详细测试,以下方法证实用处不大。

此方法仅能实现目前浏览的子频道 中的消息防撤回,因为其余子频道的消息没有进行下载,仅仅更新了消息预览。
且消息并没有保存下来,切换子频道后会触发重新加载子频道消息,获取消息的逻辑还是在QQ中(ipcRenderer.invoke('[gpro-invoke][0]','getChannelMsg')),导致防撤回的消息丢失。

如有兴趣防撤回当前子频道的消息,请继续阅读。

效果:

QQ子频道防撤回效果

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级别的日志已经打印出需要的信息了,包括onRecvMsgonAddSendMsg等。尝试撤回消息后发现打印了一个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);
  });
},
最后修改:2021 年 11 月 11 日
如果觉得我的文章对你有用,请随意赞赏