mirror of
https://github.com/snowy-yang/qqbotsdk.git
synced 2026-10-21 07:59:07 +00:00
refactor!: Bot 门面接管启动,移除 main/run_loop 与控制台脚本入口
bot.run() 同步启动、bot.start() 嵌入外部 asyncio 应用; bot.call_api 通用 REST、bot.api 类型化 API 面、bot.services 自定义组件注入; services 登记扩到 8 项(含 Bot 自身,可标注注入)。
This commit is contained in:
1 parent
1f91b1acca
commit
0515c6e4c0
10 files changed
+317
-697
No files matched your search
@@ -1,44 +0,0 @@
|
||||
name: 部署文档(同步 docs 分支)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "index.html"
|
||||
- "_sidebar.md"
|
||||
- "README.md"
|
||||
- "docs/**"
|
||||
- "examples/**"
|
||||
- "CHANGELOG.md"
|
||||
- ".github/workflows/deploy-docs.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
sync-docs:
|
||||
name: 组装并强推 docs 分支
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# docs 分支只含文档站文件(孤儿分支,与源码历史无关),
|
||||
# 供 Cloudflare Pages 直连:生产分支 docs、构建命令留空、输出目录 /
|
||||
- name: 组装纯文档并推送 docs 分支
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
rm -rf site && mkdir site
|
||||
cp index.html README.md _sidebar.md CHANGELOG.md site/
|
||||
cp -r docs examples site/
|
||||
cp .nojekyll site/ 2>/dev/null || true
|
||||
find site -type d -name __pycache__ -exec rm -rf {} +
|
||||
cd site
|
||||
git init -q -b docs
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add -A
|
||||
git commit -qm "docs: 同步自 main@${GITHUB_SHA:0:7}"
|
||||
git push -q --force "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" docs
|
||||
-324
@@ -1,324 +0,0 @@
|
||||
# qqbotsdk API 参考
|
||||
|
||||
按公开使用面组织。上手流程见 [GUIDE.md](GUIDE.md),分层与装配见 [ARCHITECTURE.md](ARCHITECTURE.md)。
|
||||
|
||||
## 顶层导出
|
||||
|
||||
```python
|
||||
from qqbotsdk import (
|
||||
main, run_loop, # 入口(组件在 run_loop 显式装配)
|
||||
EventEmitter, EventQueue, # 分发层
|
||||
BaseProtocol, # 协议处理器接口(自定义接入方式时继承)
|
||||
Config, Connecter, # 配置与连接器接口
|
||||
)
|
||||
```
|
||||
|
||||
| 名称 | 说明 |
|
||||
|---|---|
|
||||
| `main(emitter=None)` | 同步入口:装配组件并阻塞运行,Ctrl+C 优雅退出 |
|
||||
| `run_loop(emitter=None)` | 异步入口:`asyncio.run(run_loop(ee))` 的内部实现,可自行 await |
|
||||
| `EventEmitter(queue=None)` | 分发主体,`@ee.on(..., background=True)` 注册 handler(可选后台并发);`queue` 不传自动创建 |
|
||||
| `ee.services` | `dict[type, Any]`,按类型登记可注入组件;`run_loop` 装配 SDK 内置组件,也可登记自定义类型 |
|
||||
| `EventQueue()` | 业务事件通道(连接层 → 分发层,单向);`EventEmitter` 默认自建,需多处共享时手动传入(`ee.queue` 取实例) |
|
||||
| `Config` | frozen dataclass,环境变量一次性读齐(见下文) |
|
||||
| `Connecter` | 连接适配器抽象基类(ABC),按 `CONNECTER` 自动选择实现 |
|
||||
| `BaseProtocol` | 协议处理器抽象基类(`protocol.py`),自定义接入方式时继承并实现 `on_frame(payload)` |
|
||||
|
||||
子包按需导入:
|
||||
|
||||
```python
|
||||
from qqbotsdk.api import BotApi, ApiError
|
||||
from qqbotsdk.payloads import GroupAtMessage, EVENT_TYPES, parse_event
|
||||
from qqbotsdk.events import Event
|
||||
```
|
||||
|
||||
## EventEmitter
|
||||
|
||||
```python
|
||||
ee = EventEmitter()
|
||||
|
||||
@ee.on("GROUP_AT_MESSAGE_CREATE") # 业务事件用 t 名(仅 op=0 会分发到这里)
|
||||
async def handler(...): ...
|
||||
|
||||
@ee.on("GROUP_AT_MESSAGE_CREATE", background=True) # 后台并发执行,不阻塞事件流
|
||||
async def slow(...): ...
|
||||
```
|
||||
|
||||
- **只分发业务事件**:`EventEmitter` 只处理 op=0(DISPATCH)事件,按 payload 的 `t` 名路由。协议帧(HELLO/READY/INVALID_SESSION/op=13 等)由各接入方式的协议处理器(`WebsocketProtocol`/`WebhookProtocol`)在连接器内就地处理,不进事件队列、也不会触达你的 handler。
|
||||
- **注册与顺序**:同一函数对同一事件重复注册只生效一次;handler 按注册顺序串行执行(`background=True` 的除外)。
|
||||
- **异常隔离**:handler 异常就地记录(loguru),不影响同事件其他 handler 与主循环。
|
||||
- **后台并发**:`background=True` 的 handler 进后台任务,emit 不等它;同一事件内失去先后保证,仅用于业务 handler。`run_loop` 退出时统一取消在飞后台任务(`ee.close()`)。
|
||||
- 业务 handler 的返回值不回流;回复消息请在 handler 里显式调 `BotApi`。
|
||||
|
||||
### handler 参数注入约定
|
||||
|
||||
按参数类型标注解析,可任意混用:
|
||||
|
||||
| 标注 | 得到 |
|
||||
|---|---|
|
||||
| payload dataclass(`payloads` 中的类型) | `Event.typed` 解析对象 |
|
||||
| `Event` | 事件封装对象 |
|
||||
| 组件类型(`BotApi`/`Session`/`Config` 等已登记进 `ee.services` 的类型) | 登记的实例 |
|
||||
|
||||
无标注或标注以上三者皆非的形参在分发时抛 `ValueError`(记日志、按 handler 隔离)——SDK 不注入原始 d,要事件体字段就标注 payload dataclass,要完整信封就标注 `Event`。
|
||||
|
||||
## Event
|
||||
|
||||
`from qqbotsdk.events import Event`,业务事件推送信封(op/t/d/id)的轻量封装,只封装信封语义、不做字段名映射;事件体字段请标注对应 payload dataclass(见下文)获取解析结果:
|
||||
|
||||
| 属性 | 说明 |
|
||||
|---|---|
|
||||
| `.raw` | 完整线上 payload(含 op/t/d) |
|
||||
| `.data` | `d` 的 dict 拷贝 |
|
||||
| `.typed` | `d` 的 dataclass 解析结果(未知事件类型为 dict) |
|
||||
| `.type` | 事件 t 名 |
|
||||
| `.event_id` | 推送信封顶层的事件 id(与 op/t/d 平级,形如 `INTERACTION_CREATE:uuid`);非消息事件(`INTERACTION_CREATE`/`GROUP_ADD_ROBOT` 等)的被动回复凭据只有这里能拿到,事件体里的裸 UUID 不行(官方点名的常见坑);消息事件的被动回复凭据是事件体的 `d.id`(payload 的 `.id` 字段,即 msg_id),不用这里 |
|
||||
|
||||
## Config
|
||||
|
||||
`Config.load()` 从环境变量一次性读齐(frozen,启动后不变):
|
||||
|
||||
| 字段 | 环境变量 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `app_id` | `APPID` | 必填 | 缺失启动即 ValueError |
|
||||
| `app_secret` | `APPSECRET` | 必填 | 同上 |
|
||||
| `connecter` | `CONNECTER` | `websocket` | 仅允许 `websocket`/`webhook` |
|
||||
| `base_url` | `BASE_URL` | `https://api.bot.qq.com` | 官方 API 根地址 |
|
||||
| `timeout` | `TIMEOUT` | `5000`(毫秒) | 转为 aiohttp `ClientTimeout(total=…)` |
|
||||
| `intents_file` | `INTENTS_FILE` | `intents.toml` | 事件订阅配置路径 |
|
||||
| `webhook_host` | `WEBHOOK_HOST` | `0.0.0.0` | webhook 监听地址 |
|
||||
| `webhook_port` | `WEBHOOK_PORT` | `8080` | webhook 监听端口 |
|
||||
| `webhook_path` | `WEBHOOK_PATH` | `/qqbot/webhook` | webhook 回调路径 |
|
||||
|
||||
## BotApi
|
||||
|
||||
`from qqbotsdk.api import BotApi`。经参数注入(`api: BotApi`)。自动附带 `Authorization: QQBot <token>` 鉴权头并刷新 token;HTTP ≥400 抛 `ApiError`。
|
||||
|
||||
实现按域拆在 `qqbotsdk/api/` 包内(`core` 请求内核 + `v2`/`channel`/`guild`/`panel`/`group`/`audio`/`forum` 方法集组合成 `BotApi`),方法名与下表一致;429 限流自动重试(最多 3 次,优先 `Retry-After`,退避封顶 30s),401 强制刷新 token 后重试一次。
|
||||
|
||||
### 通用
|
||||
|
||||
| 方法 | 说明 |
|
||||
|---|---|
|
||||
| `request(method, path, **kwargs)` | 通用请求,kwargs 透传 aiohttp(`json=`/`params=` 等) |
|
||||
| `get` / `post` / `delete` / `put` / `patch`(path, **kwargs) | `request` 快捷方式 |
|
||||
| `me()` | 机器人自身信息(GET /users/@me) |
|
||||
| `generate_url_link(callback_data=None)` | 生成机器人分享链接(POST /v2/generate_url_link),返回 `{"data": {"url": …}}`;用户经链接添加时 `callback_data`(≤32 字符)透传给开发者 |
|
||||
| `respond_interaction(interaction_id, code=0)` | 回应互动事件(PUT /interactions/{id});按钮点击(INTERACTION_CREATE type=11)收到后必须回应,否则客户端持续 loading,同一 id 仅可回应一次 |
|
||||
|
||||
常量:`MSG_TYPE_TEXT=0`、`MSG_TYPE_MARKDOWN=2`、`MSG_TYPE_MEDIA=7`;`FILE_TYPE_IMAGE=1`、`FILE_TYPE_VIDEO=2`、`FILE_TYPE_VOICE=3`、`FILE_TYPE_FILE=4`;流式消息 `STREAM_INPUT_APPEND`/`STREAM_INPUT_REPLACE`、`STREAM_GENERATING=1`/`STREAM_FINISHED=10`、`STREAM_CONTENT_TEXT`/`STREAM_CONTENT_MARKDOWN`;音频 `AUDIO_STATUS_START=0`/`PAUSE=1`/`RESUME=2`/`STOP=3`;论坛 `FORUM_FORMAT_TEXT=1`/`HTML=2`/`MARKDOWN=3`/`JSON=4`。
|
||||
|
||||
按钮构造:`button(label, data, *, type=1, permission=2, style=1)` 生成单个按钮 dict,`keyboard(*rows)` 组装为 `keyboard=` 参数(单按钮独占一行,按钮列表同行并排)。点击回调经 `INTERACTION_CREATE` 事件处理:`Interaction.button_data` 取被点按钮的 `data`,`Interaction.id` 用于 `respond_interaction`,**被动回复的 `event_id` 用 `Event.event_id`(网关帧最外层 id),不能用 `Interaction.id`**。
|
||||
|
||||
### v2 群聊(群 openid)
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|---|---|---|
|
||||
| `post_group_message(group_openid, content="", msg_id=None, msg_seq=1, media=None, event_id=None, markdown=None, keyboard=None, **extra)` | POST /v2/groups/{group_openid}/messages | `msg_id`/`event_id` 二选一即被动回复(`event_id` 支持 GROUP_ADD_ROBOT / INTERACTION_CREATE 等事件);`media` 传上传返回的 `{"file_info": …}`(自动 msg_type=7);`markdown` 为字符串或完整 dict(自动 msg_type=2,与 content 互斥);`keyboard` 为内嵌键盘 `{"content": {"rows": [...]}}` 或 `{"id": …}`;其余字段经 `extra` 透传 |
|
||||
| `upload_group_file(group_openid, file_type, url, srv_send_msg=False)` | POST /v2/groups/{group_openid}/files | 富媒体上传,返回含 `file_info`;`srv_send_msg=True` 上传即发送 |
|
||||
| `withdraw_group_message(group_openid, message_id)` | DELETE /v2/groups/{group_openid}/messages/{message_id} | 发出 2 分钟内可撤回 |
|
||||
| `mute_group_member(group_openid, mute_expire_at)` | POST /v2/groups/{group_openid}/restrict_chat_setting | `mute_expire_at` 为 RFC3339 到期时间;传早于当前的时间即解禁 |
|
||||
| `prepare_group_upload(group_openid, file_type, file_size, file_name, md5, sha1, md5_10m)` | POST /v2/groups/{group_openid}/upload_prepare | 大文件分片预上传,返回 `upload_id`/`block_size`/各分片预签名 `parts`;逐片 PUT 后调 `finish_group_upload_part`,全部完成后携 `upload_id` 调 `upload_group_file` 合并;`md5_10m` 为文件前约 10MB 的 MD5 |
|
||||
| `finish_group_upload_part(group_openid, upload_id, part_index, block_size=None, md5=None)` | POST …/upload_part_finish | 每个分片 PUT 成功后调用确认 |
|
||||
|
||||
### v2 单聊(用户 openid)
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|---|---|---|
|
||||
| `post_c2c_message(openid, content="", msg_id=None, msg_seq=1, media=None, event_id=None, markdown=None, keyboard=None, **extra)` | POST /v2/users/{openid}/messages | 同 `post_group_message` |
|
||||
| `upload_c2c_file(openid, file_type, url, srv_send_msg=False)` | POST /v2/users/{openid}/files | 同 `upload_group_file` |
|
||||
| `withdraw_c2c_message(openid, message_id)` | DELETE /v2/users/{openid}/messages/{message_id} | 2 分钟内可撤回 |
|
||||
| `post_c2c_stream_message(openid, content_raw="", *, stream_msg_id=None, index=0, input_state=STREAM_GENERATING, input_mode=None, content_type=STREAM_CONTENT_MARKDOWN, msg_id=None, event_id=None, msg_seq=None, is_wakeup=None)` | POST /v2/users/{openid}/stream_messages | 流式分批发送(AI 回复逐段下发):首片不传 `stream_msg_id`,响应 `id` 即后续分片要携带的值,`index` 从 0 递增,以 `input_state=STREAM_FINISHED` 收尾;`input_mode="replace"` 时 `content_raw` 须以上游已下发前缀开头;`is_wakeup=True` 为召回消息,不校验被动回复有效期 |
|
||||
| `prepare_c2c_upload(openid, …)` / `finish_c2c_upload_part(openid, …)` | POST /v2/users/{openid}/upload_prepare · upload_part_finish | 单聊分片上传,用法同群聊 `prepare_group_upload`/`finish_group_upload_part` |
|
||||
|
||||
### 群管理(群 openid)
|
||||
|
||||
多数接口要求机器人拥有群管理员身份;成员/黑名单/批量移除等能力官方标注"内邀接入中",未开通时服务端返回业务码 11253。常量:`APPROVE`/`DECLINE`(审批动作)、`OP_ADD`/`OP_DEL`(黑名单/白名单操作)。
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|---|---|---|
|
||||
| `get_group_info(group_openid)` | GET /v2/groups/{group_openid}/info | 群名称/简介/分类/标签/成员数 |
|
||||
| `get_group_members(group_openid, cursor=None)` | GET …/members | 成员列表,每页最多 30 条,`next_cursor` 翻页(空串=末页) |
|
||||
| `get_group_member(group_openid, member_openid)` | GET …/members/{member_openid} | 成员昵称/角色(member/owner/admin)/入群时间 |
|
||||
| `get_group_bot_state(group_openid)` | GET …/bot_state | 机器人自身状态:入群时间、是否允许主动推送、群内消息接收设置、角色 |
|
||||
| `remove_group_members(group_openid, member_openids, add_to_member_blacklist=False)` | POST …/batch_remove_members | 批量移除,单次最多 20 个,可选拉黑 |
|
||||
| `get_group_join_requests(group_openid, cursor=None, limit=None)` | GET …/join_request_list | 入群申请列表(含验证问答),需群管理员;limit 默认 20 最大 50 |
|
||||
| `review_group_join_request(group_openid, member_openid, op, join_request_id=None, reject_reason=None, add_to_member_blacklist=None)` | POST …/approval_join_request/{member_openid} | `op`:`APPROVE`/`DECLINE`;拒绝时可附理由并拉黑 |
|
||||
| `get_group_mute_state(group_openid)` | GET …/restrict_chat_setting | 禁言状态:全员禁言规则(定时/周期)与被禁言成员列表 |
|
||||
| `get_group_blacklist(group_openid, cursor=None, limit=None)` | GET …/member_blacklist | 黑名单列表,limit 默认 20 最大 100 |
|
||||
| `update_group_blacklist(group_openid, op, member_openids)` | POST …/member_blacklist | `op`:`OP_ADD`/`OP_DEL`,单次最多 20 个;成员在群中时无法拉黑 |
|
||||
| `get_join_approval_strategies(cursor=None, limit=None)` | GET /v2/groups/join_approval_strategy | 自动审批策略列表(机器人维度全局配置),按创建时间倒序 |
|
||||
| `create_join_approval_strategy(*, group_openids=None, group_ids=None, is_enable=None, expire_at=None, remark=None)` | POST /v2/groups/join_approval_strategy | 关联群两种标识二选一(互斥,最多 100 个);`is_enable` "on"/"off";默认一年过期;仅机器人有群管理员身份时生效;返回 `strategy_id` |
|
||||
| `update_join_approval_strategy(strategy_id, *, is_enable=None, expire_at=None, group_action=None, remark=None)` | PATCH …/{strategy_id} | 只传变更项;`group_action` 为 `{"op": "add"/"del", "group_openids": […]}`,群标识形式须与创建时一致 |
|
||||
| `delete_join_approval_strategy(strategy_id)` | DELETE …/{strategy_id} | 删除策略 |
|
||||
| `execute_join_approval_strategy(strategy_id)` | POST …/{strategy_id}/execute | 全量扫描关联群并自动通过白名单申请;异步约 10 分钟 |
|
||||
| `update_join_approval_strategy_whitelist(strategy_id, op, whitelist_users)` | POST …/{strategy_id}/whitelist_users | QQ 号码字符串列表,单次最多 10000、上限 10 万 |
|
||||
|
||||
### 频道消息(channel_id / guild_id)
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|---|---|---|
|
||||
| `post_channel_message(channel_id, content="", msg_id=None, msg_seq=1, image=None, **extra)` | POST /channels/{channel_id}/messages | `image` 为图片 URL;embed/ark/markdown 等经 `extra` 透传 |
|
||||
| `withdraw_channel_message(channel_id, message_id, hide_tip=False)` | DELETE /channels/{channel_id}/messages/{message_id} | **仅私域机器人**;`hide_tip` 对应官方 `hidetip` |
|
||||
| `put_channel_reaction(channel_id, message_id, emoji_type, emoji_id)` | PUT …/reactions/{type}/{emoji_id} | `emoji_type=1` 系统表情,`emoji_id` 为序号字符串 |
|
||||
| `delete_channel_reaction(channel_id, message_id, emoji_type, emoji_id)` | DELETE …/reactions/{type}/{emoji_id} | 删除自己的表态 |
|
||||
| `create_dms(recipient_id, source_guild_id)` | POST /users/@me/dms | 创建私信会话(需与用户同频道),返回私信的 `guild_id`/`channel_id` |
|
||||
| `post_dms_message(guild_id, content="", msg_id=None, msg_seq=1, image=None, **extra)` | POST /dms/{guild_id}/messages | 发私信,参数同 `post_channel_message`;主动消息每天单用户 2 条、累计 200 条,被动不限 |
|
||||
| `withdraw_dms_message(guild_id, message_id, hide_tip=False)` | DELETE /dms/{guild_id}/messages/{message_id} | 撤回自己发的私信,**仅私域** |
|
||||
| `get_channel_permissions(channel_id, user_id)` / `get_channel_role_permissions(channel_id, role_id)` | GET …/members/{user_id}/permissions · …/roles/{role_id}/permissions | 子频道权限位图(字符串) |
|
||||
| `update_channel_permissions(channel_id, user_id, *, add=None, remove=None)` / `update_channel_role_permissions(channel_id, role_id, …)` | PUT 同路径 | `add`/`remove` 为字符串权限位图,至少传一个,需管理员权限 |
|
||||
|
||||
### 频道管理
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|---|---|---|
|
||||
| `create_guild_announce(guild_id, channel_id, message_id, announces_type=0, **extra)` | POST /guilds/{guild_id}/announces | `announces_type`:0 成员公告 / 1 欢迎公告;推荐频道公告经 extra(与消息公告互斥) |
|
||||
| `create_channel(guild_id, name, **fields)` | POST /guilds/{guild_id}/channels | 创建子频道,**私域**需管理员权限;`fields` 透传 type/sub_type/position/parent_id/private_type/speak_permission 等;触发 CHANNEL_CREATE |
|
||||
| `update_channel(channel_id, **fields)` | PATCH /channels/{channel_id} | 修改子频道,**私域**;只传变更项;触发 CHANNEL_UPDATE |
|
||||
| `delete_channel(channel_id)` | DELETE /channels/{channel_id} | 删除子频道,**私域**;不可恢复;触发 CHANNEL_DELETE |
|
||||
| `delete_guild_announce(guild_id, message_id)` | DELETE /guilds/{guild_id}/announces/{message_id} | 推荐频道公告传 `message_id="all"` |
|
||||
| `pin_channel_message(channel_id, message_id)` | PUT /channels/{channel_id}/pins/{message_id} | 添加精华消息 |
|
||||
| `unpin_channel_message(channel_id, message_id)` | DELETE /channels/{channel_id}/pins/{message_id} | 移出精华消息 |
|
||||
| `get_channel_pins(channel_id)` | GET /channels/{channel_id}/pins | 精华消息列表 |
|
||||
| `get_channel_schedules(channel_id, since=None)` | GET /channels/{channel_id}/schedules | 默认当天日程;`since` 毫秒时间戳 |
|
||||
| `get_channel_schedule(channel_id, schedule_id)` | GET /channels/{channel_id}/schedules/{schedule_id} | 单个日程 |
|
||||
| `create_channel_schedule(channel_id, name, start_timestamp, end_timestamp, jump_channel_id, remind_type="0", description="")` | POST /channels/{channel_id}/schedules | 时间为毫秒时间戳字符串;`remind_type` "0" 不提醒 / "1" 开始时;请求体自动包在 `schedule` 字段;单管理员每天限 10 次 |
|
||||
| `update_channel_schedule(channel_id, schedule_id, **fields)` | PATCH /channels/{channel_id}/schedules/{schedule_id} | `fields` 为 schedule 内字段透传 |
|
||||
| `delete_channel_schedule(channel_id, schedule_id)` | DELETE /channels/{channel_id}/schedules/{schedule_id} | 删除日程 |
|
||||
| `create_guild_role(guild_id, name="", color=None, hoist=None)` | POST /guilds/{guild_id}/roles | 创建身份组,返回 `{"role_id", "role"}`;`color` 为 ARGB 十进制 |
|
||||
| `update_guild_role(guild_id, role_id, name=None, color=None, hoist=None)` | PATCH /guilds/{guild_id}/roles/{role_id} | 只传变更项;系统默认身份组不可改 |
|
||||
| `delete_guild_role(guild_id, role_id)` | DELETE /guilds/{guild_id}/roles/{role_id} | 只能删除自建身份组 |
|
||||
| `add_guild_member_role(guild_id, user_id, role_id)` / `remove_guild_member_role(…)` | PUT / DELETE /guilds/{guild_id}/members/{user_id}/roles/{role_id} | 授予/收回成员身份组 |
|
||||
| `remove_guild_member(guild_id, user_id, add_blacklist=False, delete_history_msg_days=0)` | DELETE /guilds/{guild_id}/members/{user_id} | 踢人,**私域**需踢人权限;撤回消息仅支持 3/7/15/30 或 -1 全部 |
|
||||
| `mute_guild(guild_id, mute_end_timestamp=None, mute_seconds=None)` | PATCH /guilds/{guild_id}/mute | 全员禁言,两个时间字段二选一;解除都传 "0" |
|
||||
| `mute_guild_members(guild_id, user_ids, …)` | PATCH /guilds/{guild_id}/mute | 批量禁言(带 `user_ids`),单次最多 20 人 |
|
||||
| `create_api_permission_demand(guild_id, channel_id, path, method, desc)` | POST /guilds/{guild_id}/api_permission/demand | 生成接口权限授权链接发到子频道,由频道管理员授权 |
|
||||
| `mute_guild_member(guild_id, user_id, mute_seconds=0)` | PATCH /guilds/{guild_id}/members/{user_id}/mute | 禁言秒数(上限 28 天),0 解禁 |
|
||||
|
||||
### 频道查询
|
||||
|
||||
| 方法 | 端点 |
|
||||
|---|---|
|
||||
| `get_guild(guild_id)` | GET /guilds/{guild_id} |
|
||||
| `get_me_guilds(before=None, after=None, limit=None)` | GET /users/@me/guilds |
|
||||
| `get_guild_channels(guild_id)` | GET /channels/{guild_id}/channels |
|
||||
| `get_guild_members(guild_id)` | GET /guilds/{guild_id}/members |
|
||||
| `get_role_members(guild_id, role_id)` | GET /guilds/{guild_id}/roles/{role_id}/members |
|
||||
| `get_channel_online_nums(channel_id)` | GET /channels/{channel_id}/online_nums |
|
||||
| `get_guild_message_setting(guild_id)` | GET /guilds/{guild_id}/message/setting |
|
||||
| `get_guild_api_permissions(guild_id)` | GET /guilds/{guild_id}/api_permission |
|
||||
| `get_channel(channel_id)` | GET /channels/{channel_id} |
|
||||
| `get_guild_roles(guild_id)` | GET /guilds/{guild_id}/roles |
|
||||
| `get_guild_member(guild_id, user_id)` | GET /guilds/{guild_id}/members/{user_id} |
|
||||
|
||||
### 指令面板(斜线指令)与全局菜单
|
||||
|
||||
斜线指令面板即用户在聊天输入框输入 `/` 唤起的指令列表,支持 c2c(单聊)、group(群聊)、channel(文字子频道)、dm(频道私信)四种场景;c2c/group 支持 `target_type="specific"` 按指定对象生效(关联对象每次最多 20 个,详情最多查 1000 个),channel/dm 仅全局生效。一个机器人最多 20 个面板,每面板最多 20 个元素(`name` ≤14 字符、`desc` ≤30 字符)。常量:`SCOPE_C2C`/`SCOPE_GROUP`/`SCOPE_CHANNEL`/`SCOPE_DM`、`TARGET_ALL`/`TARGET_SPECIFIC`、`ITEM_COMMAND`/`ITEM_LINK`、`TARGET_OP_ADD`/`TARGET_OP_DEL`。
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|---|---|---|
|
||||
| `create_panel(scope, panel, *, target_type=None, user_openids=None, group_openids=None, **extra)` | POST /v2/panels | 返回 `{"panel_id": …}`;`panel` 为 `{"items": [{"type": "command"/"link", "name", "desc", "only_admin", "link"}], "remark"}`,command 类型点击后 `name` 填入输入框,link 类型点击跳转 |
|
||||
| `get_panels(scope, cursor=None, limit=None)` | GET /v2/panels | 分页列表(按设置时间倒序),返回 `records`/`next_cursor`(空串=末页)/`is_end`;`limit` 默认 20、最大 50 |
|
||||
| `get_panel(panel_id)` | GET /v2/panels/{panel_id} | 面板详情,specific 面板另带关联的 `user_openids`/`group_openids` |
|
||||
| `update_panel(panel_id, panel)` | PUT /v2/panels/{panel_id} | 整体覆盖元素与备注,不影响已关联对象;返回 `{"version": …}` |
|
||||
| `delete_panel(panel_id)` | DELETE /v2/panels/{panel_id} | 删除面板,删除后对所有对象失效 |
|
||||
| `update_panel_targets(panel_id, op, user_openids=None, group_openids=None)` | PUT /v2/panels/{panel_id}/target | `op`:`"add"`/`"del"`;`user_openids` 仅 c2c、`group_openids` 仅 group;channel/dm 与全局面板不支持 |
|
||||
| `get_menu()` | GET /v2/menu | 查询全局自定义菜单(仅 C2C、对所有用户生效),返回 `{"version", "menu"}`;未设置过时 `menu` 为空 |
|
||||
| `update_menu(menu=None)` | PUT /v2/menu | 整体覆盖菜单(≤10 项,子菜单 ≤5 且不可再嵌套);`send_message` 类型点击后把文本(如 `/help`)填入输入框,常与斜线指令配合 |
|
||||
|
||||
### 音频与论坛(私域)
|
||||
|
||||
音频接口仅音频类机器人可用(需联系平台开通);论坛仅论坛子频道,发帖/删帖为异步任务(返回 `task_id`,结果经 FORUM_* 事件推送)。
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
|---|---|---|
|
||||
| `control_channel_audio(channel_id, audio_url=None, text=None, status=AUDIO_STATUS_START)` | POST /channels/{channel_id}/audio | 播放控制(START/PAUSE/RESUME/STOP);`audio_url`/`text` 仅 START 时传 |
|
||||
| `put_channel_mic(channel_id)` / `delete_channel_mic(channel_id)` | PUT / DELETE /channels/{channel_id}/mic | 机器人上麦 / 下麦 |
|
||||
| `get_channel_threads(channel_id)` | GET /channels/{channel_id}/threads | 帖子列表 `{"threads", "is_finish"}` |
|
||||
| `get_channel_thread(channel_id, thread_id)` | GET …/threads/{thread_id} | 帖子详情 |
|
||||
| `put_channel_thread(channel_id, title, content, format=FORUM_FORMAT_TEXT)` | PUT /channels/{channel_id}/threads | 发帖,`format` 为 FORUM_FORMAT_* 常量;返回 `task_id` |
|
||||
| `delete_channel_thread(channel_id, thread_id)` | DELETE …/threads/{thread_id} | 删帖,返回 `task_id` |
|
||||
|
||||
### ApiError
|
||||
|
||||
`ApiError(RuntimeError)`,重试后仍失败(HTTP ≥400)时抛出:
|
||||
|
||||
| 属性 | 说明 |
|
||||
|---|---|
|
||||
| `.status` | HTTP 状态码 |
|
||||
| `.code` | 官方业务 code(响应体中的 `code`) |
|
||||
| `.message` | 官方错误描述 |
|
||||
|
||||
鉴权阶段(换取 access_token)的失败抛 `TokenError`(`from qqbotsdk.token import TokenError`),与 `ApiError` 同构(`RuntimeError` 子类,同样携带 `.status`/`.code`/`.message`)——appid/secret 配置错误在这里暴露 QQ 返回的真实原因。
|
||||
|
||||
## 事件 payload
|
||||
|
||||
`qqbotsdk.payloads` 定义事件 `d` 的 dataclass。handler 标注类型即得解析对象;未知事件类型回退原始 dict。所有字段均可为 None(线上缺省即 None),嵌套 `author` 自动解析为 `Author`。
|
||||
|
||||
### 事件名 → dataclass 对照
|
||||
|
||||
下表左列是 intents.toml 的订阅分组(订阅粒度即分组,组内事件无法单独开关),中列是同时属于该分组、会被一起推送的事件 `t` 名:
|
||||
|
||||
| intents 分组 | 事件 t 名 | dataclass |
|
||||
|---|---|---|
|
||||
| GROUP_AND_C2C_EVENT | `C2C_MESSAGE_CREATE` | `C2CMessage` |
|
||||
| | `GROUP_AT_MESSAGE_CREATE` / `GROUP_MESSAGE_CREATE` | `GroupAtMessage` |
|
||||
| | `FRIEND_ADD` / `FRIEND_DEL` | `FriendAdd` / `FriendDel` |
|
||||
| | `GROUP_ADD_ROBOT` / `GROUP_DEL_ROBOT` | `GroupAddRobot` / `GroupDelRobot` |
|
||||
| | `C2C_MSG_REJECT` / `C2C_MSG_RECEIVE` | `C2CMsgReject` / `C2CMsgReceive` |
|
||||
| | `GROUP_MSG_REJECT` / `GROUP_MSG_RECEIVE` | `GroupMsgReject` / `GroupMsgReceive` |
|
||||
| | `SUBSCRIBE_MESSAGE_STATUS` | `SubscribeMessageStatus` |
|
||||
| GROUP_MEMBER_EVENT | `GROUP_MEMBER_ADD` / `GROUP_MEMBER_REMOVE` | `GroupMemberChange` |
|
||||
| | `GROUP_JOIN_REQUEST`(仅机器人是群管理员时推送) | `GroupJoinRequest` |
|
||||
| PUBLIC_GUILD_MESSAGES | `AT_MESSAGE_CREATE` | `AtMessage` |
|
||||
| | `PUBLIC_MESSAGE_DELETE` | `MessageDelete` |
|
||||
| GUILD_MESSAGES(私域) | `MESSAGE_DELETE` | `MessageDelete` |
|
||||
| DIRECT_MESSAGE | `DIRECT_MESSAGE_CREATE` | `DirectMessage` |
|
||||
| GUILDS | `GUILD_CREATE` / `GUILD_UPDATE` / `GUILD_DELETE` | `Guild` |
|
||||
| | `CHANNEL_CREATE` / `CHANNEL_UPDATE` / `CHANNEL_DELETE` | `Channel` |
|
||||
| GUILD_MESSAGE_REACTIONS | `MESSAGE_REACTION_ADD` / `…_REMOVE` | `MessageReaction` |
|
||||
| MESSAGE_AUDIT | `MESSAGE_AUDIT_PASS` / `…_REJECT` | `MessageAudit` |
|
||||
| INTERACTION | `INTERACTION_CREATE` | `Interaction` |
|
||||
| FORUMS_EVENT(私域) | `FORUM_THREAD_CREATE` / `…_UPDATE` / `…_DELETE` | `ForumThread` |
|
||||
| | `FORUM_POST_CREATE` / `…_DELETE` | `ForumPost` |
|
||||
| | `FORUM_REPLY_CREATE` / `…_DELETE` | `ForumReply` |
|
||||
| AUDIO_ACTION | `AUDIO_START` / `AUDIO_FINISH` / `AUDIO_ON_MIC` / `AUDIO_OFF_MIC` | `AudioAction` |
|
||||
| —(DISPATCH 协议事件) | `READY` | `Ready` |
|
||||
|
||||
未收录的事件(如私域 `MESSAGE_CREATE`、`DIRECT_MESSAGE_DELETE`、`GUILD_MEMBER_*`)没有对应 dataclass 可标注,请标注 `Event` 经 `.typed`(未知事件类型为 dict)或 `.raw` 访问;新增事件在 `payloads.py` 加 dataclass 并登记 `EVENT_TYPES` 即可。
|
||||
|
||||
`GROUP_MESSAGE_CREATE` 是群消息**全量模式**:需在开放平台开通"接收所有消息"权限,群里每条消息(不限 @机器人)都推送此事件,与 `GROUP_AT_MESSAGE_CREATE` 共用 `GroupAtMessage`。官方提示同一 msg_id 可能重复推送,业务侧需按 `id` 去重。
|
||||
|
||||
### 常用 dataclass 字段
|
||||
|
||||
```python
|
||||
Author # id, user_openid, member_openid, username, bot
|
||||
GroupAtMessage # id, content, group_openid, author, timestamp,
|
||||
# message_type, message_scene, attachments, mentions, ark_data, msg_elements
|
||||
C2CMessage # id, content, author, attachments, timestamp
|
||||
AtMessage # id, content, channel_id, guild_id, author, member, timestamp
|
||||
DirectMessage # 同 AtMessage
|
||||
GroupJoinRequest # group_openid, join_request_id, member_openid, username, risk_tips,
|
||||
# apply_at, apply_source, invited_by, verify_info, auto_approved, …
|
||||
GroupMemberChange # timestamp, group_openid, member_openid, user_openid
|
||||
```
|
||||
|
||||
- `GroupAtMessage.user_openid` / `C2CMessage.user_openid`:便利属性,从 `author.member_openid`/`author.user_openid` 取发言人标识。
|
||||
- 群/单聊场景拿不到用户真实 QQ 号,只有 openid;频道场景 `Author.id`/`username` 有效。
|
||||
- 其余事件 dataclass 为 2~5 个平铺字段的同构结构(openid/时间戳/信息 dict),字段名即官方文档字段。
|
||||
|
||||
`parse_event(t, d)`:把事件 `d` 解析为对应 dataclass(`Event.typed` 的内部实现),`EVENT_TYPES` 为事件名 → 类的完整登记表。
|
||||
|
||||
## 内部模块索引
|
||||
|
||||
维护者视角的分层与装配见 [ARCHITECTURE.md](ARCHITECTURE.md):
|
||||
|
||||
| 模块 | 职责 |
|
||||
|---|---|
|
||||
| `connecter.py` | `WebsocketConnecter` / `WebhookConnecter`:接入归一为业务事件流入、出站帧流出 |
|
||||
| `protocol.py` | `BaseProtocol` 协议处理器接口(`on_frame`,由连接适配器逐帧调用) |
|
||||
| `ws_protocol.py` | IDENTIFY/RESUME 鉴权、会话语义与 `s` 序列号(心跳在 connecter 内) |
|
||||
| `webhook_protocol.py` | op=13 验证应答 |
|
||||
| `queue.py` / `session.py` / `token.py` / `crypto.py` | 业务事件通道、ws 会话状态、token 缓存、Ed25519 签名 |
|
||||
| `model.py` | Opcode、payload 类型、Intent 位掩码 |
|
||||
| `__init__.py` | `main`/`run_loop` 入口与组件显式装配 |
|
||||
@@ -98,11 +98,11 @@ sequenceDiagram
|
||||
|
||||
## 生命周期与依赖装配
|
||||
|
||||
- 全部组件在 `run_loop`(`__init__.py`)显式构造装配,无 DI 框架,也无任何全局单例。
|
||||
- 组件按类型登记进 `emitter.services`,handler 形参标注类型即得实例;用户自定义组件同样登记即可注入。
|
||||
- 唯一的 `ClientSession` 在 `run_loop` 构造,`finally` 中先 `emitter.close()` 收尾后台 handler、再统一释放连接池(全 SDK 共享一个连接池)。
|
||||
- 接入方式的选择收敛在 `run_loop` 的 `match config.connecter` 装配分支:每种接入方式构造**自己的协议处理器**并注入对应的 connecter(主体不再持有协议层);`WebhookConnecter` 经协议处理器的 `on_frame` 就地取得 op=13 应答,不感知 `EventEmitter`。两个 connecter 的依赖面一致(config + queue + 各自所需的窄依赖 + `BaseProtocol`)。
|
||||
- 用户在 `main(ee)` 之前构造的 `EventEmitter` 会被复用(连同它的队列),保证 handler 注册在主体实际使用的实例上。
|
||||
- 用户入口是 `Bot` 门面(`bot.py`):`bot = Bot()` 构造即读 .env 校验配置,`@bot.on` 注册 handler,`run()` 同步启动 / `start()` 嵌入外部 asyncio 应用;装配细节不外露。
|
||||
- 全部组件在 `Bot.start()` → `_assemble()` 显式构造装配,无 DI 框架,也无任何全局单例。
|
||||
- 组件按类型登记进 `emitter.services`(含 `Bot` 自身,handler 标注 `bot: Bot` 即得实例),handler 形参标注类型即得实例;用户自定义组件经 `bot.services` 登记即可注入。
|
||||
- 唯一的 `ClientSession` 在 `_assemble` 构造,`start()` 的 `finally` 中先 `emitter.close()` 收尾后台 handler、再统一释放连接池(全 SDK 共享一个连接池)。
|
||||
- 接入方式的选择收敛在 `_assemble` 的 `match config.connecter` 装配分支:每种接入方式构造**自己的协议处理器**并注入对应的 connecter(主体不再持有协议层);`WebhookConnecter` 经协议处理器的 `on_frame` 就地取得 op=13 应答,不感知 `EventEmitter`。两个 connecter 的依赖面一致(config + queue + 各自所需的窄依赖 + `BaseProtocol`)。
|
||||
|
||||
## 关键设计决策
|
||||
|
||||
@@ -110,14 +110,14 @@ sequenceDiagram
|
||||
2. **协议层只管会话语义,心跳归传输层**:`WebsocketProtocol` 负责 IDENTIFY/RESUME/序列号(说什么),`WebsocketConnecter` 负责连接与心跳(怎么保活)——心跳任务随连接生灭(HELLO 给出间隔后启动,断线即取消),断线期间不在出站缓冲堆积过期心跳,也免去了调度器的生命周期管理。协议处理器的应答经 `on_frame` 返回值交给 connecter:ws 与心跳一起进 connecter 私有的出站缓冲、由单任务发送(维持 ws 单写者不变量),webhook 则直接写进 HTTP 响应体。
|
||||
3. **看门狗两层互补 + 连接寿命主动重连**(见 `connecter.py` 模块注释):静默超时抓连接彻底失联,ACK 期限检查抓"有事件流但心跳已死"。阈值 `_SILENCE_FACTOR=1.1`、`_ACK_FACTOR=1.2` 是按 ACK 自然节奏校准的,**不能压到 1× 整**,否则正常抖动会周期性误杀(误杀由 RESUME 兜底,不丢消息)。重连退避只在连接稳定存活(`_STABLE_SECONDS`)后才归一,抖动的服务端不会被 1s 间隔反复探测。此外服务端约 1 小时会**无预警**断开连接(实测),故连接存活到 `_SERVER_LIFETIME - _RECONNECT_MARGIN`(默认 50 分钟)时 SDK 主动收尾重连——收帧超时取"静默阈值"与"寿命余量"两个截止中的先到者,静默仍交给看门狗,寿命到点则正常结束本轮连接、由 `run()` 判定为健康断开后立刻 RESUME 续传。
|
||||
4. **handler 异常就地隔离**:`_invoke` 捕获记日志,`dispatch` 主循环另有兜底;单条事件的单个 handler 失败不影响进程。`background=True` 的 handler 进后台任务(emit 不等它),任务由 emitter 持强引用、`close()` 统一取消。
|
||||
5. **注入约定**:handler 参数按形参标注解析——`payloads.py` 里的 dataclass → 解析后的 payload 对象;`Event` → 事件封装;登记进 `emitter.services` 的组件类型(如 `BotApi`)→ 实例;无标注或标注三者皆非 → 抛 `ValueError`(原始数据不注入,需要时标 `Event` 自取 `.raw`/`.typed`)。解析是纯同步查表,无框架、无运行时魔法:每个 handler 的 `get_type_hints` + 形参名只解析一次并缓存,但 `services` 命中在每条事件时实时判定,保证"先注册 handler、后登记组件"的装配顺序可用(`run_loop` 在分发开始前登记全部内置组件,正常流程不会误抛)。
|
||||
5. **注入约定**:handler 参数按形参标注解析——`payloads.py` 里的 dataclass → 解析后的 payload 对象;`Event` → 事件封装;登记进 `emitter.services` 的组件类型(如 `BotApi`)→ 实例;无标注或标注三者皆非 → 抛 `ValueError`(原始数据不注入,需要时标 `Event` 自取 `.raw`/`.typed`)。解析是纯同步查表,无框架、无运行时魔法:每个 handler 的 `get_type_hints` + 形参名只解析一次并缓存,但 `services` 命中在每条事件时实时判定,保证"先注册 handler、后登记组件"的装配顺序可用(`Bot.start()` 在分发开始前登记全部内置组件,正常流程不会误抛)。
|
||||
6. **事件注册不依赖第三方库**:`EventEmitter` 内部就是一个 `事件名 → handler 列表` 的字典,`on()` 追加、`emit()` 顺序遍历。不需要 pyee 那套 `once`/`new_listener`/错误事件机制(SDK 用不到),少一个依赖、语义也一眼可读。注册语义:同一函数对同一事件重复注册只生效一次(沿用原先 pyee 的行为),分发按注册顺序串行执行(`background=True` 的除外)。
|
||||
7. **解析计划与派生密钥都预计算**:`payloads._parse_plan` 把 dataclass 的"字段名 + 是否需解析成 Author"按类缓存一次(不再每次事件重跑 `dataclasses.fields` 与联合类型解包),解析时未收录字段一律忽略——线上 payload 会带新增/未声明字段,透传会让构造报错。`crypto` 按 AppSecret 缓存派生出的 Ed25519 私钥(派生约 40µs,而每个 webhook 请求都要验签)。都是"值为输入的纯函数、结果与输入一一对应",缓存安全且收益明确;不追求极致速度,只去掉明显的重复计算。
|
||||
8. **OpenAPI 请求内核自治**:`api/core.py` 统一处理鉴权头、429 限流重试(优先 `Retry-After`,脏值容错,退避封顶)与 401 刷新 token 重试一次;v2/channel/guild 三个域方法集继承内核组合成 `BotApi`,新增域加模块即可,方法对用户始终平铺在 `BotApi` 上。
|
||||
|
||||
## 扩展点
|
||||
|
||||
- **新增接入方式**(如轮询/其他平台):继承 `Connecter` 实现 `run()`、继承 `BaseProtocol` 实现 `on_frame()`,在 `run_loop` 的 `match` 装配分支加一个 `case`(构造该方式的协议处理器并注入 connecter),`Config.load` 的白名单加一个名字。
|
||||
- **新增接入方式**(如轮询/其他平台):继承 `Connecter` 实现 `run()`、继承 `BaseProtocol` 实现 `on_frame()`,在 `Bot._assemble` 的 `match` 装配分支加一个 `case`(构造该方式的协议处理器并注入 connecter),`Config.load` 的白名单加一个名字。
|
||||
- **新增业务 API**:在 `api/` 对应域模块(`v2`/`channel`/`guild`)加方法即自动平铺到 `BotApi`,全部获得鉴权、限流重试与错误处理;新增域则新建模块并组合进 `api/__init__.py` 的 `BotApi`。群/单聊消息经 `_post_message` 统一处理 msg_id/msg_seq/media 语义;已有方法覆盖:单聊/群聊消息与富媒体上传、频道消息/撤回/表态、公告、精华消息、日程、禁言、频道信息查询(guild/channels/roles/member)。
|
||||
- **新增事件类型 dataclass**:在 `payloads.py` 定义 dataclass 并登记 `EVENT_TYPES`,handler 即可按类型标注拿解析结果;未收录事件回退传 dict。当前已覆盖 intents.toml 的全部事件组(消息、成员变动、审核、表态、互动、论坛、音频)。
|
||||
|
||||
|
||||
-239
@@ -1,239 +0,0 @@
|
||||
# qqbotsdk 使用指南
|
||||
|
||||
面向使用者:从零开始接入 QQ 官方机器人,到收发消息、富媒体与常见问题。
|
||||
API 细节见 [API.md](API.md),内部架构见 [ARCHITECTURE.md](ARCHITECTURE.md)。
|
||||
|
||||
## 接入准备
|
||||
|
||||
1. 到 [QQ 开放平台](https://q.qq.com) 注册开发者账号并创建机器人,拿到 `AppID` 与 `AppSecret`(即 `.env` 里的 `APPID`/`APPSECRET`)。
|
||||
2. 确认机器人的可用场景,这决定了你能收哪些事件、发哪些消息:
|
||||
- **群 / 单聊(v2 接口)**:需要在开放平台开通对应能力,事件与消息接口均用 openid(`group_openid` / `user_openid`),与用户的 QQ 号互不可见。
|
||||
- **频道公域机器人**:只能收 `AT_MESSAGE_CREATE`(@机器人才触发),发消息必须带 `msg_id` 被动回复。
|
||||
- **频道私域机器人**:可订阅 `GUILD_MESSAGES`(全量消息)与论坛事件,可主动发消息、撤回消息。
|
||||
|
||||
## 安装
|
||||
|
||||
项目未发布到 PyPI,从 Gitea 仓库安装(或克隆到本地后用路径安装):
|
||||
|
||||
```bash
|
||||
uv add git+https://github.com/snowy-yang/qqbotsdk # 从 GitHub 安装
|
||||
uv add git+https://gitea.4i.hk/hhhge/qqbotsdk # 或 Gitea 镜像
|
||||
uv add /path/to/qqbotsdk # 本地路径安装
|
||||
```
|
||||
|
||||
要求 Python ≥ 3.12。
|
||||
|
||||
## 配置
|
||||
|
||||
在项目根目录放 `.env`(SDK 启动时自动加载,`APPID`/`APPSECRET` 缺失启动即报错):
|
||||
|
||||
```ini
|
||||
APPID=你的AppID
|
||||
APPSECRET=你的AppSecret
|
||||
CONNECTER=websocket # websocket 或 webhook,默认 websocket
|
||||
|
||||
BASE_URL=https://api.bot.qq.com # 可选,官方 API 地址
|
||||
TIMEOUT=5000 # 可选,HTTP 请求超时(毫秒)
|
||||
INTENTS_FILE=intents.toml # 可选,事件订阅配置文件路径
|
||||
|
||||
WEBHOOK_HOST=0.0.0.0 # 以下仅 webhook 接入时生效
|
||||
WEBHOOK_PORT=8080
|
||||
WEBHOOK_PATH=/qqbot/webhook
|
||||
```
|
||||
|
||||
## 事件订阅(intents.toml)
|
||||
|
||||
SDK 启动时读取 `intents.toml` 计算订阅掩码。订阅粒度就是下面表格里的大类(Intents 分组),把某组设为 `true` 即订阅该组下的全部事件:
|
||||
|
||||
```toml
|
||||
GROUP_AND_C2C_EVENT = true # 群/单聊消息与关注事件
|
||||
INTERACTION = true # 互动事件(卡片按钮回调)
|
||||
GUILDS = false # 频道/子频道增删改
|
||||
```
|
||||
|
||||
QQ 网关只认分组位掩码,**组内单个事件无法单独开关**——能收到哪些事件由订阅了哪些分组决定(各组包含哪些事件见 [API.md](API.md#事件名--dataclass-对照))。分组与特权要求:
|
||||
|
||||
| 分组 | 位掩码 | 说明 |
|
||||
|---|---|---|
|
||||
| `GUILDS` | 1 << 0 | 频道/子频道增删改 |
|
||||
| `GUILD_MEMBERS` | 1 << 1 | 成员增删改 |
|
||||
| `GUILD_MESSAGES` | 1 << 9 | 频道全量消息,**仅私域机器人** |
|
||||
| `GUILD_MESSAGE_REACTIONS` | 1 << 10 | 频道表情表态 |
|
||||
| `DIRECT_MESSAGE` | 1 << 12 | 频道私信 |
|
||||
| `GROUP_AND_C2C_EVENT` | 1 << 25 | 群/单聊消息与关注事件(群机器人核心) |
|
||||
| `INTERACTION` | 1 << 26 | 互动事件 |
|
||||
| `MESSAGE_AUDIT` | 1 << 27 | 消息审核结果 |
|
||||
| `FORUMS_EVENT` | 1 << 28 | 论坛事件,**仅私域机器人** |
|
||||
| `AUDIO_ACTION` | 1 << 29 | 音频播放/上麦下麦 |
|
||||
| `PUBLIC_GUILD_MESSAGES` | 1 << 30 | 频道公域消息(AT_MESSAGE_CREATE) |
|
||||
|
||||
分组名即 `Intent` 枚举成员名。配置只接受 `分组名 = true/false`;拼错的分组名、非布尔值,以及旧版"表内逐事件开关"的写法都会在启动时直接抛 `ValueError`(与其静默少订阅,不如启动即报错)。文件缺失则告警并按不订阅任何事件启动。
|
||||
|
||||
## 接入方式选择
|
||||
|
||||
| | WebSocket | Webhook |
|
||||
|---|---|---|
|
||||
| 网络要求 | 能访问外网即可,本地开发友好 | 需公网可达 + HTTPS,仅支持 80/443/8080/8443 端口 |
|
||||
| 配置 | 无额外配置 | 平台回调地址填 `https://<域名>:<端口><WEBHOOK_PATH>` |
|
||||
| 连接维护 | SDK 自动心跳、断线指数退避重连并 RESUME 续传;服务端约 1 小时会断开连接,SDK 会在临到期前(约 50 分钟)主动重连续传 | SDK 处理 Ed25519 验签、op=13 验证应答、60s TTL 事件去重 |
|
||||
| 适用 | 长驻进程、需要会话与重连保障 | 无状态部署、回调型服务 |
|
||||
|
||||
两种方式下 handler 的写法完全一致,切换只需改 `CONNECTER`。
|
||||
|
||||
## 编写 handler
|
||||
|
||||
用装饰器把 handler 注册到 `EventEmitter`,事件名即线上 payload 的 `t` 名(如 `GROUP_AT_MESSAGE_CREATE`)。参数按类型标注注入,可混用:
|
||||
|
||||
```python
|
||||
from qqbotsdk import EventEmitter
|
||||
|
||||
ee = EventEmitter()
|
||||
|
||||
# 方式一:标注 payload dataclass,拿到解析后的对象
|
||||
from qqbotsdk.payloads import GroupAtMessage
|
||||
|
||||
@ee.on("GROUP_AT_MESSAGE_CREATE")
|
||||
async def typed(msg: GroupAtMessage):
|
||||
print(msg.group_openid, msg.user_openid, msg.content)
|
||||
|
||||
# 方式二:参数注入,标注组件类型即拿到实例(payload 与组件可任意组合)
|
||||
from qqbotsdk.api import BotApi
|
||||
from qqbotsdk.payloads import GroupAtMessage
|
||||
|
||||
@ee.on("GROUP_AT_MESSAGE_CREATE")
|
||||
async def di(msg: GroupAtMessage, api: BotApi):
|
||||
await api.post_group_message(msg.group_openid, content="收到", msg_id=msg.id)
|
||||
```
|
||||
|
||||
handler 参数按标注解析的完整规则:
|
||||
|
||||
| 参数标注 | 得到 |
|
||||
|---|---|
|
||||
| payload dataclass(如 `GroupAtMessage`) | 解析后的 dataclass 对象 |
|
||||
| `Event` | 事件封装对象,只含信封语义(`.raw`/`.data`/`.typed`/`.type`/`.event_id`);事件体字段请标注 payload dataclass 获取 |
|
||||
| 组件类型(`BotApi`/`Session`/`Config` 等) | `emitter.services` 中按类型登记的实例 |
|
||||
|
||||
无标注或标注以上三者皆非的形参会在分发时抛 `ValueError`(记日志、按 handler 隔离)——SDK 不注入原始数据:要事件体字段就标注 payload dataclass,要完整信封就标注 `Event`。
|
||||
|
||||
注意:
|
||||
|
||||
- 只有**业务事件(op=0)**会分发给你的 handler;协议帧(HELLO/READY/INVALID_SESSION/op=13)由 SDK 的协议处理器就地处理,不触达 handler(`Ready` 是例外,它既是握手结果也是可监听的业务事件)。
|
||||
- 业务事件处理器的**返回值不会回流**,回复消息请在 handler 里显式调 API。
|
||||
- 单个 handler 抛异常只记录日志,不影响同事件其他 handler 和主循环。
|
||||
- 所在分组未订阅(intents.toml 中为 `false`)的事件不会到达 handler。
|
||||
- 自定义组件也能注入:`ee.services[MyService] = MyService(...)` 登记后,handler 形参标注 `MyService` 即可拿到实例。
|
||||
|
||||
### 后台并发 handler
|
||||
|
||||
默认同一事件的所有 handler 串行执行,某个慢 handler 会延迟后续事件。调用密集、不关心先后顺序的 handler 可加 `background=True` 丢进后台任务,事件流不再等它:
|
||||
|
||||
```python
|
||||
@ee.on("GROUP_AT_MESSAGE_CREATE", background=True)
|
||||
async def slow_work(msg: GroupAtMessage, api: BotApi):
|
||||
... # 耗时处理,不阻塞其他事件的分发
|
||||
```
|
||||
|
||||
代价是同一事件内 handler 间失去先后保证,且返回值不回流(本就只对协议事件回流),因此仅用于业务 handler。进程退出时 `run_loop` 会统一取消在飞的后台任务。
|
||||
|
||||
## 发消息
|
||||
|
||||
### 被动回复(最常用)
|
||||
|
||||
携带 `msg_id`(回复某条消息)或 `event_id`(响应某类事件,二选一)即为被动回复,**不需要任何消息频率白名单**。两个 id 来源不同:`msg_id` 是消息事件体里的 `d.id`;`event_id` 是**网关推送最外层**的 id(`event.event_id`,形如 `INTERACTION_CREATE:uuid`),不是事件体里的裸 UUID:
|
||||
|
||||
- 群聊:收到消息后 **5 分钟**内可回复,同一 `msg_id` 最多 **5 次**;
|
||||
- 单聊:**60 分钟**内可回复,同一 `msg_id` 最多 **4 次**;
|
||||
- 同一条消息多次回复必须递增 `msg_seq`(相同 `msg_id`+`msg_seq` 会发送失败);
|
||||
- 被动回复同样受内容审核。
|
||||
|
||||
```python
|
||||
await api.post_group_message(
|
||||
group_openid, content="你好", msg_id=msg.id, msg_seq=1,
|
||||
)
|
||||
```
|
||||
|
||||
### 主动消息
|
||||
|
||||
不带 `msg_id`/`event_id` 即主动消息,有额外频控(群/单聊每日每对象 1000 条),且默认关闭,需在开放平台申请。用户可在资料卡关闭主动推送(对应 `C2C_MSG_REJECT`/`GROUP_MSG_REJECT` 事件,关闭后发送会失败)。
|
||||
|
||||
### 富媒体(图片/视频/语音/文件)
|
||||
|
||||
先上传拿 `file_info`,再作为 `media` 发送(此时 SDK 自动把 `msg_type` 置为 7,`content` 作媒体下方的文字):
|
||||
|
||||
```python
|
||||
upload = await api.upload_group_file(
|
||||
group_openid, file_type=1, url="https://example.com/pic.png",
|
||||
)
|
||||
await api.post_group_message(
|
||||
group_openid, content="看图", msg_id=msg.id, media=upload,
|
||||
)
|
||||
```
|
||||
|
||||
`file_type`:1 图片、2 视频、3 语音、4 文件。也可在 `upload_*_file` 传 `srv_send_msg=True` 让上传即发送。
|
||||
|
||||
### Markdown 等其他类型
|
||||
|
||||
`post_group_message`/`post_c2c_message` 支持 `**extra` 透传官方 body 字段,例如 markdown(会覆盖默认的 `msg_type`):
|
||||
|
||||
```python
|
||||
await api.post_group_message(group_openid, content="# 标题\n正文",
|
||||
msg_id=msg.id, msg_type=2)
|
||||
```
|
||||
|
||||
### Markdown 与按钮(msg_type=2)
|
||||
|
||||
`button()`/`keyboard()`(`from qqbotsdk.api import button, keyboard`)构造内嵌按钮,配合 `markdown=` 一起发(msg_type 自动置 2):
|
||||
|
||||
```python
|
||||
await api.post_group_message(
|
||||
group_openid,
|
||||
markdown="## 标题\n**加粗**正文", # 与 content 互斥
|
||||
keyboard=keyboard(
|
||||
button("点我", data="我的按钮数据"), # 单按钮独占一行
|
||||
[button("赞", data="like"), button("踩", data="dislike")], # 列表同行并排
|
||||
),
|
||||
msg_id=msg.id,
|
||||
)
|
||||
```
|
||||
|
||||
- `button(label, data, *, type=1, permission=2, style=1)`:`type` 1 回调(点击推 `INTERACTION_CREATE`)、0 跳转链接(data 为 url)、2 指令(往输入框插入 data);`permission` 2 所有人 / 1 管理员 / 0 指定用户;`label` 官方限 10 字以内;要补嵌套字段(如 `visited_label`、`prompt`)直接修改返回的 dict。
|
||||
|
||||
**处理按钮点击**:点击回调推 `INTERACTION_CREATE` 事件,`inter.button_data` 即被点按钮的 `data`:
|
||||
|
||||
```python
|
||||
@ee.on("INTERACTION_CREATE")
|
||||
async def on_button(inter: Interaction, event: Event, api: BotApi):
|
||||
# 必须应答,否则用户端按钮一直 loading(inter.id 是事件体里的互动 id)
|
||||
await api.respond_interaction(inter.id)
|
||||
await api.post_group_message(
|
||||
inter.group_openid, content=f"你点了:{inter.button_data}",
|
||||
event_id=event.event_id,
|
||||
)
|
||||
```
|
||||
|
||||
- **`event_id` 要取网关帧最外层的 id(`event.event_id`,形如 `INTERACTION_CREATE:uuid`),不能用事件体里的裸 UUID `inter.id`**——`inter.id` 只用于 `respond_interaction`,这是官方文档点名的常见坑;
|
||||
- 需要在 intents.toml 开启 `INTERACTION` 分组;
|
||||
- Markdown 有平台权限要求,无权限时发送会抛 `ApiError`(业务 code 304036/40034127),按需回退纯文本。
|
||||
|
||||
### 撤回
|
||||
|
||||
- 群/单聊(v2):发出 **2 分钟**内可撤回(`withdraw_group_message`/`withdraw_c2c_message`);
|
||||
- 频道:仅私域机器人可撤回(`withdraw_channel_message`,`hide_tip=True` 隐藏撤回小灰条)。
|
||||
|
||||
## 运行与部署
|
||||
|
||||
```bash
|
||||
uv run python main.py
|
||||
```
|
||||
|
||||
- **WebSocket**:进程常驻即可;SDK 自动处理鉴权(IDENTIFY,断线自动 RESUME)、心跳与重连。
|
||||
- **Webhook**:平台回调地址要求公网 HTTPS,本地调试建议用反向代理或内网穿透;开放平台会先发 op=13 验证请求,SDK 自动完成签名应答。
|
||||
|
||||
## 常见问题
|
||||
|
||||
- **启动后频繁重连 / READY 收不到**:QQ 网关接口有频率限制(code 100017),连续重启需间隔约 1 分钟;SDK 已内置退避,等即可。
|
||||
- **`ApiError`**:HTTP ≥400 时抛出,`e.status`/`e.code`/`e.message` 可用于排查(如被动回复超时、msg_seq 重复、内容审核不通过)。
|
||||
- **日志出现 `TokenError: 获取 access_token 失败`**:appid/secret 配置错误或被平台拒绝,`code`/`message` 就是 QQ 返回的真实原因,对照官方错误码排查即可。
|
||||
- **handler 没被触发**:检查 intents.toml 中该事件所属分组是否为 `true`;群/单聊事件需开通对应能力;频道消息事件区分公私域。
|
||||
- **想收原始数据**:标注 `Event`,`event.raw` 是完整线上 payload(含 op/t/d),`event.typed` 是事件体解析结果(未收录事件类型为 dict)。
|
||||
- **日志**:SDK 用 loguru 输出中文日志,连接、重连、API 失败、handler 异常均有记录,可直接用 loguru 配置格式与落盘。
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from qqbotsdk import EventEmitter, main
|
||||
from qqbotsdk import Bot
|
||||
from qqbotsdk.api import BotApi, button, keyboard
|
||||
from qqbotsdk.events import Event
|
||||
from qqbotsdk.payloads import (
|
||||
@@ -21,14 +21,24 @@ from qqbotsdk.payloads import (
|
||||
Interaction,
|
||||
)
|
||||
|
||||
ee = EventEmitter()
|
||||
bot = Bot()
|
||||
|
||||
|
||||
@ee.on("GROUP_AT_MESSAGE_CREATE")
|
||||
async def on_group_message(msg: GroupAtMessage, api: BotApi) -> None:
|
||||
@bot.on("GROUP_AT_MESSAGE_CREATE")
|
||||
async def on_group_message(msg: GroupAtMessage, api: BotApi, bot: Bot) -> None:
|
||||
if not (msg.group_openid and msg.id):
|
||||
return
|
||||
|
||||
# "/me":经 bot.call_api 调通用 REST 接口(未封装接口也走这里)
|
||||
if (msg.content or "").strip() == "/me":
|
||||
me = await bot.call_api("GET", "/users/@me")
|
||||
await api.post_group_message(
|
||||
msg.group_openid,
|
||||
content=f"我是 {me.get('username', '机器人')}",
|
||||
msg_id=msg.id,
|
||||
)
|
||||
return
|
||||
|
||||
# "/img <图片url>":上传富媒体后随消息发出(msg_type 自动置为 7)
|
||||
if (msg.content or "").startswith("/img "):
|
||||
url = (msg.content or "").removeprefix("/img ").strip()
|
||||
@@ -64,7 +74,7 @@ async def on_group_message(msg: GroupAtMessage, api: BotApi) -> None:
|
||||
)
|
||||
|
||||
|
||||
@ee.on("INTERACTION_CREATE")
|
||||
@bot.on("INTERACTION_CREATE")
|
||||
async def on_button_click(inter: Interaction, event: Event, api: BotApi) -> None:
|
||||
if inter.chat_type != 1 or not inter.group_openid:
|
||||
return # 本示例只处理群聊的按钮回调
|
||||
@@ -79,7 +89,7 @@ async def on_button_click(inter: Interaction, event: Event, api: BotApi) -> None
|
||||
)
|
||||
|
||||
|
||||
@ee.on("GROUP_ADD_ROBOT")
|
||||
@bot.on("GROUP_ADD_ROBOT")
|
||||
async def on_robot_added(added: GroupAddRobot, event: Event, api: BotApi) -> None:
|
||||
# 入群是事件而非消息:用信封顶层的事件 id 作 event_id 被动回复(拿不到则跳过)
|
||||
if not (added.group_openid and event.event_id):
|
||||
@@ -92,7 +102,7 @@ async def on_robot_added(added: GroupAddRobot, event: Event, api: BotApi) -> Non
|
||||
)
|
||||
|
||||
|
||||
@ee.on("C2C_MESSAGE_CREATE")
|
||||
@bot.on("C2C_MESSAGE_CREATE")
|
||||
async def on_c2c_message(msg: C2CMessage, api: BotApi) -> None:
|
||||
user_openid = msg.user_openid
|
||||
if not (user_openid and msg.id):
|
||||
@@ -103,10 +113,10 @@ async def on_c2c_message(msg: C2CMessage, api: BotApi) -> None:
|
||||
)
|
||||
|
||||
|
||||
@ee.on("FRIEND_ADD")
|
||||
@bot.on("FRIEND_ADD")
|
||||
async def on_friend_add(added: FriendAdd) -> None:
|
||||
logger.info(f"新增用户:{added.openid}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(ee)
|
||||
bot.run()
|
||||
@@ -11,14 +11,14 @@
|
||||
运行:uv run python examples/webhook_bot/main.py
|
||||
"""
|
||||
|
||||
from qqbotsdk import EventEmitter, main
|
||||
from qqbotsdk import Bot
|
||||
from qqbotsdk.api import BotApi
|
||||
from qqbotsdk.payloads import GroupAtMessage
|
||||
|
||||
ee = EventEmitter()
|
||||
bot = Bot()
|
||||
|
||||
|
||||
@ee.on("GROUP_AT_MESSAGE_CREATE")
|
||||
@bot.on("GROUP_AT_MESSAGE_CREATE")
|
||||
async def on_group_message(msg: GroupAtMessage, api: BotApi) -> None:
|
||||
if not (msg.group_openid and msg.id):
|
||||
return
|
||||
@@ -30,4 +30,4 @@ async def on_group_message(msg: GroupAtMessage, api: BotApi) -> None:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(ee)
|
||||
bot.run()
|
||||
+1
-3
@@ -30,12 +30,10 @@ dependencies = [
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/snowy-yang/qqbotsdk"
|
||||
Documentation = "https://qqbotsdk.4i.hk"
|
||||
Repository = "https://github.com/snowy-yang/qqbotsdk"
|
||||
Changelog = "https://github.com/snowy-yang/qqbotsdk/blob/main/CHANGELOG.md"
|
||||
|
||||
[project.scripts]
|
||||
qqbotsdk = "qqbotsdk:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.12.7,<0.13.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
+23
-67
@@ -1,83 +1,39 @@
|
||||
"""qqbotsdk:QQ 官方机器人 SDK 入口。
|
||||
|
||||
用法:构造 `EventEmitter()` 注册 handler,交给 `main(ee)`
|
||||
启动;连接方式由 .env 的 CONNECTER 决定(websocket / webhook)。
|
||||
用法:`bot = Bot()` → `@bot.on(...)` 注册 handler → `bot.run()` 启动;
|
||||
连接方式由 .env 的 CONNECTER 决定(websocket / webhook)。
|
||||
架构详情见 docs/ARCHITECTURE.md。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
|
||||
from .api import BotApi
|
||||
from .config import Config
|
||||
from .bot import Bot as Bot
|
||||
from .config import Config as Config
|
||||
from .connecter import Connecter as Connecter
|
||||
from .connecter import WebhookConnecter, WebsocketConnecter
|
||||
from .emitter import EventEmitter as EventEmitter
|
||||
from .protocol import BaseProtocol as BaseProtocol
|
||||
from .queue import EventQueue as EventQueue
|
||||
from .session import Session
|
||||
from .token import AccessToken
|
||||
from .webhook_protocol import WebhookProtocol
|
||||
from .ws_protocol import WebsocketProtocol
|
||||
from .session import Session as Session
|
||||
from .token import AccessToken as AccessToken
|
||||
from .webhook_protocol import WebhookProtocol as WebhookProtocol
|
||||
from .ws_protocol import WebsocketProtocol as WebsocketProtocol
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
async def run_loop(emitter: EventEmitter | None = None) -> None:
|
||||
"""启动 SDK。可传入外部构造的 EventEmitter 以注册 handler;不传时自建。
|
||||
|
||||
全部组件在此显式装配,并按类型登记到 emitter.services 供 handler
|
||||
参数注入;共享的 HTTP 连接池随 run_loop 结束释放。
|
||||
"""
|
||||
if emitter is None:
|
||||
emitter = EventEmitter()
|
||||
|
||||
config = Config.load()
|
||||
http = ClientSession(timeout=config.timeout)
|
||||
session = Session()
|
||||
token = AccessToken(config, http)
|
||||
api = BotApi(config, http, token)
|
||||
emitter.services.update(
|
||||
{
|
||||
EventEmitter: emitter,
|
||||
EventQueue: emitter.queue,
|
||||
Config: config,
|
||||
Session: session,
|
||||
ClientSession: http,
|
||||
AccessToken: token,
|
||||
BotApi: api,
|
||||
}
|
||||
)
|
||||
|
||||
# 各接入方式配自己的协议处理器:协议帧由适配器就近处理,只有业务事件
|
||||
# (op=0)入队交给 emitter 分发(见 protocol.py / connecter.py)
|
||||
match config.connecter:
|
||||
case "websocket":
|
||||
protocol = WebsocketProtocol(config, session, token)
|
||||
connecter = WebsocketConnecter(
|
||||
config, http, token, session, emitter.queue, protocol
|
||||
)
|
||||
case "webhook":
|
||||
protocol = WebhookProtocol(config)
|
||||
connecter = WebhookConnecter(config, emitter.queue, protocol)
|
||||
case unknown:
|
||||
raise ValueError(f"未知的 CONNECTER: {unknown}")
|
||||
|
||||
logger.info(f"qqbotsdk 启动,连接方式: {config.connecter}")
|
||||
try:
|
||||
await asyncio.gather(emitter.dispatch(), connecter.run())
|
||||
finally:
|
||||
# 在飞的后台 handler 先于连接池收尾,避免用到已关闭的 ClientSession
|
||||
await emitter.close()
|
||||
# 全 SDK 共享一个连接池,统一在此释放
|
||||
await http.close()
|
||||
|
||||
|
||||
def main(emitter: EventEmitter | None = None) -> None:
|
||||
try:
|
||||
asyncio.run(run_loop(emitter))
|
||||
except KeyboardInterrupt:
|
||||
logger.info("qqbotsdk 已退出")
|
||||
__all__ = [
|
||||
"AccessToken",
|
||||
"BaseProtocol",
|
||||
"Bot",
|
||||
"BotApi",
|
||||
"Config",
|
||||
"Connecter",
|
||||
"EventEmitter",
|
||||
"EventQueue",
|
||||
"Session",
|
||||
"WebhookConnecter",
|
||||
"WebhookProtocol",
|
||||
"WebsocketConnecter",
|
||||
"WebsocketProtocol",
|
||||
]
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Bot 门面:组件装配收进门面,用户侧只接触实例本身。
|
||||
|
||||
`Bot()` 构造即读 .env 校验配置;`@bot.on` 注册 handler(转发
|
||||
EventEmitter,语义不变);`run()` 同步启动,`start()` 供嵌入外部
|
||||
asyncio 应用。连接池/token/API/services 登记与连接器选型全部内聚在
|
||||
start() 的装配里,handler 经形参标注拿所需组件(含 Bot 自身)。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from typing import Any, overload
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from loguru import logger
|
||||
|
||||
from .api import BotApi
|
||||
from .config import Config
|
||||
from .connecter import Connecter, WebhookConnecter, WebsocketConnecter
|
||||
from .emitter import EventEmitter, Handler
|
||||
from .model import Opcode
|
||||
from .queue import EventQueue
|
||||
from .session import Session
|
||||
from .token import AccessToken
|
||||
from .webhook_protocol import WebhookProtocol
|
||||
from .ws_protocol import WebsocketProtocol
|
||||
|
||||
|
||||
class Bot:
|
||||
def __init__(self, config: Config | None = None) -> None:
|
||||
self._config = config if config is not None else Config.load()
|
||||
self._emitter = EventEmitter()
|
||||
self._http: ClientSession | None = None
|
||||
self._api: BotApi | None = None
|
||||
|
||||
@property
|
||||
def config(self) -> Config:
|
||||
return self._config
|
||||
|
||||
@property
|
||||
def services(self) -> dict[type, Any]:
|
||||
"""组件登记表:自定义服务在此登记后即可按形参标注注入 handler。"""
|
||||
return self._emitter.services
|
||||
|
||||
@property
|
||||
def queue(self) -> EventQueue:
|
||||
return self._emitter.queue
|
||||
|
||||
@property
|
||||
def api(self) -> BotApi:
|
||||
"""类型化 REST 门面;启动(run/start)后才存在。"""
|
||||
if self._api is None:
|
||||
raise RuntimeError("Bot 尚未启动,api/call_api 仅在 run()/start() 之后可用")
|
||||
return self._api
|
||||
|
||||
@overload
|
||||
def on(
|
||||
self, event: Opcode | str, *, background: bool = False
|
||||
) -> Callable[[Handler], Handler]: ...
|
||||
|
||||
@overload
|
||||
def on(
|
||||
self, event: Opcode | str, fn: Handler, *, background: bool = False
|
||||
) -> Handler: ...
|
||||
|
||||
def on(
|
||||
self,
|
||||
event: Opcode | str,
|
||||
fn: Handler | None = None,
|
||||
*,
|
||||
background: bool = False,
|
||||
) -> Handler | Callable[[Handler], Handler]:
|
||||
"""注册事件处理器,语义与 EventEmitter.on 一致(装饰器/直传皆可)。"""
|
||||
if fn is None:
|
||||
return self._emitter.on(event, background=background)
|
||||
return self._emitter.on(event, fn, background=background)
|
||||
|
||||
async def call_api(self, method: str, path: str, **kwargs: Any) -> Any:
|
||||
"""通用 REST 入口:带 token 鉴权、429 退避重试与 401 自愈。
|
||||
|
||||
类型化方法见 `bot.api`;本入口面向尚未封装的接口。
|
||||
"""
|
||||
return await self.api.request(method, path, **kwargs)
|
||||
|
||||
def _assemble(self) -> Connecter:
|
||||
"""装配全部组件并按类型登记 services,返回就绪的连接器。"""
|
||||
http = ClientSession(timeout=self._config.timeout)
|
||||
session = Session()
|
||||
token = AccessToken(self._config, http)
|
||||
api = BotApi(self._config, http, token)
|
||||
self._http = http
|
||||
self._api = api
|
||||
self._emitter.services.update(
|
||||
{
|
||||
Bot: self,
|
||||
EventEmitter: self._emitter,
|
||||
EventQueue: self._emitter.queue,
|
||||
Config: self._config,
|
||||
Session: session,
|
||||
ClientSession: http,
|
||||
AccessToken: token,
|
||||
BotApi: api,
|
||||
}
|
||||
)
|
||||
|
||||
# 各接入方式配自己的协议处理器:协议帧由适配器就近处理,
|
||||
# 只有业务事件(op=0)入队交给 emitter 分发(见 protocol.py)
|
||||
match self._config.connecter:
|
||||
case "websocket":
|
||||
protocol = WebsocketProtocol(self._config, session, token)
|
||||
connecter = WebsocketConnecter(
|
||||
self._config, http, token, session, self._emitter.queue, protocol
|
||||
)
|
||||
case "webhook":
|
||||
protocol = WebhookProtocol(self._config)
|
||||
connecter = WebhookConnecter(
|
||||
self._config, self._emitter.queue, protocol
|
||||
)
|
||||
case unknown:
|
||||
raise ValueError(f"未知的 CONNECTER: {unknown}")
|
||||
return connecter
|
||||
|
||||
async def start(self) -> None:
|
||||
"""装配并常驻运行,供嵌入外部 asyncio 应用;普通脚本直接用 run()。"""
|
||||
connecter = self._assemble()
|
||||
logger.info(f"qqbotsdk 启动,连接方式: {self._config.connecter}")
|
||||
try:
|
||||
await asyncio.gather(self._emitter.dispatch(), connecter.run())
|
||||
finally:
|
||||
# 在飞的后台 handler 先于连接池收尾,避免用到已关闭的 ClientSession
|
||||
await self._emitter.close()
|
||||
if self._http is not None:
|
||||
await self._http.close()
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
asyncio.run(self.start())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("qqbotsdk 已退出")
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Bot 门面:构造校验、注册转发、注入约定与装配产物。"""
|
||||
|
||||
import pytest
|
||||
from aiohttp import ClientSession
|
||||
|
||||
from qqbotsdk import (
|
||||
AccessToken,
|
||||
Bot,
|
||||
BotApi,
|
||||
Config,
|
||||
EventEmitter,
|
||||
EventQueue,
|
||||
Session,
|
||||
WebhookConnecter,
|
||||
WebsocketConnecter,
|
||||
)
|
||||
from qqbotsdk.payloads import GroupAtMessage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("APPID", "102000000")
|
||||
monkeypatch.setenv("APPSECRET", "test-secret")
|
||||
|
||||
|
||||
def test_construction_loads_config(env: None) -> None:
|
||||
bot = Bot()
|
||||
assert bot.config.app_id == "102000000"
|
||||
assert bot.config.connecter == "websocket"
|
||||
|
||||
|
||||
def test_missing_credentials_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("APPID", raising=False)
|
||||
monkeypatch.delenv("APPSECRET", raising=False)
|
||||
with pytest.raises(ValueError):
|
||||
Bot()
|
||||
|
||||
|
||||
async def test_on_registers_handler(env: None) -> None:
|
||||
bot = Bot()
|
||||
seen: list[str] = []
|
||||
|
||||
@bot.on("GROUP_AT_MESSAGE_CREATE")
|
||||
async def on_message(msg: GroupAtMessage) -> None:
|
||||
seen.append(msg.content or "")
|
||||
|
||||
payload = {
|
||||
"op": 0,
|
||||
"id": "evt-1",
|
||||
"t": "GROUP_AT_MESSAGE_CREATE",
|
||||
"d": {"id": "msg-1", "content": "你好", "group_openid": "g1"},
|
||||
}
|
||||
await bot._emitter.emit(payload)
|
||||
assert seen == ["你好"]
|
||||
|
||||
|
||||
async def test_assemble_websocket_registers_all_services(
|
||||
env: None,
|
||||
) -> None:
|
||||
bot = Bot()
|
||||
connecter = bot._assemble()
|
||||
assert isinstance(connecter, WebsocketConnecter)
|
||||
|
||||
services = bot.services
|
||||
assert services[Bot] is bot
|
||||
assert services[EventEmitter] is bot._emitter
|
||||
assert services[EventQueue] is bot.queue
|
||||
assert services[Config] is bot.config
|
||||
assert isinstance(services[Session], Session)
|
||||
assert isinstance(services[ClientSession], ClientSession)
|
||||
assert isinstance(services[AccessToken], AccessToken)
|
||||
assert bot.api is services[BotApi]
|
||||
|
||||
await bot._http.close() # 清理装配出的连接池
|
||||
|
||||
|
||||
async def test_assemble_webhook_builds_webhook_connecter(
|
||||
env: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CONNECTER", "webhook")
|
||||
bot = Bot()
|
||||
connecter = bot._assemble()
|
||||
assert isinstance(connecter, WebhookConnecter)
|
||||
await bot._http.close()
|
||||
|
||||
|
||||
async def test_bot_instance_injectable_into_handler(env: None) -> None:
|
||||
bot = Bot()
|
||||
bot._assemble()
|
||||
got: list[object] = []
|
||||
|
||||
@bot.on("FRIEND_ADD")
|
||||
async def on_friend_add(bot: Bot) -> None:
|
||||
got.append(bot)
|
||||
|
||||
payload = {
|
||||
"op": 0,
|
||||
"id": "evt-2",
|
||||
"t": "FRIEND_ADD",
|
||||
"d": {"openid": "u1"},
|
||||
}
|
||||
await bot._emitter.emit(payload)
|
||||
assert got == [bot]
|
||||
await bot._http.close()
|
||||
|
||||
|
||||
def test_api_requires_start(env: None) -> None:
|
||||
bot = Bot()
|
||||
with pytest.raises(RuntimeError):
|
||||
_ = bot.api
|
||||
|
||||
|
||||
async def test_call_api_delegates_to_api_request(env: None) -> None:
|
||||
bot = Bot()
|
||||
calls: list[tuple[str, str, dict]] = []
|
||||
|
||||
class FakeApi:
|
||||
async def request(self, method: str, path: str, **kwargs: object) -> dict:
|
||||
calls.append((method, path, dict(kwargs)))
|
||||
return {"ok": True}
|
||||
|
||||
bot._api = FakeApi() # type: ignore[assignment]
|
||||
result = await bot.call_api("GET", "/users/@me", params={"a": "1"})
|
||||
assert result == {"ok": True}
|
||||
assert calls == [("GET", "/users/@me", {"params": {"a": "1"}})]
|
||||
Reference in new issue
Block a user