diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml deleted file mode 100644 index 0bc95a1..0000000 --- a/.github/workflows/deploy-docs.yml +++ /dev/null @@ -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 diff --git a/docs/API.md b/docs/API.md deleted file mode 100644 index 6487f9f..0000000 --- a/docs/API.md +++ /dev/null @@ -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;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` 入口与组件显式装配 | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c4212b2..6a009ce 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 的全部事件组(消息、成员变动、审核、表态、互动、论坛、音频)。 diff --git a/docs/GUIDE.md b/docs/GUIDE.md deleted file mode 100644 index 583e35d..0000000 --- a/docs/GUIDE.md +++ /dev/null @@ -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://<域名>:<端口>` | -| 连接维护 | 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 配置格式与落盘。 diff --git a/examples/group_bot/main.py b/examples/group_bot/main.py index 30be4bf..b8e1737 100644 --- a/examples/group_bot/main.py +++ b/examples/group_bot/main.py @@ -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() diff --git a/examples/webhook_bot/main.py b/examples/webhook_bot/main.py index 33813f0..7824219 100644 --- a/examples/webhook_bot/main.py +++ b/examples/webhook_bot/main.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index 2519d68..dd3e077 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/qqbotsdk/__init__.py b/src/qqbotsdk/__init__.py index 8455a25..cf6aae8 100644 --- a/src/qqbotsdk/__init__.py +++ b/src/qqbotsdk/__init__.py @@ -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", +] diff --git a/src/qqbotsdk/bot.py b/src/qqbotsdk/bot.py new file mode 100644 index 0000000..b56a1ac --- /dev/null +++ b/src/qqbotsdk/bot.py @@ -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 已退出") diff --git a/tests/test_bot.py b/tests/test_bot.py new file mode 100644 index 0000000..96003b1 --- /dev/null +++ b/tests/test_bot.py @@ -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"}})]