Skip to content

Examples

Chlorie edited this page May 22, 2020 · 8 revisions

0. 准备工作

本项目是基于 Mirai HTTP API 的,所以在使用之前请先按照其 README 文件中给出的步骤配置好插件,确认运行正常以后再进行下面的操作:

配置好插件以后就可以开始尝试本项目了。这里我给出两种方法使用本项目:

  1. 直接 clone 本项目,可以用 GitHub 的 GUI 程序,如果对命令行比较熟悉的话也可以直接用命令行。
git clone https://github.com/Chlorie/miraipp-template.git MyBot
cd MyBot
git submodule update --init
  1. 使用模板创建新 repo,因为 GitHub 的模板并不能同时复制 submodule 所以需要手动添加 submodule 到新的 repo 中。创建好新的 repo 以后 clone 到本地:
git clone https://github.com/YourGitHubName/YourRepo.git MyBot
cd MyBot
git submodule add https://github.com/Chlorie/miraipp-library.git project/miraipp

之后就可以克隆本项目尝试编译了,在编译之前请确认好本项目依赖的各个外部库是否正确安装。项目中的 main.cpp 中只有几行代码,用来测试包含文件目录是否正确以及是否能够成功编译。如果要更新 Mirai++ 的话,运行

git submodule update --remote

即可。

1. 来写一个人类本质 bot

众所周知,人类的本质是复读机(确信)。下面作为示例我就来一步一步地使用 Mirai++ 实现一个复读 bot。先找到 /project/app/main.cpp,其中就是一个复读机的示例代码,如果那一段示例代码可以无问题看懂的话就可以跳过这一节。如果仍想要更详细的解释的话,我们就从下面的这个空白模板出发!

#include <mirai/mirai.h>

int main()
{
    // Insert code here...
    return 0;
}

首先,Mirai++ 中的所有群号(类型 gid_t),QQ 号(类型 uid_t)以及消息号(类型 msgid_t)均为不同的类型,这样可以提供更好的类型安全性,降低 API 错用的可能性。要从一个 64 位整数构造一个 QQ 号的话,可以直接用构造函数:

mirai::uid_t qq(123456789);

或者可以使用 UDL(用户定义字面量)来简便地构造 uid_t,使用 UDL 需要先使用 Mirai++ 的 UDL 命名空间(这同时也会拉入标准库中的所有 UDL,包括用类似 "hello"ss 后缀来获得 std::string 字符串,用 10min 或者 2ms 等方法来构造时间间隔等等):

using namespace mirai::literals;
auto qq = 123456789_uid;

构造群号同理:

auto group = 192837465_gid;
// or
mirai::gid_t group(192837465);

Mirai HTTP API中几乎所有的操作都需要通过一个有效的 session 来发起,在Mirai++ 中 Session 类就是对此的抽象。使用前面在配置插件时候设置的 auth key 以及 bot 的 QQ 号来创建一个 session。注意要先在 Mirai Console 当中登录 bot 的 QQ,Mirai++ 不提供(也无法提供)登录的接口。

mirai::Session sess("myauthkey", 987654321_uid); // example

虽然也可以通过 HTTP 轮询的方法获取事件以及消息(使用 fetch 以及 peek 系列函数),但是为了少写几行代码我们这里使用 WebSocket 来接受并且处理消息。在 Mirai HTTP API 的设置当中确实有 WebSocket 的开启和关闭的选项,但是 API 作者也有强调过不要在那个设置文档里面进行全局的设置,建议对单个的 session 进行设置。所以接下来我们就调整本 session 的设置,开启 WebSocket。在 Mirai++ 库中,有些函数的某些参数是可选的(类型为 utils::OptionalParam<T>),这些可选参数可以使用默认构造(使用默认初始化 {} 或者空指针 nullptr 来初始化)来表示空值。比如 session 设置(Session::config)中有两个参数:第一个参数为缓存大小,这里我们不希望改动所以采用默认值;第二个参数为是否开启 WebSocket,因为我们要开启所以传递 true 值。

sess.config({}, true);

