交互式处理与会话的使用

命令式与交互式

某些情况下,我们更希望 bot 能够与用户进行“交互式操作”,而不一定是一次到位的“命令式操作”。

对比 .天气 北京 7 这样一句命令,更适合普通大众的也许是这样的交互逻辑:

>>> 天气
输入您想要查询的城市哦,亲~
>>> 北京
想要查看多少天的天气预报呢?
>>> 7
为您查询中,请稍后...
查询结果为:
(此处省略结果文字或图像)
如果您对服务满意,请给我们 5 星好评哦 ^ ^

仔细一想,事情似乎没有那么容易了!交互意味着需要停下来等待,但是此时 bot 也应该能做别的事(处理别的事件)才对,似乎情况比较复杂。

你说的对,但是 melobot 是一款拥有丰富功能的异步机器人开发框架。当然会帮你处理好这些的~ 只需要一个名为“会话”的魔法。

使用会话

什么是会话?会话是“事件处理”的高阶状态,使得事件处理可以暂时停止(异步停止,因此可以去处理别的事件,停止也可以被叫做挂起)。而随后,当满足特定规则的事件发生后(例如限制要在同一个对话域:同一群聊或同一对象的私聊),就可以从中恢复(恢复也可以被叫做被唤醒),把新来的事件更新为处理流的“当前事件”,并继续处理流的执行

没有会话状态的事件处理过程,就像 HTTP 服务一样没有“记忆”,但交互式往往就需要“记忆”。会话封装了“如何记忆”、“如何暂停并切换”以及“如何恢复”的逻辑,使你不需要在事件处理之外,去维护交互的上下文信息(状态信息)。由此就不需要去与“异步安全”这些可怕的字眼打交道。

让我们看看魔法如何发生,这里以实现上面的例子为目标。先看看所有代码,稍后我们会仔细解析:

 1from typing import Annotated
 2
 3from melobot import send_text
 4from melobot.handle import on_start_match
 5from melobot.session import suspend
 6from melobot.di import Reflect
 7from melobot.protocols.onebot.v11 import MessageEvent
 8
 9# 简化一下,先做成只适用于 OneBot 协议的处理逻辑
10@on_start_match(["天气", "weather", "查天气"], legacy_session=True)
11async def query_weather(event: Annotated[MessageEvent, Reflect()]) -> None:
12    await send_text("输入您想要查询的城市哦,亲~")
13    # 十秒没有下一条消息就结束事件处理
14    if not await suspend(timeout=10):
15        return
16    
17    city = event.text
18    await send_text("想要查看多少天的天气预报呢?")
19    if not await suspend(timeout=10):
20        return
21    
22    days = event.text
23    # 先回复一条“正在运行”的提示
24    await send_text("为您查询中,请稍后...")
25    # 查询的逻辑
26    result = _a_simple_query_func(city, days)
27    await send_text(
28        "查询结果为:\n"
29        f"{result}\n"
30        "如果您对服务满意,请给我们 5 星好评哦 ^ ^"
31    )

会话规则与会话判断

先来看看最开始的装饰器:

@on_start_match(["天气", "weather", "查天气"], legacy_session=True)

第一参数 target 可以接受一个列表,看一眼 api 文档就懂了,应该不难。后续将 legacy_session 参数置为 True,这是告诉 melobot,这个处理过程需要启用一个“传统会话”的规则。

什么是规则?首先我们说过,会话的重要特性是暂时停止,并稍后恢复,稍候是什么时候?当然是出现一个可以让会话恢复的事件的时候,因为不是所有事件都能让会话“恢复”。

melobot 如何知道哪些事件可以让某个会话恢复?就是凭借“会话规则”。会话规则是一种判断规则,让 melobot 在已经存在一个挂起的会话的前提下,对新来的事件进行是否可以唤醒会话的判断。这种判断称为“会话判断”。进行判断后如果返回真值,则认为可以唤醒。会话被唤醒,处理流中的“当前事件”将被更新,处理流的代码也将继续执行。

那这里为什么是传统规则?melobot 基事件类型有一个实例属性 scopeHashable 类型)。而传统规则是一种内置的规则,会话判断即判断两个事件的 scope 是否 =。举例来说,在 OneBot v11 协议支持中,scope 属性会被设置为 (群组 id | None, 用户 id) 二元组,用于指示当前的“对话域”。此时的会话与其他 bot 开发框架中狭义的“会话”几乎是一个语义,所以此时的规则取名“传统规则”,此时的会话也就是“传统会话”。

依赖注入 + 反射

async def query_weather(event: Annotated[MessageEvent, Reflect()]) -> None:

