Compare commits

..
4 Commits
15 changed files with 218 additions and 16 deletions

No files matched your search

+11
View File
@@ -5,8 +5,17 @@
## [Unreleased] ## [Unreleased]
## [0.2.1] - 2026-10-03
### 修复
- **群成员禁言对齐官方请求结构**:`POST /v2/groups/{group_openid}/restrict_chat_setting` 的请求体实为 `{"members": [{"op": "add"/"update"/"del", "member_openid": …, "mute_expire_at": RFC3339}]}`(单次最多 20 人、最长 30 天、仅可操作普通成员、需群管理员身份、60 QPM),0.2.0 误发顶层 `{"mute_expire_at": …}` 且签名无法指定被禁成员。签名变更:`mute_group_member(group_openid, member_openid, mute_expire_at)`(op=add 单人禁言);新增 `mute_group_members(group_openid, members)` 批量透传与 `unmute_group_member(group_openid, member_openid)`(op=del 解禁,解禁不再走"传过去时间")、`MUTE_OP_ADD`/`MUTE_OP_UPDATE`/`MUTE_OP_DEL` 常量
## [0.2.0] - 2026-10-03
### 新增 ### 新增
- **intents 掩码直传**:`Bot(intents=…)` 参数(推荐用顶层导出的 `Intent` 枚举 OR 出掩码)与 `INTENTS` 环境变量(十进制/0x 十六进制),优先级 Bot 参数 > 环境变量 > intents.toml;掩码含未收录位仅告警不拦截
- **`Bot` 门面类(新启动方式)**:`bot = Bot()` 构造即读 .env 校验配置,`@bot.on(...)` 注册 handler,`bot.run()` 同步启动;`await bot.start()` 供嵌入外部 asyncio 应用。`bot.call_api(method, path, **kwargs)` 通用 REST 入口(带鉴权/429 重试/401 自愈),`bot.api` 为类型化 API 面,`bot.services` 登记自定义组件;handler 可标注 `bot: Bot` 注入实例 - **`Bot` 门面类(新启动方式)**:`bot = Bot()` 构造即读 .env 校验配置,`@bot.on(...)` 注册 handler,`bot.run()` 同步启动;`await bot.start()` 供嵌入外部 asyncio 应用。`bot.call_api(method, path, **kwargs)` 通用 REST 入口(带鉴权/429 重试/401 自愈),`bot.api` 为类型化 API 面,`bot.services` 登记自定义组件;handler 可标注 `bot: Bot` 注入实例
- **CI 与发布**:CI 新增构建 job(`uv build` 产出 sdist/wheel 冒烟);新增 `publish.yml`——打 `v*` 标签自动构建并经 Trusted Publishing(OIDC,无需 token)发布到 PyPI - **CI 与发布**:CI 新增构建 job(`uv build` 产出 sdist/wheel 冒烟);新增 `publish.yml`——打 `v*` 标签自动构建并经 Trusted Publishing(OIDC,无需 token)发布到 PyPI
- **文档模块化重构**:`docs/GUIDE.md`/`docs/API.md` 拆为 `docs/guide/` 五页(start/config/handlers/messaging/ops)与 `docs/api/` 七页(core/v2/group/guild/panel/audio-forum/events),侧边栏分组折叠,旧链接经 docsify alias 映射;安装指引改为 `pip install qqbotsdk-py`,README 重写为快速开始入口并加 CI/PyPI 徽章 - **文档模块化重构**:`docs/GUIDE.md`/`docs/API.md` 拆为 `docs/guide/` 五页(start/config/handlers/messaging/ops)与 `docs/api/` 七页(core/v2/group/guild/panel/audio-forum/events),侧边栏分组折叠,旧链接经 docsify alias 映射;安装指引改为 `pip install qqbotsdk-py`,README 重写为快速开始入口并加 CI/PyPI 徽章
@@ -81,4 +90,6 @@
- **文档**:使用指南、API 参考、架构说明与可运行示例(`examples/`) - **文档**:使用指南、API 参考、架构说明与可运行示例(`examples/`)
- **测试**:纯逻辑单测 + 假网关 WebSocket 端到端 + Webhook 验签/去重端到端 - **测试**:纯逻辑单测 + 假网关 WebSocket 端到端 + Webhook 验签/去重端到端
[Unreleased]: https://github.com/snowy-yang/qqbotsdk/compare/v0.2.0...HEAD
[0.2.0]: https://github.com/snowy-yang/qqbotsdk/releases/tag/v0.2.0
[0.1.0]: https://github.com/snowy-yang/qqbotsdk/releases/tag/v0.1.0 [0.1.0]: https://github.com/snowy-yang/qqbotsdk/releases/tag/v0.1.0
+2 -1
View File
@@ -9,6 +9,7 @@
from qqbotsdk import ( from qqbotsdk import (
Bot, # 门面:on/run/start/call_api/api/services Bot, # 门面:on/run/start/call_api/api/services
EventEmitter, EventQueue, # 分发层 EventEmitter, EventQueue, # 分发层
Intent, # 订阅分组位掩码(IntFlag,可 OR 出掩码传 Bot(intents=…))
BaseProtocol, # 协议处理器接口(自定义接入方式时继承) BaseProtocol, # 协议处理器接口(自定义接入方式时继承)
Config, Connecter, # 配置与连接器接口 Config, Connecter, # 配置与连接器接口
) )
@@ -44,7 +45,7 @@ bot.run() # 同步入口:阻塞运行,Ctrl+C 优雅
| 成员 | 说明 | | 成员 | 说明 |
|---|---| |---|---|
| `Bot(config=None)` | 不传 config 时 `Config.load()` 读环境变量;缺 `APPID`/`APPSECRET` 构造即抛 `ValueError` | | `Bot(config=None, *, intents=None)` | 不传 config 时 `Config.load()` 读环境变量;缺 `APPID`/`APPSECRET` 构造即抛 `ValueError`。`intents` 直传订阅掩码(可用 `Intent` 枚举 OR 出来),优先级 `intents` 参数 > `INTENTS` 环境变量 > intents.toml,详见[配置与订阅](/docs/guide/config.md#掩码直传) |
| `bot.on(event, fn=None, *, background=False)` | 注册 handler;`event` 为事件 `t` 名或 `Opcode`,装饰器/直传皆可 | | `bot.on(event, fn=None, *, background=False)` | 注册 handler;`event` 为事件 `t` 名或 `Opcode`,装饰器/直传皆可 |
| `bot.run()` | 同步入口:`asyncio.run(start())`,`KeyboardInterrupt` 记日志退出 | | `bot.run()` | 同步入口:`asyncio.run(start())`,`KeyboardInterrupt` 记日志退出 |
| `await bot.start()` | 异步入口:装配组件并常驻运行,供嵌入外部 asyncio 应用 | | `await bot.start()` | 异步入口:装配组件并常驻运行,供嵌入外部 asyncio 应用 |
+4 -2
View File
@@ -11,7 +11,9 @@
| `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` 透传 | | `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` 上传即发送 | | `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 分钟内可撤回 | | `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 到期时间;传早于当前的时间即解禁 | | `mute_group_members(group_openid, members)` | POST /v2/groups/{group_openid}/restrict_chat_setting | 批量禁言/解禁;`members` 每项为 `{"op": "add"/"update"/"del", "member_openid": …, "mute_expire_at": …}`(RFC3339):add 新增禁言、update 更新到期时间、del 解禁(expire 可传空串)。单次最多 20 人、最长禁言 30 天、仅可操作普通成员(群主/管理员/机器人不可);需群管理员身份,60 QPM |
| `mute_group_member(group_openid, member_openid, mute_expire_at)` | 同上 | 单人禁言封装(op=add) |
| `unmute_group_member(group_openid, member_openid)` | 同上 | 单人解禁封装(op=del) |
| `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 | | `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 成功后调用确认 | | `finish_group_upload_part(group_openid, upload_id, part_index, block_size=None, md5=None)` | POST …/upload_part_finish | 每个分片 PUT 成功后调用确认 |
@@ -42,6 +44,6 @@
| `generate_url_link(callback_data=None)` | 生成机器人分享链接(POST /v2/generate_url_link),返回 `{"data": {"url": …}}`;用户经链接添加时 `callback_data`(≤32 字符)透传给开发者 | | `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});按钮点击收到后必须回应,否则客户端持续 loading,同一 id 仅可回应一次 | | `respond_interaction(interaction_id, code=0)` | 回应互动事件(PUT /interactions/{id});按钮点击收到后必须回应,否则客户端持续 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`。 常量:`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`;禁言 `MUTE_OP_ADD`/`MUTE_OP_UPDATE`/`MUTE_OP_DEL`。
按钮构造:`button(label, data, *, type=1, permission=2, style=1)` 生成单个按钮 dict,`keyboard(*rows)` 组装为 `keyboard=` 参数(单按钮独占一行,按钮列表同行并排)。点击回调经 `INTERACTION_CREATE` 事件处理,见[收发消息](/docs/guide/messaging.md#markdown-与按钮msg_type2)。 按钮构造:`button(label, data, *, type=1, permission=2, style=1)` 生成单个按钮 dict,`keyboard(*rows)` 组装为 `keyboard=` 参数(单按钮独占一行,按钮列表同行并排)。点击回调经 `INTERACTION_CREATE` 事件处理,见[收发消息](/docs/guide/messaging.md#markdown-与按钮msg_type2)。
+20
View File
@@ -14,6 +14,7 @@ CONNECTER=websocket # websocket 或 webhook,默认 websocket
BASE_URL=https://api.bot.qq.com # 可选,官方 API 地址 BASE_URL=https://api.bot.qq.com # 可选,官方 API 地址
TIMEOUT=5000 # 可选,HTTP 请求超时(毫秒) TIMEOUT=5000 # 可选,HTTP 请求超时(毫秒)
INTENTS_FILE=intents.toml # 可选,事件订阅配置文件路径 INTENTS_FILE=intents.toml # 可选,事件订阅配置文件路径
INTENTS= # 可选,订阅掩码直传(十进制或 0x 十六进制),优先于 intents.toml
WEBHOOK_HOST=0.0.0.0 # 以下仅 webhook 接入时生效 WEBHOOK_HOST=0.0.0.0 # 以下仅 webhook 接入时生效
WEBHOOK_PORT=8080 WEBHOOK_PORT=8080
@@ -28,6 +29,7 @@ WEBHOOK_PATH=/qqbot/webhook
| `base_url` | `BASE_URL` | `https://api.bot.qq.com` | 官方 API 根地址 | | `base_url` | `BASE_URL` | `https://api.bot.qq.com` | 官方 API 根地址 |
| `timeout` | `TIMEOUT` | `5000`(毫秒) | 转为 aiohttp `ClientTimeout(total=…)` | | `timeout` | `TIMEOUT` | `5000`(毫秒) | 转为 aiohttp `ClientTimeout(total=…)` |
| `intents_file` | `INTENTS_FILE` | `intents.toml` | 事件订阅配置路径 | | `intents_file` | `INTENTS_FILE` | `intents.toml` | 事件订阅配置路径 |
| `intents` | `INTENTS` | 空 | 订阅掩码直传,见[掩码直传](#掩码直传) |
| `webhook_host` | `WEBHOOK_HOST` | `0.0.0.0` | webhook 监听地址 | | `webhook_host` | `WEBHOOK_HOST` | `0.0.0.0` | webhook 监听地址 |
| `webhook_port` | `WEBHOOK_PORT` | `8080` | webhook 监听端口 | | `webhook_port` | `WEBHOOK_PORT` | `8080` | webhook 监听端口 |
| `webhook_path` | `WEBHOOK_PATH` | `/qqbot/webhook` | webhook 回调路径 | | `webhook_path` | `WEBHOOK_PATH` | `/qqbot/webhook` | webhook 回调路径 |
@@ -62,6 +64,24 @@ QQ 网关只认分组位掩码,**组内单个事件无法单独开关**——
分组名即 `Intent` 枚举成员名。配置只接受 `分组名 = true/false`;拼错的分组名、非布尔值,以及旧版"表内逐事件开关"的写法都会在启动时直接抛 `ValueError`(与其静默少订阅,不如启动即报错)。文件缺失则告警并按不订阅任何事件启动。 分组名即 `Intent` 枚举成员名。配置只接受 `分组名 = true/false`;拼错的分组名、非布尔值,以及旧版"表内逐事件开关"的写法都会在启动时直接抛 `ValueError`(与其静默少订阅,不如启动即报错)。文件缺失则告警并按不订阅任何事件启动。
## 掩码直传
不想用配置文件的,可以直接传订阅掩码,三种入口按优先级取先命中者:
1. **`Bot` 参数**(推荐写法,`Intent` 是 `IntFlag` 可直接 OR):
```python
from qqbotsdk import Bot, Intent
bot = Bot(intents=Intent.GROUP_AND_C2C_EVENT | Intent.INTERACTION)
```
2. **`INTENTS` 环境变量**:十进制或 `0x` 十六进制字符串,如 `INTENTS=1073741824` 或 `INTENTS=0x40000000`;
3. 都没给才回落 `intents.toml`。
掩码原样下发给网关,含 `Intent` 枚举未收录的位时仅告警不拦截(平台可能新增 SDK 尚未收录的分组)。`Intent` 从包顶层导出(`from qqbotsdk import Intent`),各位值见上面的分组表。
## 接入方式选择 ## 接入方式选择
| | WebSocket | Webhook | | | WebSocket | Webhook |
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "qqbotsdk-py" name = "qqbotsdk-py"
version = "0.1.0" version = "0.2.1"
description = "QQ 官方机器人 Python SDK(WebSocket / Webhook)" description = "QQ 官方机器人 Python SDK(WebSocket / Webhook)"
license = "MIT" license = "MIT"
license-files = ["LICENSE"] license-files = ["LICENSE"]
+2
View File
@@ -13,6 +13,7 @@ from .config import Config as Config
from .connecter import Connecter as Connecter from .connecter import Connecter as Connecter
from .connecter import WebhookConnecter, WebsocketConnecter from .connecter import WebhookConnecter, WebsocketConnecter
from .emitter import EventEmitter as EventEmitter from .emitter import EventEmitter as EventEmitter
from .model import Intent as Intent
from .protocol import BaseProtocol as BaseProtocol from .protocol import BaseProtocol as BaseProtocol
from .queue import EventQueue as EventQueue from .queue import EventQueue as EventQueue
from .session import Session as Session from .session import Session as Session
@@ -31,6 +32,7 @@ __all__ = [
"Connecter", "Connecter",
"EventEmitter", "EventEmitter",
"EventQueue", "EventQueue",
"Intent",
"Session", "Session",
"WebhookConnecter", "WebhookConnecter",
"WebhookProtocol", "WebhookProtocol",
+35 -5
View File
@@ -28,6 +28,10 @@ STREAM_GENERATING = 1 # input_state:生成中
STREAM_FINISHED = 10 # input_state:生成结束 STREAM_FINISHED = 10 # input_state:生成结束
STREAM_CONTENT_TEXT = "text" STREAM_CONTENT_TEXT = "text"
STREAM_CONTENT_MARKDOWN = "markdown" STREAM_CONTENT_MARKDOWN = "markdown"
# 群禁言 members[].op 取值(restrict_chat_setting)
MUTE_OP_ADD = "add"
MUTE_OP_UPDATE = "update"
MUTE_OP_DEL = "del"
def button( def button(
@@ -384,15 +388,41 @@ class V2Api(BaseApi):
"""撤回单聊消息(DELETE /v2/users/{openid}/messages/{message_id})。""" """撤回单聊消息(DELETE /v2/users/{openid}/messages/{message_id})。"""
return await self._withdraw_v2_message(f"/v2/users/{openid}", message_id) return await self._withdraw_v2_message(f"/v2/users/{openid}", message_id)
async def mute_group_member(self, group_openid: str, mute_expire_at: str) -> dict: async def mute_group_members(
"""群成员禁言(POST /v2/groups/{group_openid}/restrict_chat_setting)。 self, group_openid: str, members: list[dict[str, Any]]
) -> dict:
"""批量设置群成员禁言/解禁(POST .../restrict_chat_setting)。
mute_expire_at 为禁言到期时间(RFC3339 格式,如 members 单次最多 20 项,每项为 {"op": MUTE_OP_ADD/MUTE_OP_UPDATE/
"2026-01-01T12:00:00+08:00");传当前时间之前即解除禁言。 MUTE_OP_DEL, "member_openid": ..., "mute_expire_at": RFC3339 时间}:
add 新增禁言、update 更新到期时间、del 解除禁言(expire 可传
空串)。最长禁言 30 天;仅可操作普通成员(群主/管理员/机器人
不可);需群管理员身份,60 QPM。
""" """
return await self.post( return await self.post(
f"/v2/groups/{group_openid}/restrict_chat_setting", f"/v2/groups/{group_openid}/restrict_chat_setting",
json={"mute_expire_at": mute_expire_at}, json={"members": members},
)
async def mute_group_member(
self, group_openid: str, member_openid: str, mute_expire_at: str
) -> dict:
"""禁言单个群成员(mute_group_members 的 op=add 封装)。"""
return await self.mute_group_members(
group_openid,
[
{
"op": MUTE_OP_ADD,
"member_openid": member_openid,
"mute_expire_at": mute_expire_at,
}
],
)
async def unmute_group_member(self, group_openid: str, member_openid: str) -> dict:
"""解除单个群成员禁言(mute_group_members 的 op=del 封装)。"""
return await self.mute_group_members(
group_openid, [{"op": MUTE_OP_DEL, "member_openid": member_openid}]
) )
async def generate_url_link(self, callback_data: str | None = None) -> dict: async def generate_url_link(self, callback_data: str | None = None) -> dict:
+7 -1
View File
@@ -8,6 +8,7 @@ start() 的装配里,handler 经形参标注拿所需组件(含 Bot 自身
import asyncio import asyncio
from collections.abc import Callable from collections.abc import Callable
from dataclasses import replace
from typing import Any, overload from typing import Any, overload
from aiohttp import ClientSession from aiohttp import ClientSession
@@ -26,8 +27,13 @@ from .ws_protocol import WebsocketProtocol
class Bot: class Bot:
def __init__(self, config: Config | None = None) -> None: def __init__(
self, config: Config | None = None, *, intents: int | None = None
) -> None:
self._config = config if config is not None else Config.load() self._config = config if config is not None else Config.load()
# 掩码直传(可用 Intent 枚举 OR 出来),优先级高于 INTENTS 环境变量与 intents.toml
if intents is not None:
self._config = replace(self._config, intents=intents)
self._emitter = EventEmitter() self._emitter = EventEmitter()
self._http: ClientSession | None = None self._http: ClientSession | None = None
self._api: BotApi | None = None self._api: BotApi | None = None
+12
View File
@@ -26,6 +26,7 @@ class Config:
webhook_host: str webhook_host: str
webhook_port: int webhook_port: int
webhook_path: str webhook_path: str
intents: int | None = None
@classmethod @classmethod
def load(cls) -> "Config": def load(cls) -> "Config":
@@ -34,6 +35,16 @@ class Config:
raise ValueError(f"未知的接入方式: {connecter}") raise ValueError(f"未知的接入方式: {connecter}")
# TIMEOUT 以毫秒配置 # TIMEOUT 以毫秒配置
timeout = float(os.getenv("TIMEOUT", "5000")) / 1000 timeout = float(os.getenv("TIMEOUT", "5000")) / 1000
intents_raw = os.getenv("INTENTS", "")
if intents_raw:
try:
intents = int(intents_raw, 0) # 十进制或 0x 十六进制
except ValueError:
raise ValueError(
f"INTENTS 应为十进制或 0x 十六进制掩码,实际: {intents_raw!r}"
) from None
else:
intents = None
return cls( return cls(
app_id=_required_env("APPID"), app_id=_required_env("APPID"),
app_secret=_required_env("APPSECRET"), app_secret=_required_env("APPSECRET"),
@@ -44,4 +55,5 @@ class Config:
webhook_host=os.getenv("WEBHOOK_HOST", "0.0.0.0"), webhook_host=os.getenv("WEBHOOK_HOST", "0.0.0.0"),
webhook_port=int(os.getenv("WEBHOOK_PORT", "8080")), webhook_port=int(os.getenv("WEBHOOK_PORT", "8080")),
webhook_path=os.getenv("WEBHOOK_PATH", "/qqbot/webhook"), webhook_path=os.getenv("WEBHOOK_PATH", "/qqbot/webhook"),
intents=intents,
) )
+18 -1
View File
@@ -50,6 +50,23 @@ def get_intents(path: str) -> int:
return intents return intents
_KNOWN_INTENTS_MASK = sum(intent.value for intent in Intent)
def resolve_intents(config: Config) -> int:
"""掩码优先于 intents.toml:Config.intents(Bot 参数或 INTENTS 环境变量)
已给则原样下发并校验未知位,未给才回落文件。"""
if config.intents is None:
return get_intents(config.intents_file)
unknown = config.intents & ~_KNOWN_INTENTS_MASK
if unknown:
# 平台可能新增 SDK 尚未收录的分组,未知位只告警不拦截
logger.warning(
f"intents 掩码含未知位 {unknown:#x}(当前 Intent 枚举未收录),将原样下发"
)
return config.intents
class WebsocketProtocol(BaseProtocol): class WebsocketProtocol(BaseProtocol):
def __init__(self, config: Config, session: Session, token: AccessToken) -> None: def __init__(self, config: Config, session: Session, token: AccessToken) -> None:
self._config = config self._config = config
@@ -87,7 +104,7 @@ class WebsocketProtocol(BaseProtocol):
op = Opcode.IDENTIFY op = Opcode.IDENTIFY
d = { d = {
"token": f"QQBot {access_token}", "token": f"QQBot {access_token}",
"intents": get_intents(self._config.intents_file), "intents": resolve_intents(self._config),
"shard": (0, 1), "shard": (0, 1),
"properties": {}, "properties": {},
} }
+20 -2
View File
@@ -116,10 +116,28 @@ async def test_mute_and_schedule_official_field_names():
assert info["remind_type"] == "0" assert info["remind_type"] == "0"
assert "schedule_info" not in kwargs["json"] assert "schedule_info" not in kwargs["json"]
await api.mute_group_member("G1", "2026-01-01T12:00:00+08:00") await api.mute_group_member("G1", "M1", "2026-01-01T12:00:00+08:00")
method, path, kwargs = cap["call"] method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/groups/G1/restrict_chat_setting") assert (method, path) == ("POST", "/v2/groups/G1/restrict_chat_setting")
assert kwargs["json"] == {"mute_expire_at": "2026-01-01T12:00:00+08:00"} assert kwargs["json"] == {
"members": [
{
"op": "add",
"member_openid": "M1",
"mute_expire_at": "2026-01-01T12:00:00+08:00",
}
]
}
await api.unmute_group_member("G1", "M1")
assert cap["call"][2]["json"] == {"members": [{"op": "del", "member_openid": "M1"}]}
members = [
{"op": "add", "member_openid": "M1", "mute_expire_at": "t1"},
{"op": "update", "member_openid": "M2", "mute_expire_at": "t2"},
]
await api.mute_group_members("G1", members)
assert cap["call"][2]["json"] == {"members": members}
async def test_me(): async def test_me():
+21
View File
@@ -10,12 +10,15 @@ from qqbotsdk import (
Config, Config,
EventEmitter, EventEmitter,
EventQueue, EventQueue,
Intent,
Session, Session,
WebhookConnecter, WebhookConnecter,
WebsocketConnecter, WebsocketConnecter,
) )
from qqbotsdk.payloads import GroupAtMessage from qqbotsdk.payloads import GroupAtMessage
intent_mask = (1 << 25) | (1 << 26)
@pytest.fixture @pytest.fixture
def env(monkeypatch: pytest.MonkeyPatch) -> None: def env(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -29,6 +32,24 @@ def test_construction_loads_config(env: None) -> None:
assert bot.config.connecter == "websocket" assert bot.config.connecter == "websocket"
def test_intents_param_overrides_env(
env: None, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("INTENTS", str(1 << 0))
bot = Bot(intents=intent_mask)
assert bot.config.intents == intent_mask # Bot 参数 > INTENTS 环境变量
def test_intents_env_picked_up(env: None, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("INTENTS", str(intent_mask))
assert Bot().config.intents == intent_mask
def test_intents_accepts_intent_flag(env: None) -> None:
bot = Bot(intents=Intent.GROUP_AND_C2C_EVENT | Intent.INTERACTION)
assert bot.config.intents == (1 << 25) | (1 << 26)
def test_missing_credentials_raises(monkeypatch: pytest.MonkeyPatch) -> None: def test_missing_credentials_raises(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("APPID", raising=False) monkeypatch.delenv("APPID", raising=False)
monkeypatch.delenv("APPSECRET", raising=False) monkeypatch.delenv("APPSECRET", raising=False)
+50 -1
View File
@@ -1,7 +1,7 @@
import pytest import pytest
from qqbotsdk.config import Config from qqbotsdk.config import Config
from qqbotsdk.ws_protocol import get_intents from qqbotsdk.ws_protocol import get_intents, resolve_intents
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -74,3 +74,52 @@ def test_config_rejects_unknown_connecter(monkeypatch):
monkeypatch.setenv("CONNECTER", "carrier-pigeon") monkeypatch.setenv("CONNECTER", "carrier-pigeon")
with pytest.raises(ValueError, match="carrier-pigeon"): with pytest.raises(ValueError, match="carrier-pigeon"):
Config.load() Config.load()
# --- 掩码直传:Config.intents(INTENTS 环境变量 / Bot 参数)优先于 intents.toml ---
def test_config_intents_env_decimal(monkeypatch):
monkeypatch.setenv("INTENTS", str((1 << 25) | (1 << 26)))
assert Config.load().intents == (1 << 25) | (1 << 26)
def test_config_intents_env_hex(monkeypatch):
monkeypatch.setenv("INTENTS", "0x4000000")
assert Config.load().intents == 1 << 26
def test_config_intents_env_invalid(monkeypatch):
monkeypatch.setenv("INTENTS", "abc")
with pytest.raises(ValueError, match="INTENTS"):
Config.load()
def make_config(intents: int | None, path: str) -> Config:
return Config(
app_id="app",
app_secret="secret",
base_url="https://api.bot.qq.com",
timeout=None, # type: ignore[arg-type]
connecter="websocket",
intents_file=path,
webhook_host="0.0.0.0",
webhook_port=8080,
webhook_path="/x",
intents=intents,
)
def test_resolve_intents_mask_wins_over_file(tmp_path):
path = write(tmp_path, "GUILDS = true\n")
assert resolve_intents(make_config(1 << 26, path)) == 1 << 26
def test_resolve_intents_falls_back_to_file(tmp_path):
path = write(tmp_path, "GUILDS = true\nAUDIO_ACTION = true\n")
assert resolve_intents(make_config(None, path)) == (1 << 0) | (1 << 29)
def test_resolve_intents_unknown_bits_pass_through(tmp_path):
mask = (1 << 26) | (1 << 5) # 1<<5 未收录进 Intent 枚举
assert resolve_intents(make_config(mask, str(tmp_path / "nope.toml"))) == mask
+14 -1
View File
@@ -17,7 +17,9 @@ class FakeToken:
return "tok" return "tok"
def make_protocol(session: Session | None = None) -> tuple[WebsocketProtocol, Session]: def make_protocol(
session: Session | None = None, intents: int | None = None
) -> tuple[WebsocketProtocol, Session]:
session = session or Session() session = session or Session()
config = Config( config = Config(
app_id="app", app_id="app",
@@ -29,6 +31,7 @@ def make_protocol(session: Session | None = None) -> tuple[WebsocketProtocol, Se
webhook_host="0.0.0.0", webhook_host="0.0.0.0",
webhook_port=8080, webhook_port=8080,
webhook_path="/x", webhook_path="/x",
intents=intents,
) )
return WebsocketProtocol(config, session, FakeToken()), session # type: ignore[arg-type] return WebsocketProtocol(config, session, FakeToken()), session # type: ignore[arg-type]
@@ -45,6 +48,16 @@ async def test_hello_without_session_identifies():
assert reply["d"]["shard"] == (0, 1) assert reply["d"]["shard"] == (0, 1)
@pytest.mark.asyncio
async def test_identify_carries_intents_mask():
mask = (1 << 25) | (1 << 26)
protocol, _ = make_protocol(intents=mask)
reply = await protocol.on_frame({"op": Opcode.HELLO, "d": {}})
assert reply is not None
assert reply["op"] == Opcode.IDENTIFY
assert reply["d"]["intents"] == mask
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_hello_with_session_resumes(): async def test_hello_with_session_resumes():
protocol, session = make_protocol() protocol, session = make_protocol()
Generated
+1 -1
View File
@@ -688,7 +688,7 @@ wheels = [
[[package]] [[package]]
name = "qqbotsdk-py" name = "qqbotsdk-py"
version = "0.1.0" version = "0.2.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },