From 7a1b8ac691a87ebcadab8786a095637a9d8016c8 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:05:50 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=A8=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E6=8C=87=E5=8D=97=E3=80=81API=20=E5=8F=82=E8=80=83=E3=80=81?= =?UTF-8?q?=E5=8F=AF=E8=BF=90=E8=A1=8C=E7=A4=BA=E4=BE=8B=E4=B8=8E=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 21 ++++ README.md | 6 +- docs/API.md | 226 +++++++++++++++++++++++++++++++++++ docs/GUIDE.md | 184 ++++++++++++++++++++++++++++ examples/group_bot/main.py | 72 +++++++++++ examples/webhook_bot/main.py | 31 +++++ 6 files changed, 539 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md create mode 100644 docs/API.md create mode 100644 docs/GUIDE.md create mode 100644 examples/group_bot/main.py create mode 100644 examples/webhook_bot/main.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..24d699c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# 更新日志 + +本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 格式, +版本号遵循[语义化版本](https://semver.org/lang/zh-CN/)。 + +## [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/README.md b/README.md index 4c398c5..3f70b88 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ QQ 官方机器人 Python SDK(Python 3.13 / asyncio / aiohttp),支持 WebSocket 与 Webhook 两种接入方式。 +[使用指南](docs/GUIDE.md) · [API 参考](docs/API.md) · [架构](docs/ARCHITECTURE.md) · [示例](examples/) · [更新日志](CHANGELOG.md) + ## 架构 ```mermaid @@ -115,11 +117,13 @@ 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 && uv run pyright +uv run ruff check src tests examples && uv run pyright ``` ## 待完善 diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..578b1a8 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,226 @@ +# qqbotsdk API 参考 + +按公开使用面组织。上手流程见 [GUIDE.md](GUIDE.md),分层与装配见 [ARCHITECTURE.md](ARCHITECTURE.md)。 + +## 顶层导出 + +```python +from qqbotsdk import ( + main, run_loop, # 入口 + EventEmitter, BaseProtocol, EventQueue, # 分发层 + Inject, make_container, # DI + Config, Connecter, # 配置与连接器接口 +) +``` + +| 名称 | 说明 | +|---|---| +| `main(emitter=None)` | 同步入口:装配容器并阻塞运行,Ctrl+C 优雅退出 | +| `run_loop(emitter=None)` | 异步入口:`asyncio.run(run_loop(ee))` 的内部实现,可自行 await | +| `EventEmitter(queue)` | 分发主体,`@ee.on(...)` 注册 handler | +| `EventQueue()` | 事件/应答队列;自建 emitter 时传入,容器会复用同一实例 | +| `Inject` | dishka `FromDishka` 别名,handler 参数标注 `Inject[T]` 即注入 T | +| `make_container(emitter=None)` | 构建 dishka 容器;一般不用直接调,`main` 已封装 | +| `Config` | frozen dataclass,环境变量一次性读齐(见下文) | +| `Connecter` | 连接适配器 Protocol(接口),按 `CONNECTER` 自动选择实现 | +| `BaseProtocol` | 协议层抽象基类,自定义协议处理器时继承并实现 `register(emitter)` | + +子包按需导入: + +```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(EventQueue()) + +@ee.on("GROUP_AT_MESSAGE_CREATE") # DISPATCH 事件用 t 名 +@ee.on("INVALID_SESSION") # 协议事件用 Opcode 名(IDENTIFY/RESUME/HEARTBEAT/...) +async def handler(...): ... +``` + +- **事件命名**:op=0(DISPATCH)按 payload 的 `t` 名路由;其余按 `Opcode` 名路由。协议事件(op≠0)已由内置协议层处理,一般只需监听业务事件;handler 返回 dict 时协议事件的返回值会回流给 ws 发送(业务事件不会)。 +- **异常隔离**:handler 异常就地记录(loguru),不影响同事件其他 handler 与主循环。 +- **`ee.handle(payload)`**:同步分发并返回首个 handler 的非空返回值,供协议层应答 op=13 等场景,业务代码不用。 + +### handler 参数注入约定 + +按参数类型标注解析,可任意混用: + +| 标注 | 得到 | 未命中时 | +|---|---|---| +| payload dataclass(`payloads` 中的类型) | `Event.typed` 解析对象 | 回退传原始 dict | +| `Event` | 事件封装对象 | — | +| `Inject[T]` | DI 容器解析的 T(如 `BotApi`) | 回退传原始 dict | +| 无标注 | 原始事件 dict | — | + +## Event + +`from qqbotsdk.events import Event`,一条 DISPATCH 事件的轻量封装,属性兼容群(openid)与频道两套字段名: + +| 属性 | 说明 | +|---|---| +| `.raw` | 完整线上 payload(含 op/t/d) | +| `.data` | `d` 的 dict 拷贝 | +| `.typed` | `d` 的 dataclass 解析结果(未知事件类型为 dict) | +| `.type` | 事件 t 名 | +| `.user_id` | 用户标识(兼容 `user_id`/`from_user_id`) | +| `.group_id` | `group_openid` | +| `.content` | 消息文本 | +| `.message_id` | `d["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`。经 DI 注入(`api: Inject[BotApi]`)。自动附带 `Authorization: QQBot ` 鉴权头并刷新 token;HTTP ≥400 抛 `ApiError`。 + +### 通用 + +| 方法 | 说明 | +|---|---| +| `request(method, path, **kwargs)` | 通用请求,kwargs 透传 aiohttp(`json=`/`params=` 等) | +| `get` / `post` / `delete` / `put` / `patch`(path, **kwargs) | `request` 快捷方式 | +| `me()` | 机器人自身信息(GET /users/@me) | + +常量:`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`。 + +### v2 群聊(群 openid) + +| 方法 | 端点 | 说明 | +|---|---|---| +| `post_group_message(group_openid, content="", msg_id=None, msg_seq=1, media=None, event_id=None, **extra)` | POST /v2/groups/{group_openid}/messages | `msg_id`/`event_id` 二选一即被动回复;`media` 传上传返回的 `{"file_info": …}`(自动 msg_type=7);其余字段经 `extra` 透传(如 `msg_type=2` markdown) | +| `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 到期时间;传早于当前的时间即解禁 | + +### v2 单聊(用户 openid) + +| 方法 | 端点 | 说明 | +|---|---|---| +| `post_c2c_message(openid, content="", msg_id=None, msg_seq=1, media=None, event_id=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 分钟内可撤回 | + +### 频道消息(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_guild_announce(guild_id, channel_id, message_id, announces_type=0, **extra)` | POST /guilds/{guild_id}/announces | `announces_type`:0 成员公告 / 1 欢迎公告;推荐频道公告经 extra(与消息公告互斥) | +| `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} | 删除日程 | +| `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_guild_channels(guild_id)` | GET /channels/{guild_id}/channels | +| `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} | + +### ApiError + +`ApiError(RuntimeError)`,HTTP ≥400 时抛出: + +| 属性 | 说明 | +|---|---| +| `.status` | HTTP 状态码 | +| `.code` | 官方业务 code(响应体中的 `code`) | +| `.message` | 官方错误描述 | + +## 事件 payload + +`qqbotsdk.payloads` 定义事件 `d` 的 dataclass。handler 标注类型即得解析对象;未知事件类型回退原始 dict。所有字段均可为 None(线上缺省即 None),嵌套 `author` 自动解析为 `Author`。 + +### 事件名 → dataclass 对照 + +| intents 分组 | 事件 t 名 | dataclass | +|---|---|---| +| GROUP_AND_C2C_EVENT | `C2C_MESSAGE_CREATE` | `C2CMessage` | +| | `GROUP_AT_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` | +| 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` 即可。 + +### 常用 dataclass 字段 + +```python +Author # id, user_openid, member_openid, username, bot +GroupAtMessage # id, content, group_openid, author, timestamp +C2CMessage # id, content, author, attachments, timestamp +AtMessage # id, content, channel_id, guild_id, author, member, timestamp +DirectMessage # 同 AtMessage +``` + +- `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`:接入归一为事件流入、reply 流出 | +| `ws_protocol.py` | IDENTIFY/RESUME 鉴权、apscheduler 心跳、会话序列号 | +| `webhook_protocol.py` | op=13 验证应答 | +| `queue.py` / `session.py` / `token.py` / `crypto.py` | 队列、ws 会话状态、token 缓存、Ed25519 签名 | +| `model.py` | Opcode、payload 类型、Intent 位掩码 | +| `di.py` | dishka provider 与 `ADAPTERS` 接线表 | diff --git a/docs/GUIDE.md b/docs/GUIDE.md new file mode 100644 index 0000000..02c5547 --- /dev/null +++ b/docs/GUIDE.md @@ -0,0 +1,184 @@ +# 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` 计算订阅掩码,把想收的事件改成 `true` 即可。分组与特权要求: + +| 分组 | 位掩码 | 说明 | +|---|---|---| +| `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) | + +事件名到 payload dataclass 的完整对照见 [API.md](API.md#事件-payload)。 + +## 接入方式选择 + +| | WebSocket | Webhook | +|---|---|---| +| 网络要求 | 能访问外网即可,本地开发友好 | 需公网可达 + HTTPS,仅支持 80/443/8080/8443 端口 | +| 配置 | 无额外配置 | 平台回调地址填 `https://<域名>:<端口>` | +| 连接维护 | SDK 自动心跳、断线指数退避重连并 RESUME 续传 | SDK 处理 Ed25519 验签、op=13 验证应答、60s TTL 事件去重 | +| 适用 | 长驻进程、需要会话与重连保障 | 无状态部署、回调型服务 | + +两种方式下 handler 的写法完全一致,切换只需改 `CONNECTER`。 + +## 编写 handler + +用装饰器把 handler 注册到 `EventEmitter`,事件名即线上 payload 的 `t` 名(如 `GROUP_AT_MESSAGE_CREATE`)。三种写法可混用: + +```python +from qqbotsdk import EventEmitter, EventQueue, Inject + +ee = EventEmitter(EventQueue()) + +# 方式一:裸参数,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) + +# 方式三:依赖注入 +from qqbotsdk.api import BotApi +from qqbotsdk.events import Event + +@ee.on("GROUP_AT_MESSAGE_CREATE") +async def di(event: Event, api: Inject[BotApi]): + await api.post_group_message(event.group_id, content="收到", msg_id=event.message_id) +``` + +handler 参数按标注解析的完整规则: + +| 参数标注 | 得到 | +|---|---| +| payload dataclass(如 `GroupAtMessage`) | 解析后的 dataclass 对象;未收录事件回退原始 dict | +| `Event` | 事件封装对象(`.raw`/`.data`/`.typed`/`.user_id`/`.group_id`/`.content`/`.message_id`) | +| `Inject[T]` | DI 容器解析的 `T`(如 `BotApi`);解析失败回退传原始 dict | +| 无标注 | 原始事件 dict | + +注意: + +- 业务事件(op=0)处理器的**返回值不会回流**,回复消息请在 handler 里显式调 API。 +- 单个 handler 抛异常只记录日志,不影响同事件其他 handler 和主循环。 +- 未订阅(intents.toml 为 `false`)的事件不会到达 handler。 + +## 发消息 + +### 被动回复(最常用) + +携带 `msg_id`(回复某条消息)或 `event_id`(响应某类事件,二选一)即为被动回复,**不需要任何消息频率白名单**: + +- 群聊:收到消息后 **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) +``` + +### 撤回 + +- 群/单聊(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 重复、内容审核不通过)。 +- **handler 没被触发**:检查 intents.toml 对应事件是否为 `true`;群/单聊事件需开通对应能力;频道消息事件区分公私域。 +- **想收原始数据**:handler 裸参数或标 `Event`,`event.raw` 是完整线上 payload(含 op/t/d)。 +- **日志**:SDK 用 loguru 输出中文日志,连接、重连、API 失败、handler 异常均有记录,可直接用 loguru 配置格式与落盘。 diff --git a/examples/group_bot/main.py b/examples/group_bot/main.py new file mode 100644 index 0000000..d121197 --- /dev/null +++ b/examples/group_bot/main.py @@ -0,0 +1,72 @@ +"""群机器人示例:@消息回复、富媒体图片回复、入群欢迎、单聊回复。 + +运行前提: +1. 项目根目录 .env 配好 APPID/APPSECRET(CONNECTER 用默认 websocket); +2. intents.toml 开启 GROUP_AND_C2C_EVENT 分组的事件; +3. 机器人已在开放平台开通群聊能力,并被拉入测试群。 + +运行:uv run python examples/group_bot/main.py +""" + +from loguru import logger + +from qqbotsdk import EventEmitter, EventQueue, Inject, main +from qqbotsdk.api import BotApi +from qqbotsdk.events import Event +from qqbotsdk.payloads import C2CMessage, GroupAtMessage + +ee = EventEmitter(EventQueue()) + + +@ee.on("GROUP_AT_MESSAGE_CREATE") +async def on_group_message(msg: GroupAtMessage, api: Inject[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 + + # 普通文本:带 msg_id 被动回复(5 分钟内有效,同一条消息多次回复递增 msg_seq) + await api.post_group_message( + msg.group_openid, content=f"你说:{msg.content}", msg_id=msg.id, + ) + + +@ee.on("GROUP_ADD_ROBOT") +async def on_robot_added(event: Event, api: Inject[BotApi]) -> None: + group_openid = event.group_id + # 入群是事件而非消息:事件 ID 在推送 envelope 顶层 id 字段, + # 作 event_id 即可在 5 分钟内被动回复欢迎语(拿不到则跳过) + event_id = event.raw.get("id") + if not (group_openid and isinstance(event_id, str) and event_id): + logger.info(f"机器人加入群聊 {group_openid}(无事件 ID,跳过欢迎语)") + return + await api.post_group_message( + group_openid, content="大家好,我是本群机器人,@我即可体验~", event_id=event_id, + ) + + +@ee.on("C2C_MESSAGE_CREATE") +async def on_c2c_message(msg: C2CMessage, api: Inject[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/examples/webhook_bot/main.py b/examples/webhook_bot/main.py new file mode 100644 index 0000000..df3dbe2 --- /dev/null +++ b/examples/webhook_bot/main.py @@ -0,0 +1,31 @@ +"""Webhook 接入最小示例:HTTP 回调收群 @消息并回复。 + +运行前提: +1. 项目根目录 .env 配好 APPID/APPSECRET,并设 CONNECTER=webhook; +2. intents.toml 开启 GROUP_AT_MESSAGE_CREATE; +3. 部署在公网 HTTPS 环境(平台仅支持 80/443/8080/8443 端口, + 本地调试可内网穿透),回调地址填 + https://<你的域名>:<端口>; +4. 开放平台保存回调配置时会先发 op=13 验证请求,SDK 自动完成签名应答。 + +运行:uv run python examples/webhook_bot/main.py +""" + +from qqbotsdk import EventEmitter, EventQueue, Inject, main +from qqbotsdk.api import BotApi +from qqbotsdk.payloads import GroupAtMessage + +ee = EventEmitter(EventQueue()) + + +@ee.on("GROUP_AT_MESSAGE_CREATE") +async def on_group_message(msg: GroupAtMessage, api: Inject[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)