在使用依赖注入时,我们使用了 Annotated + Reflect 附加项的方式,这是告诉 melobot 对 event 进行反射式的依赖注入。处理流在启用会话后,内部的“当前事件”记录会被更新。如果使用常规的依赖注入,那么 event 实参在会话暂停、又恢复后并不会自动被更新。而使用反射式的获取,就可以实时映射到最新的事件。

需要特别注意的是,反射式的依赖注入,只适用于获取某些属性再使用的情景,如果需要对对象做比较底层的操作,需要使用 __origin__ 属性获取原始对象:

# 读取 text 是没有关系的,因为通过反射获取:
text = event.text
# 但是进行 isinstance, issubclass 判断,需要获取原对象
# 因为当前拿到的 event 不是 Event 类型,而是“代理对象”类型:
is_msg_event = isinstance(event.__origin__, MessageEvent)
# 传入你不清楚实现细节的函数、可调用对象,或其他位置,请一定获取原始对象
whatever = i_dont_know_whats_this_doing(event.__origin__)

如果不用依赖注入,用上下文方法 get_event() 也可以获取到最新的事件。但是就需要在函数体里每次调 get_event(),非常不方便,而且没有精确的类型注解。

当然,除了上下文获取方法,还可以试试上下文变量:

import melobot.handle as mb_handle

@on_start_match(...)
async def query_weather() -> None:
    # 需要使用事件时:
    first_text = mb_handle.event.text
    # 随后历经下一次的“暂停、恢复”
    ...
    # 再次使用事件获取新的文本内容
    second_text = mb_handle.event.text

每次读取 mb_handle.event 都是最新的事件,但是坏处是依然没有精确的类型注解。

会话暂停、恢复

await send_text("输入您想要查询的城市哦,亲~")

因为设置 legacy_session 参数为 True,在进入到事件处理过程前,就生成了一个会话。进入会话后,开始正常运行:输出一行提示语,提示用户输入。

# 十秒没有下一条消息就结束事件处理
if not await suspend(timeout=10):
    # 10s 超时,没有符合要求的事件来唤醒
    # 那么就结束
    return

随后调用了异步函数 suspend(),从而暂时陷入到会话暂停中,此时整个处理过程就被暂停了,bot 可以转向处理其他事件。当有新事件发生,并且“传统会话”的会话规则判断通过,那这个 await 行为便会完成并返回 True。这使得整个事件处理过程得以恢复执行。

如果一直没有符合要求的事件发生,且超时 10s,那 await 将返回 False,指示“非预期唤醒”。注意“非预期唤醒”处理流的“当前事件”不会被更新。

如果你需要无限期的暂停,不提供参数即可:

# 无限期等待一定只能返回 True,因此不再需要分支
await suspend()

在恢复后,自然可以获取新的文本内容:

# 有符合要求的事件,也就是同一“对话域”的事件发生
# 才会执行到这一句
city = event.text

后续的过程不过是一个又一个“暂停、恢复”的周期,不再赘述。

其他注意事项

legacy_session 参数只有文本事件的“绑定函数”拥有。其他“绑定函数”一般只会有 rule 参数。这是因为其他“绑定函数”对应处理的事件类型,一般而言搞“传统会话”没有太大意义。因此必须提供具体的规则。会话规则的自定义、调整会话创建的时机等属于高级特性与用法,将会在后面的教程中慢慢揭晓。

在拥有 legacy_session 参数的“绑定函数”中,你也可以同时使用 checker, matcherparser。但是有以下的先后运行规则:

  • 事件预处理

    • 检查(check)

    • 匹配(match)

    • 解析(parse)

  • 如果需要会话,创建并进入

  • 事件处理过程

    • <你的事件处理函数的逻辑>

提示

因此如果使用这些“绑定函数”,checker, matcherparser 运行时是不存在会话状态的。

但如果你调整了会话创建的时机,这些组件运行时也能实现“暂停”、“恢复”。

总结

本篇主要说明了如何使用会话开启交互式事件处理。

恭喜你读完了 melobot intro 部分的所有内容。这些内容应该让你对 melobot 有了基础了解,并能基于此构建基础的、适配 OneBot 的机器人程序。

这便是 melobot 的全部了吗?实际上,一切才刚刚开始 :)

从下一章开始,我们将深入了解 melobot 的各层架构与各种机制。从而解锁无序插件加载、自定义会话等高级玩法,并使用裸处理流像搭积木一样,实现多协议支持、跨协议 IO等独家玩法,以及使用 melobot 妙妙小工具,便捷完成各种小需求等有趣玩法。