WebSocket 开启了以后我们就可以开始处理消息了。要接收消息并处理的话,我们应该使用 Session::subscribe_messages 函数来开启一个连接。如果在开启连接时我们的客户端还没有开启,那么 Mirai++ 会自动为我们在另一个线程上开启客户端。Session::subscribe_messages 这个函数接受两个参数,第一个为收到消息以后的回调函数,第二个为执行产生异常时的异常处理函数。本项目使用了一些现代 C++ 的方法和范式,利用现代 C++ 的特性我们可以让代码变得更简洁,比如这里的回调函数我们可以就地写一个 lambda 表达式而不用大费周章地写一个 C++03 式的 functor。如果对现代 C++ 特性不熟悉的话现在是时候去补一补课了。

Mirai++ 也提供了一些比较方便的函数和类,比如这里的异常处理函数我们可以直接使用将所有异常的信息输出到控制台里的 error_logger。(注:Session::subscribe_* 一类函数是有返回值的,它们返回一个表示 WebSocket 连接的类型为 Connection 的对象的引用,若要在后面手动关闭连接,可以将这个返回值保存下来。这里我们不需要手动关闭连接,所以可以不保存这个连接,让 sess 被销毁时自动关闭。)

当然最后为防止程序直接结束,我们可以加上一行代码让控制台等待我们按回车时再关闭程序。

sess.subscribe_messages([&](const mirai::Event& event)
{
    // Insert code here...
}, mirai::error_logger);
std::cin.get();

接下来我们就要实现回调函数的函数体了。在 Mirai++ 中,接收到的消息连同其他事件类型被统称为“事件”,具有 Event 类型。Event 类型其实是一个 std::variant 的包装,这里我们先查询接收到的消息是不是我们想要的类型,之后再对消息进行处理。

if (event.type() != mirai::EventType::group_message) return;
const auto& e = event.get<mirai::GroupMessage>();
// const auto& e = event.get<mirai::EventType::group_message>(); 也是可以的
// 接下来处理消息 e

查询消息是否是想要的类型的方式不止一种,还可以用 Event::get_if 函数获得一个指针,若得到的是空指针就说明该事件不是想要的类型。

if (auto ptr = event.get_if<mirai::GroupMessage>()) // 同上,get_if 的参数也可以用枚举类型
    // 接下来处理 ptr 指向的消息

获得了我们想要的群消息事件以后,我们就可以处理消息了。消息本身的具体结构请直接打开消息结构的头文件查阅。因为我们要做的是一个人类本质 bot,所以这里不用对消息进行什么检查,直接使用 Session::send_group_message 函数原样发送到消息所在的群里就好了。

sess.send_message(e.sender.group.id, e.message.content);

至此,我们的复读机 bot 就写好了。是不是比想象中的简单?编译运行起来试试看吧!

下面就是这个例子的完整代码:

#include <mirai/mirai.h>

int main()
{
    mirai::Session sess(/* auth key */, /* QQ */);
    sess.config({}, true);
    sess.subscribe_messages([&](const mirai::Event& event)
    {
        if (event.type() != mirai::EventType::group_message) return;
        const auto& e = event.get<mirai::GroupMessage>();
        sess.send_message(e.sender.group.id, e.message.content);
    }, mirai::error_logger);
    std::cin.get();
    return 0;
}

2. 等等,你刚刚撤回了什么?

上一个例子中我们讲述了消息处理的方法,在这个例子中我们对其他事件进行处理,在前一节代码的基础上为我们的 bot 增加一个防撤回的功能。

程序的大致结构都是一样的,像初始化的代码什么的也都是完全一致的。不过这次我们需要对非消息的事件进行处理,所以这次我们使用 Session::subscribe_all_events 函数来接收事件。

sess.subscribe_all_events([&](const mirai::Event& event)
{
    if (event.type() != mirai::EventType::group_message) return;
    const auto& e = event.get<mirai::GroupMessage>();
    sess.send_message(e.sender.group.id, e.message.content);
}, mirai::error_logger);

