🔖 Release 2.4.2
Some checks failed
Code Coverage / Test Coverage (pydantic-v1, ubuntu-latest, 3.10) (push) Failing after 1m25s
Code Coverage / Test Coverage (pydantic-v1, ubuntu-latest, 3.11) (push) Failing after 1m24s
Code Coverage / Test Coverage (pydantic-v1, ubuntu-latest, 3.12) (push) Failing after 1m30s
Code Coverage / Test Coverage (pydantic-v1, ubuntu-latest, 3.9) (push) Failing after 1m23s
Code Coverage / Test Coverage (pydantic-v2, ubuntu-latest, 3.10) (push) Failing after 1m28s
Code Coverage / Test Coverage (pydantic-v2, ubuntu-latest, 3.11) (push) Failing after 1m33s
Code Coverage / Test Coverage (pydantic-v2, ubuntu-latest, 3.12) (push) Failing after 1m22s
Code Coverage / Test Coverage (pydantic-v2, ubuntu-latest, 3.9) (push) Failing after 1m37s
Pyright Lint / Pyright Lint (pydantic-v1) (push) Failing after 1m23s
Pyright Lint / Pyright Lint (pydantic-v2) (push) Failing after 1m19s
Ruff Lint / Ruff Lint (push) Successful in 27s
Site Deploy / publish (push) Failing after 1m31s
Code Coverage / Test Coverage (pydantic-v1, macos-latest, 3.10) (push) Has been cancelled
Code Coverage / Test Coverage (pydantic-v1, macos-latest, 3.11) (push) Has been cancelled
Code Coverage / Test Coverage (pydantic-v1, macos-latest, 3.12) (push) Has been cancelled
Code Coverage / Test Coverage (pydantic-v1, macos-latest, 3.9) (push) Has been cancelled
Code Coverage / Test Coverage (pydantic-v1, windows-latest, 3.10) (push) Has been cancelled
Code Coverage / Test Coverage (pydantic-v1, windows-latest, 3.11) (push) Has been cancelled
Code Coverage / Test Coverage (pydantic-v1, windows-latest, 3.12) (push) Has been cancelled
Code Coverage / Test Coverage (pydantic-v1, windows-latest, 3.9) (push) Has been cancelled
Code Coverage / Test Coverage (pydantic-v2, macos-latest, 3.10) (push) Has been cancelled
Code Coverage / Test Coverage (pydantic-v2, macos-latest, 3.11) (push) Has been cancelled
Code Coverage / Test Coverage (pydantic-v2, macos-latest, 3.12) (push) Has been cancelled
Code Coverage / Test Coverage (pydantic-v2, macos-latest, 3.9) (push) Has been cancelled
Code Coverage / Test Coverage (pydantic-v2, windows-latest, 3.10) (push) Has been cancelled
Code Coverage / Test Coverage (pydantic-v2, windows-latest, 3.11) (push) Has been cancelled
Code Coverage / Test Coverage (pydantic-v2, windows-latest, 3.12) (push) Has been cancelled
Code Coverage / Test Coverage (pydantic-v2, windows-latest, 3.9) (push) Has been cancelled

This commit is contained in:
noneflow[bot]
2025-03-12 03:56:28 +00:00
parent d3c5dec67b
commit ac79ae2bfc
97 changed files with 19824 additions and 2 deletions

View File

@ -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)

View File

@ -0,0 +1,4 @@
{
"label": "Alconna 命令解析拓展",
"position": 6
}

View File

@ -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&lt;foo&gt;.+)"`
- `"{:\d+}"``"(\d+)"`
- `"{foo:int}"``"(?P&lt;foo&gt;\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)`

View File

