mirror of
https://github.com/snowy-yang/qqbotsdk.git
synced 2026-10-21 13:46:18 +00:00
Compare commits
4
Commits
bcd69099b6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
706a39b07b | ||
|
|
6235f9e393 | ||
|
|
321ab70b35 | ||
|
|
7e8794bf5f |
No files matched your search
@@ -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
@@ -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
@@ -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)。
|
||||||
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
@@ -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
@@ -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():
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
Reference in new issue
Block a user