Metadata-Version: 2.5
Name: moemoe
Version: 0.0.6
Summary: QQ 官方机器人应用端框架，配合 robot 协议端使用
Project-URL: Homepage, https://github.com/nicemoe/moemoe
Project-URL: Source, https://github.com/nicemoe/moemoe
Project-URL: Issues, https://github.com/nicemoe/moemoe/issues
Author-email: nicemoe <22282320@qq.com>
License-Expression: MIT
License-File: LICENSE
Keywords: bot,qq,qqbot,robot
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
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.11
Requires-Dist: loguru>=0.7
Requires-Dist: starlette>=0.37
Provides-Extra: scheduler
Requires-Dist: apscheduler<4,>=3.10; extra == 'scheduler'
Provides-Extra: uvicorn
Requires-Dist: uvicorn>=0.30; extra == 'uvicorn'
Requires-Dist: websockets>=12; extra == 'uvicorn'
Description-Content-Type: text/markdown

# moemoe

QQ 官方机器人的应用端框架，配合 [robot](https://github.com/nicemoe/robot) 协议端使用。

```
QQ 官方 ←──WSS──→ robot（协议端，Go）──反向 WS──→ moemoe（应用端，你的代码）
```

**robot 主动拨号，所以 moemoe 是服务端。** 一个 moemoe 同时接多个 robot，每个 robot
一个 `Bot` 实例。

核心依赖只有 `starlette` 和 `loguru`：连接、凭证续期、断线重连都在 robot 那侧。

## 装

```bash
pip install moemoe[uvicorn]
```

挂进已有的 ASGI 应用就不装 `[uvicorn]`，把 `Moemoe.asgi` 交给你的服务器。

需要 Python 3.11+。

## 三分钟上手

```python
from moemoe import Moemoe

app = Moemoe(access_token='')          # 和 robot 配的一致，留空表示不校验

@app.command('ping')
async def _(event, args):
    return 'pong'                    # 返回非 None 会当消息发出去

app.run(host='127.0.0.1', port=8000)
```

robot 那侧把 `backend.url` 指到 `ws://127.0.0.1:8000/robot` 就通了。

## 多机器人

几十个 robot 连进来是常态。`Event.bot` 指回收到这条消息的那个，所以「回哪条、从哪个
机器人回」永远是确定的：

```python
@app.on_message()
async def _(event):
    await event.send(f'我是 {event.bot.name}')

# 主动推送要指名机器人
await app.bot('4013755093').send(group_openid, '早安')

# 只连了一个时可以省略
await app.bot().send(group_openid, '早安')
```

`app.bots` 是 `{bot_id: Bot}`，`app.bot(id)` 按机器人账号查找。想广播就自己遍历。

```python
event.bot.id       # 机器人账号，对应 robot 配置的 bot_id
event.bot.app_id   # 开放平台应用 ID，对应 robot 配置的 app_id
event.bot.openid   # 机器人在群内的 openid
```

`app_id` 是机器人信息，统一从 `Bot` 读取。事件帧和 `ready` 帧都会更新它，消息处理函数
拿到事件时即可使用；刚连接、还没有收到这两种帧时为空字符串。两个项目需配套更新，
新版事件帧必须包含 `app_id`，`ready.data` 也必须包含 `app_id`，缺失时报告连接错误。

## 三个发消息的方法

QQ 有两套互不相干的机制：`msg_id` 决定这条免不免费，`message_reference` 决定界面上
出不出引用气泡。

```python
await event.send('收到')                       # 回应这次事件。免费，无气泡
await event.reply('收到')                      # 同上，加引用气泡
await event.reply('看这条', target='REFIDX_x')  # 引用别的消息
```

| | `msg_id` | `message_reference` | 额度 |
|---|---|---|---|
| `event.send` | ✓ | — | 免费 |
| `event.reply` | ✓ | ✓ | 免费 |
| `bot.send` | — | — | 烧主动消息额度，走审核 |

**`Event` 只管回应手上这条，发给「某个目标」是 `Bot` 的事。** 两边都叫 `send`，
对象本身说明了是哪一种 —— `Event` 手上有那条消息所以免费，`Bot` 手上什么都没有
所以只能主动发：

```python
await event.bot.send(event.group_openid, '这条不蹭凭据')
await app.bot().send(group_openid, '早安')      # 跟事件无关的推送也是同一个方法
```

处理函数跑太久、`send` 的凭据过期了，就是上面第一种写法。

平台的规矩（moemoe 不代管，超了会拿到 QQ 的错误码）：

- 凭据群聊 **5 分钟**、单聊 **60 分钟**内有效
- 同一条 `msg_id` 最多回 **5 次**（单聊 4 次）
- 引用的值是引用索引 `REFIDX_xxx`，不是 `message_id`，两者不通用
- QQ 不是每条消息都给引用索引，拿不到时 `reply` 的效果等同 `send`

### 免费还是烧额度，robot 自己定

```python
await app.bot().send(gid, '早安')
```

不用你判断。每条消息都在信封上写 `passive: "auto"`，robot 按手上的凭据三步退让：

1. 你给了 `msg_id`（`event.send` 就是这种）→ 还能用就用它
2. 那条过期了、五次回满了 → 换这个会话最新那条
3. 一条能用的都没有 → 摘掉凭据走主动消息，要额度也要过审

第三步是有意的：攥着一张必定被拒的凭据发过去只会换回一个错误码，消息就没了。所以
**哪怕你用的是 `event.send`，robot 也可能把它改成主动消息发出去** —— 回执里的
`passive.used` 说的是真话，想知道烧没烧额度看它。

`msg_seq` 也归 robot 盖，你不用数。那串号跟着 `msg_id` 走，推送和回复取的是同一个
池子，两边各数各的会撞出重复消息。

先问清楚再决定：

```python
out = await app.bot().call('GET', '/robot/passive', {'scene': gid})
if out['left']:
    ...
```

## 命令

```python
@app.command('weather', alias=('天气', 'w'), patterns=r'今天.*天气')
async def _(event, args):
    """查天气"""                        # docstring 进 command.doc
    city = args.get(0, '北京')
    return f'{city}今天晴'
```

`Moemoe(prefixes=('/', ''))` 表示带不带斜杠都认，`nicknames=('萌萌',)` 让「萌萌，天气」
也能触发。

正则触发时命名组直接从 `args.match` 取：

```python
@app.command('开关', patterns=r'^(?P<act>启用|停用)(?P<what>\w+)$')
async def _(event, args):
    return f'{args.match["act"]}了{args.match["what"]}'
```

按名字触发的命令 `args.match` 是 None。

群里默认要 @ 机器人才响应。整个应用改掉用 `Moemoe(to_me=False)`，单条命令写
`@app.command('ping', to_me=False)`，命令自己写的说了算。

**关掉它之前要先订上 `GROUP_MESSAGE_CREATE`** —— 没订的话根本收不到没 @ 的消息，
改了也没用。

`args` 认引号：`/say "a b" c` 拆成 `['a b', 'c']`。`args.rest()` 拿原文，
`args.int(0)` 按整数取。

### to_me：群里默认要求 @ 机器人

命令默认 `to_me=True`，群里必须 @ 机器人才触发。订了全量群消息时，别人聊天里打个
`/ping` 不会让机器人抢答。

**`to_me=True` 就是严格要求 @，昵称不算。** 两者的关系取决于你在 robot 那侧订了
哪些事件：

| robot 订的事件 | `to_me=True`（默认） | `to_me=False` |
|---|---|---|
| 只订 `GROUP_AT_MESSAGE_CREATE` | 每条都是 @ 来的，命令照常触发，昵称也能用 | 一样 |
| 订了 `GROUP_MESSAGE_CREATE` | **必须 @**，昵称触发不了 | 昵称、裸命令都能触发 |

也就是说，订了全量群消息又想用昵称触发的话，那几条命令要写 `to_me=False`：

```python
@app.command('ping', to_me=False)     # 「萌萌，ping」和裸的「/ping」都能触发
async def _(event, args):
    return 'pong'
```

单聊一律算冲机器人来的，不受影响。

权限谓词可以用 `&` `|` `~` 组合：

```python
from moemoe import GROUP_ADMIN, SUPERUSER

@app.command('kick', permission=GROUP_ADMIN | SUPERUSER, denied='你不行')
async def _(event, args): ...
```

`SUPERUSER` 读的是 `Moemoe(superusers=[...])`。

`Permission` 接收可检查签名的同步函数，支持 `(event)` 或 `(event, superusers)`。
函数内部的异常按原样抛出，不会被当成参数数量不符而重试。

`await app.invoke(event, '命令名', '参数')` 跳过正文匹配，但仍检查该命令的
`permission` 和 `to_me`，返回值与普通命令一样自动发送。群消息触发的内部命令如果
不要求 @，应在注册时设置 `to_me=False`，或使用应用的同名默认设置。

命令名与参数之间支持空格、制表符和换行。注册时命令名或任一别名冲突，整次注册都不生效。

## 多轮问答

处理函数可以停下来等后续消息，状态留在局部变量里，不用维护一张「谁在玩」的表。

```python
from moemoe import SessionTimeout

@app.command('改名')
async def _(event, args):
    try:
        answer = await event.prompt('叫什么？', timeout=30)
    except SessionTimeout:
        return '等太久了'
    return f'好的，以后叫你{answer.text}'
```

群级抢答用 `capture`，它既是异步迭代器也是异步上下文管理器：

```python
@app.command('猜数')
async def _(event, args):
    answer = random.randint(1, 10)
    async for guess in event.capture(scope='scene', timeout=20,
                                     match=lambda one: one.text.isdigit()):
        if int(guess.text) == answer:
            return f'{guess.username} 答对了'
        await guess.send('不对，再猜')
    return f'没人猜出来，答案是 {answer}'
```

要点：

- `scope='user'`（默认）只等这个人，`scope='scene'` 等这个群/单聊里的任何人
- **`timeout` 是每条消息的等待上限**，收到一条就重新计时
- `async for` 在超时或关闭时正常结束，不用自己接异常；`prompt` 超时抛 `SessionTimeout`
- `match` 挡掉不相干的消息，被挡的照常走命令和事件处理函数
- **被会话接住的消息不再走命令**，否则一句「42」会既当答案又当命令
- 会话按机器人连接、群聊/私聊场景和发送者隔离；重连后的消息不会进入旧连接的会话
- 关闭后先读完已接收的消息，后续每次等待都会立即结束；提前关闭会同时解除任务回调
- 挂着的会话数有上限，`Moemoe(sessions=1000)`，超了抛 `TooManySessions`

## 消息段

```python
from moemoe import At, Image, Keyboard, Markdown, Text

await event.send(Text('看图') + Image('https://...'))
await event.send(Markdown('## 标题\n正文 **加粗**') + Keyboard(rows=[...]))
await event.send(At('U9') + ' 在吗')
```

段帮你兜住 QQ 那三条互斥规矩：markdown 和纯文本互斥（文本段会并进 markdown 正文）、
一条消息只挂一个富媒体（多图拆成多条）、按钮挂在 markdown 上（只给按钮时正文包一层）。

`Keyboard(rows=...)` 原样透传给 QQ，字段照官方[消息按钮](https://bot.q.qq.com/wiki/develop/api-v2/server-inter/message/trans/msg-btn.html)文档填。

不想用糖就直接给 dict 当请求体：

```python
await event.send({'msg_type': 2, 'markdown': {'custom_template_id': 'tpl'}})
```

文本段和多个 Markdown 段按组合顺序拼接；任一 Markdown 要求图片校验时，整段保留该要求。
`Keyboard` 的 `rows` 与 `id` 恰好提供一个，一条消息只允许一个 Keyboard；不支持的消息段直接报错。

## 直接调官方接口

moemoe 不重新建模 QQ 的载荷。路径和字段照[腾讯的文档](https://bot.q.qq.com/wiki/develop/api-v2/)写：

```python
await event.call('GET', f'/v2/groups/{gid}/members')
await event.bot.call('POST', f'/v2/groups/{gid}/batch_remove_members',
                     {'member_openids': ['xxx']})
```

事件也一样，类型化访问只是取值方便，**`event.raw` 是 QQ 的原文**：

```python
event.group_openid      # 好写
event.raw               # QQ 给什么这里就有什么
```

4xx / 5xx 抛 `CallError`，里面带着 QQ 的原始错误体和 trace：

```python
from moemoe import CallError

try:
    await event.call('GET', f'/v2/groups/{gid}/members')
except CallError as error:
    error.status        # HTTP 状态码，robot 自己出错时是 0
    error.body          # QQ 的响应体，err_code 在里面
    error.trace         # 报障要附的链路号
```

`Moemoe(timeout=30)` 的调用超时覆盖请求发送和等待回执。断开后的 `Bot` 拒绝新调用；
同一机器人重连时，旧连接会关闭并回收所属任务，后续消息使用新连接。

## 日志消息编号

日志中的消息 ID 统一使用原始 ID 的 MD5 中间十六位（`hexdigest()[8:24]`），转成大写。
接收、命令匹配和回复日志共用原消息的摘要，发送成功记录 `reply sent`。
摘要只用于日志，传给 robot 的凭据和返回给业务的消息 ID 保持原值。
非消息事件使用原始事件 ID 的同一摘要规则，以 `Event` 标记，接收与回复共用编号。
DEBUG 日志保留原始消息和事件 ID。`<Bot ...>` 显示 `bot_id`。无需业务配置。

## 钩子

```python
@app.on_startup
async def _():
    ...                                  # 在事件循环里跑

@app.on_shutdown
async def _():
    ...                                  # 关服前收尾，会话会先被关掉

@app.on_bot_connect
async def _(bot):
    logger.info('{} 上线', bot)          # 钩子里可以调接口

@app.on_bot_disconnect
async def _(bot):
    ...

@app.on_error
async def _(failure):
    failure.error                        # 抛出来的异常
    failure.event                        # 出错时手上那条事件，没有就是 None
    failure.handler                      # 哪个处理函数
```

启动钩子失败会中止启动，并执行关闭流程。关闭钩子和事件处理函数各自隔离异常，错误会记录并交给 `on_error`。应用退出时会取消并等待它持有的后台任务；机器人断开时也会等待该连接的任务完成清理。

后台任务通过 `task = app.spawn(handler, *args, bot=bot)` 创建，返回的 Task 可用于等待或取消。
省略 `bot` 时由应用持有，指定时跟随该机器人连接关闭。

## HTTP 路由

跟 WebSocket 那条路共用端口和进程：

```python
from starlette.responses import JSONResponse

@app.route('/healthz')
async def _(request):
    return JSONResponse({'bots': len(app.bots)})
```

不用装额外的东西，starlette 本来就是核心依赖。

## 定时任务

可选模块，先装：

```bash
pip install "moemoe[scheduler]"
```

```python
from moemoe.scheduler import Scheduler

scheduler = Scheduler(app)               # 自动挂 startup / shutdown

@scheduler.scheduled_job('cron', hour=7, minute=15, id='daily')
async def _():
    await app.bot().send(group_openid, '早上好')
```

调度器不是 moemoe 写的——cron 解析、时区、夏令时、错过补发，APScheduler 已经写对
十几年了。这个模块只解决接线：什么时候 `start()`、什么时候 `shutdown()`、
没装时报一句人话。认不出的属性转给底层的 `AsyncIOScheduler`，`add_job` /
`get_jobs` / `pause` 照 APScheduler 的文档写。

只支持 APScheduler 3.x。

## 插件

应用对象单独放一个模块，插件从那儿 import：

```
myrobot/
  app.py          app = Moemoe(...)
  main.py         from app import app; load_plugins('plugins'); app.run()
  plugins/
    __init__.py
    ping.py       from app import app  →  @app.command('ping')
```

```python
from moemoe import load_plugins
load_plugins('plugins')
```

## 日志

标准库日志中，`uvicorn.error` 的启动、监听地址和关闭信息保留 INFO；
其余 INFO（包括 httpx 请求和 `uvicorn.access` 访问日志）降为 DEBUG。WARNING 和 ERROR 保留原级别。

`from moemoe import logger` 拿到的就是 loguru 的全局 logger 原样再导出。
`logger.disable('moemoe')` 让库彻底闭嘴。

## 跑测试

不需要装任何东西：

```bash
python -m unittest discover -s tests -t .
```

装了 pytest 的话 `pytest` 也直接认。

## 0.0.6 更新

最低支持 Python 3.11。

- `invoke()` 与普通命令统一检查 `permission` 和 `to_me`；权限函数内部的 `TypeError` 按原样抛出，不再换参数重试。
- 命令名或别名冲突时整次注册不生效，避免留下残余入口；命令参数支持空格、制表符和换行分隔。
- 启动钩子失败会停止启动并执行关闭流程；应用关闭和机器人断开时，取消并等待所属后台任务完成清理。
- 同一机器人重连时关闭旧连接，会话按连接和群聊/私聊隔离；关闭会话会解除任务回调，并唤醒所有等待者。
- 接口调用超时覆盖请求发送与等待回执；断开的机器人拒绝新调用和新任务，取消调用时清理等待对象。
- 文本和多个 Markdown 段按原顺序拼接，保留图片校验要求；重复按钮、未知消息段和上传响应缺少 `file_info` 时直接报错。
- 统一机器人提及解析，普通群消息和私聊不会在机器人 openid 未知时误删其他人的提及。
- `spawn()` 返回可等待或取消的 Task；整理参数说明、代码排版和示例，并关闭验包时打开的 wheel 文件。

从 0.0.5 升级时，群消息触发的内部命令若不要求 @，应设置 `to_me=False`，或沿用应用的同名默认设置。
启动依赖失败后应用不再继续提供服务；`Keyboard` 的 `rows` 和 `id` 必须只提供一个。

## 0.0.5 更新

- 新增 `Bot.app_id`，表示开放平台应用 ID；在命令和事件中通过 `event.bot.app_id` 读取。
- 从事件帧和 `ready` 帧接收 `app_id`，断线积压的事件先于 `ready` 到达时也能使用，各机器人独立保存。
- `Bot.id`、`app.bot(id)` 和 `app.bots` 的键仍使用机器人账号 `bot_id`，QQ 原始事件和发送凭据不变。
- 需要配套更新 robot：事件信封必须携带 `app_id`，`ready.data` 必须携带 `app_id`。旧协议端缺少字段时会报告连接错误。

## 0.0.4 更新

- 接收消息、命令匹配和回复成功日志使用同一个大写 16 位 MD5 摘要，便于关联处理过程。
- 非消息事件同样关联接收和回复日志；原始消息、事件 ID 和发送凭据保持不变。
- 日志中的机器人标识统一为 `bot_id`，群消息显示完整的用户与群 openid。
- 第三方库的 INFO 日志降为 DEBUG，保留 Uvicorn 启动、监听地址和关闭日志。
- 回复成功日志简化为 `reply sent`，原始 ID 在 DEBUG 日志中查看。

## 状态

**0.0.6，早期版本，API 还会动。** 本版通过 Python 3.11 单元测试、构建验包和 seasun 插件加载验证。
历史版本已实连验证 QQ 收消息 → robot 转发 → moemoe 触发命令 → 回复发出去；本轮未重新实连 QQ。

没做的：命令中间件。

铁律见 [RULES.md](https://github.com/nicemoe/moemoe/blob/main/RULES.md)，设计理由见 [docs/design.md](https://github.com/nicemoe/moemoe/blob/main/docs/design.md)。