@ -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`
是否启动时拉取一次发送对象列表。

View File

@ -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: "[图片]" },
]}
/>

View File

@ -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
```
:::tip
或许你注意到了 `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 的处理方法。

View File

@ -0,0 +1,171 @@
---
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_plugin_cache_dir()
# 获取插件缓存文件
cache_file = store.get_plugin_cache_file("file_name")
# 获取插件数据目录
data_dir = store.get_plugin_data_dir()
# 获取插件数据文件
data_file = store.get_plugin_data_file("file_name")
# 获取插件配置目录
config_dir = store.get_plugin_config_dir()
# 获取插件配置文件
config_file = store.get_plugin_config_file("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_plugin_data_file("file_name")
# 写入文件内容
data_file.write_text("Hello World!")
# 读取文件内容
data = data_file.read_text()
```
:::note 提示
对于嵌套插件,子插件的存储目录将位于父插件存储目录下。
:::
## 配置项
### localstore_use_cwd
使用当前工作目录作为数据存储目录,以下数据目录配置项默认值将会对应变更
默认值:`False`
```dotenv
LOCALSTORE_USE_CWD=true
```
### localstore_cache_dir
自定义缓存目录
默认值:
`localstore_use_cwd``True` 时,缓存目录为 `<current_working_directory>/cache`,否则:
- macOS: `~/Library/Caches/nonebot2`
- Unix: `~/.cache/nonebot2` (XDG default)
- Windows: `C:\Users\<username>\AppData\Local\nonebot2\Cache`
```dotenv
LOCALSTORE_CACHE_DIR=/tmp/cache
```
### localstore_data_dir
自定义数据目录
默认值:
`localstore_use_cwd``True` 时,数据目录为 `<current_working_directory>/data`,否则:
- macOS: `~/Library/Application Support/nonebot2`
- Unix: `~/.local/share/nonebot2` or in $XDG_DATA_HOME, if defined
- Win XP (not roaming): `C:\Documents and Settings\<username>\Application Data\nonebot2`
- Win 7 (not roaming): `C:\Users\<username>\AppData\Local\nonebot2`
```dotenv
LOCALSTORE_DATA_DIR=/tmp/data
```
### localstore_config_dir
自定义配置目录
默认值:
`localstore_use_cwd``True` 时,配置目录为 `<current_working_directory>/config`,否则:
- macOS: same as user_data_dir
- Unix: `~/.config/nonebot2`
- Win XP (roaming): `C:\Documents and Settings\<username>\Local Settings\Application Data\nonebot2`
- Win 7 (roaming): `C:\Users\<username>\AppData\Roaming\nonebot2`
```dotenv
LOCALSTORE_CONFIG_DIR=/tmp/config
```
### localstore_plugin_cache_dir
自定义插件缓存目录
默认值:`{}`
```dotenv
LOCALSTORE_PLUGIN_CACHE_DIR='
{
"plugin_id": "/tmp/plugin_cache"
}
'
```
### localstore_plugin_data_dir
自定义插件数据目录
默认值:`{}`
```dotenv
LOCALSTORE_PLUGIN_DATA_DIR='
{
"plugin_id": "/tmp/plugin_data"
}
'
```
### localstore_plugin_config_dir
自定义插件配置目录
默认值:`{}`
```dotenv
LOCALSTORE_PLUGIN_CONFIG_DIR='
{
"plugin_id": "/tmp/plugin_config"
}
'
```

View File

@ -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
```
如果输出是 `没有检测到新的升级操作`,那么恭喜你,数据库已经迁移完成了,你可以启动机器人了。

View File

@ -0,0 +1,4 @@
{
"label": "数据库",
"position": 7
}

View File

@ -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)}"
)
```

View File

@ -0,0 +1,4 @@
{
"label": "开发者指南",
"position": 3
}

View File

@ -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 {1326}
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)
```

View File

@ -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
```

View File

@ -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)。
但覆盖顺序并非显而易见,出于清晰考虑,请只配置必要的选项。
:::

View File

@ -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
```

View File

@ -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`

View File

@ -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),可以帮助你更好地处理跨平台功能,包括事件处理和消息发送等。
:::

View File

@ -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`

View File

@ -0,0 +1,240 @@
---
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>
## 配置测试
在开始测试之前,我们需要对测试进行一些配置,以正确启动我们的机器人。
首先我们需要配置 pytest-asyncio在 `pyproject.toml` 的 pytest 配置部分添加:
```toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
```
然后,我们在 `tests` 目录下新建 `conftest.py` 文件,添加以下内容:
```python title=tests/conftest.py
import pytest
import nonebot
from pytest_asyncio import is_async_test
# 导入适配器
from nonebot.adapters.console import Adapter as ConsoleAdapter
def pytest_collection_modifyitems(items: list[pytest.Item]):
pytest_asyncio_tests = (item for item in items if is_async_test(item))
session_scope_marker = pytest.mark.asyncio(loop_scope="session")
for async_test in pytest_asyncio_tests:
async_test.add_marker(session_scope_marker, append=False)
@pytest.fixture(scope="session", autouse=True)
async def after_nonebot_init(after_nonebot_init: None):
# 加载适配器
driver = nonebot.get_driver()
driver.register_adapter(ConsoleAdapter)
# 加载插件
nonebot.load_from_toml("pyproject.toml")
```
这样,我们就可以在测试中使用机器人的插件了。通常,我们不需要自行初始化 NoneBotNoneBug 已经为我们运行了 `nonebot.init()`。如果需要自定义 NoneBot 初始化的参数,我们可以在 `conftest.py` 中添加 `pytest_configure` 钩子函数。例如,我们可以修改 NoneBot 配置环境为 `test` 并从环境变量中输入配置:
```python {4,6,8-10} title=tests/conftest.py
import os
import pytest
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 默认也会为我们管理 lifespan 的 startup 与 shutdown。如果不希望 NoneBug 管理 lifespan你可以在 `pytest_configure` 里添加以下配置:
```python
import pytest
from nonebug import NONEBOT_START_LIFESPAN
def pytest_configure(config: pytest.Config):
config.stash[NONEBOT_START_LIFESPAN] = False
```
## 编写插件测试
在配置完成插件加载后我们就可以在测试中使用插件了。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 用法将在后续章节中介绍。

View File

@ -0,0 +1,4 @@
{
"label": "单元测试",
"position": 5
}

View File

@ -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)
```

View File

@ -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 客户端
~~暂不支持~~