C++ Discord:差点要了我的命的 Discord 机器人(用 C++ 编写)
项目概述
如果你正考虑自己编写机器人,或者喜欢阅读其他开发者的经验分享,那么这篇文章将列出一些常见的陷阱以及如何避免它们,或者讲述我在这段旅程中所经历的一切。由于我遇到了许多挑战,所以这篇文章篇幅较长,不妨泡杯咖啡或吃点爆米花,边读边学。
这次的开发项目是用 C++ 完成的。虽然其他语言和框架会让项目变得简单得多,但我选择 C++ 的原因在于:它可以复用我用于编程直播的Twitch 机器人中的代码;它是我最熟悉的语言;而且我还想在其他 C++ 项目中使用 WebSocket。最后一个原因才是主要驱动力。
该项目的目标是创建一个 Discord 机器人,使其在我的内部服务器上 24/7 全天候运行。服务器运行的是 Linux 系统,项目开始时,我的开发机上运行的是 Ubuntu 系统,用于在 Linux 上进行游戏开发实验。项目始于对 WebSocket 框架的搜索;
- https://github.com/zaphoyd/websocketpp
- https://github.com/mattgodbolt/seasocks
- https://github.com/uNetworking/uWebSockets
- 更多信息请访问:https://github.com/facundofarias/awesome-websockets
我原本不想用 Boost。虽然我喜欢它的概念,但之前在其他项目中用它的经历让我不堪回首。事实上,我非常讨厌 C++ 中的依赖管理,但有时它还是比自己写一个自定义实现要简单一些。所以我开始研究 Seasocks,把它编译、链接都弄好了,开始用了之后才发现……Seasocks 只是一个 WebSocket 服务器,没有客户端功能。白费力气了。
第一个挑战:放弃与小小的胜利
前六个小时我都在各种框架之间切换,还写了个自定义实现。然后我就放弃了这个项目。没错,六个小时后我就放弃了,觉得太麻烦太费劲了。休息了两天后,我又重新启动了这个项目,打算自己写实现,然后继续推进。我跌倒了,但我会重新站起来。
虽然我以后使用 WebSocket 时并不需要它,但连接 Discord 需要用到安全 WebSocket。接下来的几个小时我都花在了解决这个问题上。一开始我尝试使用 OpenSSL,结果却以失败告终。在 Twitch 聊天室的帮助下,我转而使用 LibreSSL,又过了几个小时,我终于成功连接到了 Discord。这是迈出的第一步。连接在几秒钟后断开了(没有发送心跳包),但成功地从 https:// 升级到了 wss://。
我的游戏开发项目中已经实现了 TCP 和 UDP 套接字。我希望将来能够通过这些套接字实现使用 WebSocket。难点在于如何让 LibreSSL 使用这些套接字,不过这个问题相对容易解决。我尝试tls_connect_socket()用tls_connect_cbs()回调函数来读写自定义套接字实现。在完成一些原型设计后的清理工作后,我感觉自己已经胜券在握,因为已经取得了两次成功。
实现 WebSocket 协议
事情一开始比预想的要糟糕得多,rfc6455一开始确实有点吓人。正如前面提到的,为了让 TLS 与 LibreSSL 协同工作,HTTPS 到 WSS 的转换已经以某种形式实现。但是,要通过 WebSocket 发送或接收数据,需要使用特殊的帧,其中包含一个头部,其长度取决于有效负载长度和掩码,介于 2 到 14 字节之间。这正是使用 C++ 位域的绝佳时机。虽然手动位运算也能满足需求,但位域更加简洁。
struct FrameHeader
{
std::uint8_t mOpCode : 4;
std::uint8_t mReserved : 3;
std::uint8_t mFinished : 1;
std::uint8_t mLength : 7;
std::uint8_t mMask : 1;
};
我承认, rfc6455#page-28中描述帧的图片确实让我一头雾水,我的第一反应是把每个字节的最高有效位和最低有效位弄反了。不过这个问题很容易发现并解决。每个帧至少包含两个字节,读取这些字节后,可以将它们转换为帧头格式以提取信息。实际的有效载荷长度可以存储在这 7 位中,如果帧头中的长度为 126 或 127,则在前两个字节之后还会额外存储 2 个字节或 8 个字节。需要注意的是,在通过网络发送较大字节大小的数据时,要注意字节序,因为网络传输时默认采用大端字节序,即最高有效位在前。
当掩码位开启时,会额外添加 4 个字节来描述掩码。显然,这是为了防止数据包因与 HTTP 数据包相似而被缓存。虽然我不太理解这一点,但在观众的帮助下,我还是继续尝试使用异或运算符来掩码有效载荷。WebSocket 客户端需要将掩码位设置为开启,并将有效载荷的每个字节与掩码对应的字节进行异或运算。掩码是为每个数据帧随机选择的。需要注意的是,这并不能提高安全性;因为任何人都可以看到掩码并解密数据,它只是将原本相同的两个数据帧变成了不同的帧。
此时,已从 Discord 接收到 WebSocket 帧中的数据,并能够通过 JSON 解析。连接建立后,Discord 会HELLO通过网关发送一条消息,告知机器人HEARTBEAT保持连接所需的发送频率。心跳包的一部分内容是包含 Discord 发送的最后一个序列号,这很简单,此时连接可以继续保持——但除此之外,机器人无法执行其他操作。
成为一名侦探
收到请求后,HELLO机器人应该发送一条IDENTIFY包含其令牌的消息,并READY在连接成功后返回一条消息。此时,机器人应该已经连接成功……或者说,它本应如此。然而,发送消息后,我的机器人要么在消息发送IDENTIFY完毕后立即断开连接,要么在消息超时断开连接(如果消息从未发送过)。HEARTBEATREADYHEARTBEAT
我花了几个小时深入研究这个问题。我还向调试框架中添加了一个用于记录十六进制转储的调试工具。真不知道之前没有这个工具我是怎么熬过来的,但它以后肯定会帮上大忙。有了十六进制转储,我就可以开始比较实际发送的内容和预期结果了。我还编写了一个小型“测试”,它创建了一个 WebSocket 帧并对其进行解析,以确保一切正常。
迄今为止,最难解决的案例莫过于“消失的漏洞”。起初,它只是一些随机的故障,让人觉得“很奇怪”,但却找不到明显的嫌疑人。调查继续进行,并对多名嫌疑人进行了询问。当在不重新编译的情况下多次运行机器人时,我们发现了未定义行为。对于一个简单的测试帧,字符串“INDIE”被正确地解掩码为“是”“INDIE”或“是” ,却出现了不同的结果“INDGD”。深入研究有效载荷的处理过程后,我们发现了一个新手才会犯的错误:std::vector在调用时引用了内部数据push_back()。解决方案很简单:要么预留所需的空间,要么不要持有引用。
测试代码一切正常时,Discord 仍然没有响应。IDENTIFY经过READY一番深入调查,发现该IDENTIFY消息大于 125 字节,而心跳包和测试消息则更小。这最终导致发现问题出在字节序上。我首先尝试了最简单的方法,以往的经验也表明,字节序问题虽然经常被提及,但实际上似乎无需任何调整就能正常运行。然而这次却并非如此。
解决方法很简单。只需将字节的发送或接收顺序与它们在内存中的存储顺序互换即可。例如,如果 uint16 的长度为 0x1234,并且在内存中(低端序)存储为 0x34, 0x12,那么通过网络发送时,需要以(大端序)0x12, 0x34 的形式发送。同样的方法也适用于 uint64,只是需要交换的字节数更多。最终,消息READY成功送达。
发送聊天消息
收到 READY 消息后,其他消息也陆续到来,其中 MessageCreate 函数最值得深入研究。解析 JSON 对象以获取消息内容非常容易,而且添加一个非常简单的if (message == “!time”) { Respond(“time is…”); }函数也很容易——然而,简单之处也就仅限于此了。实现 Respond() 函数却需要查阅大量的 Discord 文档,最终才发现,发送消息显然需要使用 HTTP API,而不能通过 WebSocket 连接。
我不知道,也无法推测为什么在连接明明已经正常的情况下,回复消息却要通过一个完全不同的连接进行,但我确信其中必有缘由。发送 HTTP POST 请求并不难,因为我已经用 libcurl 写了一个封装库来实现这个功能,只不过我的封装库只发送 POST 参数、请求头和 URL,并不发送数据。很快我就找到了使用 CURLOPT_POSTFIELDS 发送数据的方法并实现了它。然而,我真是倒霉透了。
使用 postfields 时,你需要提供指向数据的指针,并在另一个选项中指定数据的大小。除非使用 CURLOPT_COPYPOSTFIELDS,否则数据需要由你自行管理。这一切都很简单,但却失败了。Discord 返回了空消息{"message": "Cannot send an empty message","code": 50006}。curl 的调试输出显示我的 post 数据全部已成功发送,那么为什么消息是空的呢?我检查了 JSON 数据,以及所有相关内容大约 400 次。甚至还通过命令行使用 curl 命令,并添加了相应的--libcurl file.c参数,将生成的代码与我的代码进行了比较。
经过多次尝试,我终于删除了之前天真地复制到 postfields 数据中的空终止符。问题就出在这里。作为一名游戏开发者,我对互联网或 HTTP 协议并不十分精通,但发送空终止符字节显然是非常糟糕的,Discord 会丢弃这些内容。我猜这是为了防范空字节投毒攻击,服务器会先将内容过滤掉空字节,但之后仍有可能处理未经过滤的内容。
最终,我突破了最后一道难关,取得了胜利。机器人响应了我!time的指令,并显示了我的本地时间。为了便于项目未来的维护和添加更多指令,我们进行了大量的代码清理和重构。此外,我们还计划改进我的 Discord 服务器和直播叠加层。
包起来
关键在于坚持不懈。就像用塑料勺挖混凝土墙一样。一开始我几乎放弃了这个项目,但我最终还是重新振作起来,坚持了下来。通过翻越、冲破和绕过重重障碍,我终于让我的 Discord 机器人运行起来了。这让整个项目更有成就感。想了解更多我的项目,请关注我在 twitch.tv/timbeaudet 的开发直播。
文章来源:https://dev.to/timbeaudet/the-discord-bot-that-nearly-killed-me-created-in-c-gb5