现在我们有两种事件需要处理了,如果对每一种要处理的事件类型(在我写这段文字的时候一共有27种,以后只能更多)都用 if 判断类型,再用 Event::get 函数获取事件的话,人大概是会疯掉的吧……当然我们是有别的办法的,Event 类提供了 dispatch 函数用来对不同的事件类型进行分发。Event::dispatch 接受一个回调函数,若事件的类型跟这个回调函数的参数类型匹配的话,这个函数就会被调用,否则将无事发生。

sess.subscribe_all_events([&](const mirai::Event& event)
{
    event.dispatch([&](const mirai::GroupMessage& e)
    {
        // 这里是处理群消息的代码,负责人类本质
        sess.send_message(e.sender.group.id, e.message.content);
    });
    event.dispatch([&](const mirai::GroupRecallEvent& e)
    {
        // 这里是处理群消息撤回事件的代码,接下来实现防撤回功能
    });
}, mirai::error_logger);

(注:如果有喜欢用 C++14 的泛型 lambda 表达式的同学这里可能会不愿意了,因为泛型的 lambda 可以接受任何类型的参数,这样就不能起到分离不同事件类型的作用了。不过这种问题我在设计 Mirai++ 接口的时候也是有考虑过的,如果想用泛型 lambda 同时又想只处理特定种类的事件的话,可以指定 dispatch 函数的第一个模板类型参数,如下例:

event.dispatch<mirai::GroupMessage>([&](const auto& e)
{
    // lambda 函数体的实现
});

这样就可以只处理单独的类型了。)

要实现防撤回的话当然是可以直接把这条被撤回消息的内容原样不动复读一遍的,不过这样好像没有什么针对性啊……我们不如直接引用回复那条被撤回的消息让他更尴尬(问题发言)。在处理群消息撤回事件的 lambda 表达式的函数体里面加上这一行:

sess.send_message(e.group.id, u8"不许撤回", e.message_id);

可以看到这次调用消息发送函数时使用了 3 个参数,当发送的消息是对某一条消息的回复的时候就可以使用这第 3 个参数标明要回复的消息的 ID。同时也注意到这里的第 2 个参数并不是 Message 类型的对象而是一个字符串。因为大量的消息可能都是纯文本消息,所以 Mirai++ 提供了这种方便使用的函数重载,在发送消息时候如果是纯文本可以直接用字符串。

一定要注意,发送和接收的消息的编码都是 UTF-8 编码,所以如果想要发中文出去的话字符串一定要使用 u8"..." 注明是 UTF-8 编码的字符串。另外代码本身的编码也需要保存为 UTF-8 以防万一。如果想要把接收到的消息打印到控制台里面,Mirai++ 也提供了 utils::utf8_to_local 函数来转换编码,反方向的函数也有提供。

至此防撤回的功能就这样用四行代码 (有两行都是大括号,所以这难道是大括号不换行人的胜利吗) 解决了!编译运行一下看看你的 bot 有没有做好撤回警察的工作?

下面是到这里为止的完整代码:

#include <mirai/mirai.h>

int main()
{
    mirai::Session sess(/* auth key */, /* QQ */);
    sess.config({}, true);
    sess.subscribe_all_events([&](const mirai::Event& event)
    {
        event.dispatch([&](const mirai::GroupMessage& e)
        {
            sess.send_message(e.sender.group.id, e.message.content);
        });
        event.dispatch([&](const mirai::GroupRecallEvent& e)
        {
            sess.send_message(e.group.id, u8"不许撤回", e.message_id);
        });
    }, mirai::error_logger);
    std::cin.get();
    return 0;
}

3. !ping pong!

在你的群友被这人类本质 bot 的无差别复读惹恼之前我们还是来改一下这一段代码吧。这次我们要实现的是一个用来检测延迟的功能,当有人发送“!ping”的时候,让 bot at 那个人并用“pong!”作为回应。之前的两个功能都并没有涉及到对消息内容的处理,这次的例子就来填补这个空白。

