From 7663ca610d32711574dc967faf1fab7b148b2d97 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:20:20 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=E8=AF=AF=E5=85=A5?= =?UTF-8?q?=E7=9A=84=20dist=20=E7=9B=AE=E5=BD=95=EF=BC=88=E6=97=A9?= =?UTF-8?q?=E6=9C=9F=E7=BB=84=E8=A3=85=E6=BC=94=E7=BB=83=E6=AE=8B=E7=95=99?= =?UTF-8?q?=EF=BC=8C=E6=92=A4=E9=94=80=20gitignore=20=E5=90=8E=E8=A2=AB=20?= =?UTF-8?q?add=20-A=20=E6=94=B6=E8=B5=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dist/.nojekyll | 0 dist/CHANGELOG.md | 78 -------- dist/README.md | 144 ------------- dist/_sidebar.md | 6 - dist/docs/API.md | 323 ------------------------------ dist/docs/ARCHITECTURE.md | 133 ------------ dist/docs/GUIDE.md | 242 ---------------------- dist/examples/README.md | 25 --- dist/examples/group_bot/main.py | 106 ---------- dist/examples/webhook_bot/main.py | 33 --- dist/index.html | 88 -------- 11 files changed, 1178 deletions(-) delete mode 100644 dist/.nojekyll delete mode 100644 dist/CHANGELOG.md delete mode 100644 dist/README.md delete mode 100644 dist/_sidebar.md delete mode 100644 dist/docs/API.md delete mode 100644 dist/docs/ARCHITECTURE.md delete mode 100644 dist/docs/GUIDE.md delete mode 100644 dist/examples/README.md delete mode 100644 dist/examples/group_bot/main.py delete mode 100644 dist/examples/webhook_bot/main.py delete mode 100644 dist/index.html diff --git a/dist/.nojekyll b/dist/.nojekyll deleted file mode 100644 index e69de29..0000000 diff --git a/dist/CHANGELOG.md b/dist/CHANGELOG.md deleted file mode 100644 index aec8292..0000000 --- a/dist/CHANGELOG.md +++ /dev/null @@ -1,78 +0,0 @@ -# 更新日志 - -本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 格式, -版本号遵循[语义化版本](https://semver.org/lang/zh-CN/)。 - -## [Unreleased] - -### 新增 - -- **旧版(频道侧)OpenAPI 对齐(对照旧版 wiki 全集核对)**:私信(`create_dms` 建会话、`post_dms_message` 发信、`withdraw_dms_message` 撤回);身份组增删改与成员授予/收回;频道成员列表/踢出(可拉黑+撤回历史消息)/身份组成员列表/子频道在线成员数;全员与批量成员禁言;子频道用户/身份组权限查改;频道消息频率设置;接口权限列表与授权链接申请;新增 `audio` 域(播放控制、上/下麦)与 `forum` 域(帖子列表/详情/发帖/删帖)组合进 `BotApi`(`AUDIO_STATUS_*`、`FORUM_FORMAT_*` 常量随模块导出) -- **事件全量对齐**:新增 `GROUP_MEMBER_ADD`/`GROUP_MEMBER_REMOVE`(共用 `GroupMemberChange`)、`GROUP_JOIN_REQUEST`(入群申请,含验证问答与自动审批命中信息,仅机器人是群管理员时推送)、`SUBSCRIBE_MESSAGE_STATUS`(订阅消息授权状态)四个 payload;`Intent` 枚举与 intents.toml 补上此前缺失的 `GROUP_MEMBER_EVENT (1<<24)` 分组(默认 false,需订阅请手动开启) -- **v2 OpenAPI 全量对齐(对照官方 2026-09 新版文档站逐页核对)**:新增 `group` 域方法集组合进 `BotApi`——群信息/成员列表/成员详情/机器人群内状态、入群申请列表与审批、群黑名单增删查、群成员批量移除、群禁言状态查询、入群自动审批策略(增删改查/执行/白名单,机器人维度全局配置) -- v2 消息扩展:`post_c2c_stream_message` 流式分批发送单聊消息(AI 逐段下发,`STREAM_*` 常量取值);`prepare_group_upload`/`finish_group_upload_part` 与 `prepare_c2c_upload`/`finish_c2c_upload_part` 大文件分片上传(预上传拿预签名 URL,分片 PUT 后逐片确认,最后携 `upload_id` 走原上传接口合并);`generate_url_link` 生成机器人分享链接(`callback_data` 透传) -- 频道域补充:`create_channel`/`update_channel`/`delete_channel` 子频道增删改(私域接口)、`get_me_guilds` 机器人加入的频道列表(分页) -- **斜线指令管理**:新增 `panel` 域方法集组合进 `BotApi`——指令面板(聊天输入框输入 `/` 唤起的指令列表)的增删改查与关联对象管理(`create_panel`/`get_panels`/`get_panel`/`update_panel`/`delete_panel`/`update_panel_targets`,支持 c2c/group/channel/dm 四场景,c2c/group 可按指定对象生效);附全局自定义菜单 `get_menu`/`update_menu`(仅 C2C,`send_message` 类型点击后把 `/指令` 填入输入框);`SCOPE_*`/`TARGET_*`/`ITEM_*` 等取值常量随模块导出 -- Markdown 与内嵌按钮一等支持:`post_group_message`/`post_c2c_message` 新增 `markdown`/`keyboard` 参数(自动置 msg_type=2,与 content 互斥);新增 `respond_interaction()` 回应按钮回调;`Interaction` payload 补齐 `chat_type`/`scene`/`group_openid` 等官方字段 -- `Event` 新增 `event_id` 属性(推送信封顶层的事件 id,与 op/t/d 平级):非消息事件(GROUP_ADD_ROBOT 等)的被动回复不再需要手动 `event.raw["id"]` -- 按钮快捷构造与点击处理:`button()`/`keyboard()` 生成内嵌按钮(免手写嵌套 dict);`Interaction` 新增 `button_data` 属性(被点按钮的 action.data);examples/group_bot 补 `/btn` 卡片与 `INTERACTION_CREATE` 回调示例 -- handler 后台并发:`@ee.on(..., background=True)` 把 handler 放进后台任务,不阻塞事件流(同事件内失去先后保证,返回值不回流);`run_loop` 退出经 `emitter.close()` 统一取消在飞任务 -- OpenAPI 限流与鉴权自愈:`BotApi.request` 对 429 自动重试(最多 3 次,优先 `Retry-After`,脏值容错解析,退避封顶 30s);401 强制刷新 token 后重试一次,token 被服务端提前作废不再直接抛错 -- 群消息全量模式:新增 `GROUP_MESSAGE_CREATE` 事件接收,与 `GROUP_AT_MESSAGE_CREATE` 共用 `GroupAtMessage`(需在开放平台开通"接收所有消息"权限,不限 @机器人);`GroupAtMessage` 补齐官方新增的 `message_type`/`message_scene`/`attachments`/`mentions`/`ark_data`/`msg_elements` 字段 -- **连接寿命主动重连**:服务端约 1 小时后会无预警断开连接(实测),故连接存活到 `_SERVER_LIFETIME - _RECONNECT_MARGIN`(默认 60min − 10min = 50min)时由 SDK 主动收尾重连——走正常 RESUME 续传、期间消息由网关补发,而不是被服务端掐断后由看门狗判成异常断开;收帧超时在"静默阈值"与"寿命余量"两个截止里取先到者,静默仍归看门狗处理 -- 文档站点:基于 docsify(侧边栏、全文搜索、代码复制、mermaid 图渲染、分页),`npx docsify-cli serve` 本地预览;新增 `examples/README.md` 示例索引 -- Gitea Actions CI:push(main)/Pull Request/手动触发时自动执行 `ruff check` 与 `pyright`(`.gitea/workflows/ci.yml`,uv 同步依赖后运行与本地开发一致的检查命令) -- 文档站线上部署:`scripts/deploy_docs.sh` 组装纯文档目录(不携带源码)并经 wrangler 上传 Cloudflare Pages(Direct Upload,零构建);`.gitea/workflows/deploy-docs.yml` 在 main 的文档文件变更时自动部署 - -### 变更 - -- **移除 dishka 依赖,改为内置参数注入**:handler 形参直接标注组件类型(`api: BotApi`)即注入,不再需要 `Inject[T]` 包装;组件改在 `run_loop` 显式装配并按类型登记到 `emitter.services`(自定义组件同样登记即可注入)。破坏性变更:`Inject`、`make_container`、`use_container` 随之移除,原 `api: Inject[BotApi]` 写法改为 `api: BotApi` -- `EventEmitter` 的 `queue` 参数改为可选:不传自动创建队列,`EventEmitter()` 即可直接注册 handler;需多处共享队列时仍可显式传 `EventQueue`(`ee.queue` 取实例)。文档与示例统一改为无参构造 -- **`api.py` 拆分为 `api/` 包**:`core.py` 请求内核(鉴权/限流重试/错误归一)+ `v2.py`(群/单聊与按钮构造)、`channel.py`(频道消息)、`guild.py`(频道管理与查询)三个域方法集组合成 `BotApi`;方法名与导入路径不变(`from qqbotsdk.api import BotApi, button, keyboard` 照旧),新增域加模块即可 -- **心跳归传输层**:心跳从 `WebsocketProtocol`(定时调度任务)迁入 `WebsocketConnecter`(asyncio 任务随连接生灭),断线期间不再往 reply 队列堆积过期心跳,重连也不会先冲出陈旧心跳 -- 重连退避只在连接稳定存活(≥60s)后才重置归一,抖动的服务端不再被 1s 间隔反复探测 -- `AccessToken` 并发安全:换取过程持 `asyncio.Lock` 双重检查(并发首调只发一次请求);到期判断改用 monotonic 时钟并预留 30s 提前刷新余量;新增 `invalidate()` 供 401 时强制作废 -- 组件构造签名瘦身:`WebsocketProtocol(config, session, token)` 移除未使用的 `queue`;`WebsocketConnecter` 改收 `(config, http, token, session, queue)`;`WebhookConnecter` 改收 `(config, queue, handle_event)`,op=13 应答经注入的回调取得,不再依赖具体 `EventEmitter`;`Session` 合并 `sequence_id`/`seq` 双属性为 `seq`(心跳序列号无历史时发 0 而非 null);移除服务端不会推送的 HEARTBEAT 死代码 handler;`ApiError`/`TokenError` 的错误响应解析合并为 `model.error_parts` -- **intents.toml 改为只按大类订阅**:配置项从"`[分组]` 表内逐事件开关"简化为"`分组名 = true/false`"(如 `GROUP_AND_C2C_EVENT = true`)——网关只接受分组位掩码,组内事件本就无法单独订阅,此前的逐事件开关只是"组内任一为 true 即订阅整组"的装饰。破坏性变更:旧格式(表内逐事件开关)不再兼容,与拼错的分组名、非布尔值一样在启动时直接抛 `ValueError`,而非静默少订阅 -- 形参注入缓存化:handler 的签名与类型标注解析结果(此前每条事件都重新跑 `inspect.signature`/`get_type_hints`)合并缓存,`services` 命中改为每条事件实时判定——先注册 handler、后登记组件的既有装配顺序不受影响 -- **协议帧与业务事件分流,各接入方式配专有协议处理器**:`BaseProtocol` 从 `emitter.py` 移入新模块 `protocol.py`,接口由 `register(emitter)` 改为 `async on_frame(payload)`(返回需回送的响应体);协议处理器不再注册进 emitter,而是由 `run_loop` 构造后**注入对应的 connecter**(`WebsocketConnecter(..., protocol)` / `WebhookConnecter(config, queue, protocol)`),由适配器逐帧调用。破坏性变更:只有 op=0 的业务事件进事件队列交给 `EventEmitter`,HELLO/READY/INVALID_SESSION/op=13 等协议帧在连接器内就地处理——用户 handler 不再收到协议帧;`EventEmitter.register_protocol()`、`EventEmitter.handle()`、`emitter._protocol`/`on_payload` 机制随之删除,协议类不再需要 `register()` 注册 handler。附带修掉一处时序耦合:`INVALID_SESSION` 的会话重置此前依赖分发泵在重连前消费掉该事件,现在在 connecter `break` 重连前同步完成,不会误 RESUME 已死会话 -- 事件分发热路径减负:`emit()` 先取事件名、无 listener 时不再构造 `Event`(每条未订阅事件/协议帧省一次对象与 dict 拷贝);`Event.data` 改为惰性拷贝(只用 `.typed`/`.raw` 的 handler 不再触发 `dict(d)`) -- ws 重连收尾补 `await`:取消 `reply`/`heartbeat` 任务后 `gather(..., return_exceptions=True)` 取回结果,避免 "Task exception was never retrieved" 并确保它们在下一轮重连前真正停下 -- `EventQueue.put_event`/`put_reply` 去掉多余的 `return await` -- `EventQueue` 收敛为**单向业务事件通道**:删除 `reply_queue` 与 `put_reply`/`get_reply`(协议分流后引用它的只剩 `WebsocketConnecter`)。ws 的出站帧(心跳 + 协议应答)改由 `WebsocketConnecter` 私有出站缓冲承载、`send_helper` 单任务发送(维持 aiohttp ws 单写者不变量);webhook 的应答直接写 HTTP 响应体。破坏性变更:`EventQueue.reply_queue` 与 `put_reply`/`get_reply` 不再存在 -- **移除 pyee 依赖**:`EventEmitter` 改为内置的 `事件名 → handler 列表` 注册表(`on()` 追加、`emit()` 顺序遍历),不再需要 pyee 的 `once`/`new_listener`/错误事件机制。注册语义保持不变(同函数重复注册只生效一次、按注册顺序串行执行)。破坏性变更:`once`/`remove_listener` 等 pyee 装饰性 API 不再提供(SDK 与公开文档本就不涉及) -- 热路径去掉重复计算(不改语义):`payloads` 按类缓存解析计划(不再每次事件重跑 `dataclasses.fields` 与联合类型解包,实测 7.5µs→2.5µs;未收录字段照旧忽略);`crypto` 按 AppSecret 缓存派生私钥(验签每请求省约 40µs 的密钥派生) -- `WebsocketConnecter` 的 `reply_helper` 更名为 `send_helper`(它从出站缓冲取帧发送,原名会让人以为还有 reply 队列);`Connecter` 由 `typing.Protocol` 改为 `abc.ABC`,与 `BaseProtocol` 的接口风格一致 -- webhook 去重改为摊还清理:过期判定按当前 id 做(O(1),TTL 外重发的同一 id 仍按新事件处理),整表清过期项只在表长到阈值时执行(原先每请求全表扫描,O(n)) - -### 移除 - -- **`Event` 的字段名映射属性 `user_id`/`group_id`/`content`/`message_id`**(含旧版群消息 payload 顶层 `from_user_id` 字段的回退):`Event` 收敛为推送信封封装,只保留 `.raw`/`.data`/`.typed`/`.type`/`.event_id`;事件体字段一律标注 payload dataclass(如 `msg: GroupAtMessage`)获取解析结果,或经 `.data`/`.raw` 自取。破坏性变更:原 `event.group_id` 改为 typed payload 的 `msg.group_openid`,`event.message_id` 改为 `msg.id`,以此类推 -- `EventEmitter.register_protocol()`、`EventEmitter.handle()` 与 `emitter` 持有协议层的机制(协议处理器改为由 connecter 持有并逐帧调用,见上文"变更") -- apscheduler 依赖(心跳迁入 connecter 后不再需要调度器) -- 音乐插件示例(`plugins/`、根目录 `main.py`)已拆分为独立的点歌 bot 项目(含 Markdown 卡片、按钮回调与内置短网址多线程下载代理),不再随 SDK 仓库分发 - -### 修复 - -- **INVALID_SESSION 可续传标记失效**:op=9 的 `d` 是布尔值(true=可 RESUME),此前参数注入回退把 d 字典化成 `{}`(恒 falsy),任何 INVALID_SESSION 都会清空会话——RESUME 续传永远走不到,断线期间本可回放的消息丢失。现协议帧不再经参数注入,`WebsocketProtocol` 直接读取 `d` 的真值,`resumable=true` 正确保留会话(协议帧处理测试覆盖) -- **会话重置时序**:`INVALID_SESSION` 的会话清空此前依赖分发泵在重连前消费掉该事件(慢 handler 会推迟它,导致带着已死 session_id 去 RESUME);现在连接器在 `break` 重连前同步调用协议处理器完成重置 -- token 请求失败时抛出 `TokenError`(携带 QQ 返回的 `code`/`message`),不再抛出掩盖真实原因的 `KeyError: 'access_token'` -- 修正被动回复 `event_id` 的指引:官方要求取网关帧最外层的 id(`Event.event_id`),事件体里的裸 UUID(`Interaction.id` 等)不能作 `event_id`,此前文档与 docstring 的说法有误 -- 响应 `Content-Type` 非 JSON 时不再抛 `ContentTypeError`(个别接口如回应互动返回 200 text/plain + `{}` body);JSON 解析统一切换 ujson - -## [0.1.0] - 2026-09-14 - -首个公开版本:QQ 官方机器人 Python SDK(Python 3.13 / asyncio / aiohttp)。 - -### 新增 - -- **双接入方式**:WebSocket 长连接与 Webhook HTTP 回调,由 `.env` 的 `CONNECTER` 切换,handler 写法完全一致 -- **协议层**:HELLO 鉴权(IDENTIFY/RESUME 自动切换)、apscheduler 定时心跳、READY 会话捕获、INVALID_SESSION 处理;断线指数退避重连 + 心跳/静默双看门狗,重连自动 RESUME 续传 -- **Webhook**:Ed25519 验签、op=13 验证请求自动应答、60s TTL 事件去重 -- **事件分发**:`EventEmitter` 按 op/t 名路由,payload dataclass 类型化参数、`Event` 封装与 dishka 依赖注入(`Inject[T]`)三种 handler 写法,异常就地隔离 -- **事件订阅**:`intents.toml` 声明式控制订阅掩码 -- **业务 API**(`BotApi`):v2 群/单聊消息收发与撤回、富媒体上传、群禁言;频道消息/表态/撤回、公告、精华、日程、频道禁言、频道与成员信息查询;HTTP ≥400 统一抛 `ApiError` -- **文档**:使用指南、API 参考、架构说明与可运行示例(`examples/`) -- **测试**:纯逻辑单测 + 假网关 WebSocket 端到端 + Webhook 验签/去重端到端 - -[0.1.0]: https://gitea.4i.hk/hhhge/qqbotsdk/releases/tag/v0.1.0 diff --git a/dist/README.md b/dist/README.md deleted file mode 100644 index 11c6b7b..0000000 --- a/dist/README.md +++ /dev/null @@ -1,144 +0,0 @@ -# qqbotsdk - -QQ 官方机器人 Python SDK(Python 3.13 / asyncio / aiohttp),支持 WebSocket 与 Webhook 两种接入方式。 - -[使用指南](docs/GUIDE.md) · [API 参考](docs/API.md) · [架构](docs/ARCHITECTURE.md) · [示例](examples/) · [更新日志](CHANGELOG.md) - -## 架构 - -```mermaid -flowchart TB - subgraph 接入层 - WS["WebsocketConnecter(ws 长连接 / 重连 / 心跳看门狗)"] - WH["WebhookConnecter(http 回调 / 验签 / 去重)"] - end - subgraph 协议层 - WSP["WebsocketProtocol(握手 / 会话 / 序列号)"] - WHP["WebhookProtocol(op=13 验证应答)"] - end - subgraph 分发层 - Q[("EventQueue
业务事件 op=0")] - EE["EventEmitter(路由 / 参数注入 / 异常隔离)"] - end - subgraph 业务层 - API["BotApi(REST + ApiError)"] - USER["用户 handler @ee.on(...)"] - end - QQ["QQ 开放平台"] - - WS -. "逐帧 on_frame" .-> WSP - WH -. "op=13 逐帧 on_frame" .-> WHP - WS -->|"业务事件"| Q - WH -->|"业务事件"| Q - Q --> EE - EE --> USER - USER --> API - API --> QQ - WS --> QQ -``` - -组件在 `run_loop` 显式装配,并按类型登记到 `emitter.services` 供 handler 参数注入(无全局单例);协议帧由连接适配器交给各自的协议处理器就地处理,只有 op=0 的业务事件经队列进入分发——依赖关系与设计决策详见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。 - -## 特性 - -- 内置协议处理器(`ws_protocol.py` / `webhook_protocol.py`):HELLO 鉴权(IDENTIFY/RESUME 自动切换)、READY 会话捕获、INVALID_SESSION 会话失效处理;心跳由连接器按连接生命周期驱动 -- 断线自动重连(指数退避 + 心跳看门狗),重连时自动 RESUME 续传 -- Webhook 接入自带 Ed25519 验签、op=13 验证应答与 60s TTL 事件去重 -- 事件订阅由项目根目录的 `intents.toml` 控制,按大类(Intents 分组)开关,将对应分组改为 `true` 即可 -- Markdown 与内嵌按钮一等支持:`post_group_message(..., markdown=..., keyboard=...)` 自动置 msg_type=2,`respond_interaction()` 回应按钮回调 -- 组件在 `run_loop` 显式装配(无 DI 框架依赖),全 SDK 共享一个 HTTP 连接池 - -## 配置 - -`.env`(`APPID`/`APPSECRET` 必填,缺失启动即报错): - -```ini -APPID=你的AppID -APPSECRET=你的AppSecret -BASE_URL=https://api.bot.qq.com # 可选,默认官方地址 -TIMEOUT=5000 # 可选,毫秒 -CONNECTER=websocket # websocket 或 webhook - -# 仅 webhook 接入时 -WEBHOOK_HOST=0.0.0.0 -WEBHOOK_PORT=8080 -WEBHOOK_PATH=/qqbot/webhook -``` - -## 使用 - -编写入口(如 `main.py`),把自己构造的 `EventEmitter` 传给 `main()`: - -```python -from qqbotsdk import EventEmitter, main - -ee = EventEmitter() - -# 方式一:裸参数(d 为原始事件数据) -@ee.on("GROUP_AT_MESSAGE_CREATE") -async def on_group_message(d): - print("收到群消息:", d) - -# 方式二:类型化 payload(dataclass,见 qqbotsdk.payloads) -from qqbotsdk.payloads import GroupAtMessage - -@ee.on("GROUP_AT_MESSAGE_CREATE") -async def on_group_message(msg: GroupAtMessage): - print(msg.group_openid, msg.user_openid, msg.content) - -# 方式三:参数注入(按形参标注装配,payload 与 `BotApi` 等组件可任意组合) -from qqbotsdk.api import BotApi -from qqbotsdk.events import Event -from qqbotsdk.payloads import GroupAtMessage - -@ee.on("GROUP_AT_MESSAGE_CREATE") -async def handle(msg: GroupAtMessage, event: Event, api: BotApi): - print(event.event_id) # 信封信息(op/t/d/id)经 Event 取用,事件字段走 payload - # 被动回复:带 msg_id(群聊 5 分钟/单聊 60 分钟内有效,最多分别回复 5/4 次); - # 同一条消息多次回复递增 msg_seq - await api.post_group_message( - msg.group_openid, - content=f"你说:{msg.content}", - msg_id=msg.id, - ) # 失败抛 ApiError;富媒体先 upload_group_file 拿 file_info - -# 业务事件(op=0)才进分发,返回值不回流,回复请显式调 BotApi; -# 协议帧(HELLO/READY/INVALID_SESSION/op=13)由各接入方式的协议处理器 -# 在连接器内就地处理,不进队列、也不会触达 handler。 -# 单个 handler 抛异常不会影响其他 handler 和主循环,异常会被记录。 - -if __name__ == "__main__": - main(ee) -``` - -运行: - -```bash -uv run python main.py -``` - -Webhook 接入:将 `.env` 中 `CONNECTER` 改为 `webhook`,运行后把 -`http://<你的域名>:8080/qqbot/webhook` 配置到 QQ 开放平台回调地址(仅支持 80/443/8080/8443 端口,需公网 HTTPS)。 - -完整可运行示例见 [examples/](examples/):群机器人(文本/图片回复、入群欢迎、单聊回复)与 Webhook 最小接入。 - -## 开发 - -```bash -uv run pytest # 单元测试 -uv run ruff check src tests examples && uv run pyright -``` - -推送到 main 与 Pull Request 会由 Gitea Actions 自动执行同样的 ruff + pyright 检查([.gitea/workflows/ci.yml](.gitea/workflows/ci.yml))。 - -文档站点基于 [docsify](https://docsify.js.org/)(无需构建,仓库根 `index.html` + Markdown),本地预览: - -```bash -npx docsify-cli serve # 打开 http://localhost:3000 -``` - -线上部署在 Cloudflare Pages(Direct Upload,Gitea 无法走 Pages 的 Git 直连):`bash scripts/deploy_docs.sh` 组装 `dist/` 并经 wrangler 上传,站点地址 `https://<项目名>.pages.dev`;项目名默认 `qqbotsdk-docs`,可传参或 `PROJECT_NAME` 覆盖。CI 自动部署:Gitea 仓库配置 `CLOUDFLARE_API_TOKEN`(权限 Account → Cloudflare Pages → Edit)与 `CLOUDFLARE_ACCOUNT_ID` 两个 secrets 后,main 上文档文件变更即自动部署([.gitea/workflows/deploy-docs.yml](.gitea/workflows/deploy-docs.yml))。 - -## 待完善 - -- 更多 OpenAPI 封装(按需在 `api.py` 的 `BotApi` 上加薄封装即可) diff --git a/dist/_sidebar.md b/dist/_sidebar.md deleted file mode 100644 index e5949c3..0000000 --- a/dist/_sidebar.md +++ /dev/null @@ -1,6 +0,0 @@ -- [首页](/README.md) -- [使用指南](/docs/GUIDE.md) -- [API 参考](/docs/API.md) -- [架构设计](/docs/ARCHITECTURE.md) -- [示例](/examples/README.md) -- [更新日志](/CHANGELOG.md) diff --git a/dist/docs/API.md b/dist/docs/API.md deleted file mode 100644 index 6d1f3c3..0000000 --- a/dist/docs/API.md +++ /dev/null @@ -1,323 +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` 解析对象 | 回退传原始 d | -| `Event` | 事件封装对象 | — | -| 组件类型(`BotApi`/`Session`/`Config` 等已登记进 `ee.services` 的类型) | 登记的实例 | 回退传原始 d | -| 无标注 | 原始 d(业务事件即事件 dict;协议事件可能是标量,如 `INVALID_SESSION` 的 `resumable: bool`) | — | - -## 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;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_*`)handler 收到原始 dict;新增事件在 `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` 入口与组件显式装配 | diff --git a/dist/docs/ARCHITECTURE.md b/dist/docs/ARCHITECTURE.md deleted file mode 100644 index 9739ca4..0000000 --- a/dist/docs/ARCHITECTURE.md +++ /dev/null @@ -1,133 +0,0 @@ -# qqbotsdk 架构 - -本文描述 SDK 的分层结构、数据流与扩展点。阅读对象:维护者与想深入定制的使用者。 - -## 总览 - -```mermaid -flowchart TB - subgraph 接入层["接入层 connecter.py"] - WS["WebsocketConnecter\ngateway握手 + ws长连接\n心跳随连接生灭 + 心跳看门狗\n指数退避重连(稳定连接才归一)"] - WH["WebhookConnecter\naiohttp server\n验签 + TTL去重"] - end - - subgraph 协议层["协议层 protocol.py"] - WSP["WebsocketProtocol\nHELLO→IDENTIFY/RESUME\nREADY/INVALID_SESSION + s 序列号"] - WHP["WebhookProtocol\nop=13 验证应答"] - end - - subgraph 分发层["分发层 emitter.py"] - Q[("EventQueue\n业务事件通道")] - EE["EventEmitter\nop=0/t 路由 + 参数注入\nhandler 异常隔离\nbackground 后台并发"] - end - - subgraph 业务层["业务层"] - API["BotApi\nREST 封装(core + v2/channel/guild)\n429/401 自动重试"] - USER["用户 handler\n@ee.on(...)"] - end - - subgraph 基础设施["基础设施"] - CFG["Config\n.env 一次性读齐"] - HTTP[("ClientSession\n共享连接池")] - TK["AccessToken\n缓存 + 自动续期\n并发锁 + 401 作废"] - CRY["crypto\nEd25519 验签/签名"] - end - - QQ["QQ 开放平台"] - - WS -- "业务事件 op=0" --> Q - WH -- "业务事件 op=0" --> Q - Q -- "dispatch 泵" --> EE - EE -- "Event 注入" --> USER - USER -- "api: BotApi" --> API - API --> QQ - WS --> QQ - TK --> HTTP - API --> TK - WSP --> TK - WS -. "逐帧 on_frame\n(协议帧就地处理,不入队)" .-> WSP - WH -. "op=13 逐帧 on_frame" .-> WHP - WSP -. "IDENTIFY/RESUME 应答" .-> WS - WHP --> CRY - WH --> CRY - CFG -. "构造传参" .-> WS & WH & API & TK & WSP & WHP -``` - -## 数据流(WebSocket 接入为例) - -```mermaid -sequenceDiagram - participant QQ as QQ 开放平台 - participant WS as WebsocketConnecter - participant P as WebsocketProtocol - participant Q as EventQueue - participant EE as EventEmitter - participant H as 用户 handler - - QQ->>WS: HELLO (op=10) - WS->>P: on_frame(握手判定) - P-->>WS: IDENTIFY / RESUME payload - WS->>QQ: send(过滤 NOT_SET 字段) - - loop 每次心跳间隔(心跳任务随连接生灭) - WS->>Q: HEARTBEAT payload - WS->>QQ: send - QQ->>WS: HEARTBEAT_ACK(刷新看门狗时钟,不进队列) - end - - QQ->>WS: DISPATCH 事件 (op=0, t=GROUP_AT_MESSAGE_CREATE) - WS->>P: on_frame(记 s 序列号) - WS->>Q: put_event(仅 op=0) - Q->>EE: dispatch 泵 - EE->>H: 按形参标注注入 Event/BotApi - H->>EE: 返回值不回流 -``` - -## 分层规则 - -依赖方向自上而下,下层不认识上层: - -| 层 | 模块 | 职责 | 不允许出现 | -|---|---|---|---| -| 接入层 | `connecter.py` | 传输细节:握手、心跳、重连、验签、去重;逐帧驱动协议处理器 | 事件业务语义、协议状态机 | -| 协议层 | `protocol.py` `ws_protocol.py` `webhook_protocol.py` | 适配器专有的协议处理(`BaseProtocol.on_frame`):鉴权/验证/会话语义 | 传输细节、业务语义 | -| 分发层 | `emitter.py` | 只分发业务事件(op=0):路由、参数注入、异常隔离、后台并发 | 传输细节、协议帧、协议状态机 | -| 业务层 | `api/` `payloads.py` + 用户代码 | OpenAPI 调用、payload 解析与机器人逻辑 | 协议细节 | - -跨层共享的最小公共物:`model.py`(Opcode/Payload/Intent)、`protocol.py`(协议处理器接口,被接入层与协议层共同依赖)、`queue.py`(事件通道)、`config.py`、`session.py`(ws 会话状态)、`payloads.py`(事件 dataclass 与解析,被 `events.py`/`emitter.py` 使用)。 - -## 生命周期与依赖装配 - -- 全部组件在 `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 注册在主体实际使用的实例上。 - -## 关键设计决策 - -1. **业务事件走管线,协议帧走专有处理器**:只有 op=0 的业务事件经 `EventQueue` 异步流转给 `EventEmitter` 分发;HELLO/READY/INVALID_SESSION/op=13 等协议帧由 connecter 逐帧调用其专有协议处理器(`BaseProtocol.on_frame`)就地处理。这样用户 handler 不会看到协议帧,且"断线前重置会话"这类时序不再取决于分发泵的进度——`INVALID_SESSION` 的会话清空在 connecter `break` 重连前已同步完成,不会误 RESUME 一个已死会话。代价是多一跳队列延迟(可忽略)。 -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`)→ 实例;未标注或未登记 → 回退传**原始 d**。解析是纯同步查表,无框架、无运行时魔法:每个 handler 的 `get_type_hints` + 形参名只解析一次并缓存,但 `services` 命中在每条事件时实时判定,保证"先注册 handler、后登记组件"的装配顺序可用。 -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` 的白名单加一个名字。 -- **新增业务 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 的全部事件组(消息、成员变动、审核、表态、互动、论坛、音频)。 - -## 测试 - -`tests/` 覆盖: - -- 纯逻辑:crypto 往返、intents 解析、Config 校验、协议帧处理(`test_route.py`:IDENTIFY/RESUME 选路、READY 捕获、s 序列号、INVALID_SESSION 重置、业务事件不产生应答)、webhook 去重(含 TTL 过期与摊还清理)、序列号单调性、handler 异常隔离、handler 注册语义(同函数重复注册只生效一次、按注册顺序执行)、payload dataclass 解析(含未收录字段忽略、解析计划缓存)、BotApi 各业务方法的拼参(Recording 子类截获 request)。 -- **网络集成**(`test_ws_integration.py` / `test_webhook_integration.py`): - - WebSocket:aiohttp 假网关(token 接口 + gateway + ws 协议行为)端到端跑通 HELLO → IDENTIFY → READY 会话捕获 → 心跳/ACK(心跳任务随连接生灭)→ 业务事件 dataclass 分发,并断言协议帧(HELLO/HEARTBEAT_ACK)不会触达 handler; - - Webhook:真实起 `WebhookConnecter` 的 http server(TestServer),覆盖 op=13 验证应答(签名内容正确性)、合法签名放行分发、坏签名 401、同 id 去重。 - -另有 429/401 重试、token 并发锁、background 后台并发与 `close()` 收尾的专项单测。两套集成测试验证的都是真实连接路径,改接入层/协议层后跑 `uv run pytest` 即可回归。 diff --git a/dist/docs/GUIDE.md b/dist/docs/GUIDE.md deleted file mode 100644 index 4de7276..0000000 --- a/dist/docs/GUIDE.md +++ /dev/null @@ -1,242 +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://gitea.4i.hk/hhhge/qqbotsdk # 从仓库安装 -uv add /path/to/qqbotsdk # 本地路径安装 -``` - -要求 Python ≥ 3.13。 - -## 配置 - -在项目根目录放 `.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://<域名>:<端口>` | -| 连接维护 | 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() - -# 方式一:裸参数,d 为原始事件 dict -@ee.on("GROUP_AT_MESSAGE_CREATE") -async def raw(d): - print(d) - -# 方式二:标注 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 对象;未收录事件回退原始 dict | -| `Event` | 事件封装对象,只含信封语义(`.raw`/`.data`/`.typed`/`.type`/`.event_id`);事件体字段请标注 payload dataclass 获取 | -| 组件类型(`BotApi`/`Session`/`Config` 等) | `emitter.services` 中按类型登记的实例;未登记回退传原始 d | -| 无标注 | 原始 d(即事件 dict) | - -注意: - -- 只有**业务事件(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`;群/单聊事件需开通对应能力;频道消息事件区分公私域。 -- **想收原始数据**:handler 裸参数或标 `Event`,`event.raw` 是完整线上 payload(含 op/t/d)。 -- **日志**:SDK 用 loguru 输出中文日志,连接、重连、API 失败、handler 异常均有记录,可直接用 loguru 配置格式与落盘。 diff --git a/dist/examples/README.md b/dist/examples/README.md deleted file mode 100644 index 885e28c..0000000 --- a/dist/examples/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# 示例 - -可运行的完整示例,均以仓库根目录为工作目录启动(`.env`、`intents.toml` 放在根目录)。 - -| 示例 | 接入方式 | 内容 | -|---|---|---| -| `group_bot/main.py` | WebSocket | @消息被动回复、`/img` 富媒体图片、`/btn` Markdown 按钮卡片与点击回调、入群欢迎、单聊回复 | -| `webhook_bot/main.py` | Webhook | HTTP 回调收群 @消息并被动回复的最小接入 | - -## 运行前提 - -1. 根目录 `.env` 配好 `APPID`/`APPSECRET`(`webhook_bot` 需另设 `CONNECTER=webhook`); -2. `intents.toml` 订阅对应分组(`group_bot` 需 `GROUP_AND_C2C_EVENT` 与 `INTERACTION`,`webhook_bot` 需 `GROUP_AND_C2C_EVENT`;仓库默认配置已满足两者); -3. `group_bot` 要求机器人已开通群聊能力并拉入测试群;`webhook_bot` 要求公网 HTTPS 部署(平台仅支持 80/443/8080/8443 端口,本地调试可用内网穿透)。 - -## 运行 - -```bash -uv run python examples/group_bot/main.py -uv run python examples/webhook_bot/main.py -``` - -Webhook 示例启动后,把 `https://<你的域名>:<端口>/qqbot/webhook` 填到开放平台回调地址;保存时平台先发 op=13 验证请求,SDK 自动完成签名应答。 - -接入方式切换与 handler 写法详见 [使用指南](docs/GUIDE.md#接入方式选择)。 diff --git a/dist/examples/group_bot/main.py b/dist/examples/group_bot/main.py deleted file mode 100644 index d98e7fd..0000000 --- a/dist/examples/group_bot/main.py +++ /dev/null @@ -1,106 +0,0 @@ -"""群机器人示例:@消息回复、富媒体图片、按钮卡片与点击回调、入群欢迎、单聊回复。 - -运行前提: -1. 项目根目录 .env 配好 APPID/APPSECRET(CONNECTER 用默认 websocket); -2. intents.toml 订阅 GROUP_AND_C2C_EVENT 与 INTERACTION 两个分组(仓库默认已开启); -3. 机器人已在开放平台开通群聊能力,并被拉入测试群。 - -运行:uv run python examples/group_bot/main.py -""" - -from loguru import logger - -from qqbotsdk import EventEmitter, main -from qqbotsdk.api import BotApi, button, keyboard -from qqbotsdk.events import Event -from qqbotsdk.payloads import C2CMessage, GroupAddRobot, GroupAtMessage, Interaction - -ee = EventEmitter() - - -@ee.on("GROUP_AT_MESSAGE_CREATE") -async def on_group_message(msg: GroupAtMessage, api: BotApi) -> None: - if not (msg.group_openid and msg.id): - return - - # "/img <图片url>":上传富媒体后随消息发出(msg_type 自动置为 7) - if (msg.content or "").startswith("/img "): - url = (msg.content or "").removeprefix("/img ").strip() - if not url: - return - upload = await api.upload_group_file(msg.group_openid, file_type=1, url=url) - await api.post_group_message( - msg.group_openid, - content="图来了", - msg_id=msg.id, - media=upload, - ) - return - - # "/btn":发一张带回调按钮的 Markdown 卡片 - if (msg.content or "").strip() == "/btn": - await api.post_group_message( - msg.group_openid, - markdown="## 按钮示例\n点下面的按钮试试:", - keyboard=keyboard( - button("打个招呼", data="btn|hello"), - [button("赞", data="btn|like"), button("踩", data="btn|dislike")], - ), - msg_id=msg.id, - ) - return - - # 普通文本:带 msg_id 被动回复(5 分钟内有效,同一条消息多次回复递增 msg_seq) - await api.post_group_message( - msg.group_openid, - content=f"你说:{msg.content}", - msg_id=msg.id, - ) - - -@ee.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 # 本示例只处理群聊的按钮回调 - # 必须应答,否则用户端按钮一直 loading(这里的 id 是事件体里的互动 id) - await api.respond_interaction(inter.id) - # 被动回复的 event_id 是网关帧最外层的 id(形如 INTERACTION_CREATE:uuid), - # 不是事件体里的裸 UUID——官方文档点名的常见坑 - await api.post_group_message( - inter.group_openid, - content=f"你点了:{inter.button_data}", - event_id=event.event_id, - ) - - -@ee.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): - logger.info(f"机器人加入群聊 {added.group_openid}(无事件 ID,跳过欢迎语)") - return - await api.post_group_message( - added.group_openid, - content="大家好,我是本群机器人,@我即可体验~", - event_id=event.event_id, - ) - - -@ee.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): - return - # 单聊被动回复 60 分钟内有效,同一 msg_id 最多回复 4 次 - await api.post_c2c_message( - user_openid, content=f"你说:{msg.content}", msg_id=msg.id - ) - - -@ee.on("FRIEND_ADD") -async def on_friend_add(d: dict) -> None: - logger.info(f"新增用户:{d.get('user_openid')}") - - -if __name__ == "__main__": - main(ee) diff --git a/dist/examples/webhook_bot/main.py b/dist/examples/webhook_bot/main.py deleted file mode 100644 index 33813f0..0000000 --- a/dist/examples/webhook_bot/main.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Webhook 接入最小示例:HTTP 回调收群 @消息并回复。 - -运行前提: -1. 项目根目录 .env 配好 APPID/APPSECRET,并设 CONNECTER=webhook; -2. intents.toml 订阅 GROUP_AND_C2C_EVENT 分组(仓库默认已开启); -3. 部署在公网 HTTPS 环境(平台仅支持 80/443/8080/8443 端口, - 本地调试可内网穿透),回调地址填 - https://<你的域名>:<端口>; -4. 开放平台保存回调配置时会先发 op=13 验证请求,SDK 自动完成签名应答。 - -运行:uv run python examples/webhook_bot/main.py -""" - -from qqbotsdk import EventEmitter, main -from qqbotsdk.api import BotApi -from qqbotsdk.payloads import GroupAtMessage - -ee = EventEmitter() - - -@ee.on("GROUP_AT_MESSAGE_CREATE") -async def on_group_message(msg: GroupAtMessage, api: BotApi) -> None: - if not (msg.group_openid and msg.id): - return - await api.post_group_message( - msg.group_openid, - content=f"收到:{msg.content}", - msg_id=msg.id, - ) - - -if __name__ == "__main__": - main(ee) diff --git a/dist/index.html b/dist/index.html deleted file mode 100644 index 70db2d1..0000000 --- a/dist/index.html +++ /dev/null @@ -1,88 +0,0 @@ - - - - - qqbotsdk 文档 - - - - - - - -
加载中…
- - - - - - - - - - - - - - - -