Metadata-Version: 2.5
Name: nicemoe
Version: 1.0.0
Summary: OneBot v11 应用端框架
Author-email: nicemoe <255655@qq.com>
License: MIT
License-File: LICENSE
Keywords: asgi,bot,chatbot,onebot,onebot11,qq
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: Chat
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: loguru>=0.7
Requires-Dist: starlette>=0.27
Provides-Extra: scheduler
Requires-Dist: apscheduler<4,>=3.9; extra == 'scheduler'
Provides-Extra: standard
Requires-Dist: uvicorn[standard]>=0.35; extra == 'standard'
Description-Content-Type: text/markdown

# nicemoe

OneBot v11 应用端框架。

严格按 [OneBot v11 规范](https://github.com/botuniverse/onebot-11) 实现，
第三方依赖只有 `starlette` 和 `loguru`。

> 术语：规范里的「OneBot」指**实现端**（NapCat / Lagrange / LLOneBot），
> 本框架是**应用端** —— 消费事件、调用 API。所以应用类叫 `Reactor`；`OneBot`
> 这个词在本项目里**只**表示实现端，日志里的 `[OneBot 15853655]` 就是它。
>
> ⚠ 0.6.0 起 `OneBot` 类改名为 `Reactor`，没保留别名 —— 那个名字和规范正好用反了，
> 留着只会一直误导。`OneBot.init(...)` → `Reactor.init(...)`。

## 从 0.9.9 升级

**代码一行不用改**，日志格式可以在 `config.py` 里自定义了，不用改库。

```python
LOG_CONSOLE_FORMAT = ('<fg #6bcf7f>[{time:YYYY-MM-DD HH:mm:ss.SSS}]</> '
                      '<fg #d98fd4>[seasun]</> '
                      '<level>[{level}]</level> '
                      '{message}')
LOG_TAG_COLOR = 'fg #5fd0d0'
```

前者是控制台那条完整格式串 —— 顺带能把 `[nicemoe]` 换成自己项目的名字、把毫秒去掉、
把方括号换成竖线。后者只管 `[OneBot xxx]` 那一块的颜色。两个都不写就用库的缺省，跟
0.9.9 长得一样。

颜色标签是 loguru 那套：色名（`<green>` / `<light-blue>` …）跟着**用户的终端配色方案**走，
`<fg #rrggbb>` 是写死的 24 位真彩。

⚠ 分成两个是因为 `[OneBot xxx]` **不在** `CONSOLE_FORMAT` 里 —— 它是 `log()` 拼的正文
前缀，不是 sink 的格式串。那边只收颜色不收整串：方括号、`{}` 占位、后面跟正文这套结构
归库管，换掉会让 `log()` 的两个参数对不上。

不走配置的话，`setup_logging(console_format=..., tag_color=...)` 是同一组参数。

**为什么加这个：** 0.9.5 到 0.9.8 连着四版都在改行首三块的配色，最后 0.9.9 原样退回
0.9.4，净效果为零。根子是**库不该替使用者选颜色** —— ANSI 色名不是颜色，是调色板下标，
`<cyan>` 编译出来的 `\033[36m` 意思是「取第 6 格」，那一格是什么 hex 由终端定义（xterm
是纯青，Windows Terminal 偏蓝，VS Code 更暗）；而 SSH 时上色发生在**客户端**，服务器只吐
字节。所以照着任何一个终端调好的色值，换个终端就不成立。库只选**语义位置**（三块互不
相同、都避开 `[WARNING]` 的黄），具体色值交给用户，这两个配置项就是那个出口。

## 从 0.9.5 / 0.9.6 / 0.9.7 / 0.9.8 升级

**代码一行不用改**，行首三块的配色**全部退回 0.9.4 的样子**（0.9.9 发的）。

```python
<green>[时间]</green>  <cyan>[nicemoe]</cyan>  <magenta>[OneBot xxx]</magenta>
```

格式串跟 0.9.4 **逐字相同**。这四版依次试过 `<fg #4a9eda>`、`<light-blue>`、一整套薄荷
/ 天蓝 / 浅紫的十六进制、以及绿 / 洋红 / 青，**全部作废** —— 中间踩了哪几版不用管，从
0.9.4 或其中任何一版升上来，配色都回到同一个样子。想自己定色见上面那节。

## 从 0.9.4 升级

**修了一个发大图会打断心跳的 bug**，另外命令完成行换了一个结果词。

（这一版还动过 `[OneBot xxx]` 的颜色，0.9.9 已经退回来了，见上。）

### `Reactor.run()` 换掉 uvicorn 的 WebSocket 实现

发一张大图（base64 一帧上兆）之后，日志里会冒出来这个：

```
[ERROR] keepalive ping failed
AssertionError: assert waiter is None or waiter.cancelled()
```

uvicorn 默认的 `ws='auto'` 挑的是 websockets 的 **legacy** 实现，那份 `drain()`
上没有锁，而 `_drain_helper` 抄自 asyncio、只容得下一个等待者。大帧把 64 KiB 写缓冲
撑爆 → transport 暂停 → 我们的发送停在那个等待者上，此时 keepalive ping 醒来也去
`drain()`，断言当场炸。

**后果比那行 ERROR 严重**：`keepalive_ping` 的 `except Exception` 打完日志就让协程
退出，不重启也不 `fail_connection()` —— 这条连接**余生都没有心跳**，对端要是不发 FIN
就悄悄死掉，我们发现不了。实现端主动 ping 我们时走的 `pong()` 也在同一条 drain 上，
所以光关 ping 不换实现是修不干净的。

这一版起 `run()` 默认：

```python
uvicorn_kwargs.setdefault('ws', 'websockets-sansio')    # 直接 transport.write()，没有 drain
uvicorn_kwargs.setdefault('ws_ping_interval', None)     # 不主动 ping
```

不主动 ping 不违反规范：OneBot v11 的存活检测走 `heartbeat` 元事件，WS 层 ping 规范
一个字没提；RFC 6455 也只把发 Ping 定为 MAY，MUST 的「收到 Ping 必须回 Pong」sansio
照做。nb1 是同样的行为 —— 它那条链是 aiocqhttp → Quart → hypercorn → wsproto，
`websocket_ping_interval` 默认就是 `None`。

⚠ `uvicorn[standard]` 的下限从 `>=0.23` 提到 **`>=0.35`**（`websockets-sansio` 是那一版
进的）。用 `uvicorn main:asgi` 命令行起的话这两个默认值管不着，要自己带
`--ws websockets-sansio`。

### `blocked` → `silently`

有按字符串抓日志的监控 / 告警要跟着改。

```
改前  Message 10049 is handled as a command blocked (3ms)
改后  Message 10049 is handled as a command silently (3ms)
```

**`blocked` → `silently`**，因为它断言了一个框架看不见的**原因**。框架知道的只有
「`Finish` 冒出来了、没带话」，谁拦的它不知道 —— `ctx.finish()` 是公开 API，处理函数
自己收尾走的也是这条，日志却报「被拦下」。而 `Finish` 对这个状况的自称本来就是
`finished silently`（`exceptions.py`），换完日志和异常口径一致。

这跟 0.9.2 判掉 `replied` 是同一条理由：代码里本来就那么叫，只有日志是例外。

顺带，`sent` / `unsent` / `silently` 现在落在同一根轴上 —— 发了 / 想发没发成 /
压根没打算发。

| 词 | 什么时候 |
|---|---|
| `sent` | 处理函数 return 了东西，发出去了 |
| `finished` | `raise Finish('话')`，那句话发出去了 |
| `unsent` | 有话要回，但实现端没收下 |
| `silently` | `raise Finish()` 不带话，一个字都没出去 |
| `done` | 跑完了，没回话 |
| `denied` | `permission` 谓词没过 |

## 从 0.9.3 升级

**代码一行不用改**，插件加载那两行换措辞。

```
改前  [DEBUG] Plugin loading: 'plugin.member'
      [INFO]  Plugin loaded: 'plugin.member'
改后  [DEBUG] Plugin importing: 'plugin.member'
      [INFO]  Succeeded to import and load "plugin.member"
```

成功那条说的是「导入**并加载**」：`import_module` 只是把模块导入，真正让插件生效的是
模块里那些装饰器在导入过程中跑起来（注册命令、挂处理函数），两件事都完成了才打。
前面那条相应从 `loading` 改成 `importing` —— 那一步做的确实只是 import，两条读成一对
（进行时 → 完成时）。

⚠ 成功那条的措辞照搬 nb1 的 `Succeeded to {act} "{module_path}"`，是**框架里唯一破
约定的一行**：动词开头（约定 1 要主语在前）、名字用双引号（约定 4 要 `!r`）。
`log.py` 的约定里记了这条例外，别照着它写新日志。

## 从 0.9.2 升级

**代码一行不用改**，撤回一处措辞。

**`Scheduler started: 9 schedules` 改回 `9 jobs`。** 0.9.2 把它改成了 `schedules`，
这一版又回来了 —— `job` 是 APScheduler 自己的词（`scheduled_job` / `add_job` /
`get_jobs`，那几个改不了），日志换个说法就跟你在代码里写的、和 `get_jobs()` 查到的
对不上，同一样东西两个叫法。`__repr__` 一起回退。

## 从 0.9.1 升级

**代码一行不用改**，全是日志措辞。有按字符串抓日志的监控 / 告警要跟着改一遍。

```
改前  Message 10049 is handled as a command replied (172ms)
      Message 10049 is handled as a command aborted (3ms)
改后  Message 10049 is handled as a command sent (172ms)
      Message 10049 is handled as a command blocked (3ms)
```

**`replied` → `sent`**，因为代码里本来就这么叫：局部变量是 `sent`、发失败那条
WARNING 写的是 `reply not sent`、失败的结果词叫 `unsent` —— 只有成功那个叫
`replied`，四处口径里它是唯一的例外。换完 `sent` / `unsent` 天然成对。

**`aborted` → `blocked`**，因为 `aborted` 的主语错了。它读起来像「命令自己中止了」，
而实际发生的是**闸门把它拦下**：冷却中、没授权、被拉黑、开关关着。

结果词从这一版起是六个（`blocked` 后来在 0.9.5 改叫 `silently`，见上）。

⚠ `sent` 和 `finished` 对读日志的人是同一件事（话发出去了），只是内部一个走 return
一个走 `Finish`。没合并 —— 想合的话在 `CommandRegistry.run` 那两处都写 `sent` 即可。

**插件逐个报，INFO。**

```
改前  [DEBUG] Loading plugin 'plugin.member'
      [INFO]  Loaded 24 plugins from plugin
改后  [DEBUG] Plugin loading: 'plugin.member'      ← 卡住时是谁卡的
      [INFO]  Plugin loaded: 'plugin.member'       ← 新增，默认就看得见
      [DEBUG] Plugins loaded: 24 from 'plugin'     ← 降级，逐个那行已经说全了
```

0.8.6 曾把逐个那行降到 DEBUG（理由是「几十个插件刷几十行」），这一版把**成功**那条
提到 INFO —— 「哪些插件进来了」是开机时最常被问的一件事，而汇总行只报数，插件名
写错、目录漏放的时候它一个字都不说。

两条逐个的行分工不同，都留着：`loading` 打在 import **之前**（插件在模块级连数据库
卡死时，日志最后一行正好告诉你是谁卡的），`loaded` 打在之后。前者仍在 DEBUG。

⚠ 这两行的措辞在 0.9.4 又改了一次，见上面那节。

⚠ 三条都改成了**主语在前 + 名字在末尾**。原来那两行是 loader 里仅有的动词开头的
（约定 1 要求主语在前），而同一个文件里的错误行 `Plugin 'x' failed to load` 反而是
对的 —— 三条日志两种句式。名字放末尾则前缀对齐成一列：这些行成串出现，插件名长短
不一，动词跟在名字后面的话每行落点都不同，扫下来是参差的。

⚠ 汇总行降到 DEBUG —— 逐个那行已经把名字一个不落地报过了，再补一句总数是重复。
**但零个插件时它留在 INFO**：那时逐个那行一条都不打，静悄悄地什么都没加载，是最难
查的那种「机器人怎么没反应」，这条是唯一的痕迹。包名也补了 `!r`：使用者的包十有
八九就叫 `plugin`，不加引号读起来是 `24 plugins from plugin`，分不清那是包名还是单词。

**`Scheduler started: 9 jobs` → `9 schedules`。**

`job` 是 APScheduler 的词，日志里换成了 `schedules`，`__repr__` 一起改。

⚠ **0.9.3 又改回 `jobs` 了** —— 换个说法就跟 `get_jobs()` / `scheduled_job` 对不上。
从 0.9.1 直接升到 0.9.3 的话，这一项等于没发生过。

## 从 0.9.0 升级

**代码一行不用改**，拿掉一行日志。

**事件总线的完成行没了。** 就是这句：

```
Message 37 is handled by 1 (4ms)
```

N 是被调用的处理函数条数 —— 那不是这条事件的**归宿**，是框架内部跑了几个回调，
用户看不见、排查时也不指向任何结论；而只要项目里挂了一个「每条消息都要看一眼」的
常驻处理函数，它就变成每条群消息都打，噪音盖过信号。0.9.0 的 `test=` 只是让这个
数字变准，没解决「这行该不该在」。

归宿本来就各有出口：命令走 `is handled as a command <结局>`，被会话吃掉走
`is captured by session`，出错走 `raised while handling`。以上都不是的话，收包
那一行已经说明「这条消息到了」，再补一句「跑了 N 个回调」没有增加任何信息。

nb1 也没有这种日志 —— 它 INFO 级只有 `is ignored` / `is handled as a command` /
`is handled as natural language` 三条，全是归宿，「哪个处理函数跑没跑」一律 DEBUG。

⚠ 有按这行做监控 / 告警的要改。`emit()` 回的 `Dispatch.handled` **没动**，程序里
照样拿得到；`test=` 也照旧，它省掉的是函数调用本身，跟日志无关。

## 从 0.8.9 升级

**代码一行不用改**，新增一个能力。

**处理函数可以声明前置判定：`@app.on('message', test=...)`。** 判定不过就**根本不调用**
处理函数，也不计进 `Dispatch.handled`：

```python
@app.on('message', test=lambda ctx: ctx.event.text.strip() in games)
async def guess(ctx): ...
```

这条是给**常驻监听器**用的 —— 挂在 `message` 上、每条消息都要看一眼、但绝大多数情况
第一行就 return 的那种。判断写在函数体里的话，框架看到的是「每条消息都有人处理」，
`Dispatch.handled` 恒等于监听器个数；挪到 `test=` 之后判定不过连函数调用都省了。

判定同步异步都行，签名和处理函数一样是 `(ctx)`。判定自己抛异常时当作没通过（宁可
不跑，也不拿一个未知状态去跑），异常照样走 `on_error`，不静默。

`on_message` / `on_notice` / `on_request` / `on_meta_event` 也都收 `test=`。

⚠ `handlers_for()` 的语义没变，它答的是「命中了哪些」，不看 `test` —— 想知道这次
实际跑了几个，用 `emit()` 回的 `Dispatch.handled`。

⚠ 这是 nb1 `on_natural_language` 那套的搬运：它的 `NLProcessor.test()` 不过就不调
`func`，`procs_empty` 时连日志都不打。nb1 把「每条消息都要看」和「处理了这条消息」
分成 `message_preprocessor` 和处理函数两个注册位，本框架合成了一个，`test=` 是把那条
界线补回来。

## 从 0.8.8 升级

**代码一行不用改**，三处都是日志行为的调整。

**框架代发的消息失败了，不再当成命令炸了。** 「代发」指三处：权限不足时的 `denied`
文案、`Finish('话')` 带的那句、以及处理函数 `return` 的返回值 —— 都是「你 return
一句，框架替你发」的便利糖。以前发失败会抛异常、打整篇 traceback、触发 `on_error`；
现在记一行 WARNING 就结束：

```
[WARNING] Message 331816574 reply not sent: Action send_msg failed，retcode=1200，Timeout: ...
[INFO]    Message 331816574 is handled as a command unsent (22013ms)
```

实现端上传大图超时（NapCat 的 retcode 1200）是最常撞上的场景，而它多半是**假失败**
—— 消息最后还是送达了。命令本身已经跑完，把它判成异常是过度反应；那条 traceback
从入口到 asyncio 没有一帧是业务代码，能用的只有 retcode 和 message。

⚠ **业务代码自己写的 `await ctx.send(...)` 照旧抛**。那是你主动发起的调用，成没成
该由你决定怎么办。这次只包框架代发的那三处。

⚠ 完成行多了第五个结果词 `unsent`（有话要回但没发出去）。发失败时**不会**再写
`sent` —— 按消息号 grep 出来就那一行，写「发出去了」而实际一个字没送到，比不打还坏。

**异常不再打抛出点之上的调用栈。** loguru 的 `backtrace` 默认开着，而这是个 asyncio
常驻服务，那截栈永远是 `main.py → uvicorn.run → run_forever → Handle._run`，条条一样、
零信息量，还把有用的几帧挤到屏幕外。实测同一个异常 21 行 → 6 行。异常自己的 traceback
和 `diagnose` 的变量值照打。想调回 loguru 默认：`LOG_BACKTRACE = True`。

**收包行的消息内容不再截断。** 以前超过 120 字加省略号，而卡片消息（`[CQ:json]`）、
长转发、带一串参数的 CQ 码，要看的正是省略号后面那截。换行照样压成空格，一条消息
仍然占一行。

⚠ 我们**发出去**的消息不进日志（`describe()` 的调用点全是收到的事件，`call_action`
一行日志都不打），所以这里不存在「自己发的 base64 把日志冲垮」。唯一的例外是实现端
把机器人自己发的消息回推成 `message_sent` 事件，那是实现端的开关，默认关着。

## 从 0.8.7 升级

**只有一处要改**：中间件里读 `ctx.state['result']` 拿处理函数返回值的写法失效了，
那个键不再存在（见下）。其余全是加法，运行时行为没动。

**类型注解终于对下游生效了。** 包里一直缺 PEP 561 要求的 `py.typed` 标记文件，而
classifiers 里写着 `Typing :: Typed` —— 没有那个文件，mypy / pyright 对**装了这个包的
项目**完全忽略我们的注解，`Reactor` 一律降成 `Any`。0.8.5 整版都在改注解（让子类化
之后 IDE 认得出自己加的属性），那一版对类型检查器的收益其实是**零**：IDE 靠源码推断
才让它看起来生效了，装成依赖的下游一点都拿不到。

**`ctx` 身上两个最常用的字段有类型了。**

```python
ctx.app    # 0.8.7：Any    →    0.8.8：Reactor
ctx.bot    # 0.8.7：Any    →    0.8.8：Bot
```

原来为躲循环导入标成 `Any`，代价是整条下游都退化成无类型 —— 处理函数拿到的就是
`ctx`，它身上最常用的两个字段没类型，别处注解写得再全也白搭。`permission`、`errors`、
`session` 里的 `ctx: Any` / `bot: Any` 一并换掉了。

⚠ 只有 `Reactor` 走 `TYPE_CHECKING`（`app` 反过来要 import 那几个模块，真导会成环），
其余都是普通运行时导入。这个区别有实际后果：`TYPE_CHECKING` 下的注解只是字符串，
`typing.get_type_hints()` 解析不出来，靠反射读注解的东西（文档生成、参数校验）会当场
`NameError`。

**`MessageLike` 现在反射得出来了。** 它定义在两个类之前，所以里面是 `'MessageSegment'`
/ `'Message'` 两个前向引用字符串 —— 别的模块 import 它之后，那两个名字在**它们的**
命名空间里并不存在。`Command.denied`、`session.prompt`、`Context.send`、
`ErrorInfo.send` 这些公开签名全中招，`get_type_hints()` 一律 NameError。现在在
`message.py` 末尾用真类对象再绑了一次，两份并存（前一份给本模块内先执行的注解用）。

**handler 的返回值不再占 `ctx.state`。**

```python
@app.middleware
async def mw(ctx, next_):
    ctx.state['result'] = await fetch(...)   # 0.8.7：悄悄改掉命令要回的话
    await next_()                            # 0.8.8：只是个普通中间量
```

`state` 的文档定位一直是「中间件之间传东西用，框架不碰」，而框架自己往里塞了个
`'result'` 键 —— 那是个**没有命名空间的公共 dict**，撞上就是静默改行为，而写的人多半
以为自己只是存了个中间量。返回值改走闭包局部变量，撞不上了。

⚠ 反过来，原来靠读 `ctx.state['result']` 在中间件里后处理返回值的写法**没有替代品**。
真需要的话让 handler 自己写进 `ctx.state['你的键名']`。

**新增两个小东西。**

```python
event.deepcopy()          # 和 copy() 行为完全一样，只是名字对得上行为
                          # ⚠ copy() 保留不动：它继承 dict 的名字却是深拷贝，
                          #   改成浅的会悄悄破坏依赖它的代码

from nicemoe.testing import reset_cq_warnings
reset_cq_warnings()       # 忘掉「哪些 CQ 功能名已经警告过」，测试之间互不干扰
```

后者原来只能伸手改 `nicemoe.message._WARNED_CQ`。那个集合**仍然是进程级**的，这是
故意的 —— 它记的是「这处代码写错了、已经提醒过作者」，不是应用状态，一个进程里造两个
`Reactor` 没道理为同一行代码警告两遍。

## 从 0.8.6 升级

**代码不用改**，这一版只动日志正文。但**日志正文全变了** —— 有按字符串抓日志的
监控 / 告警规则的话得跟着改一遍。

**连接 / 断开去掉在线数。**

```
改前  [OneBot 15853655] Connected, 1 online
      [OneBot 15853655] Disconnected (1000), 0 online
改后  [OneBot 15853655] Connected
      [OneBot 15853655] Disconnected (1000)
```

绝大多数部署只连一个号，那个数字恒等于 1，连上写 1、断开写 0，没有信息量。
真要看在线情况的用 `len(app.bots)`，那是给代码读的，不该占日志的版面。
`as event` / `as api` 后缀和断线原因都留着 —— 那两个只在不寻常时才出现。

**其余 32 条正文按五条约定统一了一遍**，约定写在 `log.py` 的模块 docstring 里：

1. 一行一句话，**主语在前**，首字母大写，不加句号 —— 主语在前才 grep 得动
2. 组件生死一律 `<Component> started[: 细节]` / `<Component> stopped`
3. 出错的动词只有三个：`raised`（别人的代码抛了，带堆栈）、`… failed: {exc}`
   （我们发起的动作没成）、`… dropped/skipped: {原因}`（我们主动丢的）
4. 标识符一律 `!r` 包起来 —— 名字带空格或中文时没引号看不出边界
5. 事件现场统一挂行尾（`… raised while handling {事件}`），不自造箭头

挑几条对照：

```
改前  nicemoe started: 8 workers, queue size 1000
      Scheduler started with 9 jobs
      Received non-JSON data, dropped: '…'
      Event queue full (1000), dropped message.group, 3 dropped so far
      Authentication failed, refusing connection from ('1.2.3.4', 5678)
      invoke() called with unknown command '查询'
      Alias '猫猫头' for '表情包' is blank, skipped
      Failed to fetch member role (123/456): …
      command 查询 raised <- Message 10049 from 255655@1028: 查询 天鹅坪

改后  Reactor started: 8 workers, queue size 1000
      Scheduler started: 9 jobs
      Frame dropped: not JSON ('…')
      Event message.group dropped: queue full (1000), 3 dropped so far
      Connection from ('1.2.3.4', 5678) refused: authentication failed
      Unknown command '查询' passed to invoke()
      Alias '猫猫头' for '表情包' skipped: blank
      Member role fetch failed (123/456): …
      Command '查询' raised while handling Message 10049 from 255655@1028: 查询 天鹅坪
```

顺带修掉两处：**别名撞名的告警没说是谁想注册它**（另外三条别名告警都写了
`for '{命令名}'`，只有这条没有，读起来是 `Alias '测试' skipped: already taken
by '测试'`）；**`stop_propagation` 那行漏了 `[OneBot xxx]` 前缀**，它跟收包行、
完成行、出错行是同一条消息的四行，只有它按账号 grep 不到。

## 从 0.8.5 升级

**代码不用改**，这一版只动类型注解，运行时行为一个字节没变。

**子类化之后 IDE 不认识自己加的属性。** `init()` 是 classmethod，运行时
`return cls(**values)` 给的是你的子类，但返回类型写死成 `-> 'Reactor'`：

```python
class Driver(Reactor):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.database: Database = Database()

driver = Driver.init(config)
driver.database          # IDE：类 Reactor 的特性引用 database 未解析
```

自己标 `driver: Driver = Driver.init(config)` 也没用 —— 警告只会换成「应为
Driver，实际为 Reactor」，根因在返回类型上，得写 `cast` 才压得住。现在返回类型
跟着**实际调用的类**走，子类化之后下游不用再打补丁。

同样改掉的还有几个返回 `self` 的链式方法 —— `Message.append` / `.extend` /
`.reduce`、`Session.__aiter__`、`FakeOneBot.__aenter__` —— 以及
`MessageSegment.from_dict`。`Message` 的子类链式调一串下来不再被降格成 `Message`。

`MessageSegment.text()` / `.at()` 这批工厂方法**没动**：它们内部就是
`return MessageSegment(...)`，子类调用拿到的确实是基类实例，跟着类走反而是错的。

⚠ 用 `TypeVar` 不是 `typing.Self` —— 后者要 3.11，而这个包从 3.9 起步，
也不值得为它引 `typing_extensions`。

**插件加载日志收成一行。** 逐个的 `Loading plugin xxx` 降到 DEBUG，INFO 级只剩
`Loaded 23 plugins from plugin` 那条汇总 —— 二三十个插件就刷二三十行，正常启动时
没人一个个看。要查谁在模块级卡住时开 DEBUG，那行照样打在 import 之前。

## 从 0.8.4 升级

**代码不用改**，除非你直接调过 `EventBus.emit()` —— 它现在返回
`Dispatch(quick_op, handled)` 而不是裸的快速操作结果，取的时候写 `.quick_op`。

这一版全在修 bug，其中三个会咬人。

**每处理一个事件泄漏一条。** 出错上报要的现场（哪条连接、哪个事件）存在一张按 task
索引的旁表里，而清它的回调只有**挂起过**的处理函数才挂得上 —— 没挂起的（也就是绝大
多数）一条都清不掉，关服也清不干净。实测 1000 条群消息漏 1.5 MB，按 5 条/秒算一天
几百 MB。现场改成绑在 done_callback 上，跟 task 同生共死。

**配置里少写一对方括号会静默失效。** `SUPERUSERS = '15853655'` 被拆成一把单个数字，
于是**谁都不是超管**；`NICKNAMES = '萌萌'` 方向反过来，「萌」开头的消息全算在跟机器人
说话。跟 0.7.0 给 `alias`、0.8.0 给 `patterns` 修的是同一个坑，现在统一成一个规矩：
传字符串就是一个，传元组才是多个。`COMMAND_PREFIXES` 同理。

**api / event 分离的连接配置跑不起来。** 连接表按 self_id 单键存，第二条一连上就把
第一条当「同账号重连」踢掉，永远凑不齐一对。现在按 `(账号, 角色)` 存、两条并存，而且
event 那条上的 API 调用会自动转给同账号的 api 连接 —— 处理函数里照写
`await ctx.send(...)`。看物理连接用新增的 `app.connections`，`app.bots` 仍然按账号。

另外两处：非 ASCII 的 `ACCESS_TOKEN` 现在**构造时**就报错（令牌走 HTTP 头，头带不了
非 ASCII），原来是每次握手抛一个 `hmac.compare_digest` 的 TypeError，信息跟真实原因
毫无关系；回显用户输入时 `[CQ:xxx]` 的告警只认规范里的段类型了 —— 原来功能名是从待发
文本里抓的，等于把去重键交给群友，随便编几百个名字就能刷屏。

## 从 0.7.x 升级

**代码不用改**，但日志的默认行为变了两处，一增一减。

**增**：每处理完一件事都留一行 INFO。原来只有收包那条，「机器人没反应」时分不清
是没收到、卡住了、还是跑完了但被中间件静默拦下 —— 而最后那种在用户侧和前两种
长得一模一样。现在按消息号 grep 一次就能把全程捞出来：

```
Message 10026 from 255655@1028613677: /G 查看黑名单 1
Message 10026 command SUPERUSER finished in 31ms
```

一个处理函数都没命中的事件**不打** —— 那是绝大多数群消息的归宿，打出来只会是
一行「什么都没做」。

**减**：第三方 stdlib 日志新增门槛 `LOG_INTERCEPT_LEVEL`，默认 `WARNING`
（见下文）。原来 root 是 `NOTSET`，`LOG_LEVEL='DEBUG'` 会连带打开 httpx、
websockets 的 INFO；现在不会了。**排查网络问题时看不到 httpx 了就调它**：

```python
LOG_INTERCEPT_LEVEL = 'INFO'
```

另外修了一个静默失效：`patterns=r'^来一发$'` 传单条字符串，原来会被逐字拆成
`^`、`来`、`一`… 每条都编译得过、都匹配不上，命令就是**不触发也不报错**。
和 0.7.0 给 `alias` 修的是同一个坑。

## 从 0.6.x 升级

两处改名，都没留兼容别名（1.0 之前不背包袱）：

```python
@app.command('查询', aliases=('查',))     # 0.6.x
@app.command('查询', alias='查')          # 0.7.0 —— 单数，一个别名不用再包元组

ctx.command.extra                        # 0.6.x
ctx.command.meta                         # 0.7.0
```

`alias=` 单复数换过来是因为绝大多数命令只有一个别名，而 `aliases=('查')`
**是字符串不是元组**，会被拆成单个字符当成多个别名，还不报错。现在传字符串就是
一个别名，传元组才是多个。

新增：`@app.command(..., middleware=False)`（救援类命令跳过中间件链，见下文）、
`ctx.is_private`。

## 安装

```bash
pip install nicemoe[standard]                # 带上 uvicorn
pip install "nicemoe[standard,scheduler]"    # 再带上定时任务（APScheduler）
```

核心只依赖 `starlette` 和 `loguru`。ASGI 服务器和定时任务都是可选的 ——
不做定时任务的人不该被迫装 APScheduler。

## 三分钟上手

```python
# config.py —— 和代码分开，进 .gitignore
ACCESS_TOKEN = ''                   # 和实现端配的一致，留空表示不校验
SUPERUSERS = [10001000]
LOG_FILE = 'logs/{time:YYYY-MM-DD}.log'   # 不写就只打控制台
NICKNAMES = ['萌萌']                # 「萌萌，查询 xxx」也能触发，昵称会被剥掉
COMMAND_PREFIXES = ('/', '')        # 带不带 / 都认
HOST, PORT = '127.0.0.1', 8080
```

```python
# main.py
import config
from nicemoe import Reactor, logger              # 核心
from nicemoe.exceptions import Finish           # 异常在 exceptions
from nicemoe.permission import GROUP_ADMIN, SUPERUSER   # 权限在 permission

app = Reactor.init(config)    # 大写配置项自动映射；不认识的安静忽略
                                    # 日志也在这一句里配好（见下）
asgi = app.asgi                     # 也可以 uvicorn 模块名:asgi


@app.command('查询', alias='查', cooldown=5)
async def query(ctx):
    """查询角色信息。"""
    if not ctx.args:
        raise Finish('用法：查询 <区服> <角色名>')
    return f"{ctx.args[0]} 的 {ctx.args.rest(1)}"    # 返回非 None 自动发出去


@app.command('踢', permission=SUPERUSER | GROUP_ADMIN, denied='需要管理员权限')
async def kick(ctx):
    await ctx.bot.set_group_kick(group_id=ctx.group_id, user_id=ctx.args.int(0))


@app.command('绑定')
async def bind(ctx):
    reply = await ctx.prompt('要绑定哪个角色？', timeout=60)
    return f"已绑定 {reply.text}"


if __name__ == '__main__':
    app.run()                       # host/port 从 config 来
```

实现端的反向 WebSocket 地址填 `ws://你的地址:端口/onebot/v11/ws`。

> 配置用 **Python 文件**而不是 `.env`，图的是类型是真的：`ONLY_TO_ME = False`
> 就是布尔假，不用担心 `'false'` 这个字符串是真值那种坑；`COMMAND_PREFIXES`
> 直接是元组，不用编码成 `/,#,` 那种谁都看不懂的写法。`config.py` 本身在
> `.gitignore` 里，所以令牌直接写字面量就行；要部署到 CI／容器里再换成
> `os.getenv(...)`，改一行的事。
>
> `app` 是个**普通对象**，不是全局单例 —— 你能造第二个、能在测试里造一次性的。
> 插件想拿到它就 `from app import onebot`（应用对象单独放一个模块，别放 main.py，
> 否则会和 `load_plugins` 形成循环导入）。

## 特点

**连接是一等公民。** `Bot` 就是一条连接，`app.bots` 是公开的在线表（按账号），
`app.connections` 按 `(账号, 角色)` 看物理连接。断线时未完成的 API 调用**立刻**抛
`ConnectionClosed`，不用干等超时。

规范允许把 API 和事件拆成两条连接，那时两条**并存**（不会互相顶掉），而且 event
那条上的 API 调用会自动转给同账号的 api 连接 —— 处理函数里照写 `await ctx.send(...)`，
不用关心事件是从哪条连接进来的。

**收包不会被业务拖住。** 有界队列 + worker 池，handler 一进入挂起点就把
worker 放掉。2 个 worker 能同时撑住任意多局进行中的游戏，不会死锁。

**会话状态就是局部变量。**

```python
@app.command('猜成语')
async def guess(ctx):
    answer = pick_idiom()
    async with ctx.capture(scope='group', match=is_idiom, timeout=60) as stream:
        async for guess in stream:
            if guess.text == answer:
                return MessageSegment.at(guess.user_id) + ' 答对了'
    return f'没人猜出来，答案是 {answer}'
```

不需要全局的「谁在玩」表，超时和异常退出自动收尾。

**处理函数只有一种签名：`(ctx)`。** 事件和命令都一样 —— 区别只在于命令的上下文
多了 `args` / `command`。所以 `await ctx.send(...)` 两边都能用：

```python
@app.on('notice.group_increase')
async def welcome(ctx):
    await ctx.send(MessageSegment.at(ctx.user_id) + ' 欢迎进群～')
```

**消息只用数组格式。** 裸字符串一律当纯文本，不解析其中的 CQ 码 ——
`f"欢迎 {昵称} 加入"` 不会因为有人把昵称改成 `[CQ:at,qq=all]` 就 @全体成员。

**权限是可组合的谓词，不是固定等级表。** 库只给协议定义的那几个事实和
`|` `&` `~` 运算，等级体系你自己搭：

```python
VIP = Permission(lambda ctx: ctx.user_id in vip_set)

@app.command('特权', permission=SUPERUSER | VIP)
@app.command('群管', permission=GROUP_ADMIN & ~ANONYMOUS)
```

判角色不只读 `sender.role`（规范上它「不保证存在」），查不到就回落
`get_group_member_info`，带缓存，且在 `notice.group_admin` 时主动失效。

**`only_to_me` 是触发条件不是权限。** 不满足时当作**没命中这条命令**，
消息落到事件总线，而不是「命中了但拒绝」。at 和昵称会被剥掉，所以
`@机器人 /查询 天鹅坪` 里 `ctx.args.rest()` 就是 `天鹅坪`。

**日志有默认值，但没有副作用。**

```
[2026-08-04 11:42:51.551] [nicemoe] [INFO] [OneBot 15853655] Message 10050 from 255655@1028613677: 查询 天鹅坪
[2026-08-04 11:42:51.552] [nicemoe] [ERROR] [OneBot 15853655] command 查询 raised <- Message 10050 from ...
```

收到的每条消息按 **INFO** 打（机器人的主要工作内容，不该开 DEBUG 才看得见），
心跳这类元事件压到 TRACE。出错时把**现场**拼在后面，群里热闹时也对得上是哪条消息。

日志跟其它配置项一样写在 `config.py` 里，`init` 顺手配好 —— 不用在代码里
手动拼一遍格式串：

```python
LOG_LEVEL = 'INFO'                        # 缺省 INFO
LOG_FILE = 'logs/{time:YYYY-MM-DD}.log'   # 缺省不写文件
LOG_RETENTION = '14 days'                 # 缺省 14 days
```

一个都不写也照配，缺省是 INFO + 只打控制台 —— 跟 `HOST`、`WORKERS` 一样，
配置项不写就用框架默认值。

**`init` 是框架唯一会动全局日志状态的入口**，要它完全不碰就传
`configure_logging=False`。`Reactor(...)` 直接构造**永远没有副作用**，
`import nicemoe` 也不会偷偷装 sink —— 所以单测里造几百个应用不会互相污染。
库内部的日志另有 `logger.disable('nicemoe')` 一句关干净。

`from nicemoe import logger` 省掉一行 import，但它**就是 loguru 的全局 logger
原样再导出**，不是包装层 —— `nicemoe.logger is loguru.logger` 为真。

stdlib logging 默认被接管，所以 uvicorn / asyncio 那些库的日志也是同一个格式、
一起落盘，不会出现半屏 `WARNING:  Invalid HTTP request received.` 却在归档里
找不到的情况。

⚠ 接管之后，**第三方**的 stdlib 日志另有一道门槛 `LOG_INTERCEPT_LEVEL`，默认
`WARNING`：

```python
LOG_LEVEL = 'DEBUG'              # 你的和框架的日志
LOG_INTERCEPT_LEVEL = 'WARNING'  # 第三方的（httpx / asyncio / websockets…）
```

两档分开是有理由的。平时裸用 httpx 看不到它打日志，是因为没人给 logging 装
handler 时走的是 `logging.lastResort`，而那个的级别是 WARNING —— 接管把这层默认
保护拿掉了。不补回来的话，`LOG_LEVEL='DEBUG'` 会连带打开所有第三方库的门：httpx
每个请求一行、websockets 每次握手一行，你要看的自己的 debug 反而被冲没。

排查网络问题时把它调成 `'INFO'` 或 `'DEBUG'`，httpx 在干什么就都看得见了。

**定时任务接了 APScheduler，但调度器不是我们写的。**

```python
from nicemoe.ext.scheduler import Scheduler
scheduler = Scheduler(app)          # 自动挂 startup / shutdown

@scheduler.scheduled_job('cron', hour=7, minute=15, misfire_grace_time=300)
async def daily_report():
    ...
```

cron 解析、时区、夏令时、错过补发自己写一定错，APScheduler 写对十几年了。
库只管**接线**：什么时候 `start()`（必须在事件循环里）、什么时候 `shutdown()`
（`wait=False`，一个卡住的 job 不该拖住关服）、没装时报一句人话。
认不出的属性直接转给底层调度器，照 APScheduler 的文档写即可。

**能脱离真 QQ 跑测试。**

```python
from nicemoe.testing import FakeOneBot, running

async with running(app), FakeOneBot(app) as impl:
    await impl.send_group_message('/查询 天鹅坪 张三')
    call = await impl.expect_action('send_msg')
    assert call['params']['message'].extract_plain_text() == '天鹅坪 的 张三'
    await impl.reply(call, {'message_id': 1})
```

## 不做的事

不做 OneBot v12 抽象层、不做插件热重载、不做依赖注入、不做 NLP 意图路由。

冷却、功能开关、黑名单、授权、使用统计这些都**不在库里** —— 它们需要知道
「数据存在哪」，属于使用者的产品形态。库提供 `@app.middleware` 挂载点和
`ctx.command.meta` 元数据透传，够用了。

## 开发

```bash
python -m unittest discover -s tests -t .   # 473 个用例，不开端口
python -m tests.smoke_uvicorn                # 真 uvicorn + 真 WebSocket
```

## 许可

MIT