Mirai HTTP API 发来的消息是以消息链的形式构成的,消息的不同部分(图片,文字,at消息等)串接起来形成一整条消息。在 Mirai++ 中,消息段是用 Segment 类来表示的,它跟 Event 类一样,也是在很多不同类型组成的联合体上的一个包装。整个一条消息由很多消息段构成,对应的类型就是 MessageChain(其实就是 vector<Segment> 的别名)。如果又要像 Event 那样对每一段消息都进行分发的话估计用户真的要掀桌了,所以 Mirai++ 提供了一个在 MessageChain 上面的包装 Message 类。没错,这个消息类终于出现了。Message 类提供了很多帮助处理消息的成员函数。它可以从消息链、单独消息段、字符串等类型构造,可以使用我们熟悉的 == 以及 != 运算符来判断是否相等,也可以用 ++= 运算符来连接多条消息。

说了这么多,其实比较繁琐的工作都已经在 Mirai++ 库内部实现了,真正实现这一节功能的代码仍然只有短短几行:

if (e.message.content == "!ping")
{
    const auto msg = mirai::Message(mirai::msg::At{ e.sender.id }) + " pong!";
    sess.send_message(e.sender.group.id, msg);
}

这里先构造了一条消息仅包含一个 @ 消息段,之后又在后面连接了一段纯文本消息段“pong!”,最后将这条消息发送给对应的群。

至此的完整代码如下:

#include <mirai/mirai.h>

int main()
{
    mirai::Session sess(/* auth key */, /* QQ */);
    sess.config({}, true);
    sess.subscribe_all_events([&](const mirai::Event& event)
    {
        event.dispatch([&](const mirai::GroupMessage& e)
        {
            if (e.message.content == "!ping")
            {
                const auto msg = mirai::Message(mirai::msg::At{ e.sender.id }) + " pong!";
                sess.send_message(e.sender.group.id, msg);
            }
        });
        event.dispatch([&](const mirai::GroupRecallEvent& e)
        {
            sess.send_message(e.group.id, u8"不许撤回", e.message_id);
        });
    }, mirai::error_logger);
    std::cin.get();
    return 0;
}

4. 来自未来的消息

万物的答案是什么?

如果用这种问题问我们可怜的 bot,它一定会绞尽脑汁使劲想上一阵子吧。在这一节中我们就要实现这样一个功能——在群内发送 “@bot xxx的答案是什么?”,让 bot 想十秒以后再回复 “xxx的答案是 42”。

4.1. match_types 与结构化绑定

这个问题需要分成几部分来考虑,首先就是解决匹配消息的问题。前一节中讲过,Message 类提供了不少方便用来处理消息的函数。在这里,因为我们想要的消息格式非常固定,所以可以使用 match_types 函数来匹配并提取出各消息段。

首先,因为下面会频繁用到一些命名空间,我们在这里定义一些命名空间别名让代码更简洁一些:

namespace msg = mirai::msg;
namespace ut = mirai::utils;

这里我们需要匹配的消息格式由一个 At 段和一个 Plain(纯文字)段组成,所以我们就可以采用如下的方式匹配这样的格式并且获取这两个消息段:

// 与前面一样,这是在消息处理函数内部,e 是 const mirai::GroupMessage&
const auto opt = e.message.content.match_types<msg::At, msg::Plain>();

match_types 函数返回的是一个 std::optional,当类型匹配成功的时候包含值,否则不包含值。其包含的值是一个元组(std::tuple),包含着对匹配成功的各个消息段的引用。这个 API 使用的时候大概就是这样:

// 首先匹配模式,并且判断是否匹配成功
if (const auto opt = e.message.content.match_types<msg::At, msg::Plain>())
{
    const auto [at, plain] = *opt; // 将元组解构成我们想要的两部分
    // 对 at 和 plain 进行处理
}

获得了两个消息段的值以后,我们就可以进行下面的操作了:判断 at 的目标是不是我们的 bot,判断 plain 是否以“的答案是什么?”结尾,休眠 10 秒,回复答案。

