Young's Toy Box
HomePostsGitHub

如何写一个 Lagrange?

孩子们,这不好笑

“孩子们,这不好笑” ——林文轩

如何从头开始写一个 QQ Bot 协议端实现?

作为一个货真价实的“吃百家饭”的协议端开发者,我参与过 NapCatQQ 和 Lagrange.Core 的开发,并且自己也维护两个类似 Lagrange 的协议实现,分别是 tanebi  (TypeScript) 和 saltify-adapter-lagrange  (Kotlin)。下面,我将从我的实践出发,介绍一个 QQ Bot 协议端需要包含哪些部分,以及一些关键的细节。

概念

相信这篇文章的读者对协议端和应用端的概念并不陌生,但为了保险起见,还是进行简要介绍:

现代 QQ Bot 框架分两大类,一类是协议端,负责与 QQ 服务器通信,实现收发消息、接收通知等基础功能;另一类是应用端,负责解析用户调用并给出回应,实现较为高层次的功能。这两者之间通过网络进行通信,最为常用的通信协议是 OneBot 11 。老实说,虽然 Lagrange 的开发者看不上这个协议,并且设计了 Milky  来取代 OneBot 11,但不得不承认,OneBot 11 仍然会在一段时间内占据主要地位。

说到协议端,又可以细分为两种:

我们接下来会重点介绍纯协议实现的开发。

项目架构

以下的内容假设你要编写一个类似 Lagrange.Core 的库,而不是实现了 OneBot 11 之类的通信协议的完整应用程序。

Lagrange.Core 区分了外层内层(Internal)。从概念上讲,内层负责的有:

外层负责的有:

我们以 Lagrange V2 的文件夹结构为例:

基础设施

签名服务

NTQQ 协议要求所有数据包进行签名,这无疑增加了协议端的开发难度。目前没有公开的签名服务实现,并且已知的实现方式都是基于入侵式的 Hook 或者内存注入,而非算法实现。Lagrange.Core 也没有实现签名,而是通过 HTTP API 调用公开的签名服务来对数据包进行签名。

加密算法

Lagrange.Core 中用到的加密算法如下:

Warning

请保证你所用的语言支持上述算法(除 TEA 变体之外,TEA 的变体需要自行编写),或者你有信心自己编写一套 ECDH/AES 算法。可以从 Lagrange.Core 的仓库中找到这些算法的具体实现。

TLV

QQ 的数据包中使用了一种他们称作 TLV 的格式,尽管这个格式和传统的 Type-Length-Value 格式 有很大不同。QQ 所用的 TLV 格式的编码内容主要分以下三种:

例如,假设我们有这样的一个 Schema(用伪代码描述):

int32 a; int64 b; byte[16] c; byte[] d Prefix int8; byte[] e Prefix int16 LengthIncludePrefix;

编码如下的数据:

a = 1; b = 2; c = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10}; d = {0x11, 0x12, 0x13}; e = {0x14, 0x15, 0x16};

那么最终的二进制数据包将是:

00 00 00 01 // 1 in int32 00 00 00 00 00 00 00 02 // 2 in int64 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 10 03 11 12 13 // Length = 3 (byte[]) 00 05 14 15 16 // Length = 3 (byte[]) + 2 (int16) = 5

Tagged TLV

Tagged TLV 是 TLV 的一种变体,它实际上对应一个特殊的 Schema:

int16 tag; byte[] value Prefix int16;

这种结构常见于与登录有关的数据包中。tag 的值是一个整数,表示该数据包的“类型”标记;value 则是一个变长字节数组,包含了该数据包的具体内容。一个登录数据包通常包含多个 Tagged TLV,在编码时,先用一个 int16 来表示总共的 Tagged TLV 的数量,然后按照上面的 Schema 依次编码每个 Tagged TLV。

ProtoBuf

QQ 的数据包大量使用了 ProtoBuf  作为编码格式。因此,我们需要一个用于编解码 ProtoBuf 的基础设施,将序列化的 ProtoBuf 二进制信息与程序中的结构体相对应。传统的做法一般是使用 protoc.proto 文件编译到各种语言的序列化代码;但出于某种奇怪的传统和对效率的极致追求,Lagrange 的实现者采用了另一种思路,直接通过属性标记将 ProtoBuf 高效集成到强类型对象结构的声明中。

Lagrange V1 使用的是 protobuf-net ;V2 则使用了自主编写的、兼容 Native AOT 的序列化框架 Lagrange.Proto 。笔者也分别用 TypeScript Kotlin  实现了两个类似的框架。

Uin 和 Uid

Uin 就是 QQ 号,是旧版 QQ 协议用于标识用户的方式。随着 NTQQ 协议的引入,Uin 逐渐被 Uid 取代。Uid 是一个全局唯一的标识符,通常是一个字符串,用于在 NT 协议中标识用户。从设计上来看,Uid 是一个更为灵活的标识符,能够防止恶意用户通过猜测 Uin 来进行攻击。Uid 向 Uin 的转换是容易的,但反过来就不行了。

消息处理

QQ 的消息处理是一个复杂的过程。接收消息的过程大致可以分为三部分:

同样,发送消息的过程也分为三部分:

消息标识符

在 QQ 中,定位一条消息通常需要三个因素:

在执行与消息有关的操作时,确定了消息场景的情况下,通常需要提供 Peer 标识符和消息序列号。在回复和撤回私聊消息时,还需要提供 clientSequencerandommessageUid 等参数,这些参数目前作用未知,但可以通过提供 Peer 标识符和消息序列号来获取。

Highway

QQ 的媒体文件上传与一般数据包使用不同的信道,这个信道被称为 Highway。使用 Highway 上传文件,需要先将媒体文件的元信息和哈希上报到 QQ 服务器,获得一个文件 ID 和上传地址(如果服务器已存在哈希值相同的文件,则会告知客户端无需上传),随后协议端可以向返回的地址上传文件。上传完成后,协议端即可将文件 ID 和其他元信息编码进消息段,完成最终的消息发送。

元信息处理

上传图片时需要提供图片的格式尺寸,这需要协议端进行解析。上传视频时,同样需要解析视频的格式时长尺寸,并且提供一张缩略图

上传语音时,需要将各种形式的语音转换为 SILK 格式 再上传,这是一种针对人类语音的压缩格式。社区为此已经开发了多种工具,其中的一部分列举如下:

哈希算法方面,QQ 在处理文件数据时使用了 MD5 SHA-1  两种哈希算法,即使这两种算法都已经被认为不够安全。此外,在私聊文件场景下,还使用了一种独特的 TriSHA1 算法,在文件体积超过 30MB 时,采样文件的头、中、尾各 10MB 进行 SHA-1 哈希计算,可以参考 LagrangeV2 中的实现 

后记

本文内容基于 QQ Bot 社区多年的研究成果,在这里向所有致力于编写协议端的开发者致以由衷的感谢。撰写过程中参考了大量已有的代码,所引用之处已在正文中以超链接的形式注明。

由于本文涉及较多专业概念,可能会有部分内容不够准确或清晰。如有谬误,欢迎通过 GitHub  或作者社交媒体账号反馈。

CC BY-NC 4.0 2025 © Wesley Young.