mirror of
https://github.com/nonebot/nonebot2.git
synced 2025-07-28 08:41:29 +00:00
🔖 Release 2.3.2
This commit is contained in:
@ -0,0 +1,141 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
description: Alconna 命令解析拓展
|
||||
|
||||
slug: /best-practice/alconna/
|
||||
---
|
||||
|
||||
import Tabs from "@theme/Tabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
|
||||
# Alconna 插件
|
||||
|
||||
[`nonebot-plugin-alconna`](https://github.com/nonebot/plugin-alconna) 是一类提供了拓展响应规则的插件。
|
||||
该插件使用 [Alconna](https://github.com/ArcletProject/Alconna) 作为命令解析器,
|
||||
是一个简单、灵活、高效的命令参数解析器,并且不局限于解析命令式字符串。
|
||||
|
||||
该插件提供了一类新的事件响应器辅助函数 `on_alconna`,以及 `AlconnaResult` 等依赖注入函数。
|
||||
|
||||
该插件声明了一个 `Matcher` 的子类 `AlconnaMatcher`,并在 `AlconnaMatcher` 中添加了一些新的方法,例如:
|
||||
|
||||
- `assign`:基于 `Alconna` 解析结果,执行满足目标路径的处理函数
|
||||
- `dispatch`:类似 `CommandGroup`,对目标路径创建一个新的 `AlconnaMatcher`,并将解析结果分配给该 `AlconnaMatcher`
|
||||
- `got_path`:类似 `got`,但是可以指定目标路径,并且能够验证解析结果是否可用
|
||||
- ...
|
||||
|
||||
基于 `Alconna` 的特性,该插件同时提供了一系列便捷的消息段标注。
|
||||
标注可用于在 `Alconna` 中匹配消息中除 text 外的其他消息段,也可用于快速创建各适配器下的消息段。所有标注位于 `nonebot_plugin_alconna.adapters` 中。
|
||||
|
||||
该插件同时通过提供 `UniMessage` (通用消息模型) 实现了**跨平台接收和发送消息**的功能。
|
||||
|
||||
## 安装插件
|
||||
|
||||
在使用前请先安装 `nonebot-plugin-alconna` 插件至项目环境中,可参考[获取商店插件](../../tutorial/store.mdx#安装插件)来了解并选择安装插件的方式。如:
|
||||
|
||||
在**项目目录**下执行以下命令:
|
||||
|
||||
<Tabs groupId="install">
|
||||
<TabItem value="cli" label="使用 nb-cli">
|
||||
|
||||
```shell
|
||||
nb plugin install nonebot-plugin-alconna
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="pip" label="使用 pip">
|
||||
|
||||
```shell
|
||||
pip install nonebot-plugin-alconna
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="pdm" label="使用 pdm">
|
||||
|
||||
```shell
|
||||
pdm add nonebot-plugin-alconna
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## 导入插件
|
||||
|
||||
由于 `nonebot-plugin-alconna` 作为插件,因此需要在使用前对其进行**加载**并**导入**其中的 `on_alconna` 来使用命令拓展。使用 `require` 方法可轻松完成这一过程,可参考 [跨插件访问](../../advanced/requiring.md) 一节进行了解。
|
||||
|
||||
```python
|
||||
from nonebot import require
|
||||
|
||||
require("nonebot_plugin_alconna")
|
||||
|
||||
from nonebot_plugin_alconna import on_alconna
|
||||
```
|
||||
|
||||
## 使用插件
|
||||
|
||||
在前面的[深入指南](../../appendices/session-control.mdx)中,我们已经得到了一个天气插件。
|
||||
现在我们将使用 `Alconna` 来改写这个插件。
|
||||
|
||||
<details>
|
||||
<summary>插件示例</summary>
|
||||
|
||||
```python title=weather/__init__.py
|
||||
from nonebot import on_command
|
||||
from nonebot.rule import to_me
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot.adapters import Message
|
||||
from nonebot.params import CommandArg, ArgPlainText
|
||||
|
||||
weather = on_command("天气", rule=to_me(), aliases={"weather", "天气预报"})
|
||||
|
||||
@weather.handle()
|
||||
async def handle_function(matcher: Matcher, args: Message = CommandArg()):
|
||||
if args.extract_plain_text():
|
||||
matcher.set_arg("location", args)
|
||||
|
||||
@weather.got("location", prompt="请输入地名")
|
||||
async def got_location(location: str = ArgPlainText()):
|
||||
if location not in ["北京", "上海", "广州", "深圳"]:
|
||||
await weather.reject(f"你想查询的城市 {location} 暂不支持,请重新输入!")
|
||||
await weather.finish(f"今天{location}的天气是...")
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
```python {5-9,13-15,17-18}
|
||||
from nonebot.rule import to_me
|
||||
from arclet.alconna import Alconna, Args
|
||||
from nonebot_plugin_alconna import Match, on_alconna
|
||||
|
||||
weather = on_alconna(
|
||||
Alconna("天气", Args["location?", str]),
|
||||
aliases={"weather", "天气预报"},
|
||||
rule=to_me(),
|
||||
)
|
||||
|
||||
|
||||
@weather.handle()
|
||||
async def handle_function(location: Match[str]):
|
||||
if location.available:
|
||||
weather.set_path_arg("location", location.result)
|
||||
|
||||
@weather.got_path("location", prompt="请输入地名")
|
||||
async def got_location(location: str):
|
||||
if location not in ["北京", "上海", "广州", "深圳"]:
|
||||
await weather.reject(f"你想查询的城市 {location} 暂不支持,请重新输入!")
|
||||
await weather.finish(f"今天{location}的天气是...")
|
||||
```
|
||||
|
||||
在上面的代码中,我们使用 `Alconna` 来解析命令,`on_alconna` 用来创建响应器,使用 `Match` 来获取解析结果。
|
||||
|
||||
关于更多 `Alconna` 的使用方法,可参考 [Alconna 文档](https://arclet.top/docs/tutorial/alconna),
|
||||
或阅读 [Alconna 基本介绍](./command.md) 一节。
|
||||
|
||||
关于更多 `on_alconna` 的使用方法,可参考 [插件文档](https://github.com/nonebot/plugin-alconna/blob/master/docs.md),
|
||||
或阅读 [响应规则的使用](./matcher.mdx) 一节。
|
||||
|
||||
## 交流与反馈
|
||||
|
||||
QQ 交流群: [🔗 链接](https://jq.qq.com/?_wv=1027&k=PUPOnCSH)
|
||||
|
||||
友链: [📚 文档](https://graiax.cn/guide/message_parser/alconna.html)
|
@ -0,0 +1,4 @@
|
||||
{
|
||||
"label": "Alconna 命令解析拓展",
|
||||
"position": 6
|
||||
}
|
@ -0,0 +1,640 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
description: Alconna 基本介绍
|
||||
---
|
||||
|
||||
# Alconna 本体
|
||||
|
||||
[`Alconna`](https://github.com/ArcletProject/Alconna) 隶属于 `ArcletProject`,是一个简单、灵活、高效的命令参数解析器, 并且不局限于解析命令式字符串。
|
||||
|
||||
我们通过一个例子来讲解 **Alconna** 的核心 —— `Args`, `Subcommand`, `Option`:
|
||||
|
||||
```python
|
||||
from arclet.alconna import Alconna, Args, Subcommand, Option
|
||||
|
||||
|
||||
alc = Alconna(
|
||||
"pip",
|
||||
Subcommand(
|
||||
"install",
|
||||
Args["package", str],
|
||||
Option("-r|--requirement", Args["file", str]),
|
||||
Option("-i|--index-url", Args["url", str]),
|
||||
)
|
||||
)
|
||||
|
||||
res = alc.parse("pip install nonebot2 -i URL")
|
||||
|
||||
print(res)
|
||||
# matched=True, header_match=(origin='pip' result='pip' matched=True groups={}), subcommands={'install': (value=Ellipsis args={'package': 'nonebot2'} options={'index-url': (value=None args={'url': 'URL'})} subcommands={})}, other_args={'package': 'nonebot2', 'url': 'URL'}
|
||||
|
||||
print(res.all_matched_args)
|
||||
# {'package': 'nonebot2', 'url': 'URL'}
|
||||
```
|
||||
|
||||
这段代码通过`Alconna`创捷了一个接受主命令名为`pip`, 子命令为`install`且子命令接受一个 **Args** 参数`package`和二个 **Option** 参数`-r`和`-i`的命令参数解析器, 通过`parse`方法返回解析结果 **Arparma** 的实例。
|
||||
|
||||
## 命令头
|
||||
|
||||
命令头是指命令的前缀 (Prefix) 与命令名 (Command) 的组合,例如 !help 中的 ! 与 help。
|
||||
|
||||
| 前缀 | 命令名 | 匹配内容 | 说明 |
|
||||
| :--------------------------: | :--------: | :---------------------------------------------------------: | :--------------: |
|
||||
| - | "foo" | `"foo"` | 无前缀的纯文字头 |
|
||||
| - | 123 | `123` | 无前缀的元素头 |
|
||||
| - | "re:\d{2}" | `"32"` | 无前缀的正则头 |
|
||||
| - | int | `123` 或 `"456"` | 无前缀的类型头 |
|
||||
| [int, bool] | - | `True` 或 `123` | 无名的元素类头 |
|
||||
| ["foo", "bar"] | - | `"foo"` 或 `"bar"` | 无名的纯文字头 |
|
||||
| ["foo", "bar"] | "baz" | `"foobaz"` 或 `"barbaz"` | 纯文字头 |
|
||||
| [int, bool] | "foo" | `[123, "foo"]` 或 `[False, "foo"]` | 类型头 |
|
||||
| [123, 4567] | "foo" | `[123, "foo"]` 或 `[4567, "foo"]` | 元素头 |
|
||||
| [nepattern.NUMBER] | "bar" | `[123, "bar"]` 或 `[123.456, "bar"]` | 表达式头 |
|
||||
| [123, "foo"] | "bar" | `[123, "bar"]` 或 `"foobar"` 或 `["foo", "bar"]` | 混合头 |
|
||||
| [(int, "foo"), (456, "bar")] | "baz" | `[123, "foobaz"]` 或 `[456, "foobaz"]` 或 `[456, "barbaz"]` | 对头 |
|
||||
|
||||
对于无前缀的类型头,此时会将传入的值尝试转为 BasePattern,例如 `int` 会转为 `nepattern.INTEGER`。如此该命令头会匹配对应的类型, 例如 `int` 会匹配 `123` 或 `"456"`,但不会匹配 `"foo"`。解析后,Alconna 会将命令头匹配到的值转为对应的类型,例如 `int` 会将 `"123"` 转为 `123`。
|
||||
|
||||
:::tip
|
||||
|
||||
**正则内容只在命令名上生效,前缀中的正则会被转义**
|
||||
|
||||
:::
|
||||
|
||||
除了通过传入 `re:xxx` 来使用正则表达式外,Alconna 还提供了一种更加简洁的方式来使用正则表达式,称为 Bracket Header:
|
||||
|
||||
```python
|
||||
from alconna import Alconna
|
||||
|
||||
|
||||
alc = Alconna(".rd{roll:int}")
|
||||
assert alc.parse(".rd123").header["roll"] == 123
|
||||
```
|
||||
|
||||
Bracket Header 类似 python 里的 f-string 写法,通过 "{}" 声明匹配类型
|
||||
|
||||
"{}" 中的内容为 "name:type or pat":
|
||||
|
||||
- "{}", "{:}" ⇔ "(.+)", 占位符
|
||||
- "{foo}" ⇔ "(?P<foo>.+)"
|
||||
- "{:\d+}" ⇔ "(\d+)"
|
||||
- "{foo:int}" ⇔ "(?P<foo>\d+)",其中 "int" 部分若能转为 `BasePattern` 则读取里面的表达式
|
||||
|
||||
## 参数声明(Args)
|
||||
|
||||
`Args` 是用于声明命令参数的组件, 可以通过以下几种方式构造 **Args** :
|
||||
|
||||
- `Args[key, var, default][key1, var1, default1][...]`
|
||||
- `Args[(key, var, default)]`
|
||||
- `Args.key[var, default]`
|
||||
|
||||
其中,key **一定**是字符串,而 var 一般为参数的类型,default 为具体的值或者 **arclet.alconna.args.Field**
|
||||
|
||||
其与函数签名类似,但是允许含有默认值的参数在前;同时支持 keyword-only 参数不依照构造顺序传入 (但是仍需要在非 keyword-only 参数之后)。
|
||||
|
||||
### key
|
||||
|
||||
`key` 的作用是用以标记解析出来的参数并存放于 **Arparma** 中,以方便用户调用。
|
||||
|
||||
其有三种为 Args 注解的标识符: `?`、`/`、 `!`, 标识符与 key 之间建议以 `;` 分隔:
|
||||
|
||||
- `!` 标识符表示该处传入的参数应**不是**规定的类型,或**不在**指定的值中。
|
||||
- `?` 标识符表示该参数为**可选**参数,会在无参数匹配时跳过。
|
||||
- `/` 标识符表示该参数的类型注解需要隐藏。
|
||||
|
||||
另外,对于参数的注释也可以标记在 `key` 中,其与 key 或者标识符 以 `#` 分割:
|
||||
`foo#这是注释;?` 或 `foo?#这是注释`
|
||||
|
||||
:::tip
|
||||
|
||||
`Args` 中的 `key` 在实际命令中并不需要传入(keyword 参数除外):
|
||||
|
||||
```python
|
||||
from arclet.alconna import Alconna, Args
|
||||
|
||||
|
||||
alc = Alconna("test", Args["foo", str])
|
||||
alc.parse("test --foo abc") # 错误
|
||||
alc.parse("test abc") # 正确
|
||||
```
|
||||
|
||||
若需要 `test --foo abc`,你应该使用 `Option`:
|
||||
|
||||
```python
|
||||
from arclet.alconna import Alconna, Args, Option
|
||||
|
||||
|
||||
alc = Alconna("test", Option("--foo", Args["foo", str]))
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
### var
|
||||
|
||||
var 负责命令参数的**类型检查**与**类型转化**
|
||||
|
||||
`Args` 的`var`表面上看需要传入一个 `type`,但实际上它需要的是一个 `nepattern.BasePattern` 的实例:
|
||||
|
||||
```python
|
||||
from arclet.alconna import Args
|
||||
from nepattern import BasePattern
|
||||
|
||||
|
||||
# 表示 foo 参数需要匹配一个 @number 样式的字符串
|
||||
args = Args["foo", BasePattern("@\d+")]
|
||||
```
|
||||
|
||||
`pip` 示例中可以传入 `str` 是因为 `str` 已经注册在了 `nepattern.global_patterns` 中,因此会替换为 `nepattern.global_patterns[str]`
|
||||
|
||||
`nepattern.global_patterns`默认支持的类型有:
|
||||
|
||||
- `str`: 匹配任意字符串
|
||||
- `int`: 匹配整数
|
||||
- `float`: 匹配浮点数
|
||||
- `bool`: 匹配 `True` 与 `False` 以及他们小写形式
|
||||
- `hex`: 匹配 `0x` 开头的十六进制字符串
|
||||
- `url`: 匹配网址
|
||||
- `email`: 匹配 `xxxx@xxx` 的字符串
|
||||
- `ipv4`: 匹配 `xxx.xxx.xxx.xxx` 的字符串
|
||||
- `list`: 匹配类似 `["foo","bar","baz"]` 的字符串
|
||||
- `dict`: 匹配类似 `{"foo":"bar","baz":"qux"}` 的字符串
|
||||
- `datetime`: 传入一个 `datetime` 支持的格式字符串,或时间戳
|
||||
- `Any`: 匹配任意类型
|
||||
- `AnyString`: 匹配任意类型,转为 `str`
|
||||
- `Number`: 匹配 `int` 与 `float`,转为 `int`
|
||||
|
||||
同时可以使用 typing 中的类型:
|
||||
|
||||
- `Literal[X]`: 匹配其中的任意一个值
|
||||
- `Union[X, Y]`: 匹配其中的任意一个类型
|
||||
- `Optional[xxx]`: 会自动将默认值设为 `None`,并在解析失败时使用默认值
|
||||
- `List[X]`: 匹配一个列表,其中的元素为 `X` 类型
|
||||
- `Dict[X, Y]`: 匹配一个字典,其中的 key 为 `X` 类型,value 为 `Y` 类型
|
||||
- ...
|
||||
|
||||
:::tip
|
||||
|
||||
几类特殊的传入标记:
|
||||
|
||||
- `"foo"`: 匹配字符串 "foo" (若没有某个 `BasePattern` 与之关联)
|
||||
- `RawStr("foo")`: 匹配字符串 "foo" (即使有 `BasePattern` 与之关联也不会被替换)
|
||||
- `"foo|bar|baz"`: 匹配 "foo" 或 "bar" 或 "baz"
|
||||
- `[foo, bar, Baz, ...]`: 匹配其中的任意一个值或类型
|
||||
- `Callable[[X], Y]`: 匹配一个参数为 `X` 类型的值,并返回通过该函数调用得到的 `Y` 类型的值
|
||||
- `"re:xxx"`: 匹配一个正则表达式 `xxx`,会返回 Match[0]
|
||||
- `"rep:xxx"`: 匹配一个正则表达式 `xxx`,会返回 `re.Match` 对象
|
||||
- `{foo: bar, baz: qux}`: 匹配字典中的任意一个键, 并返回对应的值 (特殊的键 ... 会匹配任意的值)
|
||||
- ...
|
||||
|
||||
**特别的**,你可以不传入 `var`,此时会使用 `key` 作为 `var`, 匹配 `key` 字符串。
|
||||
|
||||
:::
|
||||
|
||||
#### MultiVar 与 KeyWordVar
|
||||
|
||||
`MultiVar` 是一个特殊的标注,用于告知解析器该参数可以接受多个值,类似于函数中的 `*args`,其构造方法形如 `MultiVar(str)`。
|
||||
|
||||
同样的还有 `KeyWordVar`,类似于函数中的 `*, name: type`,其构造方法形如 `KeyWordVar(str)`,用于告知解析器该参数为一个 keyword-only 参数。
|
||||
|
||||
:::tip
|
||||
|
||||
`MultiVar` 与 `KeyWordVar` 组合时,代表该参数为一个可接受多个 key-value 的参数,类似于函数中的 `**kwargs`,其构造方法形如 `MultiVar(KeyWordVar(str))`
|
||||
|
||||
`MultiVar` 与 `KeyWordVar` 也可以传入 `default` 参数,用于指定默认值
|
||||
|
||||
`MultiVar` 不能在 `KeyWordVar` 之后传入
|
||||
|
||||
:::
|
||||
|
||||
### default
|
||||
|
||||
`default` 传入的是该参数的默认值或者 `Field`,以携带对于该参数的更多信息。
|
||||
|
||||
默认情况下 (即不声明) `default` 的值为特殊值 `Empty`。这也意味着你可以将默认值设置为 `None` 表示默认值为空值。
|
||||
|
||||
`Field` 构造需要的参数说明如下:
|
||||
|
||||
- default: 参数单元的默认值
|
||||
- alias: 参数单元默认值的别名
|
||||
- completion: 参数单元的补全说明生成函数
|
||||
- unmatch_tips: 参数单元的错误提示生成函数,其接收一个表示匹配失败的元素的参数
|
||||
- missing_tips: 参数单元的缺失提示生成函数
|
||||
|
||||
## 选项与子命令(Option & Subcommand)
|
||||
|
||||
`Option` 和 `Subcommand` 可以传入一组 `alias`,如 `Option("--foo|-F|--FOO|-f")`,`Subcommand("foo", alias=["F"])`
|
||||
|
||||
传入别名后,选项与子命令会选择其中长度最长的作为其名称。若传入为 "--foo|-f",则命令名称为 "--foo"
|
||||
|
||||
:::tip 特别提醒!!!
|
||||
|
||||
Option 的名字或别名**没有要求**必须在前面写上 `-`
|
||||
|
||||
Option 与 Subcommand 的唯一区别在于 Subcommand 可以传入自己的 **Option** 与 **Subcommand**
|
||||
|
||||
:::
|
||||
|
||||
他们拥有如下共同参数:
|
||||
|
||||
- `help_text`: 传入该组件的帮助信息
|
||||
- `dest`: 被指定为解析完成时标注匹配结果的标识符,不传入时默认为选项或子命令的名称 (name)
|
||||
- `requires`: 一段指定顺序的字符串列表,作为唯一的前置序列与命令嵌套替换
|
||||
对于命令 `test foo bar baz qux <a:int>` 来讲,因为`foo bar baz` 仅需要判断是否相等, 所以可以这么编写:
|
||||
|
||||
```python
|
||||
Alconna("test", Option("qux", Args.a[int], requires=["foo", "bar", "baz"]))
|
||||
```
|
||||
|
||||
- `default`: 默认值,在该组件未被解析时使用使用该值替换。
|
||||
特别的,使用 `OptionResult` 或 `SubcomanndResult` 可以设置包括参数字典在内的默认值:
|
||||
|
||||
```python
|
||||
from arclet.alconna import Option, OptionResult
|
||||
|
||||
opt1 = Option("--foo", default=False)
|
||||
opt2 = Option("--foo", default=OptionResult(value=False, args={"bar": 1}))
|
||||
```
|
||||
|
||||
### Action
|
||||
|
||||
`Option` 可以特别设置传入一类 `Action`,作为解析操作
|
||||
|
||||
`Action` 分为三类:
|
||||
|
||||
- `store`: 无 Args 时, 仅存储一个值, 默认为 Ellipsis; 有 Args 时, 后续的解析结果会覆盖之前的值
|
||||
- `append`: 无 Args 时, 将多个值存为列表, 默认为 Ellipsis; 有 Args 时, 每个解析结果会追加到列表中, 当存在默认值并且不为列表时, 会自动将默认值变成列表, 以保证追加的正确性
|
||||
- `count`: 无 Args 时, 计数器加一; 有 Args 时, 表现与 STORE 相同, 当存在默认值并且不为数字时, 会自动将默认值变成 1, 以保证计数器的正确性。
|
||||
|
||||
`Alconna` 提供了预制的几类 `Action`:
|
||||
|
||||
- `store`(默认),`store_value`,`store_true`,`store_false`
|
||||
- `append`,`append_value`
|
||||
- `count`
|
||||
|
||||
## 解析结果(Arparma)
|
||||
|
||||
`Alconna.parse` 会返回由 **Arparma** 承载的解析结果
|
||||
|
||||
`Arparma` 有如下属性:
|
||||
|
||||
- 调试类
|
||||
|
||||
- matched: 是否匹配成功
|
||||
- error_data: 解析失败时剩余的数据
|
||||
- error_info: 解析失败时的异常内容
|
||||
- origin: 原始命令,可以类型标注
|
||||
|
||||
- 分析类
|
||||
- header_match: 命令头部的解析结果,包括原始头部、解析后头部、解析结果与可能的正则匹配组
|
||||
- main_args: 命令的主参数的解析结果
|
||||
- options: 命令所有选项的解析结果
|
||||
- subcommands: 命令所有子命令的解析结果
|
||||
- other_args: 除主参数外的其他解析结果
|
||||
- all_matched_args: 所有 Args 的解析结果
|
||||
|
||||
`Arparma` 同时提供了便捷的查询方法 `query[type]()`,会根据传入的 `path` 查找参数并返回
|
||||
|
||||
`path` 支持如下:
|
||||
|
||||
- `main_args`, `options`, ...: 返回对应的属性
|
||||
- `args`: 返回 all_matched_args
|
||||
- `main_args.xxx`, `options.xxx`, ...: 返回字典中 `xxx`键对应的值
|
||||
- `args.xxx`: 返回 all_matched_args 中 `xxx`键对应的值
|
||||
- `options.foo`, `foo`: 返回选项 `foo` 的解析结果 (OptionResult)
|
||||
- `options.foo.value`, `foo.value`: 返回选项 `foo` 的解析值
|
||||
- `options.foo.args`, `foo.args`: 返回选项 `foo` 的解析参数字典
|
||||
- `options.foo.args.bar`, `foo.bar`: 返回选项 `foo` 的参数字典中 `bar` 键对应的值 ...
|
||||
|
||||
## 元数据(CommandMeta)
|
||||
|
||||
`Alconna` 的元数据相当于其配置,拥有以下条目:
|
||||
|
||||
- `description`: 命令的描述
|
||||
- `usage`: 命令的用法
|
||||
- `example`: 命令的使用样例
|
||||
- `author`: 命令的作者
|
||||
- `fuzzy_match`: 命令是否开启模糊匹配
|
||||
- `fuzzy_threshold`: 模糊匹配阈值
|
||||
- `raise_exception`: 命令是否抛出异常
|
||||
- `hide`: 命令是否对 manager 隐藏
|
||||
- `hide_shortcut`: 命令的快捷指令是否在 help 信息中隐藏
|
||||
- `keep_crlf`: 命令解析时是否保留换行字符
|
||||
- `compact`: 命令是否允许第一个参数紧随头部
|
||||
- `strict`: 命令是否严格匹配,若为 False 则未知参数将作为名为 $extra 的参数
|
||||
- `context_style`: 命令上下文插值的风格,None 为关闭,bracket 为 {...},parentheses 为 $(...)
|
||||
- `extra`: 命令的自定义额外信息
|
||||
|
||||
元数据一定使用 `meta=...` 形式传入:
|
||||
|
||||
```python
|
||||
from arclet.alconna import Alconna, CommandMeta
|
||||
|
||||
alc = Alconna(..., meta=CommandMeta("foo", example="bar"))
|
||||
```
|
||||
|
||||
## 命名空间配置
|
||||
|
||||
命名空间配置 (以下简称命名空间) 相当于 `Alconna` 的默认配置,其优先度低于 `CommandMeta`。
|
||||
|
||||
`Alconna` 默认使用 "Alconna" 命名空间。
|
||||
|
||||
命名空间有以下几个属性:
|
||||
|
||||
- name: 命名空间名称
|
||||
- prefixes: 默认前缀配置
|
||||
- separators: 默认分隔符配置
|
||||
- formatter_type: 默认格式化器类型
|
||||
- fuzzy_match: 默认是否开启模糊匹配
|
||||
- raise_exception: 默认是否抛出异常
|
||||
- builtin_option_name: 默认的内置选项名称(--help, --shortcut, --comp)
|
||||
- disable_builtin_options: 默认禁用的内置选项(--help, --shortcut, --comp)
|
||||
- enable_message_cache: 默认是否启用消息缓存
|
||||
- compact: 默认是否开启紧凑模式
|
||||
- strict: 命令是否严格匹配
|
||||
- context_style: 命令上下文插值的风格
|
||||
- ...
|
||||
|
||||
### 新建命名空间并替换
|
||||
|
||||
```python
|
||||
from arclet.alconna import Alconna, namespace, Namespace, Subcommand, Args, config
|
||||
|
||||
|
||||
ns = Namespace("foo", prefixes=["/"]) # 创建 "foo"命名空间配置, 它要求创建的Alconna的主命令前缀必须是/
|
||||
|
||||
alc = Alconna("pip", Subcommand("install", Args["package", str]), namespace=ns) # 在创建Alconna时候传入命名空间以替换默认命名空间
|
||||
|
||||
# 可以通过with方式创建命名空间
|
||||
with namespace("bar") as np1:
|
||||
np1.prefixes = ["!"] # 以上下文管理器方式配置命名空间,此时配置会自动注入上下文内创建的命令
|
||||
np1.formatter_type = ShellTextFormatter # 设置此命名空间下的命令的 formatter 默认为 ShellTextFormatter
|
||||
np1.builtin_option_name["help"] = {"帮助", "-h"} # 设置此命名空间下的命令的帮助选项名称
|
||||
|
||||
# 你还可以使用config来管理所有命名空间并切换至任意命名空间
|
||||
config.namespaces["foo"] = ns # 将命名空间挂载到 config 上
|
||||
|
||||
alc = Alconna("pip", Subcommand("install", Args["package", str]), namespace=config.namespaces["foo"]) # 也是同样可以切换到"foo"命名空间
|
||||
```
|
||||
|
||||
### 修改默认的命名空间
|
||||
|
||||
```python
|
||||
from arclet.alconna import config, namespace, Namespace
|
||||
|
||||
|
||||
config.default_namespace.prefixes = [...] # 直接修改默认配置
|
||||
|
||||
np = Namespace("xxx", prefixes=[...])
|
||||
config.default_namespace = np # 更换默认的命名空间
|
||||
|
||||
with namespace(config.default_namespace.name) as np:
|
||||
np.prefixes = [...]
|
||||
```
|
||||
|
||||
## 快捷指令
|
||||
|
||||
快捷命令可以做到标识一段命令, 并且传递参数给原命令
|
||||
|
||||
一般情况下你可以通过 `Alconna.shortcut` 进行快捷指令操作 (创建,删除)
|
||||
|
||||
`shortcut` 的第一个参数为快捷指令名称,第二个参数为 `ShortcutArgs`,作为快捷指令的配置:
|
||||
|
||||
```python
|
||||
class ShortcutArgs(TypedDict):
|
||||
"""快捷指令参数"""
|
||||
|
||||
command: NotRequired[str]
|
||||
"""快捷指令的命令"""
|
||||
args: NotRequired[list[Any]]
|
||||
"""快捷指令的附带参数"""
|
||||
fuzzy: NotRequired[bool]
|
||||
"""是否允许命令后随参数"""
|
||||
prefix: NotRequired[bool]
|
||||
"""是否调用时保留指令前缀"""
|
||||
wrapper: NotRequired[ShortcutRegWrapper]
|
||||
"""快捷指令的正则匹配结果的额外处理函数"""
|
||||
humanized: NotRequired[str]
|
||||
"""快捷指令的人类可读描述"""
|
||||
```
|
||||
|
||||
### args的使用
|
||||
|
||||
```python
|
||||
from arclet.alconna import Alconna, Args
|
||||
|
||||
|
||||
alc = Alconna("setu", Args["count", int])
|
||||
|
||||
alc.shortcut("涩图(\d+)张", {"args": ["{0}"]})
|
||||
# 'Alconna::setu 的快捷指令: "涩图(\\d+)张" 添加成功'
|
||||
|
||||
alc.parse("涩图3张").query("count")
|
||||
# 3
|
||||
```
|
||||
|
||||
### command的使用
|
||||
|
||||
```python
|
||||
from arclet.alconna import Alconna, Args
|
||||
|
||||
|
||||
alc = Alconna("eval", Args["content", str])
|
||||
|
||||
alc.shortcut("echo", {"command": "eval print(\\'{*}\\')"})
|
||||
# 'Alconna::eval 的快捷指令: "echo" 添加成功'
|
||||
|
||||
alc.shortcut("echo", delete=True) # 删除快捷指令
|
||||
# 'Alconna::eval 的快捷指令: "echo" 删除成功'
|
||||
|
||||
@alc.bind() # 绑定一个命令执行器, 若匹配成功则会传入参数, 自动执行命令执行器
|
||||
def cb(content: str):
|
||||
eval(content, {}, {})
|
||||
|
||||
alc.parse('eval print(\\"hello world\\")')
|
||||
# hello world
|
||||
|
||||
alc.parse("echo hello world!")
|
||||
# hello world!
|
||||
```
|
||||
|
||||
当 `fuzzy` 为 False 时,第一个例子中传入 `"涩图1张 abc"` 之类的快捷指令将视为解析失败
|
||||
|
||||
快捷指令允许三类特殊的 placeholder:
|
||||
|
||||
- `{%X}`: 如 `setu {%0}`,表示此处填入快捷指令后随的第 X 个参数。
|
||||
|
||||
例如,若快捷指令为 `涩图`, 配置为 `{"command": "setu {%0}"}`, 则指令 `涩图 1` 相当于 `setu 1`
|
||||
|
||||
- `{*}`: 表示此处填入所有后随参数,并且可以通过 `{*X}` 的方式指定组合参数之间的分隔符。
|
||||
|
||||
- `{X}`: 表示此处填入可能的正则匹配的组:
|
||||
|
||||
- 若 `command` 中存在匹配组 `(xxx)`,则 `{X}` 表示第 X 个匹配组的内容
|
||||
- 若 `command` 中存储匹配组 `(?P<xxx>...)`, 则 `{X}` 表示 **名字** 为 X 的匹配结果
|
||||
|
||||
除此之外, 通过 **Alconna** 内置选项 `--shortcut` 可以动态操作快捷指令
|
||||
|
||||
例如:
|
||||
|
||||
- `cmd --shortcut <key> <cmd>` 来增加一个快捷指令
|
||||
- `cmd --shortcut list` 来列出当前指令的所有快捷指令
|
||||
- `cmd --shortcut delete key` 来删除一个快捷指令
|
||||
|
||||
```python
|
||||
from arclet.alconna import Alconna, Args
|
||||
|
||||
|
||||
alc = Alconna("eval", Args["content", str])
|
||||
|
||||
alc.shortcut("echo", {"command": "eval print(\\'{*}\\')"})
|
||||
|
||||
alc.parse("eval --shortcut list")
|
||||
# 'echo'
|
||||
```
|
||||
|
||||
## 紧凑命令
|
||||
|
||||
`Alconna`, `Option` 与 `Subcommand` 可以设置 `compact=True` 使得解析命令时允许名称与后随参数之间没有分隔:
|
||||
|
||||
```python
|
||||
from arclet.alconna import Alconna, Option, CommandMeta, Args
|
||||
|
||||
|
||||
alc = Alconna("test", Args["foo", int], Option("BAR", Args["baz", str], compact=True), meta=CommandMeta(compact=True))
|
||||
|
||||
assert alc.parse("test123 BARabc").matched
|
||||
```
|
||||
|
||||
这使得我们可以实现如下命令:
|
||||
|
||||
```python
|
||||
from arclet.alconna import Alconna, Option, Args, append
|
||||
|
||||
|
||||
alc = Alconna("gcc", Option("--flag|-F", Args["content", str], action=append, compact=True))
|
||||
print(alc.parse("gcc -Fabc -Fdef -Fxyz").query[list]("flag.content"))
|
||||
# ['abc', 'def', 'xyz']
|
||||
```
|
||||
|
||||
当 `Option` 的 `action` 为 `count` 时,其自动支持 `compact` 特性:
|
||||
|
||||
```python
|
||||
from arclet.alconna import Alconna, Option, count
|
||||
|
||||
|
||||
alc = Alconna("pp", Option("--verbose|-v", action=count, default=0))
|
||||
print(alc.parse("pp -vvv").query[int]("verbose.value"))
|
||||
# 3
|
||||
```
|
||||
|
||||
## 模糊匹配
|
||||
|
||||
模糊匹配会应用在任意需要进行名称判断的地方,如 **命令名称**,**选项名称** 和 **参数名称** (如指定需要传入参数名称)。
|
||||
|
||||
```python
|
||||
from arclet.alconna import Alconna, CommandMeta
|
||||
|
||||
|
||||
alc = Alconna("test_fuzzy", meta=CommandMeta(fuzzy_match=True))
|
||||
|
||||
alc.parse("test_fuzy")
|
||||
# test_fuzy is not matched. Do you mean "test_fuzzy"?
|
||||
```
|
||||
|
||||
## 半自动补全
|
||||
|
||||
半自动补全为用户提供了推荐后续输入的功能
|
||||
|
||||
补全默认通过 `--comp` 或 `-cp` 或 `?` 触发:(命名空间配置可修改名称)
|
||||
|
||||
```python
|
||||
from arclet.alconna import Alconna, Args, Option
|
||||
|
||||
|
||||
alc = Alconna("test", Args["abc", int]) + Option("foo") + Option("bar")
|
||||
alc.parse("test --comp")
|
||||
|
||||
'''
|
||||
output
|
||||
|
||||
以下是建议的输入:
|
||||
* <abc: int>
|
||||
* --help
|
||||
* -h
|
||||
* -sct
|
||||
* --shortcut
|
||||
* foo
|
||||
* bar
|
||||
'''
|
||||
```
|
||||
|
||||
## Duplication
|
||||
|
||||
**Duplication** 用来提供更好的自动补全,类似于 **ArgParse** 的 **Namespace**
|
||||
|
||||
普通情况下使用,需要利用到 **ArgsStub**、**OptionStub** 和 **SubcommandStub** 三个部分
|
||||
|
||||
以pip为例,其对应的 Duplication 应如下构造:
|
||||
|
||||
```python
|
||||
from arclet.alconna import Alconna, Args, Option, OptionResult, Duplication, SubcommandStub, Subcommand, count
|
||||
|
||||
|
||||
class MyDup(Duplication):
|
||||
verbose: OptionResult
|
||||
install: SubcommandStub
|
||||
|
||||
|
||||
alc = Alconna(
|
||||
"pip",
|
||||
Subcommand(
|
||||
"install",
|
||||
Args["package", str],
|
||||
Option("-r|--requirement", Args["file", str]),
|
||||
Option("-i|--index-url", Args["url", str]),
|
||||
),
|
||||
Option("-v|--version"),
|
||||
Option("-v|--verbose", action=count),
|
||||
)
|
||||
|
||||
res = alc.parse("pip -v install ...") # 不使用duplication获得的提示较少
|
||||
print(res.query("install"))
|
||||
# (value=Ellipsis args={'package': '...'} options={} subcommands={})
|
||||
|
||||
result = alc.parse("pip -v install ...", duplication=MyDup)
|
||||
print(result.install)
|
||||
# SubcommandStub(_origin=Subcommand('install', args=Args('package': str)), _value=Ellipsis, available=True, args=ArgsStub(_origin=Args('package': str), _value={'package': '...'}, available=True), dest='install', options=[OptionStub(_origin=Option('requirement', args=Args('file': str)), _value=None, available=False, args=ArgsStub(_origin=Args('file': str), _value={}, available=False), dest='requirement', aliases=['r', 'requirement'], name='requirement'), OptionStub(_origin=Option('index-url', args=Args('url': str)), _value=None, available=False, args=ArgsStub(_origin=Args('url': str), _value={}, available=False), dest='index-url', aliases=['index-url', 'i'], name='index-url')], subcommands=[], name='install')
|
||||
```
|
||||
|
||||
**Duplication** 也可以如 **Namespace** 一样直接标明参数名称和类型:
|
||||
|
||||
```python
|
||||
from typing import Optional
|
||||
from arclet.alconna import Duplication
|
||||
|
||||
|
||||
class MyDup(Duplication):
|
||||
package: str
|
||||
file: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
```
|
||||
|
||||
## 上下文插值
|
||||
|
||||
当 `context_style` 条目被设置后,传入的命令中符合上下文插值的字段会被自动替换成当前上下文中的信息。
|
||||
|
||||
上下文可以在 `parse` 中传入:
|
||||
|
||||
```python
|
||||
from arclet.alconna import Alconna, Args, CommandMeta
|
||||
|
||||
alc = Alconna("test", Args["foo", int], meta=CommandMeta(context_style="parentheses"))
|
||||
|
||||
alc.parse("test $(bar)", {"bar": 123})
|
||||
# {"foo": 123}
|
||||
```
|
||||
|
||||
context_style 的值分两种:
|
||||
|
||||
- `"bracket"`: 插值格式为 `{...}`,例如 `{foo}`
|
||||
- `"parentheses"`: 插值格式为 `$(...)`,例如 `$(bar)`
|
@ -0,0 +1,76 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
description: 配置项
|
||||
---
|
||||
|
||||
# 配置项
|
||||
|
||||
## alconna_auto_send_output
|
||||
|
||||
- **类型**: `bool`
|
||||
- **默认值**: `False`
|
||||
|
||||
是否全局启用输出信息自动发送,不启用则会在触发特殊内置选项后仍然将解析结果传递至响应器。
|
||||
|
||||
## alconna_use_command_start
|
||||
|
||||
- **类型**: `bool`
|
||||
- **默认值**: `False`
|
||||
|
||||
是否读取 Nonebot 的配置项 `COMMAND_START` 来作为全局的 Alconna 命令前缀
|
||||
|
||||
## alconna_auto_completion
|
||||
|
||||
- **类型**: `bool`
|
||||
- **默认值**: `False`
|
||||
|
||||
是否全局启用命令自动补全,启用后会在参数缺失或触发 `--comp` 选项时自自动启用交互式补全。
|
||||
|
||||
## alconna_use_origin
|
||||
|
||||
- **类型**: `bool`
|
||||
- **默认值**: `False`
|
||||
|
||||
是否全局使用原始消息 (即未经过 to_me 等处理的),该选项会影响到 Alconna 的匹配行为。
|
||||
|
||||
## alconna_use_command_sep
|
||||
|
||||
- **类型**: `bool`
|
||||
- **默认值**: `False`
|
||||
|
||||
是否读取 Nonebot 的配置项 `COMMAND_SEP` 来作为全局的 Alconna 命令分隔符。
|
||||
|
||||
## alconna_global_extensions
|
||||
|
||||
- **类型**: `List[str]`
|
||||
- **默认值**: `[]`
|
||||
|
||||
全局加载的扩展,路径以 . 分隔,如 `foo.bar.baz:DemoExtension`。
|
||||
|
||||
## alconna_context_style
|
||||
|
||||
- **类型**: `Optional[Literal["bracket", "parentheses"]]`
|
||||
- **默认值**: `None`
|
||||
|
||||
全局命令上下文插值的风格,None 为关闭,bracket 为 `{...}`,parentheses 为 `$(...)`。
|
||||
|
||||
## alconna_enable_saa_patch
|
||||
|
||||
- **类型**: `bool`
|
||||
- **默认值**: `False`
|
||||
|
||||
是否启用 SAA 补丁。
|
||||
|
||||
## alconna_apply_filehost
|
||||
|
||||
- **类型**: `bool`
|
||||
- **默认值**: `False`
|
||||
|
||||
是否启用文件托管。
|
||||
|
||||
## alconna_apply_fetch_targets
|
||||
|
||||
- **类型**: `bool`
|
||||
- **默认值**: `False`
|
||||
|
||||
是否启动时拉取一次发送对象列表。
|
@ -0,0 +1,607 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
description: 响应规则的使用
|
||||
---
|
||||
|
||||
import Messenger from "@site/src/components/Messenger";
|
||||
|
||||
# Alconna 插件
|
||||
|
||||
展示:
|
||||
|
||||
```python
|
||||
from nonebot_plugin_alconna import At, Image, on_alconna
|
||||
from arclet.alconna import Args, Option, Alconna, Arparma, MultiVar, Subcommand
|
||||
|
||||
|
||||
alc = Alconna(
|
||||
["/", "!"],
|
||||
"role-group",
|
||||
Subcommand(
|
||||
"add",
|
||||
Args["name", str],
|
||||
Option("member", Args["target", MultiVar(At)]),
|
||||
),
|
||||
Option("list"),
|
||||
Option("icon", Args["icon", Image])
|
||||
)
|
||||
rg = on_alconna(alc, auto_send_output=True)
|
||||
|
||||
|
||||
@rg.handle()
|
||||
async def _(result: Arparma):
|
||||
if result.find("list"):
|
||||
img: bytes = await gen_role_group_list_image()
|
||||
await rg.finish(Image(raw=img))
|
||||
if result.find("add"):
|
||||
group = await create_role_group(result.query[str]("add.name"))
|
||||
if result.find("add.member"):
|
||||
ats = result.query[tuple[At, ...]]("add.member.target")
|
||||
group.extend(member.target for member in ats)
|
||||
await rg.finish("添加成功")
|
||||
```
|
||||
|
||||
## 响应器使用
|
||||
|
||||
本插件基于 **Alconna**,为 **Nonebot** 提供了一类新的事件响应器辅助函数 `on_alconna`:
|
||||
|
||||
```python
|
||||
def on_alconna(
|
||||
command: Alconna | str,
|
||||
skip_for_unmatch: bool = True,
|
||||
auto_send_output: bool = False,
|
||||
aliases: set[str | tuple[str, ...]] | None = None,
|
||||
comp_config: CompConfig | None = None,
|
||||
extensions: list[type[Extension] | Extension] | None = None,
|
||||
exclude_ext: list[type[Extension] | str] | None = None,
|
||||
use_origin: bool = False,
|
||||
use_cmd_start: bool = False,
|
||||
use_cmd_sep: bool = False,
|
||||
**kwargs,
|
||||
...,
|
||||
):
|
||||
```
|
||||
|
||||
- `command`: Alconna 命令或字符串,字符串将通过 `AlconnaFormat` 转换为 Alconna 命令
|
||||
- `skip_for_unmatch`: 是否在命令不匹配时跳过该响应
|
||||
- `auto_send_output`: 是否自动发送输出信息并跳过响应
|
||||
- `aliases`: 命令别名, 作用类似于 `on_command` 中的 aliases
|
||||
- `comp_config`: 补全会话配置, 不传入则不启用补全会话
|
||||
- `extensions`: 需要加载的匹配扩展, 可以是扩展类或扩展实例
|
||||
- `exclude_ext`: 需要排除的匹配扩展, 可以是扩展类或扩展的id
|
||||
- `use_origin`: 是否使用未经 to_me 等处理过的消息
|
||||
- `use_cmd_start`: 是否使用 COMMAND_START 作为命令前缀
|
||||
- `use_cmd_sep`: 是否使用 COMMAND_SEP 作为命令分隔符
|
||||
|
||||
`on_alconna` 返回的是 `Matcher` 的子类 `AlconnaMatcher` ,其拓展了如下方法:
|
||||
|
||||
- `.assign(path, value, or_not)`: 用于对包含多个选项/子命令的命令的分派处理(具体请看[条件控制](./matcher.mdx#条件控制))
|
||||
- `.got_path(path, prompt, middleware)`: 在 `got` 方法的基础上,会以 path 对应的参数为准,读取传入 message 的最后一个消息段并验证转换
|
||||
- `.set_path_arg(key, value)`, `.get_path_arg(key)`: 类似 `set_arg` 和 `got_arg`,为 `got_path` 的特化版本
|
||||
- `.reject_path(path[, prompt, fallback])`: 类似于 `reject_arg`,对应 `got_path`
|
||||
- `.dispatch`: 同样的分派处理,但是是类似 `CommandGroup` 一样返回新的 `AlconnaMatcher`
|
||||
- `.got`, `send`, `reject`, ... : 拓展了 prompt 类型,即支持使用 `UniMessage` 作为 prompt
|
||||
|
||||
实例:
|
||||
|
||||
```python
|
||||
from nonebot import require
|
||||
require("nonebot_plugin_alconna")
|
||||
|
||||
from arclet.alconna import Alconna, Option, Args
|
||||
from nonebot_plugin_alconna import on_alconna, Match, UniMessage
|
||||
|
||||
|
||||
login = on_alconna(Alconna(["/"], "login", Args["password?", str], Option("-r|--recall"))) # 这里["/"]指命令前缀必须是/
|
||||
|
||||
# /login -r 触发
|
||||
@login.assign("recall")
|
||||
async def login_exit():
|
||||
await login.finish("已退出")
|
||||
|
||||
# /login xxx 触发
|
||||
@login.assign("password")
|
||||
async def login_handle(pw: Match[str]):
|
||||
if pw.available:
|
||||
login.set_path_arg("password", pw.result)
|
||||
|
||||
# /login 触发
|
||||
@login.got_path("password", prompt=UniMessage.template("{:At(user, $event.get_user_id())} 请输入密码"))
|
||||
async def login_got(password: str):
|
||||
assert password
|
||||
await login.send("登录成功")
|
||||
```
|
||||
|
||||
## 依赖注入
|
||||
|
||||
本插件提供了一系列依赖注入函数,便于在响应函数中获取解析结果:
|
||||
|
||||
- `AlconnaResult`: `CommandResult` 类型的依赖注入函数
|
||||
- `AlconnaMatches`: `Arparma` 类型的依赖注入函数
|
||||
- `AlconnaDuplication`: `Duplication` 类型的依赖注入函数
|
||||
- `AlconnaMatch`: `Match` 类型的依赖注入函数
|
||||
- `AlconnaQuery`: `Query` 类型的依赖注入函数
|
||||
|
||||
同时,基于 [`Annotated` 支持](https://github.com/nonebot/nonebot2/pull/1832),添加了两类注解:
|
||||
|
||||
- `AlcMatches`:同 `AlconnaMatches`
|
||||
- `AlcResult`:同 `AlconnaResult`
|
||||
|
||||
可以看到,本插件提供了几类额外的模型:
|
||||
|
||||
- `CommandResult`: 解析结果,包括了源命令 `source: Alconna` ,解析结果 `result: Arparma`,以及可能的输出信息 `output: str | None` 字段
|
||||
- `Match`: 匹配项,表示参数是否存在于 `all_matched_args` 内,可用 `Match.available` 判断是否匹配,`Match.result` 获取匹配的值
|
||||
- `Query`: 查询项,表示参数是否可由 `Arparma.query` 查询并获得结果,可用 `Query.available` 判断是否查询成功,`Query.result` 获取查询结果
|
||||
|
||||
**Alconna** 默认依赖注入的目标参数皆不需要使用依赖注入函数, 该效果对于 `AlconnaMatcher.got_path` 下的 Arg 同样有效:
|
||||
|
||||
```python
|
||||
async def handle(
|
||||
result: CommandResult,
|
||||
arp: Arparma,
|
||||
dup: Duplication,
|
||||
source: Alconna,
|
||||
abc: str, # 类似 Match, 但是若匹配结果不存在对应字段则跳过该 handler
|
||||
foo: Match[str],
|
||||
bar: Query[int] = Query("ttt.bar", 0) # Query 仍然需要一个默认值来传递 path 参数
|
||||
):
|
||||
...
|
||||
```
|
||||
|
||||
:::note
|
||||
|
||||
如果你更喜欢 Depends 式的依赖注入,`nonebot_plugin_alconna` 同时提供了一系列的依赖注入函数,他们包括:
|
||||
|
||||
- `AlconnaResult`: `CommandResult` 类型的依赖注入函数
|
||||
- `AlconnaMatches`: `Arparma` 类型的依赖注入函数
|
||||
- `AlconnaDuplication`: `Duplication` 类型的依赖注入函数
|
||||
- `AlconnaMatch`: `Match` 类型的依赖注入函数,其能够额外传入一个 middleware 函数来处理得到的参数
|
||||
- `AlconnaQuery`: `Query` 类型的依赖注入函数,其能够额外传入一个 middleware 函数来处理得到的参数
|
||||
- `AlconnaExecResult`: 提供挂载在命令上的 callback 的返回结果 (`Dict[str, Any]`) 的依赖注入函数
|
||||
- `AlconnaExtension`: 提供指定类型的 `Extension` 的依赖注入函数
|
||||
|
||||
:::
|
||||
|
||||
实例:
|
||||
|
||||
```python
|
||||
from nonebot import require
|
||||
require("nonebot_plugin_alconna")
|
||||
|
||||
from nonebot_plugin_alconna import (
|
||||
on_alconna,
|
||||
Match,
|
||||
Query,
|
||||
AlconnaMatch,
|
||||
AlcResult
|
||||
)
|
||||
from arclet.alconna import Alconna, Args, Option, Arparma
|
||||
|
||||
|
||||
test = on_alconna(
|
||||
Alconna(
|
||||
"test",
|
||||
Option("foo", Args["bar", int]),
|
||||
Option("baz", Args["qux", bool, False])
|
||||
),
|
||||
auto_send_output=True
|
||||
)
|
||||
|
||||
@test.handle()
|
||||
async def handle_test1(result: AlcResult):
|
||||
await test.send(f"matched: {result.matched}")
|
||||
await test.send(f"maybe output: {result.output}")
|
||||
|
||||
@test.handle()
|
||||
async def handle_test2(result: Arparma):
|
||||
await test.send(f"head result: {result.header_result}")
|
||||
await test.send(f"args: {result.all_matched_args}")
|
||||
|
||||
@test.handle()
|
||||
async def handle_test3(bar: Match[int] = AlconnaMatch("bar")):
|
||||
if bar.available:
|
||||
await test.send(f"foo={bar.result}")
|
||||
|
||||
@test.handle()
|
||||
async def handle_test4(qux: Query[bool] = Query("baz.qux", False)):
|
||||
if qux.available:
|
||||
await test.send(f"baz.qux={qux.result}")
|
||||
```
|
||||
|
||||
## 多平台适配
|
||||
|
||||
本插件提供了通用消息段标注, 通用消息段序列, 使插件使用者可以忽略平台之间字段的差异
|
||||
|
||||
响应器使用示例中使用了消息段标注,其中 `At` 属于通用标注,而 `Image` 属于 `onebot12` 适配器下的标注。
|
||||
|
||||
具体介绍和使用请查看 [通用信息组件](./uniseg.mdx#通用消息段)
|
||||
|
||||
本插件为以下适配器提供了专门的适配器标注:
|
||||
|
||||
| 协议名称 | 路径 |
|
||||
| ------------------------------------------------------------------- | ------------------------------------ |
|
||||
| [OneBot 协议](https://github.com/nonebot/adapter-onebot) | adapters.onebot11, adapters.onebot12 |
|
||||
| [Telegram](https://github.com/nonebot/adapter-telegram) | adapters.telegram |
|
||||
| [飞书](https://github.com/nonebot/adapter-feishu) | adapters.feishu |
|
||||
| [GitHub](https://github.com/nonebot/adapter-github) | adapters.github |
|
||||
| [QQ bot](https://github.com/nonebot/adapter-qq) | adapters.qq |
|
||||
| [钉钉](https://github.com/nonebot/adapter-ding) | adapters.ding |
|
||||
| [Dodo](https://github.com/nonebot/adapter-dodo) | adapters.dodo |
|
||||
| [Console](https://github.com/nonebot/adapter-console) | adapters.console |
|
||||
| [开黑啦](https://github.com/Tian-que/nonebot-adapter-kaiheila) | adapters.kook |
|
||||
| [Mirai](https://github.com/ieew/nonebot_adapter_mirai2) | adapters.mirai |
|
||||
| [Ntchat](https://github.com/JustUndertaker/adapter-ntchat) | adapters.ntchat |
|
||||
| [MineCraft](https://github.com/17TheWord/nonebot-adapter-minecraft) | adapters.minecraft |
|
||||
| [BiliBili Live](https://github.com/wwweww/adapter-bilibili) | adapters.bilibili |
|
||||
| [Walle-Q](https://github.com/onebot-walle/nonebot_adapter_walleq) | adapters.onebot12 |
|
||||
| [Discord](https://github.com/nonebot/adapter-discord) | adapters.discord |
|
||||
| [Red 协议](https://github.com/nonebot/adapter-red) | adapters.red |
|
||||
| [Satori 协议](https://github.com/nonebot/adapter-satori) | adapters.satori |
|
||||
|
||||
## 条件控制
|
||||
|
||||
本插件可以通过 `assign` 来控制一个具体的响应函数是否在不满足条件时跳过响应。
|
||||
|
||||
```python
|
||||
...
|
||||
from nonebot import require
|
||||
require("nonebot_plugin_alconna")
|
||||
...
|
||||
|
||||
from arclet.alconna import Alconna, Subcommand, Option, Args
|
||||
from nonebot_plugin_alconna import on_alconna, CommandResult
|
||||
|
||||
|
||||
pip = Alconna(
|
||||
"pip",
|
||||
Subcommand(
|
||||
"install", Args["pak", str],
|
||||
Option("--upgrade"),
|
||||
Option("--force-reinstall")
|
||||
),
|
||||
Subcommand("list", Option("--out-dated"))
|
||||
)
|
||||
|
||||
pip_cmd = on_alconna(pip)
|
||||
|
||||
# 仅在命令为 `pip install pip` 时响应
|
||||
@pip_cmd.assign("install.pak", "pip")
|
||||
async def update(res: CommandResult):
|
||||
...
|
||||
|
||||
# 仅在命令为 `pip list` 时响应
|
||||
@pip_cmd.assign("list")
|
||||
async def list_(res: CommandResult):
|
||||
...
|
||||
|
||||
# 在命令为 `pip install xxx` 时响应
|
||||
@pip_cmd.assign("install")
|
||||
async def install(res: CommandResult):
|
||||
...
|
||||
```
|
||||
|
||||
此外,使用 `AlconnaMatcher.dispatch` 还能像 `CommandGroup` 一样为每个分发设置独立的 matcher:
|
||||
|
||||
```python
|
||||
update_cmd = pip_cmd.dispatch("install.pak", "pip")
|
||||
|
||||
@update_cmd.handle()
|
||||
async def update(arp: CommandResult):
|
||||
...
|
||||
```
|
||||
|
||||
另外,`AlconnaMatcher` 有类似于 `got` 的 `got_path`:
|
||||
|
||||
```python
|
||||
from nonebot_plugin_alconna import At, Match, UniMessage, on_alconna
|
||||
|
||||
|
||||
test_cmd = on_alconna(Alconna("test", Args["target?", Union[str, At]]))
|
||||
|
||||
@test_cmd.handle()
|
||||
async def tt_h(target: Match[Union[str, At]]):
|
||||
if target.available:
|
||||
test_cmd.set_path_arg("target", target.result)
|
||||
|
||||
@test_cmd.got_path("target", prompt="请输入目标")
|
||||
async def tt(target: Union[str, At]):
|
||||
await test_cmd.send(UniMessage(["ok\n", target]))
|
||||
```
|
||||
|
||||
`got_path` 与 `assign`,`Match`,`Query` 等地方一样,都需要指明 `path` 参数 (即对应 Arg 验证的路径)
|
||||
|
||||
`got_path` 会获取消息的最后一个消息段并转为 path 对应的类型,例如示例中 `target` 对应的 Arg 里要求 str 或 At,则 got 后用户输入的消息只有为 text 或 at 才能进入处理函数。
|
||||
|
||||
:::tip
|
||||
|
||||
`path` 支持 ~XXX 语法,其会把 ~ 替换为可能的父级路径:
|
||||
|
||||
```python
|
||||
pip = Alconna(
|
||||
"pip",
|
||||
Subcommand(
|
||||
"install",
|
||||
Args["pak", str],
|
||||
Option("--upgrade|-U"),
|
||||
Option("--force-reinstall"),
|
||||
),
|
||||
Subcommand("list", Option("--out-dated")),
|
||||
)
|
||||
|
||||
pipcmd = on_alconna(pip)
|
||||
pip_install_cmd = pipcmd.dispatch("install")
|
||||
|
||||
|
||||
@pip_install_cmd.assign("~upgrade")
|
||||
async def pip1_u(pak: Query[str] = Query("~pak")):
|
||||
await pip_install_cmd.finish(f"pip upgrading {pak.result}...")
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
## 响应器创建装饰
|
||||
|
||||
本插件提供了一个 `funcommand` 装饰器, 其用于将一个接受任意参数, 返回 `str` 或 `Message` 或 `MessageSegment` 的函数转换为命令响应器:
|
||||
|
||||
```python
|
||||
from nonebot_plugin_alconna import funcommand
|
||||
|
||||
|
||||
@funcommand()
|
||||
async def echo(msg: str):
|
||||
return msg
|
||||
```
|
||||
|
||||
其等同于:
|
||||
|
||||
```python
|
||||
from arclet.alconna import Alconna, Args
|
||||
from nonebot_plugin_alconna import on_alconna, AlconnaMatch, Match
|
||||
|
||||
|
||||
echo = on_alconna(Alconna("echo", Args["msg", str]))
|
||||
|
||||
@echo.handle()
|
||||
async def echo_exit(msg: Match[str] = AlconnaMatch("msg")):
|
||||
await echo.finish(msg.result)
|
||||
|
||||
```
|
||||
|
||||
## 类Koishi构造器
|
||||
|
||||
本插件提供了一个 `Command` 构造器,其基于 `arclet.alconna.tools` 中的 `AlconnaString`, 以类似 `Koishi` 中注册命令的方式来构建一个 **AlconnaMatcher** :
|
||||
|
||||
```python
|
||||
from nonebot_plugin_alconna import Command, Arparma
|
||||
|
||||
|
||||
book = (
|
||||
Command("book", "测试")
|
||||
.option("writer", "-w <id:int>")
|
||||
.option("writer", "--anonymous", {"id": 0})
|
||||
.usage("book [-w <id:int> | --anonymous]")
|
||||
.shortcut("测试", {"args": ["--anonymous"]})
|
||||
.build()
|
||||
)
|
||||
|
||||
@book.handle()
|
||||
async def _(arp: Arparma):
|
||||
await book.send(str(arp.options))
|
||||
```
|
||||
|
||||
甚至,你可以设置 `action` 来设定响应行为:
|
||||
|
||||
```python
|
||||
book = (
|
||||
Command("book", "测试")
|
||||
.option("writer", "-w <id:int>")
|
||||
.option("writer", "--anonymous", {"id": 0})
|
||||
.usage("book [-w <id:int> | --anonymous]")
|
||||
.shortcut("测试", {"args": ["--anonymous"]})
|
||||
.action(lambda options: str(options)) # 会自动通过 bot.send 发送
|
||||
.build()
|
||||
)
|
||||
```
|
||||
|
||||
## 返回值中间件
|
||||
|
||||
在 `AlconnaMatch`,`AlconnaQuery` 或 `got_path` 中,你可以使用 `middleware` 参数来传入一个对返回值进行处理的函数:
|
||||
|
||||
```python
|
||||
from nonebot_plugin_alconna import image_fetch
|
||||
|
||||
|
||||
mask_cmd = on_alconna(
|
||||
Alconna("search", Args["img?", Image]),
|
||||
)
|
||||
|
||||
|
||||
@mask_cmd.handle()
|
||||
async def mask_h(matcher: AlconnaMatcher, img: Match[bytes] = AlconnaMatch("img", image_fetch)):
|
||||
result = await search_img(img.result)
|
||||
await matcher.send(result.content)
|
||||
```
|
||||
|
||||
其中,`image_fetch` 是一个中间件,其接受一个 `Image` 对象,并提取图片的二进制数据返回。
|
||||
|
||||
## 匹配拓展
|
||||
|
||||
本插件提供了一个 `Extension` 类,其用于自定义 AlconnaMatcher 的部分行为
|
||||
|
||||
例如一个 `LLMExtension` 可以如下实现 (仅举例):
|
||||
|
||||
```python
|
||||
from nonebot_plugin_alconna import Extension, Alconna, on_alconna, Interface
|
||||
|
||||
|
||||
class LLMExtension(Extension):
|
||||
@property
|
||||
def priority(self) -> int:
|
||||
return 10
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return "LLMExtension"
|
||||
|
||||
def __init__(self, llm):
|
||||
self.llm = llm
|
||||
|
||||
def post_init(self, alc: Alconna) -> None:
|
||||
self.llm.add_context(alc.command, alc.meta.description)
|
||||
|
||||
async def receive_wrapper(self, bot, event, receive):
|
||||
resp = await self.llm.input(str(receive))
|
||||
return receive.__class__(resp.content)
|
||||
|
||||
def before_catch(self, name, annotation, default):
|
||||
return name == "llm"
|
||||
|
||||
def catch(self, interface: Interface):
|
||||
if interface.name == "llm":
|
||||
return self.llm
|
||||
|
||||
matcher = on_alconna(
|
||||
Alconna(...),
|
||||
extensions=[LLMExtension(LLM)]
|
||||
)
|
||||
...
|
||||
```
|
||||
|
||||
那么添加了 `LLMExtension` 的响应器便能接受任何能通过 llm 翻译为具体命令的自然语言消息,同时可以在响应器中为所有 `llm` 参数注入模型变量。
|
||||
|
||||
目前 `Extension` 的功能有:
|
||||
|
||||
- `validate`: 对于事件的来源适配器或 bot 选择是否接受响应
|
||||
- `output_converter`: 输出信息的自定义转换方法
|
||||
- `message_provider`: 从传入事件中自定义提取消息的方法
|
||||
- `receive_provider`: 对传入的消息 (Message 或 UniMessage) 的额外处理
|
||||
- `context_provider`: 对命令上下文的额外处理
|
||||
- `permission_check`: 命令对消息解析并确认头部匹配(即确认选择响应)时对发送者的权限判断
|
||||
- `parse_wrapper`: 对命令解析结果的额外处理
|
||||
- `send_wrapper`: 对发送的消息 (Message 或 UniMessage) 的额外处理
|
||||
- `before_catch`: 自定义依赖注入的绑定确认函数
|
||||
- `catch`: 自定义依赖注入处理函数
|
||||
- `post_init`: 响应器创建后对命令对象的额外处理
|
||||
|
||||
例如内置的 `DiscordSlashExtension`,其可自动将 Alconna 对象翻译成 slash 指令并注册,且将收到的指令交互事件转为指令供命令解析:
|
||||
|
||||
```python
|
||||
from nonebot_plugin_alconna import Match, on_alconna
|
||||
from nonebot_plugin_alconna.builtins.extensions.discord import DiscordSlashExtension
|
||||
|
||||
|
||||
alc = Alconna(
|
||||
["/"],
|
||||
"permission",
|
||||
Subcommand("add", Args["plugin", str]["priority?", int]),
|
||||
Option("remove", Args["plugin", str]["time?", int]),
|
||||
meta=CommandMeta(description="权限管理"),
|
||||
)
|
||||
|
||||
matcher = on_alconna(alc, extensions=[DiscordSlashExtension()])
|
||||
|
||||
@matcher.assign("add")
|
||||
async def add(plugin: Match[str], priority: Match[int]):
|
||||
await matcher.finish(f"added {plugin.result} with {priority.result if priority.available else 0}")
|
||||
|
||||
@matcher.assign("remove")
|
||||
async def remove(plugin: Match[str], time: Match[int]):
|
||||
await matcher.finish(f"removed {plugin.result} with {time.result if time.available else -1}")
|
||||
```
|
||||
|
||||
目前插件提供了 4 个内置的 `Extension`,它们在 `nonebot_plugin_alconna.builtins.extensions` 下:
|
||||
|
||||
- `ReplyRecordExtension`: 将消息事件中的回复暂存在 extension 中,使得解析用的消息不带回复信息,同时可以在后续的处理中获取回复信息。
|
||||
- `DiscordSlashExtension`: 将 Alconna 的命令自动转换为 Discord 的 Slash Command,并将 Slash Command 的交互事件转换为消息交给 Alconna 处理。
|
||||
- `MarkdownOutputExtension`: 将 Alconna 的自动输出转换为 Markdown 格式
|
||||
- `TelegramSlashExtension`: 将 Alconna 的命令注册在 Telegram 上以获得提示。
|
||||
|
||||
:::tip
|
||||
|
||||
全局的 Extension 可延迟加载 (即若有全局拓展加载于部分 AlconnaMatcher 之后,这部分响应器会被追加拓展)
|
||||
|
||||
:::
|
||||
|
||||
## 补全会话
|
||||
|
||||
补全会话基于 [`半自动补全`](./command.md#半自动补全),用于指令参数缺失或参数错误时给予交互式提示,类似于 `got-reject`:
|
||||
|
||||
```python
|
||||
from nonebot_plugin_alconna import Alconna, Args, Field, At, on_alconna
|
||||
|
||||
alc = Alconna(
|
||||
"添加教师",
|
||||
Args["name", str, Field(completion=lambda: "请输入姓名")],
|
||||
Args["phone", int, Field(completion=lambda: "请输入手机号")],
|
||||
Args["at", [str, At], Field(completion=lambda: "请输入教师号")],
|
||||
)
|
||||
|
||||
cmd = on_alconna(alc, comp_config={"lite": True}, skip_for_unmatch=False)
|
||||
|
||||
@cmd.handle()
|
||||
async def handle(result: Arparma):
|
||||
cmd.finish("添加成功")
|
||||
```
|
||||
|
||||
此时,当用户输入 `添加教师` 时,会自动提示用户输入姓名,手机号和教师号,用户输入后会自动进入下一个提示:
|
||||
|
||||
<Messenger
|
||||
msgs={[
|
||||
{ position: "right", msg: "添加教师" },
|
||||
{ position: "left", msg: "以下是建议的输入: \n- name: 请输入姓名" },
|
||||
{ position: "right", msg: "foo" },
|
||||
{ position: "left", msg: "以下是建议的输入: \n- phone: 请输入手机号" },
|
||||
{ position: "right", msg: "12345" },
|
||||
{ position: "left", msg: "以下是建议的输入: \n- at: 请输入教师号" },
|
||||
{ position: "right", msg: "@me" },
|
||||
{ position: "left", msg: "添加成功" },
|
||||
]}
|
||||
/>
|
||||
|
||||
补全会话配置如下:
|
||||
|
||||
```python
|
||||
class CompConfig(TypedDict):
|
||||
tab: NotRequired[str]
|
||||
"""用于切换提示的指令的名称"""
|
||||
enter: NotRequired[str]
|
||||
"""用于输入提示的指令的名称"""
|
||||
exit: NotRequired[str]
|
||||
"""用于退出会话的指令的名称"""
|
||||
timeout: NotRequired[int]
|
||||
"""超时时间"""
|
||||
hide_tabs: NotRequired[bool]
|
||||
"""是否隐藏所有提示"""
|
||||
hides: NotRequired[Set[Literal["tab", "enter", "exit"]]]
|
||||
"""隐藏的指令"""
|
||||
disables: NotRequired[Set[Literal["tab", "enter", "exit"]]]
|
||||
"""禁用的指令"""
|
||||
lite: NotRequired[bool]
|
||||
"""是否使用简洁版本的补全会话(相当于同时配置 disables、hides、hide_tabs)"""
|
||||
```
|
||||
|
||||
## 内置插件
|
||||
|
||||
类似于 Nonebot 本身提供的内置插件,`nonebot_plugin_alconna` 提供了两个内置插件:`echo` 和 `help`。
|
||||
|
||||
你可以用本插件的 `load_builtin_plugin(s)` 来加载它们:
|
||||
|
||||
```python
|
||||
from nonebot_plugin_alconna import load_builtin_plugins
|
||||
|
||||
load_builtin_plugins("echo", "help")
|
||||
```
|
||||
|
||||
其中 `help` 仅能列出所有 Alconna 指令。
|
||||
|
||||
<Messenger
|
||||
msgs={[
|
||||
{ position: "right", msg: "/帮助" },
|
||||
{
|
||||
position: "left",
|
||||
msg: "# 当前可用的命令有:\n 0 /echo : echo 指令\n 1 /help : 显示所有命令帮助\n# 输入'命令名 -h|--help' 查看特定命令的语法",
|
||||
},
|
||||
{ position: "right", msg: "/echo [图片]" },
|
||||
{ position: "left", msg: "[图片]" },
|
||||
]}
|
||||
/>
|
@ -0,0 +1,590 @@
|
||||
---
|
||||
sidebar_position: 5
|
||||
description: 通用消息组件
|
||||
---
|
||||
|
||||
import Tabs from "@theme/Tabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
|
||||
# 通用消息组件
|
||||
|
||||
`uniseg` 模块属于 `nonebot-plugin-alconna` 的子插件,其提供了一套通用的消息组件,用于在 `nonebot-plugin-alconna` 下构建通用消息。
|
||||
|
||||
## 通用消息段
|
||||
|
||||
适配器下的消息段标注会匹配适配器特定的 `MessageSegment`, 而通用消息段与适配器消息段的区别在于:
|
||||
通用消息段会匹配多个适配器中相似类型的消息段,并返回 `uniseg` 模块中定义的 [`Segment` 模型](https://nonebot.dev/docs/next/best-practice/alconna/utils#%E9%80%9A%E7%94%A8%E6%B6%88%E6%81%AF%E6%AE%B5), 以达到**跨平台接收消息**的作用。
|
||||
|
||||
`nonebot-plugin-alconna.uniseg` 提供了类似 `MessageSegment` 的通用消息段,并可在 `Alconna` 下直接标注使用:
|
||||
|
||||
```python
|
||||
class Segment:
|
||||
"""基类标注"""
|
||||
children: List["Segment"]
|
||||
|
||||
class Text(Segment):
|
||||
"""Text对象, 表示一类文本元素"""
|
||||
text: str
|
||||
styles: Dict[Tuple[int, int], List[str]]
|
||||
|
||||
class At(Segment):
|
||||
"""At对象, 表示一类提醒某用户的元素"""
|
||||
flag: Literal["user", "role", "channel"]
|
||||
target: str
|
||||
display: Optional[str]
|
||||
|
||||
class AtAll(Segment):
|
||||
"""AtAll对象, 表示一类提醒所有人的元素"""
|
||||
here: bool
|
||||
|
||||
class Emoji(Segment):
|
||||
"""Emoji对象, 表示一类表情元素"""
|
||||
id: str
|
||||
name: Optional[str]
|
||||
|
||||
class Media(Segment):
|
||||
url: Optional[str]
|
||||
id: Optional[str]
|
||||
path: Optional[Union[str, Path]]
|
||||
raw: Optional[Union[bytes, BytesIO]]
|
||||
mimetype: Optional[str]
|
||||
name: str
|
||||
|
||||
to_url: ClassVar[Optional[MediaToUrl]]
|
||||
|
||||
class Image(Media):
|
||||
"""Image对象, 表示一类图片元素"""
|
||||
|
||||
class Audio(Media):
|
||||
"""Audio对象, 表示一类音频元素"""
|
||||
duration: Optional[int]
|
||||
|
||||
class Voice(Media):
|
||||
"""Voice对象, 表示一类语音元素"""
|
||||
duration: Optional[int]
|
||||
|
||||
class Video(Media):
|
||||
"""Video对象, 表示一类视频元素"""
|
||||
|
||||
class File(Segment):
|
||||
"""File对象, 表示一类文件元素"""
|
||||
id: str
|
||||
name: Optional[str]
|
||||
|
||||
class Reply(Segment):
|
||||
"""Reply对象,表示一类回复消息"""
|
||||
id: str
|
||||
"""此处不一定是消息ID,可能是其他ID,如消息序号等"""
|
||||
msg: Optional[Union[Message, str]]
|
||||
origin: Optional[Any]
|
||||
|
||||
class Reference(Segment):
|
||||
"""Reference对象,表示一类引用消息。转发消息 (Forward) 也属于此类"""
|
||||
id: Optional[str]
|
||||
"""此处不一定是消息ID,可能是其他ID,如消息序号等"""
|
||||
children: List[Union[RefNode, CustomNode]]
|
||||
|
||||
class Hyper(Segment):
|
||||
"""Hyper对象,表示一类超级消息。如卡片消息、ark消息、小程序等"""
|
||||
format: Literal["xml", "json"]
|
||||
raw: Optional[str]
|
||||
content: Optional[Union[dict, list]]
|
||||
|
||||
class Other(Segment):
|
||||
"""其他 Segment"""
|
||||
origin: MessageSegment
|
||||
|
||||
```
|
||||
|
||||
:::tips
|
||||
|
||||
或许你注意到了 `Segment` 上有一个 `children` 属性。
|
||||
|
||||
这是因为在 [`Satori`](https://satori.js.org/zh-CN/) 协议的规定下,一类元素可以用其子元素来代表一类兼容性消息
|
||||
(例如,qq 的商场表情在某些平台上可以用图片代替)。
|
||||
|
||||
为此,本插件提供了两种方式来表达 "获取子元素" 的方法:
|
||||
|
||||
```python
|
||||
from nonebot_plugin_alconna.builtins.uniseg.chronocat import MarketFace
|
||||
from nonebot_plugin_alconna import Args, Image, Alconna, select, select_first
|
||||
|
||||
# 表示这个指令需要的图片要么直接是 Image 要么是在 MarketFace 元素内的 Image
|
||||
alc1 = Alconna("make_meme", Args["img", [Image, Image.from_(MarketFace)]])
|
||||
|
||||
# 表示这个指令需要的图片会在目标元素下进行搜索,将所有符合 Image 的元素选出来并将第一个作为结果
|
||||
alc2 = Alconna("make_meme", Args["img", select(Image, index=0)]) # 也可以使用 select_first(Image)
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
## 通用消息序列
|
||||
|
||||
`nonebot-plugin-alconna.uniseg` 同时提供了一个类似于 `Message` 的 `UniMessage` 类型,其元素为经过通用标注转换后的通用消息段。
|
||||
|
||||
你可以用如下方式获取 `UniMessage`:
|
||||
|
||||
<Tabs groupId="get_unimsg">
|
||||
<TabItem value="depend" label="使用依赖注入">
|
||||
|
||||
通过提供的 `UniversalMessage` 或 `UniMsg` 依赖注入器来获取 `UniMessage`。
|
||||
|
||||
```python
|
||||
from nonebot_plugin_alconna.uniseg import UniMsg, At, Reply
|
||||
|
||||
|
||||
matcher = on_xxx(...)
|
||||
|
||||
@matcher.handle()
|
||||
async def _(msg: UniMsg):
|
||||
reply = msg[Reply, 0]
|
||||
print(reply.origin)
|
||||
if msg.has(At):
|
||||
ats = msg.get(At)
|
||||
print(ats)
|
||||
...
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="method" label="使用 UniMessage.generate">
|
||||
|
||||
注意,`generate` 方法在响应器以外的地方如果不传入 `event` 与 `bot` 则无法处理 reply。
|
||||
|
||||
```python
|
||||
from nonebot import Message, EventMessage
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
|
||||
|
||||
matcher = on_xxx(...)
|
||||
|
||||
@matcher.handle()
|
||||
async def _(message: Message = EventMessage()):
|
||||
msg = await UniMessage.generate(message=message)
|
||||
msg1 = UniMessage.generate_without_reply(message=message)
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
不仅如此,你还可以通过 `UniMessage` 的 `export` 与 `send` 方法来**跨平台发送消息**。
|
||||
|
||||
`UniMessage.export` 会通过传入的 `bot: Bot` 参数,或上下文中的 `Bot` 对象读取适配器信息,并使用对应的生成方法把通用消息转为适配器对应的消息序列:
|
||||
|
||||
```python
|
||||
from nonebot import Bot, on_command
|
||||
from nonebot_plugin_alconna.uniseg import Image, UniMessage
|
||||
|
||||
|
||||
test = on_command("test")
|
||||
|
||||
@test.handle()
|
||||
async def handle_test():
|
||||
await test.send(await UniMessage(Image(path="path/to/img")).export())
|
||||
```
|
||||
|
||||
除此之外 `UniMessage.send` 方法基于 `UniMessage.export` 并调用各适配器下的发送消息方法,返回一个 `Receipt` 对象,用于修改/撤回消息:
|
||||
|
||||
```python
|
||||
from nonebot import Bot, on_command
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
|
||||
|
||||
test = on_command("test")
|
||||
|
||||
@test.handle()
|
||||
async def handle():
|
||||
receipt = await UniMessage.text("hello!").send(at_sender=True, reply_to=True)
|
||||
await receipt.recall(delay=1)
|
||||
```
|
||||
|
||||
而在 `AlconnaMatcher` 下,`got`, `send`, `reject` 等可以发送消息的方法皆支持使用 `UniMessage`,不需要手动调用 export 方法:
|
||||
|
||||
```python
|
||||
from arclet.alconna import Alconna, Args
|
||||
from nonebot_plugin_alconna import Match, AlconnaMatcher, on_alconna
|
||||
from nonebot_plugin_alconna.uniseg import At, UniMessage
|
||||
|
||||
|
||||
test_cmd = on_alconna(Alconna("test", Args["target?", At]))
|
||||
|
||||
@test_cmd.handle()
|
||||
async def tt_h(matcher: AlconnaMatcher, target: Match[At]):
|
||||
if target.available:
|
||||
matcher.set_path_arg("target", target.result)
|
||||
|
||||
@test_cmd.got_path("target", prompt="请输入目标")
|
||||
async def tt(target: At):
|
||||
await test_cmd.send(UniMessage([target, "\ndone."]))
|
||||
```
|
||||
|
||||
:::caution
|
||||
|
||||
在响应器以外的地方,除非启用了 `alconna_apply_fetch_targets` 配置项,否则 `bot` 参数必须手动传入。
|
||||
|
||||
:::
|
||||
|
||||
### 构造
|
||||
|
||||
如同 `Message`, `UniMessage` 可以传入单个字符串/消息段,或可迭代的字符串/消息段:
|
||||
|
||||
```python
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage, At
|
||||
|
||||
|
||||
msg = UniMessage("Hello")
|
||||
msg1 = UniMessage(At("user", "124"))
|
||||
msg2 = UniMessage(["Hello", At("user", "124")])
|
||||
```
|
||||
|
||||
`UniMessage` 上同时存在便捷方法,令其可以链式地添加消息段:
|
||||
|
||||
```python
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage, At, Image
|
||||
|
||||
|
||||
msg = UniMessage.text("Hello").at("124").image(path="/path/to/img")
|
||||
assert msg == UniMessage(
|
||||
["Hello", At("user", "124"), Image(path="/path/to/img")]
|
||||
)
|
||||
```
|
||||
|
||||
### 拼接消息
|
||||
|
||||
`str`、`UniMessage`、`Segment` 对象之间可以直接相加,相加均会返回一个新的 `UniMessage` 对象:
|
||||
|
||||
```python
|
||||
# 消息序列与消息段相加
|
||||
UniMessage("text") + Text("text")
|
||||
# 消息序列与字符串相加
|
||||
UniMessage([Text("text")]) + "text"
|
||||
# 消息序列与消息序列相加
|
||||
UniMessage("text") + UniMessage([Text("text")])
|
||||
# 字符串与消息序列相加
|
||||
"text" + UniMessage([Text("text")])
|
||||
# 消息段与消息段相加
|
||||
Text("text") + Text("text")
|
||||
# 消息段与字符串相加
|
||||
Text("text") + "text"
|
||||
# 消息段与消息序列相加
|
||||
Text("text") + UniMessage([Text("text")])
|
||||
# 字符串与消息段相加
|
||||
"text" + Text("text")
|
||||
```
|
||||
|
||||
如果需要在当前消息序列后直接拼接新的消息段,可以使用 `Message.append`、`Message.extend` 方法,或者使用自加:
|
||||
|
||||
```python
|
||||
msg = UniMessage([Text("text")])
|
||||
# 自加
|
||||
msg += "text"
|
||||
msg += Text("text")
|
||||
msg += UniMessage([Text("text")])
|
||||
# 附加
|
||||
msg.append(Text("text"))
|
||||
# 扩展
|
||||
msg.extend([Text("text")])
|
||||
```
|
||||
|
||||
### 使用消息模板
|
||||
|
||||
`UniMessage.template` 同样类似于 `Message.template`,可以用于格式化消息,大体用法参考 [消息模板](../../tutorial/message#使用消息模板)。
|
||||
|
||||
这里额外说明 `UniMessage.template` 的拓展控制符
|
||||
|
||||
相比 `Message`,UniMessage 对于 {:XXX} 做了另一类拓展。其能够识别例如 At(xxx, yyy) 或 Emoji(aaa, bbb)的字符串并执行
|
||||
|
||||
以 At(...) 为例:
|
||||
|
||||
```python title=使用通用消息段的拓展控制符
|
||||
>>> from nonebot_plugin_alconna.uniseg import UniMessage
|
||||
>>> UniMessage.template("{:At(user, target)}").format(target="123")
|
||||
UniMessage(At("user", "123"))
|
||||
>>> UniMessage.template("{:At(type=user, target=id)}").format(id="123")
|
||||
UniMessage(At("user", "123"))
|
||||
>>> UniMessage.template("{:At(type=user, target=123)}").format()
|
||||
UniMessage(At("user", "123"))
|
||||
```
|
||||
|
||||
而在 `AlconnaMatcher` 中,{:XXX} 更进一步地提供了获取 `event` 和 `bot` 中的属性的功能:
|
||||
|
||||
```python title=在AlconnaMatcher中使用通用消息段的拓展控制符
|
||||
from arclet.alconna import Alconna, Args
|
||||
from nonebot_plugin_alconna import At, Match, UniMessage, AlconnaMatcher, on_alconna
|
||||
|
||||
|
||||
test_cmd = on_alconna(Alconna("test", Args["target?", At]))
|
||||
|
||||
@test_cmd.handle()
|
||||
async def tt_h(matcher: AlconnaMatcher, target: Match[At]):
|
||||
if target.available:
|
||||
matcher.set_path_arg("target", target.result)
|
||||
|
||||
@test_cmd.got_path(
|
||||
"target",
|
||||
prompt=UniMessage.template("{:At(user, $event.get_user_id())} 请确认目标")
|
||||
)
|
||||
async def tt():
|
||||
await test_cmd.send(
|
||||
UniMessage.template("{:At(user, $event.get_user_id())} 已确认目标为 {target}")
|
||||
)
|
||||
```
|
||||
|
||||
另外也有 `$message_id` 与 `$target` 两个特殊值。
|
||||
|
||||
### 检查消息段
|
||||
|
||||
我们可以通过 `in` 运算符或消息序列的 `has` 方法来:
|
||||
|
||||
```python
|
||||
# 是否存在消息段
|
||||
At("user", "1234") in message
|
||||
# 是否存在指定类型的消息段
|
||||
At in message
|
||||
```
|
||||
|
||||
我们还可以使用 `only` 方法来检查消息中是否仅包含指定的消息段:
|
||||
|
||||
```python
|
||||
# 是否都为 "test"
|
||||
message.only("test")
|
||||
# 是否仅包含指定类型的消息段
|
||||
message.only(Text)
|
||||
```
|
||||
|
||||
### 获取消息纯文本
|
||||
|
||||
类似于 `Message.extract_plain_text()`,用于获取通用消息的纯文本:
|
||||
|
||||
```python
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage, At
|
||||
|
||||
|
||||
# 提取消息纯文本字符串
|
||||
assert UniMessage(
|
||||
[At("user", "1234"), "text"]
|
||||
).extract_plain_text() == "text"
|
||||
```
|
||||
|
||||
### 遍历
|
||||
|
||||
通用消息序列继承自 `List[Segment]` ,因此可以使用 `for` 循环遍历消息段:
|
||||
|
||||
```python
|
||||
for segment in message: # type: Segment
|
||||
...
|
||||
```
|
||||
|
||||
### 过滤、索引与切片
|
||||
|
||||
消息序列对列表的索引与切片进行了增强,在原有列表 `int` 索引与 `slice` 切片的基础上,支持 `type` 过滤索引与切片:
|
||||
|
||||
```python
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage, At, Text, Reply
|
||||
|
||||
|
||||
message = UniMessage(
|
||||
[
|
||||
Reply(...),
|
||||
"text1",
|
||||
At("user", "1234"),
|
||||
"text2"
|
||||
]
|
||||
)
|
||||
# 索引
|
||||
message[0] == Reply(...)
|
||||
# 切片
|
||||
message[0:2] == UniMessage([Reply(...), Text("text1")])
|
||||
# 类型过滤
|
||||
message[At] == Message([At("user", "1234")])
|
||||
# 类型索引
|
||||
message[At, 0] == At("user", "1234")
|
||||
# 类型切片
|
||||
message[Text, 0:2] == UniMessage([Text("text1"), Text("text2")])
|
||||
```
|
||||
|
||||
我们也可以通过消息序列的 `include`、`exclude` 方法进行类型过滤:
|
||||
|
||||
```python
|
||||
message.include(Text, At)
|
||||
message.exclude(Reply)
|
||||
```
|
||||
|
||||
同样的,消息序列对列表的 `index`、`count` 方法也进行了增强,可以用于索引指定类型的消息段:
|
||||
|
||||
```python
|
||||
# 指定类型首个消息段索引
|
||||
message.index(Text) == 1
|
||||
# 指定类型消息段数量
|
||||
message.count(Text) == 2
|
||||
```
|
||||
|
||||
此外,消息序列添加了一个 `get` 方法,可以用于获取指定类型指定个数的消息段:
|
||||
|
||||
```python
|
||||
# 获取指定类型指定个数的消息段
|
||||
message.get(Text, 1) == UniMessage([Text("test1")])
|
||||
```
|
||||
|
||||
## 消息发送
|
||||
|
||||
前面提到,通用消息可用 `UniMessage.send` 发送自身:
|
||||
|
||||
```python
|
||||
async def send(
|
||||
self,
|
||||
target: Union[Event, Target, None] = None,
|
||||
bot: Optional[Bot] = None,
|
||||
fallback: bool = True,
|
||||
at_sender: Union[str, bool] = False,
|
||||
reply_to: Union[str, bool] = False,
|
||||
) -> Receipt:
|
||||
```
|
||||
|
||||
实际上,`UniMessage` 同时提供了获取消息事件 id 与消息发送对象的方法:
|
||||
|
||||
<Tabs groupId="get_unimsg">
|
||||
<TabItem value="depend" label="使用依赖注入">
|
||||
|
||||
通过提供的 `MessageTarget`, `MessageId` 或 `MsgTarget`, `MsgId` 依赖注入器来获取消息事件 id 与消息发送对象。
|
||||
|
||||
```python
|
||||
from nonebot_plugin_alconna.uniseg import MessageId, MsgTarget
|
||||
|
||||
|
||||
matcher = on_xxx(...)
|
||||
|
||||
@matcher.handle()
|
||||
asycn def _(target: MsgTarget, msg_id: MessageId):
|
||||
...
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="method" label="使用 UniMessage 的方法">
|
||||
|
||||
```python
|
||||
from nonebot import Event, Bot
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage, Target
|
||||
|
||||
|
||||
matcher = on_xxx(...)
|
||||
|
||||
@matcher.handle()
|
||||
asycn def _(bot: Bot, event: Event):
|
||||
target: Target = UniMessage.get_target(event, bot)
|
||||
msg_id: str = UniMessage.get_message_id(event, bot)
|
||||
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
`send`, `get_target`, `get_message_id` 中与 `event`, `bot` 相关的参数都会尝试从上下文中获取对象。
|
||||
|
||||
### 消息发送对象
|
||||
|
||||
消息发送对象是用来描述响应消息时的发送对象或者主动发送消息时的目标对象的对象,它包含了以下属性:
|
||||
|
||||
```python
|
||||
class Target:
|
||||
id: str
|
||||
"""目标id;若为群聊则为group_id或者channel_id,若为私聊则为user_id"""
|
||||
parent_id: str
|
||||
"""父级id;若为频道则为guild_id,其他情况下可能为空字符串(例如 Feishu 下可作为部门 id)"""
|
||||
channel: bool
|
||||
"""是否为频道,仅当目标平台符合频道概念时"""
|
||||
private: bool
|
||||
"""是否为私聊"""
|
||||
source: str
|
||||
"""可能的事件id"""
|
||||
self_id: Union[str, None]
|
||||
"""机器人id,若为 None 则 Bot 对象会随机选择"""
|
||||
selector: Union[Callable[[Bot], Awaitable[bool]], None]
|
||||
"""选择器,用于在多个 Bot 对象中选择特定 Bot"""
|
||||
extra: Dict[str, Any]
|
||||
"""额外信息,用于适配器扩展"""
|
||||
```
|
||||
|
||||
其构造时需要如下参数:
|
||||
|
||||
- `id` 为目标id;若为群聊则为 group_id 或者 channel_id,若为私聊则为user_id
|
||||
- `parent_id` 为父级id;若为频道则为 guild_id,其他情况下可能为空字符串(例如 Feishu 下可作为部门 id)
|
||||
- `channel` 为是否为频道,仅当目标平台符合频道概念时
|
||||
- `private` 为是否为私聊
|
||||
- `source` 为可能的事件id
|
||||
- `self_id` 为机器人id,若为 None 则 Bot 对象会随机选择
|
||||
- `selector` 为选择器,用于在多个 Bot 对象中选择特定 Bot
|
||||
- `scope` 为适配器范围,用于传入内置的特定选择器
|
||||
- `adapter` 为适配器名称,若为 None 则需要明确指定 Bot 对象
|
||||
- `platform` 为平台名称,仅当目标适配器存在多个平台时使用
|
||||
- `extra` 为额外信息,用于适配器扩展
|
||||
|
||||
通过 `Target` 对象,我们可以在 `UniMessage.send` 中指定发送对象:
|
||||
|
||||
```python
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage, MsgTarget, Target, SupportScope
|
||||
|
||||
|
||||
matcher = on_xxx(...)
|
||||
|
||||
@matcher.handle()
|
||||
async def _(target: MsgTarget):
|
||||
await UniMessage("Hello!").send(target=target)
|
||||
target1 = Target("xxxx", scope=SupportScope.qq_client)
|
||||
await UniMessage("Hello!").send(target=target1)
|
||||
```
|
||||
|
||||
### 主动发送消息
|
||||
|
||||
`UniMessage.send` 也可以用于主动发送消息:
|
||||
|
||||
```python
|
||||
from nonebot_plugin_alconna.uniseg import UniMessage, Target, SupportScope
|
||||
from nonebot import get_driver
|
||||
|
||||
|
||||
driver = get_driver()
|
||||
|
||||
@driver.on_startup
|
||||
async def on_startup():
|
||||
target = Target("xxxx", scope=SupportScope.qq_client)
|
||||
await UniMessage("Hello!").send(target=target)
|
||||
```
|
||||
|
||||
## 自定义消息段
|
||||
|
||||
`uniseg` 提供了部分方法来允许用户自定义 Segment 的序列化和反序列化:
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
|
||||
from nonebot.adapters import Bot
|
||||
from nonebot.adapters import MessageSegment as BaseMessageSegment
|
||||
from nonebot.adapters.satori import Custom, Message, MessageSegment
|
||||
|
||||
from nonebot_plugin_alconna.uniseg.builder import MessageBuilder
|
||||
from nonebot_plugin_alconna.uniseg.exporter import MessageExporter
|
||||
from nonebot_plugin_alconna.uniseg import Segment, custom_handler, custom_register
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarketFace(Segment):
|
||||
tabId: str
|
||||
faceId: str
|
||||
key: str
|
||||
|
||||
|
||||
@custom_register(MarketFace, "chronocat:marketface")
|
||||
def mfbuild(builder: MessageBuilder, seg: BaseMessageSegment):
|
||||
if not isinstance(seg, Custom):
|
||||
raise ValueError("MarketFace can only be built from Satori Message")
|
||||
return MarketFace(**seg.data)(*builder.generate(seg.children))
|
||||
|
||||
|
||||
@custom_handler(MarketFace)
|
||||
async def mfexport(exporter: MessageExporter, seg: MarketFace, bot: Bot, fallback: bool):
|
||||
if exporter.get_message_type() is Message:
|
||||
return MessageSegment("chronocat:marketface", seg.data)(await exporter.export(seg.children, bot, fallback))
|
||||
|
||||
```
|
||||
|
||||
具体而言,你可以使用 `custom_register` 来增加一个从 MessageSegment 到 Segment 的处理方法;使用 `custom_handler` 来增加一个从 Segment 到 MessageSegment 的处理方法。
|
@ -0,0 +1,61 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
description: 存储数据文件到本地
|
||||
---
|
||||
|
||||
# 数据存储
|
||||
|
||||
在使用插件的过程中,难免会需要存储一些持久化数据,例如用户的个人信息、群组的信息等。除了使用数据库等第三方存储之外,还可以使用本地文件来自行管理数据。NoneBot 提供了 `nonebot-plugin-localstore` 插件,可用于获取正确的数据存储路径并写入数据。
|
||||
|
||||
## 安装插件
|
||||
|
||||
在使用前请先安装 `nonebot-plugin-localstore` 插件至项目环境中,可参考[获取商店插件](../tutorial/store.mdx#安装插件)来了解并选择安装插件的方式。如:
|
||||
|
||||
在**项目目录**下执行以下命令:
|
||||
|
||||
```bash
|
||||
nb plugin install nonebot-plugin-localstore
|
||||
```
|
||||
|
||||
## 使用插件
|
||||
|
||||
`nonebot-plugin-localstore` 插件兼容 Windows、Linux 和 macOS 等操作系统,使用时无需关心操作系统的差异。同时插件提供 `nb-cli` 脚本,可以使用 `nb localstore` 命令来检查数据存储路径。
|
||||
|
||||
在使用本插件前同样需要使用 `require` 方法进行**加载**并**导入**需要使用的方法,可参考 [跨插件访问](../advanced/requiring.md) 一节进行了解,如:
|
||||
|
||||
```python
|
||||
from nonebot import require
|
||||
|
||||
require("nonebot_plugin_localstore")
|
||||
|
||||
import nonebot_plugin_localstore as store
|
||||
|
||||
# 获取插件缓存目录
|
||||
cache_dir = store.get_cache_dir("plugin_name")
|
||||
# 获取插件缓存文件
|
||||
cache_file = store.get_cache_file("plugin_name", "file_name")
|
||||
# 获取插件数据目录
|
||||
data_dir = store.get_data_dir("plugin_name")
|
||||
# 获取插件数据文件
|
||||
data_file = store.get_data_file("plugin_name", "file_name")
|
||||
# 获取插件配置目录
|
||||
config_dir = store.get_config_dir("plugin_name")
|
||||
# 获取插件配置文件
|
||||
config_file = store.get_config_file("plugin_name", "file_name")
|
||||
```
|
||||
|
||||
:::danger 警告
|
||||
在 Windows 和 macOS 系统下,插件的数据目录和配置目录是同一个目录,因此在使用时需要注意避免文件名冲突。
|
||||
:::
|
||||
|
||||
插件提供的方法均返回一个 `pathlib.Path` 路径,可以参考 [pathlib 文档](https://docs.python.org/zh-cn/3/library/pathlib.html)来了解如何使用。常用的方法有:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
data_file = store.get_data_file("plugin_name", "file_name")
|
||||
# 写入文件内容
|
||||
data_file.write_text("Hello World!")
|
||||
# 读取文件内容
|
||||
data = data_file.read_text()
|
||||
```
|
@ -0,0 +1,145 @@
|
||||
import TabItem from "@theme/TabItem";
|
||||
import Tabs from "@theme/Tabs";
|
||||
|
||||
# 数据库
|
||||
|
||||
[`nonebot-plugin-orm`](https://github.com/nonebot/plugin-orm) 是 NoneBot 的数据库支持插件。
|
||||
本插件基于 [SQLAlchemy](https://www.sqlalchemy.org/) 和 [Alembic](https://alembic.sqlalchemy.org/),提供了许多与 NoneBot 紧密集成的功能:
|
||||
|
||||
- 多 Engine / Connection 支持
|
||||
- Session 管理
|
||||
- 关系模型管理、依赖注入支持
|
||||
- 数据库迁移
|
||||
|
||||
## 安装
|
||||
|
||||
<Tabs groupId="install">
|
||||
<TabItem value="cli" label="使用 nb-cli">
|
||||
|
||||
```shell
|
||||
nb plugin install nonebot-plugin-orm
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="pip" label="使用 pip">
|
||||
|
||||
```shell
|
||||
pip install nonebot-plugin-orm
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="pdm" label="使用 pdm">
|
||||
|
||||
```shell
|
||||
pdm add nonebot-plugin-orm
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## 数据库驱动和后端
|
||||
|
||||
本插件只提供了 ORM 功能,没有数据库后端,也没有直接连接数据库后端的能力。
|
||||
所以你需要另行安装数据库驱动和数据库后端,并且配置数据库连接信息。
|
||||
|
||||
### SQLite
|
||||
|
||||
[SQLite](https://www.sqlite.org/) 是一个轻量级的嵌入式数据库,它的数据以单文件的形式存储在本地,不需要单独的数据库后端。
|
||||
SQLite 非常适合用于开发环境和小型应用,但是不适合用于大型应用的生产环境。
|
||||
|
||||
虽然不需要另行安装数据库后端,但你仍然需要安装数据库驱动:
|
||||
|
||||
<Tabs groupId="install">
|
||||
<TabItem value="pip" label="使用 pip">
|
||||
|
||||
```shell
|
||||
pip install "nonebot-plugin-orm[sqlite]"
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="pdm" label="使用 pdm">
|
||||
|
||||
```shell
|
||||
pdm add "nonebot-plugin-orm[sqlite]"
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
默认情况下,数据库文件为 `<data path>/nonebot-plugin-orm/db.sqlite3`(数据目录由 [nonebot-plugin-localstore](../data-storing) 提供)。
|
||||
或者,你可以通过配置 `SQLALCHEMY_DATABASE_URL` 来指定数据库文件路径:
|
||||
|
||||
```shell
|
||||
SQLALCHEMY_DATABASE_URL=sqlite+aiosqlite:///file_path
|
||||
```
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
[PostgreSQL](https://www.postgresql.org/) 是世界上最先进的开源关系数据库之一,对各种高级且广泛应用的功能有最好的支持,是中小型应用的首选数据库。
|
||||
|
||||
<Tabs groupId="install">
|
||||
<TabItem value="pip" label="使用 pip">
|
||||
|
||||
```shell
|
||||
pip install nonebot-plugin-orm[postgresql]
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="pdm" label="使用 pdm">
|
||||
|
||||
```shell
|
||||
pdm add nonebot-plugin-orm[postgresql]
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
```shell
|
||||
SQLALCHEMY_DATABASE_URL=postgresql+psycopg://user:password@host:port/dbname[?key=value&key=value...]
|
||||
```
|
||||
|
||||
### MySQL / MariaDB
|
||||
|
||||
[MySQL](https://www.mysql.com/) 和 [MariaDB](https://mariadb.com/) 是经典的开源关系数据库,适合用于中小型应用。
|
||||
|
||||
<Tabs groupId="install">
|
||||
<TabItem value="pip" label="使用 pip">
|
||||
|
||||
```shell
|
||||
pip install nonebot-plugin-orm[mysql]
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="pdm" label="使用 pdm">
|
||||
|
||||
```shell
|
||||
pdm add nonebot-plugin-orm[mysql]
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
```shell
|
||||
SQLALCHEMY_DATABASE_URL=mysql+aiomysql://user:password@host:port/dbname[?key=value&key=value...]
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
本插件提供了数据库迁移功能(此功能依赖于 [nb-cli 脚手架](../../quick-start#安装脚手架))。
|
||||
在安装了新的插件或机器人之后,你需要执行一次数据库迁移操作,将数据库同步至与机器人一致的状态:
|
||||
|
||||
```shell
|
||||
nb orm upgrade
|
||||
```
|
||||
|
||||
运行完毕后,可以检查一下:
|
||||
|
||||
```shell
|
||||
nb orm check
|
||||
```
|
||||
|
||||
如果输出是 `没有检测到新的升级操作`,那么恭喜你,数据库已经迁移完成了,你可以启动机器人了。
|
@ -0,0 +1,4 @@
|
||||
{
|
||||
"label": "数据库",
|
||||
"position": 7
|
||||
}
|
@ -0,0 +1,378 @@
|
||||
# 开发者指南
|
||||
|
||||
开发者指南内容较多,故分为了一个示例以及数个专题。
|
||||
阅读(并且最好跟随实践)示例后,你将会对使用 `nonebot-plugin-orm` 开发插件有一个基本的认识。
|
||||
如果想要更深入地学习关于 [SQLAlchemy](https://www.sqlalchemy.org/) 和 [Alembic](https://alembic.sqlalchemy.org/) 的知识,或者在使用过程中遇到了问题,可以查阅专题以及其官方文档。
|
||||
|
||||
## 示例
|
||||
|
||||
### 模型定义
|
||||
|
||||
首先,我们需要设计存储的数据的结构。
|
||||
例如天气插件,需要存储**什么地方 (`location`)** 的**天气是什么 (`weather`)**。
|
||||
其中,一个地方只会有一种天气,而不同地方可能有相同的天气。
|
||||
所以,我们可以设计出如下的模型:
|
||||
|
||||
```python title=weather/__init__.py showLineNumbers
|
||||
from nonebot_plugin_orm import Model
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
class Weather(Model):
|
||||
location: Mapped[str] = mapped_column(primary_key=True)
|
||||
weather: Mapped[str]
|
||||
```
|
||||
|
||||
其中,`primary_key=True` 意味着此列 (`location`) 是主键,即内容是唯一的且非空的。
|
||||
每一个模型必须有至少一个主键。
|
||||
|
||||
我们可以用以下代码检查模型生成的数据库模式是否正确:
|
||||
|
||||
```python
|
||||
from sqlalchemy.schema import CreateTable
|
||||
|
||||
print(CreateTable(Weather.__table__))
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE TABLE weather_weather (
|
||||
location VARCHAR NOT NULL,
|
||||
weather VARCHAR NOT NULL,
|
||||
CONSTRAINT pk_weather_weather PRIMARY KEY (location)
|
||||
)
|
||||
```
|
||||
|
||||
可以注意到表名是 `weather_weather` 而不是 `Weather` 或者 `weather`。
|
||||
这是因为 `nonebot-plugin-orm` 会自动为模型生成一个表名,规则是:`<插件模块名>_<类名小写>`。
|
||||
|
||||
你也可以通过指定 `__tablename__` 属性来自定义表名:
|
||||
|
||||
```python {2}
|
||||
class Weather(Model):
|
||||
__tablename__ = "weather"
|
||||
...
|
||||
```
|
||||
|
||||
```sql {1}
|
||||
CREATE TABLE weather (
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
但是,并不推荐你这么做,因为这可能会导致不同插件间的表名重复,引发冲突。
|
||||
特别是当你会发布插件时,你并不知道其他插件会不会使用相同的表名。
|
||||
|
||||
### 首次迁移
|
||||
|
||||
我们成功定义了模型,现在启动机器人试试吧:
|
||||
|
||||
```shell
|
||||
$ nb run
|
||||
01-02 15:04:05 [SUCCESS] nonebot | NoneBot is initializing...
|
||||
01-02 15:04:05 [ERROR] nonebot_plugin_orm | 启动检查失败
|
||||
01-02 15:04:05 [ERROR] nonebot | Application startup failed. Exiting.
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
click.exceptions.UsageError: 检测到新的升级操作:
|
||||
[('add_table',
|
||||
Table('weather', MetaData(), Column('location', String(), table=<weather>, primary_key=True, nullable=False), Column('weather', String(), table=<weather>, nullable=False), schema=None))]
|
||||
```
|
||||
|
||||
咦,发生了什么?
|
||||
`nonebot-plugin-orm` 试图阻止我们启动机器人。
|
||||
原来是我们定义了模型,但是数据库中并没有对应的表,这会导致插件不能正常运行。
|
||||
所以,我们需要迁移数据库。
|
||||
|
||||
首先,我们需要创建一个迁移脚本:
|
||||
|
||||
```shell
|
||||
nb orm revision -m "first revision" --branch-label weather
|
||||
```
|
||||
|
||||
其中,`-m` 参数是迁移脚本的描述,`--branch-label` 参数是迁移脚本的分支,一般为插件模块名。
|
||||
执行命令过后,出现了一个 `weather/migrations` 目录,其中有一个 `xxxxxxxxxxxx_first_revision.py` 文件:
|
||||
|
||||
```shell {4,5}
|
||||
weather
|
||||
├── __init__.py
|
||||
├── config.py
|
||||
└── migrations
|
||||
└── xxxxxxxxxxxx_first_revision.py
|
||||
```
|
||||
|
||||
这就是我们创建的迁移脚本,它记录了数据库模式的变化。
|
||||
我们可以查看一下它的内容:
|
||||
|
||||
```python title=weather/migrations/xxxxxxxxxxxx_first_revision.py {25-33,39-41} showLineNumbers
|
||||
"""first revision
|
||||
|
||||
迁移 ID: xxxxxxxxxxxx
|
||||
父迁移:
|
||||
创建时间: 2006-01-02 15:04:05.999999
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "xxxxxxxxxxxx"
|
||||
down_revision: str | Sequence[str] | None = None
|
||||
branch_labels: str | Sequence[str] | None = ("weather",)
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade(name: str = "") -> None:
|
||||
if name:
|
||||
return
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"weather_weather",
|
||||
sa.Column("location", sa.String(), nullable=False),
|
||||
sa.Column("weather", sa.String(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("location", name=op.f("pk_weather_weather")),
|
||||
info={"bind_key": "weather"},
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade(name: str = "") -> None:
|
||||
if name:
|
||||
return
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table("weather_weather")
|
||||
# ### end Alembic commands ###
|
||||
```
|
||||
|
||||
可以注意到脚本的主体部分(其余是模版代码,请勿修改)是:
|
||||
|
||||
```python
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table( # CREATE TABLE
|
||||
"weather_weather", # weather_weather
|
||||
sa.Column("location", sa.String(), nullable=False), # location VARCHAR NOT NULL,
|
||||
sa.Column("weather", sa.String(), nullable=False), # weather VARCHAR NOT NULL,
|
||||
sa.PrimaryKeyConstraint("location", name=op.f("pk_weather_weather")), # CONSTRAINT pk_weather_weather PRIMARY KEY (location)
|
||||
info={"bind_key": "weather"},
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
```
|
||||
|
||||
```python
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table("weather_weather") # DROP TABLE weather_weather;
|
||||
# ### end Alembic commands ###
|
||||
```
|
||||
|
||||
虽然我们不是很懂这些代码的意思,但是可以注意到它们几乎与 SQL 语句 (DDL) 一一对应。
|
||||
显然,它们是用来创建和删除表的。
|
||||
|
||||
我们还可以注意到,`upgrade()` 和 `downgrade()` 函数中的代码是**互逆**的。
|
||||
也就是说,执行一次 `upgrade()` 函数,再执行一次 `downgrade()` 函数后,数据库的模式就会回到原来的状态。
|
||||
|
||||
这就是迁移脚本的作用:记录数据库模式的变化,以便我们在不同的环境中(例如开发环境和生产环境)**可复现地**、**可逆地**同步数据库模式,正如 git 对我们的代码做的事情那样。
|
||||
|
||||
对了,不要忘记还有一段注释:`commands auto generated by Alembic - please adjust!`。
|
||||
它在提醒我们,这些代码是由 Alembic 自动生成的,我们应该检查它们,并且根据需要进行调整。
|
||||
|
||||
:::caution 注意
|
||||
迁移脚本冗长且繁琐,我们一般不会手写它们,而是由 Alembic 自动生成。
|
||||
一般情况下,Alembic 足够智能,可以正确地生成迁移脚本。
|
||||
但是,在复杂或有歧义的情况下,我们可能需要手动调整迁移脚本。
|
||||
所以,**永远要检查迁移脚本,并且在开发环境中测试!**
|
||||
|
||||
**迁移脚本中任何一处错误都足以使数据付之东流!**
|
||||
:::
|
||||
|
||||
确定迁移脚本正确后,我们就可以执行迁移脚本,将数据库模式同步到数据库中:
|
||||
|
||||
```shell
|
||||
nb orm upgrade
|
||||
```
|
||||
|
||||
现在,我们可以正常启动机器人了。
|
||||
|
||||
开发过程中,我们可能会频繁地修改模型,这意味着我们需要频繁地创建并执行迁移脚本,非常繁琐。
|
||||
实际上,此时我们不在乎数据安全,只需要数据库模式与模型定义一致即可。
|
||||
所以,我们可以关闭 `nonebot-plugin-orm` 的启动检查:
|
||||
|
||||
```shell title=.env.dev
|
||||
ALEMBIC_STARTUP_CHECK=false
|
||||
```
|
||||
|
||||
现在,每次启动机器人时,数据库模式会自动与模型定义同步,无需手动迁移。
|
||||
|
||||
### 会话管理
|
||||
|
||||
我们已经成功定义了模型,并且迁移了数据库,现在可以开始使用数据库了……吗?
|
||||
并不能,因为模型只是数据结构的定义,并不能通过它操作数据(如果你曾经使用过 [Tortoise ORM](https://tortoise.github.io/),可能会知道 `await Weather.get(location="上海")` 这样的面向对象编程。
|
||||
但是 SQLAlchemy 不同,选择了命令式编程)。
|
||||
我们需要使用**会话**操作数据:
|
||||
|
||||
```python title=weather/__init__.py {10,13} showLineNumbers
|
||||
from nonebot import on_command
|
||||
from nonebot.adapters import Message
|
||||
from nonebot.params import CommandArg
|
||||
from nonebot_plugin_orm import async_scoped_session
|
||||
|
||||
weather = on_command("天气")
|
||||
|
||||
|
||||
@weather.handle()
|
||||
async def _(session: async_scoped_session, args: Message = CommandArg()):
|
||||
location = args.extract_plain_text()
|
||||
|
||||
if wea := await session.get(Weather, location):
|
||||
await weather.finish(f"今天{location}的天气是{wea.weather}")
|
||||
|
||||
await weather.finish(f"未查询到{location}的天气")
|
||||
```
|
||||
|
||||
我们通过 `session: async_scoped_session` 依赖注入获得了一个会话,然后使用 `await session.get(Weather, location)` 查询数据库。
|
||||
`async_scoped_session` 是一个有作用域限制的会话,作用域为当前事件、当前事件响应器。
|
||||
会话产生的模型实例(例如此处的 `wea := await session.get(Weather, location)`)作用域与会话相同。
|
||||
|
||||
:::caution 注意
|
||||
此处提到的“会话”指的是 ORM 会话,而非 [NoneBot 会话](../../../appendices/session-control),两者的生命周期也是不同的(NoneBot 会话的生命周期中可能包含多个事件,不同的事件也会有不同的事件响应器)。
|
||||
具体而言,就是不要将 ORM 会话和模型实例存储在 NoneBot 会话状态中:
|
||||
|
||||
```python {12}
|
||||
from nonebot.params import ArgPlainText
|
||||
from nonebot.typing import T_State
|
||||
|
||||
|
||||
@weather.got("location", prompt="请输入地名")
|
||||
async def _(state: T_State, session: async_scoped_session, location: str = ArgPlainText()):
|
||||
wea = await session.get(Weather, location)
|
||||
|
||||
if not wea:
|
||||
await weather.finish(f"未查询到{location}的天气")
|
||||
|
||||
state["weather"] = wea # 不要这么做,除非你知道自己在做什么
|
||||
```
|
||||
|
||||
当然非要这么做也不是不可以:
|
||||
|
||||
```python {6}
|
||||
@weather.handle()
|
||||
async def _(state: T_State, session: async_scoped_session):
|
||||
# 通过 await session.merge(state["weather"]) 获得了此 ORM 会话中的相应模型实例,
|
||||
# 而非直接使用会话状态中的模型实例,
|
||||
# 因为先前的 ORM 会话已经关闭了。
|
||||
wea = await session.merge(state["weather"])
|
||||
await weather.finish(f"今天{state['location']}的天气是{wea.weather}")
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
当有数据更改时,我们需要提交事务,也要注意会话作用域问题:
|
||||
|
||||
```python title=weather/__init__.py {12,20} showLineNumbers
|
||||
from nonebot.params import Depends
|
||||
|
||||
|
||||
async def get_weather(
|
||||
session: async_scoped_session, args: Message = CommandArg()
|
||||
) -> Weather:
|
||||
location = args.extract_plain_text()
|
||||
|
||||
if not (wea := await session.get(Weather, location)):
|
||||
wea = Weather(location=location, weather="未知")
|
||||
session.add(wea)
|
||||
# await session.commit() # 不应该在其他地方提交事务
|
||||
|
||||
return wea
|
||||
|
||||
|
||||
@weather.handle()
|
||||
async def _(session: async_scoped_session, wea: Weather = Depends(get_weather)):
|
||||
await weather.send(f"今天的天气是{wea.weather}")
|
||||
await session.commit() # 而应该在事件响应器结束前提交事务
|
||||
```
|
||||
|
||||
当然我们也可以获得一个新的会话,不过此时就要手动管理会话了:
|
||||
|
||||
```python title=weather/__init__.py {5-6} showLineNumbers
|
||||
from nonebot_plugin_orm import get_session
|
||||
|
||||
|
||||
async def get_weather(location: str) -> str:
|
||||
session = get_session()
|
||||
async with session.begin():
|
||||
wea = await session.get(Weather, location)
|
||||
|
||||
if not wea:
|
||||
wea = Weather(location=location, weather="未知")
|
||||
session.add(wea)
|
||||
|
||||
return wea.weather
|
||||
|
||||
|
||||
@weather.handle()
|
||||
async def _(args: Message = CommandArg()):
|
||||
wea = await get_weather(args.extract_plain_text())
|
||||
await weather.send(f"今天的天气是{wea}")
|
||||
```
|
||||
|
||||
### 依赖注入
|
||||
|
||||
在上面的示例中,我们都是通过会话获得数据的。
|
||||
不过,我们也可以通过依赖注入获得数据:
|
||||
|
||||
```python title=weather/__init__.py {12-14} showLineNumbers
|
||||
from sqlalchemy import select
|
||||
from nonebot.params import Depends
|
||||
from nonebot_plugin_orm import SQLDepends
|
||||
|
||||
|
||||
def extract_arg_plain_text(args: Message = CommandArg()) -> str:
|
||||
return args.extract_plain_text()
|
||||
|
||||
|
||||
@weather.handle()
|
||||
async def _(
|
||||
wea: Weather = SQLDepends(
|
||||
select(Weather).where(Weather.location == Depends(extract_arg_plain_text))
|
||||
),
|
||||
):
|
||||
await weather.send(f"今天的天气是{wea.weather}")
|
||||
```
|
||||
|
||||
其中,`SQLDepends` 是一个特殊的依赖注入,它会根据类型标注和 SQL 语句提供数据,SQL 语句中也可以有子依赖。
|
||||
|
||||
不同的类型标注也会获得不同形式的数据:
|
||||
|
||||
```python title=weather/__init__.py {5} showLineNumbers
|
||||
from collections.abc import Sequence
|
||||
|
||||
@weather.handle()
|
||||
async def _(
|
||||
weas: Sequence[Weather] = SQLDepends(
|
||||
select(Weather).where(Weather.weather == Depends(extract_arg_plain_text))
|
||||
),
|
||||
):
|
||||
await weather.send(f"今天的天气是{weas[0].weather}的城市有{','.join(wea.location for wea in weas)}")
|
||||
```
|
||||
|
||||
支持的类型标注请参见 [依赖注入](dependency)。
|
||||
|
||||
我们也可以像 [类作为依赖](../../../advanced/dependency#类作为依赖) 那样,在类属性中声明子依赖:
|
||||
|
||||
```python title=weather/__init__.py {5-6,10} showLineNumbers
|
||||
from collections.abc import Sequence
|
||||
|
||||
class Weather(Model):
|
||||
location: Mapped[str] = mapped_column(primary_key=True)
|
||||
weather: Mapped[str] = Depends(extract_arg_plain_text)
|
||||
# weather: Annotated[Mapped[str], Depends(extract_arg_plain_text)] # Annotated 支持
|
||||
|
||||
|
||||
@weather.handle()
|
||||
async def _(weas: Sequence[Weather]):
|
||||
await weather.send(
|
||||
f"今天的天气是{weas[0].weather}的城市有{','.join(wea.location for wea in weas)}"
|
||||
)
|
||||
```
|
@ -0,0 +1,4 @@
|
||||
{
|
||||
"label": "开发者指南",
|
||||
"position": 3
|
||||
}
|
@ -0,0 +1,240 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
description: 依赖注入
|
||||
---
|
||||
|
||||
# 依赖注入
|
||||
|
||||
`nonebot-plugin-orm` 提供了强大且灵活的依赖注入,可以方便地帮助你获取数据库会话和查询数据。
|
||||
|
||||
## 数据库会话
|
||||
|
||||
### AsyncSession
|
||||
|
||||
新数据库会话,常用于有独立的数据库操作逻辑的插件。
|
||||
|
||||
```python {13,26}
|
||||
from nonebot import on_message
|
||||
from nonebot.params import Depends
|
||||
from nonebot_plugin_orm import AsyncSession, Model, async_scoped_session
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
message = on_message()
|
||||
|
||||
|
||||
class Message(Model):
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
|
||||
async def get_message(session: AsyncSession) -> Message:
|
||||
# 等价于 session = get_session()
|
||||
async with session:
|
||||
msg = Message()
|
||||
|
||||
session.add(msg)
|
||||
await session.commit()
|
||||
await session.refresh(msg)
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
@message.handle()
|
||||
async def _(session: async_scoped_session, msg: Message = Depends(get_message)):
|
||||
await session.rollback() # 无法回退 get_message() 中的更改
|
||||
await message.send(str(msg.id)) # msg 被存储,msg.id 递增
|
||||
```
|
||||
|
||||
### async_scoped_session
|
||||
|
||||
数据库作用域会话,常用于事件响应器和有与响应逻辑相关的数据库操作逻辑的插件。
|
||||
|
||||
```python {13,26}
|
||||
from nonebot import on_message
|
||||
from nonebot.params import Depends
|
||||
from nonebot_plugin_orm import Model, async_scoped_session
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
message = on_message()
|
||||
|
||||
|
||||
class Message(Model):
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
|
||||
async def get_message(session: async_scoped_session) -> Message:
|
||||
# 等价于 session = get_scoped_session()
|
||||
msg = Message()
|
||||
|
||||
session.add(msg)
|
||||
await session.flush()
|
||||
await session.refresh(msg)
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
@message.handle()
|
||||
async def _(session: async_scoped_session, msg: Message = Depends(get_message)):
|
||||
await session.rollback() # 可以回退 get_message() 中的更改
|
||||
await message.send(str(msg.id)) # msg 没有被存储,msg.id 不变
|
||||
```
|
||||
|
||||
## 查询数据
|
||||
|
||||
### Model
|
||||
|
||||
支持类作为依赖。
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
|
||||
from nonebot.params import Depends
|
||||
from nonebot_plugin_orm import Model
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
||||
def get_id() -> int: ...
|
||||
|
||||
|
||||
class Message(Model):
|
||||
id: Annotated[Mapped[int], Depends(get_id)] = mapped_column(
|
||||
primary_key=True, autoincrement=True
|
||||
)
|
||||
|
||||
|
||||
async def _(msg: Message):
|
||||
# 等价于 msg = (
|
||||
# await (await session.stream(select(Message).where(Message.id == get_id())))
|
||||
# .scalars()
|
||||
# .one_or_none()
|
||||
# )
|
||||
...
|
||||
```
|
||||
|
||||
### SQLDepends
|
||||
|
||||
参数为一个 SQL 语句,决定依赖注入的内容,SQL 语句中可以使用子依赖。
|
||||
|
||||
```python {11-13}
|
||||
from nonebot.params import Depends
|
||||
from nonebot_plugin_orm import Model, SQLDepends
|
||||
from sqlalchemy import select
|
||||
|
||||
|
||||
def get_id() -> int: ...
|
||||
|
||||
|
||||
async def _(
|
||||
model: Model = SQLDepends(select(Model).where(Model.id == Depends(get_id))),
|
||||
): ...
|
||||
```
|
||||
|
||||
参数可以是任意 SQL 语句,但不建议使用 `select` 以外的语句,因为语句可能没有返回值(`returning` 除外),而且代码不清晰。
|
||||
|
||||
### 类型标注
|
||||
|
||||
类型标注决定依赖注入的数据结构,主要影响以下几个层面:
|
||||
|
||||
- 迭代器(`session.execute()`)或异步迭代器(`session.stream()`)
|
||||
- 标量(`session.execute().scalars()`)或元组(`session.execute()`)
|
||||
- 一个(`session.execute().one_or_none()`,注意 `None` 时可能触发 [重载](../../../appendices/overload#重载))或全部(`session.execute()` / `session.execute().all()`)
|
||||
- 连续(`session().execute()`)或分块(`session.execute().partitions()`)
|
||||
|
||||
具体如下(可以使用父类型作为类型标注):
|
||||
|
||||
- ```python
|
||||
async def _(rows_partitions: AsyncIterator[Sequence[Tuple[Model, ...]]]):
|
||||
# 等价于 rows_partitions = await (await session.stream(sql).partitions())
|
||||
|
||||
async for partition in rows_partitions:
|
||||
for row in partition:
|
||||
print(row[0], row[1], ...)
|
||||
```
|
||||
|
||||
- ```python
|
||||
async def _(model_partitions: AsyncIterator[Sequence[Model]]):
|
||||
# 等价于 model_partitions = await (await session.stream(sql).scalars().partitions())
|
||||
|
||||
async for partition in model_partitions:
|
||||
for model in partition:
|
||||
print(model)
|
||||
```
|
||||
|
||||
- ```python
|
||||
async def _(row_partitions: Iterator[Sequence[Tuple[Model, ...]]]):
|
||||
# 等价于 row_partitions = await session.execute(sql).partitions()
|
||||
|
||||
for partition in rows_partitions:
|
||||
for row in partition:
|
||||
print(row[0], row[1], ...)
|
||||
```
|
||||
|
||||
- ```python
|
||||
async def _(model_partitions: Iterator[Sequence[Model]]):
|
||||
# 等价于 model_partitions = await (await session.execute(sql).scalars().partitions())
|
||||
|
||||
for partition in model_partitions:
|
||||
for model in partition:
|
||||
print(model)
|
||||
```
|
||||
|
||||
- ```python
|
||||
async def _(rows: sa_async.AsyncResult[Tuple[Model, ...]]):
|
||||
# 等价于 rows = await session.stream(sql)
|
||||
|
||||
async for row in rows:
|
||||
print(row[0], row[1], ...)
|
||||
```
|
||||
|
||||
- ```python
|
||||
async def _(models: sa_async.AsyncScalarResult[Model]):
|
||||
# 等价于 models = await session.stream(sql).scalars()
|
||||
|
||||
async for model in models:
|
||||
print(model)
|
||||
```
|
||||
|
||||
- ```python
|
||||
async def _(rows: sa.Result[Tuple[Model, ...]]):
|
||||
# 等价于 rows = await session.execute(sql)
|
||||
|
||||
for row in rows:
|
||||
print(row[0], row[1], ...)
|
||||
```
|
||||
|
||||
- ```python
|
||||
async def _(models: sa.ScalarResult[Model]):
|
||||
# 等价于 models = await session.execute(sql).scalars()
|
||||
|
||||
for model in models:
|
||||
print(model)
|
||||
```
|
||||
|
||||
- ```python
|
||||
async def _(rows: Sequence[Tuple[Model, ...]]):
|
||||
# 等价于 rows = await (await session.stream(sql).all())
|
||||
|
||||
for row in rows:
|
||||
print(row[0], row[1], ...)
|
||||
```
|
||||
|
||||
- ```python
|
||||
async def _(models: Sequence[Model]):
|
||||
# 等价于 models = await (await session.stream(sql).scalars().all())
|
||||
|
||||
for model in models:
|
||||
print(model)
|
||||
```
|
||||
|
||||
- ```python
|
||||
async def _(row: Tuple[Model, ...]):
|
||||
# 等价于 row = await (await session.stream(sql).one_or_none())
|
||||
|
||||
print(row[0], row[1], ...)
|
||||
```
|
||||
|
||||
- ```python
|
||||
async def _(model: Model):
|
||||
# 等价于 model = await (await session.stream(sql).scalars().one_or_none())
|
||||
|
||||
print(model)
|
||||
```
|
@ -0,0 +1,147 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
description: 测试
|
||||
---
|
||||
|
||||
# 测试
|
||||
|
||||
百思不如一试,测试是发现问题的最佳方式。
|
||||
|
||||
不同的用户会有不同的配置,为了提高项目的兼容性,我们需要在不同数据库后端上测试。
|
||||
手动进行大量的、重复的测试不可靠,也不现实,因此我们推荐使用 [GitHub Actions](https://github.com/features/actions) 进行自动化测试:
|
||||
|
||||
```yaml title=.github/workflows/test.yml {12-42,52-53} showLineNumbers
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
db:
|
||||
- sqlite+aiosqlite:///db.sqlite3
|
||||
- postgresql+psycopg://postgres:postgres@localhost:5432/postgres
|
||||
- mysql+aiomysql://mysql:mysql@localhost:3306/mymysql
|
||||
|
||||
fail-fast: false
|
||||
|
||||
env:
|
||||
SQLALCHEMY_DATABASE_URL: ${{ matrix.db }}
|
||||
|
||||
services:
|
||||
postgresql:
|
||||
image: ${{ startsWith(matrix.db, 'postgresql') && 'postgres' || '' }}
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
mysql:
|
||||
image: ${{ startsWith(matrix.db, 'mysql') && 'mysql' || '' }}
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: mysql
|
||||
MYSQL_USER: mysql
|
||||
MYSQL_PASSWORD: mysql
|
||||
MYSQL_DATABASE: mymysql
|
||||
ports:
|
||||
- 3306:3306
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Run migrations
|
||||
run: pipx run nb-cli orm upgrade
|
||||
|
||||
- name: Run tests
|
||||
run: pytest
|
||||
```
|
||||
|
||||
如果项目还需要考虑跨平台和跨 Python 版本兼容,测试矩阵中还需要增加这两个维度。
|
||||
但是,我们没必要在所有平台和 Python 版本上运行所有数据库的测试,因为很显然,PostgreSQL 和 MySQL 这类独立的数据库后端不会受平台和 Python 影响,而且 Github Actions 的非 Linux 平台不支持运行独立服务:
|
||||
|
||||
| | Python 3.9 | Python 3.10 | Python 3.11 | Python 3.12 |
|
||||
| ----------- | ---------- | ----------- | ----------- | --------------------------- |
|
||||
| **Linux** | SQLite | SQLite | SQLite | SQLite / PostgreSQL / MySQL |
|
||||
| **Windows** | SQLite | SQLite | SQLite | SQLite |
|
||||
| **macOS** | SQLite | SQLite | SQLite | SQLite |
|
||||
|
||||
```yaml title=.github/workflows/test.yml {12-24} showLineNumbers
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
||||
db: ["sqlite+aiosqlite:///db.sqlite3"]
|
||||
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.12"
|
||||
db: postgresql+psycopg://postgres:postgres@localhost:5432/postgres
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.12"
|
||||
db: mysql+aiomysql://mysql:mysql@localhost:3306/mymysql
|
||||
|
||||
fail-fast: false
|
||||
|
||||
env:
|
||||
SQLALCHEMY_DATABASE_URL: ${{ matrix.db }}
|
||||
|
||||
services:
|
||||
postgresql:
|
||||
image: ${{ startsWith(matrix.db, 'postgresql') && 'postgres' || '' }}
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
mysql:
|
||||
image: ${{ startsWith(matrix.db, 'mysql') && 'mysql' || '' }}
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: mysql
|
||||
MYSQL_USER: mysql
|
||||
MYSQL_PASSWORD: mysql
|
||||
MYSQL_DATABASE: mymysql
|
||||
ports:
|
||||
- 3306:3306
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: Run migrations
|
||||
run: pipx run nb-cli orm upgrade
|
||||
|
||||
- name: Run tests
|
||||
run: pytest
|
||||
```
|
@ -0,0 +1,158 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
description: 用户指南
|
||||
---
|
||||
|
||||
# 用户指南
|
||||
|
||||
`nonebot-plugin-orm` 功能强大且复杂,使用上有一定难度。
|
||||
不过,对于用户而言,只需要掌握部分功能即可。
|
||||
|
||||
:::caution 注意
|
||||
请注意区分插件的项目名(如:`nonebot-plugin-wordcloud`)和模块名(如:`nonebot_plugin_wordcloud`)。`nonebot-plugin-orm` 中统一使用插件模块名。参见 [插件命名规范](../../developer/plugin-publishing#插件命名规范)。
|
||||
:::
|
||||
|
||||
## 示例
|
||||
|
||||
### 创建新机器人
|
||||
|
||||
我们想要创建一个机器人,并安装 `nonebot-plugin-wordcloud` 插件,只需要执行以下命令:
|
||||
|
||||
```shell
|
||||
nb init # 初始化项目文件夹
|
||||
|
||||
pip install nonebot-plugin-orm[sqlite] # 安装 nonebot-plugin-orm,并附带 SQLite 支持
|
||||
|
||||
nb plugin install nonebot-plugin-wordcloud # 安装插件
|
||||
|
||||
# nb orm heads # 查看有什么插件使用到了数据库(可选)
|
||||
|
||||
nb orm upgrade # 升级数据库
|
||||
|
||||
# nb orm check # 检查一下数据库模式是否与模型定义一致(可选)
|
||||
|
||||
nb run # 启动机器人
|
||||
```
|
||||
|
||||
### 卸载插件
|
||||
|
||||
我们已经安装了 `nonebot-plugin-wordcloud` 插件,但是现在想要卸载它,并且**删除它的数据**,只需要执行以下命令:
|
||||
|
||||
```shell
|
||||
nb plugin uninstall nonebot-plugin-wordcloud # 卸载插件
|
||||
|
||||
# nb orm heads # 查看有什么插件使用到了数据库。(可选)
|
||||
|
||||
nb orm downgrade nonebot_plugin_wordcloud@base # 降级数据库,删除数据
|
||||
|
||||
# nb orm check # 检查一下数据库模式是否与模型定义一致(可选)
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
接下来,让我们了解下示例中出现的 CLI 命令的含义:
|
||||
|
||||
### heads
|
||||
|
||||
显示所有的分支头。一般一个分支对应一个插件。
|
||||
|
||||
```shell
|
||||
nb orm heads
|
||||
```
|
||||
|
||||
输出格式为 `<迁移 ID> (<插件模块名>) (<头部类型>)`:
|
||||
|
||||
```
|
||||
46327b837dd8 (nonebot_plugin_chatrecorder) (head)
|
||||
9492159f98f7 (nonebot_plugin_user) (head)
|
||||
71a72119935f (nonebot_plugin_session_orm) (effective head)
|
||||
ade8cdca5470 (nonebot_plugin_wordcloud) (head)
|
||||
```
|
||||
|
||||
### upgrade
|
||||
|
||||
升级数据库。每次安装新的插件或更新插件版本后,都需要执行此命令。
|
||||
|
||||
```shell
|
||||
nb orm upgrade <插件模块名>@<迁移 ID>
|
||||
```
|
||||
|
||||
其中,`<插件模块名>@<迁移 ID>` 是可选参数。如果不指定,则会将所有分支升级到最新版本,这也是最常见的用法:
|
||||
|
||||
```shell
|
||||
nb orm upgrade
|
||||
```
|
||||
|
||||
### downgrade
|
||||
|
||||
降级数据库。当需要回滚插件版本或删除插件时,可以执行此命令。
|
||||
|
||||
```shell
|
||||
nb orm downgrade <插件模块名>@<迁移 ID>
|
||||
```
|
||||
|
||||
其中,`<迁移 ID>` 也可以是 `base`,即回滚到初始状态。常用于卸载插件后删除其数据:
|
||||
|
||||
```shell
|
||||
nb orm downgrade <插件模块名>@base
|
||||
```
|
||||
|
||||
### check
|
||||
|
||||
检查数据库模式是否与模型定义一致。机器人启动前会自动运行此命令(`ALEMBIC_STARTUP_CHECK=true` 时),并在检查失败时阻止启动。
|
||||
|
||||
```shell
|
||||
nb orm check
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
### sqlalchemy_database_url
|
||||
|
||||
默认数据库连接 URL。参见 [数据库驱动和后端](.#数据库驱动和后端) 和 [引擎配置 — SQLAlchemy 2.0 文档](https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls)。
|
||||
|
||||
```shell
|
||||
SQLALCHEMY_DATABASE_URL=dialect+driver://username:password@host:port/database
|
||||
```
|
||||
|
||||
### sqlalchemy_bind
|
||||
|
||||
bind keys(一般为插件模块名)到数据库连接 URL、[`create_async_engine()`](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.create_async_engine) 参数字典或 [`AsyncEngine`](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.AsyncEngine) 实例的字典。
|
||||
例如,我们想要让 `nonebot-plugin-wordcloud` 插件使用一个 SQLite 数据库,并开启 [Echo 选项](https://docs.sqlalchemy.org/en/20/core/engines.html#sqlalchemy.create_engine.params.echo) 便于 debug,而其他插件使用默认的 PostgreSQL 数据库,可以这样配置:
|
||||
|
||||
```shell
|
||||
SQLALCHEMY_BINDS='{
|
||||
"": "postgresql+psycopg://scott:tiger@localhost/mydatabase",
|
||||
"nonebot_plugin_wordcloud": {
|
||||
"url": "sqlite+aiosqlite://",
|
||||
"echo": true
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### sqlalchemy_engine_options
|
||||
|
||||
[`create_async_engine()`](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html#sqlalchemy.ext.asyncio.create_async_engine) 默认参数字典。
|
||||
|
||||
```shell
|
||||
SQLALCHEMY_ENGINE_OPTIONS='{
|
||||
"pool_size": 5,
|
||||
"max_overflow": 10,
|
||||
"pool_timeout": 30,
|
||||
"pool_recycle": 3600,
|
||||
"echo": true
|
||||
}'
|
||||
```
|
||||
|
||||
### sqlalchemy_echo
|
||||
|
||||
开启 [Echo 选项](https://docs.sqlalchemy.org/en/20/core/engines.html#sqlalchemy.create_engine.params.echo) 和 [Echo Pool 选项](https://docs.sqlalchemy.org/en/20/core/engines.html#sqlalchemy.create_engine.params.echo_pool) 便于 debug。
|
||||
|
||||
```shell
|
||||
SQLALCHEMY_ECHO=true
|
||||
```
|
||||
|
||||
:::caution 注意
|
||||
以上配置之间有覆盖关系,遵循特殊优先于一般的原则,具体为 [`sqlalchemy_database_url`](#sqlalchemy_database_url) > [`sqlalchemy_bind`](#sqlalchemy_bind) > [`sqlalchemy_echo`](#sqlalchemy_echo) > [`sqlalchemy_engine_options`](#sqlalchemy_engine_options)。
|
||||
但覆盖顺序并非显而易见,出于清晰考虑,请只配置必要的选项。
|
||||
:::
|
@ -0,0 +1,299 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
description: 部署你的机器人
|
||||
---
|
||||
|
||||
# 部署
|
||||
|
||||
import Tabs from "@theme/Tabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
|
||||
在编写完成各类插件后,我们需要长期运行机器人来使得用户能够正常使用。通常,我们会使用云服务器来部署机器人。
|
||||
|
||||
我们在开发插件时,机器人运行的环境称为开发环境;而在部署后,机器人运行的环境称为生产环境。与开发环境不同的是,在生产环境中,开发者通常不能随意地修改/添加/删除代码,开启或停止服务。
|
||||
|
||||
## 部署前准备
|
||||
|
||||
### 项目依赖管理
|
||||
|
||||
由于部署后的机器人运行在生产环境中,因此,为确保机器人能够正常运行,我们需要保证机器人的运行环境与开发环境一致。我们可以通过以下几种方式来进行依赖管理:
|
||||
|
||||
<Tabs groupId="tool">
|
||||
<TabItem value="poetry" label="Poetry" default>
|
||||
|
||||
[Poetry](https://python-poetry.org/) 是一个 Python 项目的依赖管理工具。它可以通过声明项目所依赖的库,为你管理(安装/更新)它们。Poetry 提供了一个 `poetry.lock` 文件,以确保可重复安装,并可以构建用于分发的项目。
|
||||
|
||||
Poetry 会在安装依赖时自动生成 `poetry.lock` 文件,在**项目目录**下执行以下命令:
|
||||
|
||||
```bash
|
||||
# 初始化 poetry 配置
|
||||
poetry init
|
||||
# 添加项目依赖,这里以 nonebot2[fastapi] 为例
|
||||
poetry add nonebot2[fastapi]
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="pdm" label="PDM">
|
||||
|
||||
[PDM](https://pdm.fming.dev/) 是一个现代 Python 项目的依赖管理工具。它采用 [PEP621](https://www.python.org/dev/peps/pep-0621/) 标准,依赖解析快速;同时支持 [PEP582](https://www.python.org/dev/peps/pep-0582/) 和 [virtualenv](https://virtualenv.pypa.io/)。PDM 提供了一个 `pdm.lock` 文件,以确保可重复安装,并可以构建用于分发的项目。
|
||||
|
||||
PDM 会在安装依赖时自动生成 `pdm.lock` 文件,在**项目目录**下执行以下命令:
|
||||
|
||||
```bash
|
||||
# 初始化 pdm 配置
|
||||
pdm init
|
||||
# 添加项目依赖,这里以 nonebot2[fastapi] 为例
|
||||
pdm add nonebot2[fastapi]
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="pip" label="pip">
|
||||
|
||||
[pip](https://pip.pypa.io/) 是 Python 包管理工具。他并不是一个依赖管理工具,为了尽可能保证环境的一致性,我们可以使用 `requirements.txt` 文件来声明依赖。
|
||||
|
||||
```bash
|
||||
pip freeze > requirements.txt
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### 安装 Docker
|
||||
|
||||
[Docker](https://www.docker.com/) 是一个应用容器引擎,可以让开发者打包应用以及依赖包到一个可移植的镜像中,然后发布到服务器上。
|
||||
|
||||
我们可以参考 [Docker 官方文档](https://docs.docker.com/get-docker/) 来安装 Docker 。
|
||||
|
||||
在 Linux 上,我们可以使用以下一键脚本来安装 Docker 以及 Docker Compose Plugin:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com | sh -s -- --mirror Aliyun
|
||||
```
|
||||
|
||||
在 Windows/macOS 上,我们可以使用 [Docker Desktop](https://docs.docker.com/desktop/) 来安装 Docker 以及 Docker Compose Plugin。
|
||||
|
||||
### 安装脚手架 Docker 插件
|
||||
|
||||
我们可以使用 [nb-cli-plugin-docker](https://github.com/nonebot/cli-plugin-docker) 来快速部署机器人。
|
||||
|
||||
插件可以帮助我们生成配置文件并构建 Docker 镜像,以及启动/停止/重启机器人。使用以下命令安装脚手架 Docker 插件:
|
||||
|
||||
```bash
|
||||
nb self install nb-cli-plugin-docker
|
||||
```
|
||||
|
||||
## Docker 部署
|
||||
|
||||
### 快速部署
|
||||
|
||||
使用脚手架命令即可一键生成配置并部署:
|
||||
|
||||
```bash
|
||||
nb docker up
|
||||
```
|
||||
|
||||
当看到 `Running` 字样时,说明机器人已经启动成功。我们可以通过以下命令来查看机器人的运行日志:
|
||||
|
||||
<Tabs groupId="deploy-tool">
|
||||
<TabItem value="nb-cli" label="NB CLI" default>
|
||||
|
||||
```bash
|
||||
nb docker logs
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="docker-compose" label="Docker Compose">
|
||||
|
||||
```bash
|
||||
docker compose logs
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
如果需要停止机器人,我们可以使用以下命令:
|
||||
|
||||
<Tabs groupId="deploy-tool">
|
||||
<TabItem value="nb-cli" label="NB CLI" default>
|
||||
|
||||
```bash
|
||||
nb docker down
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="docker-compose" label="Docker Compose">
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### 自定义部署
|
||||
|
||||
在部分情况下,我们需要事先生成 Docker 配置文件,再到生产环境进行部署;或者自动生成的配置文件并不能满足复杂场景,需要根据实际需求手动修改配置文件。我们可以使用以下命令来生成基础配置文件:
|
||||
|
||||
```bash
|
||||
nb docker generate
|
||||
```
|
||||
|
||||
nb-cli 将会在项目目录下生成 `docker-compose.yml` 和 `Dockerfile` 等配置文件。在 nb-cli 完成配置文件的生成后,我们可以根据部署环境的实际情况使用 nb-cli 或者 Docker Compose 来启动机器人。
|
||||
|
||||
我们可以参考 [Dockerfile 文件规范](https://docs.docker.com/engine/reference/builder/)和 [Compose 文件规范](https://docs.docker.com/compose/compose-file/)修改这两个文件。
|
||||
|
||||
修改完成后我们可以直接启动或者手动构建镜像:
|
||||
|
||||
<Tabs groupId="deploy-tool">
|
||||
<TabItem value="nb-cli" label="NB CLI" default>
|
||||
|
||||
```bash
|
||||
# 启动机器人
|
||||
nb docker up
|
||||
# 手动构建镜像
|
||||
nb docker build
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="docker-compose" label="Docker Compose">
|
||||
|
||||
```bash
|
||||
# 启动机器人
|
||||
docker compose up -d
|
||||
# 手动构建镜像
|
||||
docker compose build
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### 持续集成
|
||||
|
||||
我们可以使用 GitHub Actions 来实现持续集成(CI),我们只需要在 GitHub 上发布 Release 即可自动构建镜像并推送至镜像仓库。
|
||||
|
||||
首先,我们需要在 [Docker Hub](https://hub.docker.com/) (或者其他平台,如:[GitHub Packages](https://github.com/features/packages)、[阿里云容器镜像服务](https://www.alibabacloud.com/zh/product/container-registry)等)上创建镜像仓库,用于存放镜像。
|
||||
|
||||
前往项目仓库的 `Settings` > `Secrets` > `actions` 栏目 `New Repository Secret` 添加构建所需的密钥:
|
||||
|
||||
- `DOCKERHUB_USERNAME`: 你的 Docker Hub 用户名
|
||||
- `DOCKERHUB_TOKEN`: 你的 Docker Hub PAT([创建方法](https://docs.docker.com/docker-hub/access-tokens/))
|
||||
|
||||
将以下文件添加至**项目目录**下的 `.github/workflows/` 目录下,并将文件中高亮行中的仓库名称替换为你的仓库名称:
|
||||
|
||||
```yaml title=.github/workflows/build.yml
|
||||
name: Docker Hub Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Setup Docker
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Generate Tags
|
||||
uses: docker/metadata-action@v4
|
||||
id: metadata
|
||||
with:
|
||||
images: |
|
||||
# highlight-next-line
|
||||
{organization}/{repository}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
|
||||
- name: Build and Publish
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
labels: ${{ steps.metadata.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
```
|
||||
|
||||
### 持续部署
|
||||
|
||||
在完成发布并构建镜像后,我们可以自动将镜像部署到服务器上。
|
||||
|
||||
前往项目仓库的 `Settings` > `Secrets` > `actions` 栏目 `New Repository Secret` 添加部署所需的密钥:
|
||||
|
||||
- `DEPLOY_HOST`: 部署服务器的 SSH 地址
|
||||
- `DEPLOY_USER`: 部署服务器用户名
|
||||
- `DEPLOY_KEY`: 部署服务器私钥([创建方法](https://github.com/appleboy/ssh-action#setting-up-a-ssh-key))
|
||||
- `DEPLOY_PATH`: 部署服务器上的项目路径
|
||||
|
||||
将以下文件添加至**项目目录**下的 `.github/workflows/` 目录下,在构建成功后触发部署:
|
||||
|
||||
```yaml title=.github/workflows/deploy.yml
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Docker Hub Release
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: Start Deployment
|
||||
uses: bobheadxi/deployments@v1
|
||||
id: deployment
|
||||
with:
|
||||
step: start
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
env: bot
|
||||
|
||||
- name: Run Remote SSH Command
|
||||
uses: appleboy/ssh-action@master
|
||||
env:
|
||||
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_KEY }}
|
||||
envs: DEPLOY_PATH
|
||||
script: |
|
||||
cd $DEPLOY_PATH
|
||||
docker compose up -d --pull always
|
||||
|
||||
- name: update deployment status
|
||||
uses: bobheadxi/deployments@v0.6.2
|
||||
if: always()
|
||||
with:
|
||||
step: finish
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
status: ${{ job.status }}
|
||||
env: ${{ steps.deployment.outputs.env }}
|
||||
deployment_id: ${{ steps.deployment.outputs.deployment_id }}
|
||||
```
|
||||
|
||||
将上一部分的 `docker-compose.yml` 文件以及 `.env.prod` 配置文件添加至 `DEPLOY_PATH` 目录下,并修改 `docker-compose.yml` 文件中的镜像配置,替换为 Docker Hub 的仓库名称:
|
||||
|
||||
```diff
|
||||
- build: .
|
||||
+ image: {organization}/{repository}:latest
|
||||
```
|
@ -0,0 +1,64 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
description: 使用 sentry 进行错误跟踪
|
||||
---
|
||||
|
||||
# 错误跟踪
|
||||
|
||||
在应用实际运行过程中,可能会出现各种各样的错误。可能是由于代码逻辑错误,也可能是由于用户输入错误,甚至是由于第三方服务的错误。这些错误都会导致应用的运行出现问题,这时候就需要对错误进行跟踪,以便及时发现问题并进行修复。NoneBot 提供了 `nonebot-plugin-sentry` 插件,支持 [sentry](https://sentry.io/) 平台,可以方便地进行错误跟踪。
|
||||
|
||||
## 安装插件
|
||||
|
||||
在使用前请先安装 `nonebot-plugin-sentry` 插件至项目环境中,可参考[获取商店插件](../tutorial/store.mdx#安装插件)来了解并选择安装插件的方式。如:
|
||||
|
||||
在**项目目录**下执行以下命令:
|
||||
|
||||
```bash
|
||||
nb plugin install nonebot-plugin-sentry
|
||||
```
|
||||
|
||||
## 使用插件
|
||||
|
||||
在安装完成之后,仅需要对插件进行简单的配置即可使用。
|
||||
|
||||
### 获取 sentry DSN
|
||||
|
||||
前往 [sentry](https://sentry.io/) 平台,注册并创建一个新的项目,然后在项目设置中找到 `Client Keys (DSN)`,复制其中的 `DSN` 值。
|
||||
|
||||
### 配置插件
|
||||
|
||||
:::caution 注意
|
||||
错误跟踪通常在生产环境中使用,因此开发环境中 `sentry_dsn` 留空即会停用插件。
|
||||
:::
|
||||
|
||||
在项目 dotenv 配置文件中添加以下配置即可使用:
|
||||
|
||||
```dotenv
|
||||
SENTRY_DSN=<your_sentry_dsn>
|
||||
```
|
||||
|
||||
## 配置项
|
||||
|
||||
配置项具体含义参考 [Sentry Docs](https://docs.sentry.io/platforms/python/configuration/options/)。
|
||||
|
||||
- `sentry_dsn: str`
|
||||
- `sentry_debug: bool = False`
|
||||
- `sentry_release: str | None = None`
|
||||
- `sentry_release: str | None = None`
|
||||
- `sentry_environment: str | None = nonebot env`
|
||||
- `sentry_server_name: str | None = None`
|
||||
- `sentry_sample_rate: float = 1.`
|
||||
- `sentry_max_breadcrumbs: int = 100`
|
||||
- `sentry_attach_stacktrace: bool = False`
|
||||
- `sentry_send_default_pii: bool = False`
|
||||
- `sentry_in_app_include: List[str] = Field(default_factory=list)`
|
||||
- `sentry_in_app_exclude: List[str] = Field(default_factory=list)`
|
||||
- `sentry_request_bodies: str = "medium"`
|
||||
- `sentry_with_locals: bool = True`
|
||||
- `sentry_ca_certs: str | None = None`
|
||||
- `sentry_before_send: Callable[[Any, Any], Any | None] | None = None`
|
||||
- `sentry_before_breadcrumb: Callable[[Any, Any], Any | None] | None = None`
|
||||
- `sentry_transport: Any | None = None`
|
||||
- `sentry_http_proxy: str | None = None`
|
||||
- `sentry_https_proxy: str | None = None`
|
||||
- `sentry_shutdown_timeout: int = 2`
|
@ -0,0 +1,183 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
description: 插件跨平台支持
|
||||
---
|
||||
|
||||
# 插件跨平台支持
|
||||
|
||||
import Tabs from "@theme/Tabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
|
||||
由于不同平台的事件与接口之间,存在着极大的差异性,NoneBot 通过[重载](../appendices/overload.md)的方式,使得插件可以在不同平台上正确响应。但为了减少跨平台的兼容性问题,我们应该尽可能的使用基类方法实现原生跨平台,而不是使用特定平台的方法。当基类方法无法满足需求时,我们可以使用依赖注入的方式,将特定平台的事件或机器人注入到事件处理函数中,实现针对特定平台的处理。
|
||||
|
||||
:::tip 提示
|
||||
如果需要在多平台上**使用**跨平台插件,首先应该根据[注册适配器](../advanced/adapter.md#注册适配器)一节,为机器人注册各平台对应的适配器。
|
||||
:::
|
||||
|
||||
## 基于基类的跨平台
|
||||
|
||||
在[事件通用信息](../advanced/adapter.md#获取事件通用信息)中,我们了解了事件基类能够提供的通用信息。同时,[事件响应器操作](../appendices/session-control.mdx#更多事件响应器操作)也为我们提供了基本的用户交互方式。使用这些方法,可以让我们的插件运行在任何平台上。例如,一个简单的命令处理插件:
|
||||
|
||||
```python {5,11}
|
||||
from nonebot import on_command
|
||||
from nonebot.adapters import Event
|
||||
|
||||
async def is_blacklisted(event: Event) -> bool:
|
||||
return event.get_user_id() not in BLACKLIST
|
||||
|
||||
weather = on_command("天气", rule=is_blacklisted, priority=10, block=True)
|
||||
|
||||
@weather.handle()
|
||||
async def handle_function():
|
||||
await weather.finish("今天的天气是...")
|
||||
```
|
||||
|
||||
由于此插件仅使用了事件通用信息和事件响应器操作的纯文本交互方式,这些方法不使用特定平台的信息或接口,因此是原生跨平台的,并不需要额外处理。但在一些较为复杂的需求下,例如发送图片消息时,并非所有平台都具有统一的接口,因此基类便无能为力,我们需要引入特定平台的适配器了。
|
||||
|
||||
## 基于重载的跨平台
|
||||
|
||||
重载是 NoneBot 跨平台操作的核心,在[事件类型与重载](../appendices/overload.md#重载)一节中,我们初步了解了如何通过类型注解来实现针对不同平台事件的处理方式。在[依赖注入](../advanced/dependency.mdx)一节中,我们又对依赖注入的使用方法进行了详细的介绍。结合这两节内容,我们可以实现更复杂的跨平台操作。
|
||||
|
||||
### 处理近似事件
|
||||
|
||||
对于一系列**差异不大**的事件,我们往往具有相同的处理逻辑。这时,我们不希望将相同的逻辑编写两遍,而应该复用代码,以实现在同一个事件处理函数中处理多个近似事件。我们可以使用[事件重载](../advanced/dependency.mdx#Event)的特性来实现这一功能。例如:
|
||||
|
||||
<Tabs groupId="python">
|
||||
<TabItem value="3.10" label="Python 3.10+" default>
|
||||
|
||||
```python
|
||||
from nonebot import on_command
|
||||
from nonebot.adapters import Message
|
||||
from nonebot.params import CommandArg
|
||||
from nonebot.adapters.onebot.v11 import MessageEvent as OnebotV11MessageEvent
|
||||
from nonebot.adapters.onebot.v12 import MessageEvent as OnebotV12MessageEvent
|
||||
|
||||
echo = on_command("echo", priority=10, block=True)
|
||||
|
||||
@echo.handle()
|
||||
async def handle_function(event: OnebotV11MessageEvent | OnebotV12MessageEvent, args: Message = CommandArg()):
|
||||
await echo.finish(args)
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="3.9" label="Python 3.9">
|
||||
|
||||
```python
|
||||
from typing import Union
|
||||
|
||||
from nonebot import on_command
|
||||
from nonebot.adapters import Message
|
||||
from nonebot.params import CommandArg
|
||||
from nonebot.adapters.onebot.v11 import MessageEvent as OnebotV11MessageEvent
|
||||
from nonebot.adapters.onebot.v12 import MessageEvent as OnebotV12MessageEvent
|
||||
|
||||
echo = on_command("echo", priority=10, block=True)
|
||||
|
||||
@echo.handle()
|
||||
async def handle_function(event: Union[OnebotV11MessageEvent, OnebotV12MessageEvent], args: Message = CommandArg()):
|
||||
await echo.finish(args)
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### 在依赖注入中使用重载
|
||||
|
||||
NoneBot 依赖注入系统提供了自定义子依赖的方法,子依赖的类型同样会影响到事件处理函数的重载行为。例如:
|
||||
|
||||
```python
|
||||
from datetime import datetime
|
||||
|
||||
from nonebot import on_command
|
||||
from nonebot.adapters.console import MessageEvent
|
||||
|
||||
echo = on_command("echo", priority=10, block=True)
|
||||
|
||||
def get_event_time(event: MessageEvent):
|
||||
return event.time
|
||||
|
||||
# 处理控制台消息事件
|
||||
@echo.handle()
|
||||
async def handle_function(time: datetime = Depends(get_event_time)):
|
||||
await echo.finish(time.strftime("%Y-%m-%d %H:%M:%S"))
|
||||
```
|
||||
|
||||
示例中 ,我们为 `handle_function` 事件处理函数注入了自定义的 `get_event_time` 子依赖,而此子依赖注入参数为 Console 适配器的 `MessageEvent`。因此 `handle_function` 仅会响应 Console 适配器的 `MessageEvent` ,而不能响应其他事件。
|
||||
|
||||
### 处理多平台事件
|
||||
|
||||
不同平台的事件之间,往往存在着极大的差异性。为了满足我们插件的跨平台运行,通常我们需要抽离业务逻辑,以保证代码的复用性。一个合理的做法是,在事件响应器的处理流程中,首先先针对不同平台的事件分别进行处理,提取出核心业务逻辑所需要的信息;然后再将这些信息传递给业务逻辑处理函数;最后将业务逻辑的输出以各平台合适的方式返回给用户。也就是说,与平台绑定的处理部分应该与平台无关部分尽量分离。例如:
|
||||
|
||||
```python
|
||||
import inspect
|
||||
|
||||
from nonebot import on_command
|
||||
from nonebot.typing import T_State
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot.adapters import Message
|
||||
from nonebot.params import CommandArg, ArgPlainText
|
||||
from nonebot.adapters.console import Bot as ConsoleBot
|
||||
from nonebot.adapters.onebot.v11 import Bot as OnebotBot
|
||||
from nonebot.adapters.console import MessageSegment as ConsoleMessageSegment
|
||||
|
||||
weather = on_command("天气", priority=10, block=True)
|
||||
|
||||
@weather.handle()
|
||||
async def handle_function(matcher: Matcher, args: Message = CommandArg()):
|
||||
if args.extract_plain_text():
|
||||
matcher.set_arg("location", args)
|
||||
|
||||
|
||||
async def get_weather(state: T_State, location: str = ArgPlainText()):
|
||||
if location not in ["北京", "上海", "广州", "深圳"]:
|
||||
await weather.reject(f"你想查询的城市 {location} 暂不支持,请重新输入!")
|
||||
|
||||
state["weather"] = "⛅ 多云 20℃~24℃"
|
||||
|
||||
|
||||
# 处理控制台询问
|
||||
@weather.got(
|
||||
"location",
|
||||
prompt=ConsoleMessageSegment.emoji("question") + "请输入地名",
|
||||
parameterless=[Depends(get_weather)],
|
||||
)
|
||||
async def handle_console(bot: ConsoleBot):
|
||||
pass
|
||||
|
||||
# 处理 OneBot 询问
|
||||
@weather.got(
|
||||
"location",
|
||||
prompt="请输入地名",
|
||||
parameterless=[Depends(get_weather)],
|
||||
)
|
||||
async def handle_onebot(bot: OnebotBot):
|
||||
pass
|
||||
|
||||
# 通过依赖注入或事件处理函数来进行业务逻辑处理
|
||||
|
||||
# 处理控制台回复
|
||||
@weather.handle()
|
||||
async def handle_console_reply(bot: ConsoleBot, state: T_State, location: str = ArgPlainText()):
|
||||
await weather.send(
|
||||
ConsoleMessageSegment.markdown(
|
||||
inspect.cleandoc(
|
||||
f"""
|
||||
# {location}
|
||||
|
||||
- 今天
|
||||
|
||||
{state['weather']}
|
||||
"""
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# 处理 OneBot 回复
|
||||
@weather.handle()
|
||||
async def handle_onebot_reply(bot: OnebotBot, state: T_State, location: str = ArgPlainText()):
|
||||
await weather.send(f"今天{location}的天气是{state['weather']}")
|
||||
```
|
||||
|
||||
:::tip 提示
|
||||
NoneBot 社区中有一些插件,例如[all4one](https://github.com/nonepkg/nonebot-plugin-all4one)、[send-anything-anywhere](https://github.com/felinae98/nonebot-plugin-send-anything-anywhere),可以帮助你更好地处理跨平台功能,包括事件处理和消息发送等。
|
||||
:::
|
@ -0,0 +1,96 @@
|
||||
---
|
||||
sidebar_position: 0
|
||||
description: 定时执行任务
|
||||
---
|
||||
|
||||
# 定时任务
|
||||
|
||||
[APScheduler](https://apscheduler.readthedocs.io/en/3.x/) (Advanced Python Scheduler) 是一个 Python 第三方库,其强大的定时任务功能被广泛应用于各个场景。在 NoneBot 中,定时任务作为一个额外功能,依赖于基于 APScheduler 开发的 [`nonebot-plugin-apscheduler`](https://github.com/nonebot/plugin-apscheduler) 插件进行支持。
|
||||
|
||||
## 安装插件
|
||||
|
||||
在使用前请先安装 `nonebot-plugin-apscheduler` 插件至项目环境中,可参考[获取商店插件](../tutorial/store.mdx#安装插件)来了解并选择安装插件的方式。如:
|
||||
|
||||
在**项目目录**下执行以下命令:
|
||||
|
||||
```bash
|
||||
nb plugin install nonebot-plugin-apscheduler
|
||||
```
|
||||
|
||||
## 使用插件
|
||||
|
||||
`nonebot-plugin-apscheduler` 本质上是对 [APScheduler](https://apscheduler.readthedocs.io/en/3.x/) 进行了封装以适用于 NoneBot 开发,因此其使用方式与 APScheduler 本身并无显著区别。在此我们会简要介绍其调用方法,更多的使用方面的功能请参考[APScheduler 官方文档](https://apscheduler.readthedocs.io/en/3.x/userguide.html)。
|
||||
|
||||
### 导入调度器
|
||||
|
||||
由于 `nonebot_plugin_apscheduler` 作为插件,因此需要在使用前对其进行**加载**并**导入**其中的 `scheduler` 调度器来创建定时任务。使用 `require` 方法可轻松完成这一过程,可参考 [跨插件访问](../advanced/requiring.md) 一节进行了解。
|
||||
|
||||
```python
|
||||
from nonebot import require
|
||||
|
||||
require("nonebot_plugin_apscheduler")
|
||||
|
||||
from nonebot_plugin_apscheduler import scheduler
|
||||
```
|
||||
|
||||
### 添加定时任务
|
||||
|
||||
在 [APScheduler 官方文档](https://apscheduler.readthedocs.io/en/3.x/userguide.html#adding-jobs) 中提供了以下两种直接添加任务的方式:
|
||||
|
||||
```python
|
||||
from nonebot import require
|
||||
|
||||
require("nonebot_plugin_apscheduler")
|
||||
|
||||
from nonebot_plugin_apscheduler import scheduler
|
||||
|
||||
# 基于装饰器的方式
|
||||
@scheduler.scheduled_job("cron", hour="*/2", id="job_0", args=[1], kwargs={arg2: 2})
|
||||
async def run_every_2_hour(arg1: int, arg2: int):
|
||||
pass
|
||||
|
||||
# 基于 add_job 方法的方式
|
||||
def run_every_day(arg1: int, arg2: int):
|
||||
pass
|
||||
|
||||
scheduler.add_job(
|
||||
run_every_day, "interval", days=1, id="job_1", args=[1], kwargs={arg2: 2}
|
||||
)
|
||||
```
|
||||
|
||||
:::caution 注意
|
||||
由于 APScheduler 的定时任务并不是**由事件响应器所触发的事件**,因此其任务函数无法同[事件处理函数](../tutorial/handler.mdx#事件处理函数)一样通过[依赖注入](../tutorial/event-data.mdx#认识依赖注入)获取上下文信息,也无法通过事件响应器对象的方法进行任何操作,因此我们需要使用[调用平台 API](../appendices/api-calling.mdx#调用平台-api)的方式来获取信息或收发消息。
|
||||
|
||||
相对于事件处理依赖而言,编写定时任务更像是编写普通的函数,需要我们自行获取信息以及发送信息,请**不要**将事件处理依赖的特殊语法用于定时任务!
|
||||
:::
|
||||
|
||||
关于 APScheduler 的更多使用方法,可以参考 [APScheduler 官方文档](https://apscheduler.readthedocs.io/en/3.x/index.html) 进行了解。
|
||||
|
||||
### 配置项
|
||||
|
||||
#### apscheduler_autostart
|
||||
|
||||
- **类型**: `bool`
|
||||
- **默认值**: `True`
|
||||
|
||||
是否自动启动 `scheduler` ,若不启动需要自行调用 `scheduler.start()`。
|
||||
|
||||
#### apscheduler_log_level
|
||||
|
||||
- **类型**: `int`
|
||||
- **默认值**: `30`
|
||||
|
||||
apscheduler 输出的日志等级
|
||||
|
||||
- `WARNING` = `30` (默认)
|
||||
- `INFO` = `20`
|
||||
- `DEBUG` = `10` (只有在开启 nonebot 的 debug 模式才会显示 debug 日志)
|
||||
|
||||
#### apscheduler_config
|
||||
|
||||
- **类型**: `dict`
|
||||
- **默认值**: `{ "apscheduler.timezone": "Asia/Shanghai" }`
|
||||
|
||||
`apscheduler` 的相关配置。参考[配置调度器](https://apscheduler.readthedocs.io/en/latest/userguide.html#scheduler-config), [配置参数](https://apscheduler.readthedocs.io/en/latest/modules/schedulers/base.html#apscheduler.schedulers.base.BaseScheduler)
|
||||
|
||||
配置需要包含 `apscheduler.` 作为前缀,例如 `apscheduler.timezone`。
|
@ -0,0 +1,212 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
description: 使用 NoneBug 进行单元测试
|
||||
|
||||
slug: /best-practice/testing/
|
||||
---
|
||||
|
||||
# 配置与测试事件响应器
|
||||
|
||||
import Tabs from "@theme/Tabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
|
||||
> 在计算机编程中,单元测试(Unit Testing)又称为模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。
|
||||
|
||||
为了保证代码的正确运行,我们不仅需要对错误进行跟踪,还需要对代码进行正确性检验,也就是测试。NoneBot 提供了一个测试工具——NoneBug,它是一个 [pytest](https://docs.pytest.org/en/stable/) 插件,可以帮助我们便捷地进行单元测试。
|
||||
|
||||
:::tip 提示
|
||||
建议在阅读本文档前先阅读 [pytest 官方文档](https://docs.pytest.org/en/stable/)来了解 pytest 的相关术语和基本用法。
|
||||
:::
|
||||
|
||||
## 安装 NoneBug
|
||||
|
||||
在**项目目录**下激活虚拟环境后运行以下命令安装 NoneBug:
|
||||
|
||||
<Tabs groupId="tool">
|
||||
<TabItem value="poetry" label="Poetry" default>
|
||||
|
||||
```bash
|
||||
poetry add nonebug -G test
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="pdm" label="PDM">
|
||||
|
||||
```bash
|
||||
pdm add nonebug -dG test
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="pip" label="pip">
|
||||
|
||||
```bash
|
||||
pip install nonebug
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
要运行 NoneBug 测试,还需要额外安装 pytest 异步插件 `pytest-asyncio` 或 `anyio` 以支持异步测试。文档中,我们以 `pytest-asyncio` 为例:
|
||||
|
||||
<Tabs groupId="tool">
|
||||
<TabItem value="poetry" label="Poetry" default>
|
||||
|
||||
```bash
|
||||
poetry add pytest-asyncio -G test
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="pdm" label="PDM">
|
||||
|
||||
```bash
|
||||
pdm add pytest-asyncio -dG test
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="pip" label="pip">
|
||||
|
||||
```bash
|
||||
pip install pytest-asyncio
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## 配置测试
|
||||
|
||||
在开始测试之前,我们需要对测试进行一些配置,以正确启动我们的机器人。在 `tests` 目录下新建 `conftest.py` 文件,添加以下内容:
|
||||
|
||||
```python title=tests/conftest.py
|
||||
import pytest
|
||||
import nonebot
|
||||
# 导入适配器
|
||||
from nonebot.adapters.console import Adapter as ConsoleAdapter
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def load_bot():
|
||||
# 加载适配器
|
||||
driver = nonebot.get_driver()
|
||||
driver.register_adapter(ConsoleAdapter)
|
||||
|
||||
# 加载插件
|
||||
nonebot.load_from_toml("pyproject.toml")
|
||||
```
|
||||
|
||||
这样,我们就可以在测试中使用机器人的插件了。通常,我们不需要自行初始化 NoneBot,NoneBug 已经为我们运行了 `nonebot.init()`。如果需要自定义 NoneBot 初始化的参数,我们可以在 `conftest.py` 中添加 `pytest_configure` 钩子函数。例如,我们可以修改 NoneBot 配置环境为 `test` 并从环境变量中输入配置:
|
||||
|
||||
```python {3,5,7-9} title=tests/conftest.py
|
||||
import os
|
||||
|
||||
from nonebug import NONEBOT_INIT_KWARGS
|
||||
|
||||
os.environ["ENVIRONMENT"] = "test"
|
||||
|
||||
def pytest_configure(config: pytest.Config):
|
||||
config.stash[NONEBOT_INIT_KWARGS] = {"secret": os.getenv("INPUT_SECRET")}
|
||||
```
|
||||
|
||||
## 编写插件测试
|
||||
|
||||
在配置完成插件加载后,我们就可以在测试中使用插件了。NoneBug 通过 pytest fixture `app` 提供各种测试方法,我们可以在测试中使用它来测试插件。现在,我们创建一个测试脚本来测试[深入指南](../../appendices/session-control.mdx)中编写的天气插件。首先,我们先要导入我们需要的模块:
|
||||
|
||||
<details>
|
||||
<summary>插件示例</summary>
|
||||
|
||||
```python title=weather/__init__.py
|
||||
from nonebot import on_command
|
||||
from nonebot.rule import to_me
|
||||
from nonebot.matcher import Matcher
|
||||
from nonebot.adapters import Message
|
||||
from nonebot.params import CommandArg, ArgPlainText
|
||||
|
||||
weather = on_command("天气", rule=to_me(), aliases={"weather", "天气预报"})
|
||||
|
||||
@weather.handle()
|
||||
async def handle_function(matcher: Matcher, args: Message = CommandArg()):
|
||||
if args.extract_plain_text():
|
||||
matcher.set_arg("location", args)
|
||||
|
||||
@weather.got("location", prompt="请输入地名")
|
||||
async def got_location(location: str = ArgPlainText()):
|
||||
if location not in ["北京", "上海", "广州", "深圳"]:
|
||||
await weather.reject(f"你想查询的城市 {location} 暂不支持,请重新输入!")
|
||||
await weather.finish(f"今天{location}的天气是...")
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
```python {4,5,9,11-16} title=tests/test_weather.py
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from nonebug import App
|
||||
from nonebot.adapters.console import User, Message, MessageEvent
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weather(app: App):
|
||||
from awesome_bot.plugins.weather import weather
|
||||
|
||||
event = MessageEvent(
|
||||
time=datetime.now(),
|
||||
self_id="test",
|
||||
message=Message("/天气 北京"),
|
||||
user=User(id="user"),
|
||||
)
|
||||
```
|
||||
|
||||
在上面的代码中,我们引入了 NoneBug 的测试 `App` 对象,以及必要的适配器消息与事件定义等。在测试函数 `test_weather` 中,我们导入了要进行测试的事件响应器 `weather`。请注意,由于需要等待 NoneBot 初始化并加载插件完毕,插件内容必须在**测试函数内部**进行导入。然后,我们创建了一个 `MessageEvent` 事件对象,它模拟了一个用户发送了 `/天气 北京` 的消息。接下来,我们使用 `app.test_matcher` 方法来测试 `weather` 事件响应器:
|
||||
|
||||
```python {11-15} title=tests/test_weather.py
|
||||
@pytest.mark.asyncio
|
||||
async def test_weather(app: App):
|
||||
from awesome_bot.plugins.weather import weather
|
||||
|
||||
event = MessageEvent(
|
||||
time=datetime.now(),
|
||||
self_id="test",
|
||||
message=Message("/天气 北京"),
|
||||
user=User(id="user"),
|
||||
)
|
||||
async with app.test_matcher(weather) as ctx:
|
||||
bot = ctx.create_bot()
|
||||
ctx.receive_event(bot, event)
|
||||
ctx.should_call_send(event, "今天北京的天气是...", result=None)
|
||||
ctx.should_finished(weather)
|
||||
```
|
||||
|
||||
这里我们使用 `async with` 语句并通过参数指定要测试的事件响应器 `weather` 来进入测试上下文。在测试上下文中,我们可以使用 `ctx.create_bot` 方法创建一个虚拟的机器人实例,并使用 `ctx.receive_event` 方法来模拟机器人接收到消息事件。然后,我们就可以定义预期行为来测试机器人是否正确运行。在上面的代码中,我们使用 `ctx.should_call_send` 方法来断言机器人应该发送 `今天北京的天气是...` 这条消息,并且将发送函数的调用结果作为第三个参数返回给事件处理函数。如果断言失败,测试将会不通过。我们也可以使用 `ctx.should_finished` 方法来断言机器人应该结束会话。
|
||||
|
||||
为了测试更复杂的情况,我们可以为添加更多的测试用例。例如,我们可以测试用户输入了一个不支持的地名时机器人的反应:
|
||||
|
||||
```python {17-21,23-26} title=tests/test_weather.py
|
||||
def make_event(message: str = "") -> MessageEvent:
|
||||
return MessageEvent(
|
||||
time=datetime.now(),
|
||||
self_id="test",
|
||||
message=Message(message),
|
||||
user=User(id="user"),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weather(app: App):
|
||||
from awesome_bot.plugins.weather import weather
|
||||
|
||||
async with app.test_matcher(weather) as ctx:
|
||||
... # 省略前面的测试用例
|
||||
|
||||
async with app.test_matcher(weather) as ctx:
|
||||
bot = ctx.create_bot()
|
||||
event = make_event("/天气 南京")
|
||||
ctx.receive_event(bot, event)
|
||||
ctx.should_call_send(event, "你想查询的城市 南京 暂不支持,请重新输入!", result=None)
|
||||
ctx.should_rejected(weather)
|
||||
|
||||
event = make_event("北京")
|
||||
ctx.receive_event(bot, event)
|
||||
ctx.should_call_send(event, "今天北京的天气是...", result=None)
|
||||
ctx.should_finished(weather)
|
||||
```
|
||||
|
||||
在上面的代码中,我们使用 `ctx.should_rejected` 来断言机器人应该请求用户重新输入。然后,我们再次使用 `ctx.receive_event` 方法来模拟用户回复了 `北京`,并使用 `ctx.should_finished` 来断言机器人应该结束会话。
|
||||
|
||||
更多的 NoneBug 用法将在后续章节中介绍。
|
@ -0,0 +1,4 @@
|
||||
{
|
||||
"label": "单元测试",
|
||||
"position": 5
|
||||
}
|
@ -0,0 +1,292 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
description: 测试事件响应、平台接口调用和会话控制
|
||||
---
|
||||
|
||||
# 测试事件响应与会话操作
|
||||
|
||||
import Tabs from "@theme/Tabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
|
||||
在 NoneBot 接收到事件时,事件响应器根据优先级依次通过权限、响应规则来判断当前事件是否应该触发。事件响应流程中,机器人可能会通过 `send` 发送消息或者调用平台接口来执行预期的操作。因此,我们需要对这两种操作进行单元测试。
|
||||
|
||||
在上一节中,我们对单个事件响应器进行了简单测试。但是在实际场景中,机器人可能定义了多个事件响应器,由于优先级和响应规则的存在,预期的事件响应器可能并不会被触发。NoneBug 支持同时测试多个事件响应器,以此来测试机器人的整体行为。
|
||||
|
||||
## 测试事件响应
|
||||
|
||||
NoneBug 提供了六种定义 `Rule` 和 `Permission` 预期行为的方法:
|
||||
|
||||
- `should_pass_rule`
|
||||
- `should_not_pass_rule`
|
||||
- `should_ignore_rule`
|
||||
- `should_pass_permission`
|
||||
- `should_not_pass_permission`
|
||||
- `should_ignore_permission`
|
||||
|
||||
:::tip 提示
|
||||
事件响应器类型的检查属于 `Permission` 的一部分,因此可以通过 `should_pass_permission` 和 `should_not_pass_permission` 方法来断言事件响应器类型的检查。
|
||||
:::
|
||||
|
||||
下面我们根据插件示例来测试事件响应行为,我们首先定义两个事件响应器作为测试的对象:
|
||||
|
||||
```python title=example.py
|
||||
from nonebot import on_command
|
||||
|
||||
def never_pass():
|
||||
return False
|
||||
|
||||
foo = on_command("foo")
|
||||
bar = on_command("bar", permission=never_pass)
|
||||
```
|
||||
|
||||
在这两个事件响应器中,`foo` 当收到 `/foo` 消息时会执行,而 `bar` 则不会执行。我们使用 NoneBug 来测试它们:
|
||||
|
||||
<Tabs groupId="testScope">
|
||||
<TabItem value="separate" label="独立测试" default>
|
||||
|
||||
```python {21,22,28,29} title=tests/test_example.py
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from nonebug import App
|
||||
from nonebot.adapters.console import User, Message, MessageEvent
|
||||
|
||||
def make_event(message: str = "") -> MessageEvent:
|
||||
return MessageEvent(
|
||||
time=datetime.now(),
|
||||
self_id="test",
|
||||
message=Message(message),
|
||||
user=User(id="user"),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_example(app: App):
|
||||
from awesome_bot.plugins.example import foo, bar
|
||||
|
||||
async with app.test_matcher(foo) as ctx:
|
||||
bot = ctx.create_bot()
|
||||
event = make_event("/foo")
|
||||
ctx.receive_event(bot, event)
|
||||
ctx.should_pass_rule()
|
||||
ctx.should_pass_permission()
|
||||
|
||||
async with app.test_matcher(bar) as ctx:
|
||||
bot = ctx.create_bot()
|
||||
event = make_event("/foo")
|
||||
ctx.receive_event(bot, event)
|
||||
ctx.should_not_pass_rule()
|
||||
ctx.should_not_pass_permission()
|
||||
```
|
||||
|
||||
在上面的代码中,我们分别对 `foo` 和 `bar` 事件响应器进行响应测试。我们使用 `ctx.should_pass_rule` 和 `ctx.should_pass_permission` 断言 `foo` 事件响应器应该被触发,使用 `ctx.should_not_pass_rule` 和 `ctx.should_not_pass_permission` 断言 `bar` 事件响应器应该被忽略。
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="global" label="集成测试">
|
||||
|
||||
```python title=tests/test_example.py
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from nonebug import App
|
||||
from nonebot.adapters.console import User, Message, MessageEvent
|
||||
|
||||
def make_event(message: str = "") -> MessageEvent:
|
||||
return MessageEvent(
|
||||
time=datetime.now(),
|
||||
self_id="test",
|
||||
message=Message(message),
|
||||
user=User(id="user"),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_example(app: App):
|
||||
from awesome_bot.plugins.example import foo, bar
|
||||
|
||||
async with app.test_matcher() as ctx:
|
||||
bot = ctx.create_bot()
|
||||
event = make_event("/foo")
|
||||
ctx.receive_event(bot, event)
|
||||
ctx.should_pass_rule(foo)
|
||||
ctx.should_pass_permission(foo)
|
||||
ctx.should_not_pass_rule(bar)
|
||||
ctx.should_not_pass_permission(bar)
|
||||
```
|
||||
|
||||
在上面的代码中,我们对 `foo` 和 `bar` 事件响应器一起进行响应测试。我们使用 `ctx.should_pass_rule` 和 `ctx.should_pass_permission` 断言 `foo` 事件响应器应该被触发,使用 `ctx.should_not_pass_rule` 和 `ctx.should_not_pass_permission` 断言 `bar` 事件响应器应该被忽略。通过参数,我们可以指定断言的事件响应器。
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
当然,如果需要忽略某个事件响应器的响应规则和权限检查,强行进入响应流程,我们可以使用 `should_ignore_rule` 和 `should_ignore_permission` 方法:
|
||||
|
||||
```python {21,22} title=tests/test_example.py
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from nonebug import App
|
||||
from nonebot.adapters.console import User, Message, MessageEvent
|
||||
|
||||
def make_event(message: str = "") -> MessageEvent:
|
||||
return MessageEvent(
|
||||
time=datetime.now(),
|
||||
self_id="test",
|
||||
message=Message(message),
|
||||
user=User(id="user"),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_example(app: App):
|
||||
from awesome_bot.plugins.example import foo, bar
|
||||
|
||||
async with app.test_matcher(bar) as ctx:
|
||||
bot = ctx.create_bot()
|
||||
event = make_event("/foo")
|
||||
ctx.receive_event(bot, event)
|
||||
ctx.should_ignore_rule(bar)
|
||||
ctx.should_ignore_permission(bar)
|
||||
```
|
||||
|
||||
在忽略了响应规则和权限检查之后,就会进入 `bar` 事件响应器的响应流程。
|
||||
|
||||
## 测试平台接口使用
|
||||
|
||||
上一节的示例插件测试中,我们已经尝试了测试插件对事件的消息回复。通常情况下,事件处理流程中对平台接口的使用会通过事件响应器操作或者调用平台 API 两种途径进行。针对这两种途径,NoneBug 分别提供了 `ctx.should_call_send` 和 `ctx.should_call_api` 方法来测试平台接口的使用情况。
|
||||
|
||||
1. `should_call_send`
|
||||
|
||||
定义事件响应器预期发送的消息,即通过[事件响应器操作 send](../../appendices/session-control.mdx#send)进行的操作。`should_call_send` 有四个参数:
|
||||
|
||||
- `event`:回复的目标事件。
|
||||
- `message`:预期的消息对象,可以是 `str`、`Message` 或 `MessageSegment`。
|
||||
- `result`:send 的返回值,将会返回给插件。
|
||||
- `bot`(可选):发送消息的 bot 对象。
|
||||
- `**kwargs`:send 方法的额外参数。
|
||||
|
||||
2. `should_call_api`
|
||||
定义事件响应器预期调用的平台 API 接口,即通过[调用平台 API](../../appendices/api-calling.mdx#调用平台-API)进行的操作。`should_call_api` 有四个参数:
|
||||
|
||||
- `api`:API 名称。
|
||||
- `data`:预期的请求数据。
|
||||
- `result`:call_api 的返回值,将会返回给插件。
|
||||
- `adapter`(可选):调用 API 的平台适配器对象。
|
||||
- `**kwargs`:call_api 方法的额外参数。
|
||||
|
||||
下面是一个使用 `should_call_send` 和 `should_call_api` 方法的示例:
|
||||
|
||||
我们先定义一个测试插件,在响应流程中向用户发送一条消息并调用 `Console` 适配器的 `bell` API。
|
||||
|
||||
```python {8,9} title=example.py
|
||||
from nonebot import on_command
|
||||
from nonebot.adapters.console import Bot
|
||||
|
||||
foo = on_command("foo")
|
||||
|
||||
@foo.handle()
|
||||
async def _(bot: Bot):
|
||||
await foo.send("message")
|
||||
await bot.bell()
|
||||
```
|
||||
|
||||
然后我们对该插件进行测试:
|
||||
|
||||
```python title=tests/test_example.py
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
import nonebot
|
||||
from nonebug import App
|
||||
from nonebot.adapters.console import Bot, User, Adapter, Message, MessageEvent
|
||||
|
||||
def make_event(message: str = "") -> MessageEvent:
|
||||
return MessageEvent(
|
||||
time=datetime.now(),
|
||||
self_id="test",
|
||||
message=Message(message),
|
||||
user=User(id="user"),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_example(app: App):
|
||||
from awesome_bot.plugins.example import foo
|
||||
|
||||
async with app.test_matcher(foo) as ctx:
|
||||
# highlight-start
|
||||
adapter = nonebot.get_adapter(Adapter)
|
||||
bot = ctx.create_bot(base=Bot, adapter=adapter)
|
||||
# highlight-end
|
||||
event = make_event("/foo")
|
||||
ctx.receive_event(bot, event)
|
||||
# highlight-start
|
||||
ctx.should_call_send(event, "message", result=None, bot=bot)
|
||||
ctx.should_call_api("bell", {}, result=None, adapter=adapter)
|
||||
# highlight-end
|
||||
```
|
||||
|
||||
请注意,对于在依赖注入中使用了非基类对象的情况,我们需要在 `create_bot` 方法中指定 `base` 和 `adapter` 参数,确保不会因为重载功能而出现非预期情况。
|
||||
|
||||
## 测试会话控制
|
||||
|
||||
在[会话控制](../../appendices/session-control.mdx)一节中,我们介绍了如何使用事件响应器操作来实现对用户的交互式会话。在上一节的示例插件测试中,我们其实已经使用了 `ctx.should_finished` 来断言会话结束。NoneBug 针对各种流程控制操作分别提供了相应的方法来定义预期的会话处理行为。它们分别是:
|
||||
|
||||
- `should_finished`:断言会话结束,对应 `matcher.finish` 操作。
|
||||
- `should_rejected`:断言会话等待用户输入并重新执行当前事件处理函数,对应 `matcher.reject` 系列操作。
|
||||
- `should_paused`: 断言会话等待用户输入并执行下一个事件处理函数,对应 `matcher.pause` 操作。
|
||||
|
||||
我们仅需在测试用例中的正确位置调用这些方法,就可以断言会话的预期行为。例如:
|
||||
|
||||
```python title=example.py
|
||||
from nonebot import on_command
|
||||
from nonebot.typing import T_State
|
||||
|
||||
foo = on_command("foo")
|
||||
|
||||
@foo.got("key", prompt="请输入密码")
|
||||
async def _(state: T_State, key: str = ArgPlainText()):
|
||||
if key != "some password":
|
||||
try_count = state.get("try_count", 1)
|
||||
if try_count >= 3:
|
||||
await foo.finish("密码错误次数过多")
|
||||
else:
|
||||
state["try_count"] = try_count + 1
|
||||
await foo.reject("密码错误,请重新输入")
|
||||
await foo.finish("密码正确")
|
||||
```
|
||||
|
||||
```python title=tests/test_example.py
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from nonebug import App
|
||||
from nonebot.adapters.console import User, Message, MessageEvent
|
||||
|
||||
def make_event(message: str = "") -> MessageEvent:
|
||||
return MessageEvent(
|
||||
time=datetime.now(),
|
||||
self_id="test",
|
||||
message=Message(message),
|
||||
user=User(id="user"),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_example(app: App):
|
||||
from awesome_bot.plugins.example import foo
|
||||
|
||||
async with app.test_matcher(foo) as ctx:
|
||||
bot = ctx.create_bot()
|
||||
event = make_event("/foo")
|
||||
ctx.receive_event(bot, event)
|
||||
ctx.should_call_send(event, "请输入密码", result=None)
|
||||
ctx.should_rejected(foo)
|
||||
event = make_event("wrong password")
|
||||
ctx.receive_event(bot, event)
|
||||
ctx.should_call_send(event, "密码错误,请重新输入", result=None)
|
||||
ctx.should_rejected(foo)
|
||||
event = make_event("wrong password")
|
||||
ctx.receive_event(bot, event)
|
||||
ctx.should_call_send(event, "密码错误,请重新输入", result=None)
|
||||
ctx.should_rejected(foo)
|
||||
event = make_event("wrong password")
|
||||
ctx.receive_event(bot, event)
|
||||
ctx.should_call_send(event, "密码错误次数过多", result=None)
|
||||
ctx.should_finished(foo)
|
||||
```
|
@ -0,0 +1,96 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
description: 模拟网络通信以进行测试
|
||||
---
|
||||
|
||||
# 模拟网络通信
|
||||
|
||||
NoneBot 驱动器提供了多种方法来帮助适配器进行网络通信,主要包括客户端和服务端两种类型。模拟网络通信可以帮助我们更加接近实际机器人应用场景,进行更加真实的集成测试。同时,通过这种途径,我们还可以完成对适配器的测试。
|
||||
|
||||
NoneBot 中的网络通信主要包括以下几种:
|
||||
|
||||
- HTTP 服务端(WebHook)
|
||||
- WebSocket 服务端
|
||||
- HTTP 客户端
|
||||
- WebSocket 客户端
|
||||
|
||||
下面我们将分别介绍如何使用 NoneBug 来模拟这几种通信方式。
|
||||
|
||||
## 测试 HTTP 服务端
|
||||
|
||||
当 NoneBot 作为 ASGI 服务端应用时,我们可以定义一系列的路由来处理 HTTP 请求,适配器同样也可以通过定义路由来响应机器人相关的网络通信。下面假设我们使用了一个适配器 `fake` ,它定义了一个路由 `/fake/http` ,用于接收平台 WebHook 并处理。实际应用测试时,应将该路由地址替换为**真实适配器注册的路由地址**。
|
||||
|
||||
我们首先需要获取测试用模拟客户端:
|
||||
|
||||
```python {5,6} title=tests/test_http_server.py
|
||||
from nonebug import App
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_server(app: App):
|
||||
async with app.test_server() as ctx:
|
||||
client = ctx.get_client()
|
||||
```
|
||||
|
||||
默认情况下,`app.test_server()` 会通过 `nonebot.get_asgi` 获取测试对象,我们也可以通过参数指定 ASGI 应用:
|
||||
|
||||
```python
|
||||
async with app.test_server(asgi=asgi_app) as ctx:
|
||||
...
|
||||
```
|
||||
|
||||
获取到模拟客户端后,即可像 `requests`、`httpx` 等库类似的方法进行使用:
|
||||
|
||||
```python {3,11-14,16} title=tests/test_http_server.py
|
||||
import nonebot
|
||||
from nonebug import App
|
||||
from nonebot.adapters.fake import Adapter
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_server(app: App):
|
||||
adapter = nonebot.get_adapter(Adapter)
|
||||
|
||||
async with app.test_server() as ctx:
|
||||
client = ctx.get_client()
|
||||
response = await client.post("/fake/http", json={"bot_id": "fake"})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success"}
|
||||
assert "fake" in nonebot.get_bots()
|
||||
|
||||
adapter.bot_disconnect(nonebot.get_bot("fake"))
|
||||
```
|
||||
|
||||
在上面的测试中,我们向 `/fake/http` 发送了一个模拟 POST 请求,适配器将会对该请求进行处理,我们可以通过检查请求返回是否正确、Bot 对象是否创建等途径来验证机器人是否正确运行。在完成测试后,我们通常需要对 Bot 对象进行清理,以避免对其他测试产生影响。
|
||||
|
||||
## 测试 WebSocket 服务端
|
||||
|
||||
当 NoneBot 作为 ASGI 服务端应用时,我们还可以定义一系列的路由来处理 WebSocket 通信。下面假设我们使用了一个适配器 `fake` ,它定义了一个路由 `/fake/ws` ,用于处理平台 WebSocket 连接信息。实际应用测试时,应将该路由地址替换为**真实适配器注册的路由地址**。
|
||||
|
||||
我们同样需要通过 `app.test_server()` 获取测试用模拟客户端,这里就不再赘述。在获取到模拟客户端后,我们可以通过 `client.websocket_connect` 方法来模拟 WebSocket 连接:
|
||||
|
||||
```python {3,11-15} title=tests/test_ws_server.py
|
||||
import nonebot
|
||||
from nonebug import App
|
||||
from nonebot.adapters.fake import Adapter
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ws_server(app: App):
|
||||
adapter = nonebot.get_adapter(Adapter)
|
||||
|
||||
async with app.test_server() as ctx:
|
||||
client = ctx.get_client()
|
||||
async with client.websocket_connect("/fake/ws") as ws:
|
||||
await ws.send_json({"bot_id": "fake"})
|
||||
response = await ws.receive_json()
|
||||
assert response == {"status": "success"}
|
||||
assert "fake" in nonebot.get_bots()
|
||||
```
|
||||
|
||||
在上面的测试中,我们向 `/fake/ws` 进行了 WebSocket 模拟通信,通过发送消息与机器人进行交互,然后检查机器人发送的信息是否正确。
|
||||
|
||||
## 测试 HTTP 客户端
|
||||
|
||||
~~暂不支持~~
|
||||
|
||||
## 测试 WebSocket 客户端
|
||||
|
||||
~~暂不支持~~
|
Reference in New Issue
Block a user