if (const auto opt = e.message.content.match_types<msg::At, msg::Plain>())
{
    const auto [at, plain] = *opt;
    if (at.target != sess.qq()) return;
    const std::string_view suffix = u8"的答案是什么?";
    if (!ut::ends_with(plain.view(), suffix)) return;
    std::this_thread::sleep_for(10s);
    sess.send_message(e.sender.group.id, ut::strcat(
        ut::remove_suffix(plain.view(), suffix.size()), u8"的答案是 42。"),
        e.message.source.id);
}

编译运行一下代码,测试一下看看 bot 的反应如何?什么?你说它被问傻了?

这里暴露出我们现在代码的一个问题:因为我们的 WebSocket 客户端是单线程的,所以在接收到消息,当前线程被休眠 10s 的时候,整个消息处理的循环都需要等待这一个处理速度极慢的消息完成工作才能继续。这里的示例代码仅仅是一个示范,现实中我们可能确实会遇到这种处理速度慢的消息(比如如果在某些情况下需要 bot 发送图片等等)。我们可以使用多线程的方法解决这个问题。(注:C++20 的协程被主流编译器支持了以后也许可以有更好的异步解决方法,目前 MSVC 和 clang 都是支持部分协程功能的,不过目前我还是不会在这个项目当中添加这种实验性的内容的 ,毕竟我们的编译器都很脆弱时不时就会傲娇

在 Mirai++ 中要使用多线程处理消息是非常容易的:实际上 Session::subscribe_* 系列的函数都是有第三个参数的,其类型为 ExecutionPolicy,表示该连接接收消息时的处理策略,默认为 ExecutionPolicy::single_thread。如果要使用线程池进行消息处理的话,只需要把这个参数改为 ExecutionPolicy::thread_pool 即可。

sess.subscribe_all_events(/* 消息处理回调函数 */, mirai::error_logger, mirai::ExecutionPolicy::thread_pool);

如果想要自定义线程池中的线程数量也可以提前启动线程池。

sess.start_thread_pool(8); // 指定线程池中有 8 个线程

重新编译运行一次看看,你的 bot 有没有比之前更机灵一点?

本节代码:

#include <mirai/mirai.h>
#include <regex>

int main()
{
    using namespace mirai::literals;
    namespace msg = mirai::msg;
    namespace ut = mirai::utils;

    mirai::Session sess(/* auth key */, /* QQ */);
    sess.config({}, true);
    sess.subscribe_all_events([&](const mirai::Event& event)
    {
        event.dispatch([&](const mirai::GroupMessage& e)
        {
            if (const auto opt = e.message.content.match_types<msg::At, msg::Plain>())
            {
                const auto [at, plain] = *opt;
                if (at.target != sess.qq()) return;
                static constexpr std::string_view suffix = u8"的答案是什么?";
                if (!ut::ends_with(plain.view(), suffix)) return;
                std::this_thread::sleep_for(10s);
                sess.send_message(e.sender.group.id, ut::strcat(
                    ut::remove_suffix(plain.view(), suffix.size()), u8"的答案是 42。"),
                    e.message.source.id);
            }
        });
    }, mirai::error_logger, mirai::ExecutionPolicy::thread_pool);
    std::cin.get();
    return 0;
}

4.2. stringify 与正则表达式

当然,有的时候我们要匹配的内容可能需要更加灵活、复杂,只是这样单纯的判断字符串前后缀不一定能完成我们的工作,这时候我们可能需要一些更复杂的工具,比如正则表达式来解决问题。

Message 类还提供了其他的帮助函数,Message::stringify 把整个消息处理成一个包含其全部信息的字符串,比如说 “@123456 Hi!” 这样一条消息在经过 stringify 以后就会成为 "{at:123456} Hi!"

获得这样的字符串以后我们就很容易使用正则表达式来匹配了。这样我们的 GroupMessage 消息处理函数就会变成大概这样。注意一点,要使用正则表达式匹配的话,你需要先包含 <regex> 头文件哦!(注:MSVC 的正则表达式库速度一直是个硬伤。如果你想换用 boost-regex 的话也是没有问题的,后面要说的 parse 函数兼容 boost-regex。说两句题外话,本节中的正则表达式是静态不变的,而且在编译时就已知,那么一定有什么方法在编译时生成效率超高的代码不是吗?如果你喜欢玩最前沿的东西,那么你或许会想看看编译时计算大神 Hana Dusíková编译时正则表达式库的。)

event.dispatch([&](const mirai::GroupMessage& e)
{
    const std::string str = e.message.content.stringify();
    static const std::regex reg(u8R"re(\{at:(\d+)\}\s*(.*)的答案是什么?)re");
    std::smatch match;
    if (!std::regex_match(str, match, reg)) return;
});

这样进行正则匹配,我们就把不符合格式的消息都筛掉了,但是要怎么样才能把我们想要的 QQ 号从那个捕获组 (\d+) 中提取出来呢?Mirai++ 提供了一个便捷的函数,可以直接从正则匹配的结果中处理出我们想要的类型的信息。多说无益,看一下使用例就能大致明白了。

const auto [qq, question] = mirai::utils::parse_captures<void, int64_t, std::string>(match);

C++ 的正则表达式包含了一个隐含的捕获组,它是我们匹配到的整个字符串。我们写的正则表达式里面还有 2 个捕获组,(\d+) 捕获我们要的 QQ 号,以及 (.*) 捕获我们想要的问题,所以这里我们一共有 3 个捕获组。我们希望忽略(即想要返回 void)第 0 个捕获组,将第 1 个捕获组(QQ 号)处理成整数类型,将第 2 个捕获组(问题内容)原封不动给我们截出来(即字符串)。处理好的数据是一个包含我们想要的 2 个数据的元组 std::tuple<int64_t, std::string>,用上一节提到过的结构化绑定就可以将元组结构成多个变量。

获取了 QQ 号,下面的步骤就简单了:比较 QQ 号是不是我们 bot 的,如果是的话,就让本线程休眠 10 秒,然后发送那个答案。

if (qq != sess.qq()) return;
std::this_thread::sleep_for(10s);
sess.send_message(e.sender.group.id, question + u8"的答案是 42", e.message.source.id);

余下的部分就和前面一样了。

这里有一点需要强调的就是,虽然可以使用 stringifyregex 解决所有问题,但是使用 stringify 会产生新的字符串,也就会造成更多的动态内存分配,降低程序运行的速度。这里建议如果要匹配的模式比较固定,最好还是先用 match_types 解构,之后再使用 regex 对解构后的 Plain 消息段做匹配以减少不必要的动态内存分配。

本节的完整代码:

#include <mirai/mirai.h>
#include <regex>

int main()
{
    using namespace mirai::literals;
    namespace ut = mirai::utils;

    mirai::Session sess(/* auth key */, /* QQ */);
    sess.config({}, true);
    sess.subscribe_all_events([&](const mirai::Event& event)
    {
        event.dispatch([&](const mirai::GroupMessage& e)
        {
            const std::string str = e.message.content.stringify();
            static const std::regex reg(u8R"re(\{at:(\d+)\}\s*(.*)的答案是什么?)re");
            std::smatch match;
            if (!std::regex_match(str, match, reg)) return;
            const auto [qq, question] = ut::parse_captures<void, int64_t, std::string>(match);
            if (qq != sess.qq()) return;
            std::this_thread::sleep_for(10s);
            sess.send_message(e.sender.group.id, question + u8"的答案是 42", e.message.source.id);
        });
    }, mirai::error_logger, mirai::ExecutionPolicy::thread_pool);
    std::cin.get();
    return 0;
}

?. The list goes on

我对 Mirai++ 使用的说明大概就到这里了,以后我可能还会添加更多的范例,也有可能就不添加了。不过我觉得我在代码中的注释文档已经做得够详尽了,如果想了解更多的关于 Mirai++ 库的使用方法可以打开那些头文件看一看说明。接下来的故事,花样层出的功能,就要由您来续写了。

感谢您使用 Mirai++!