mirror of
https://github.com/snowy-yang/qqbotsdk.git
synced 2026-10-21 16:44:48 +00:00
Compare commits
15
Commits
7663ca610d
..
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
321ab70b35 | ||
|
|
7e8794bf5f | ||
|
|
bcd69099b6 | ||
|
|
652a142e86 | ||
|
|
0515c6e4c0 | ||
|
|
1f91b1acca | ||
|
|
afe36c2a84 | ||
|
|
febeb95d7c | ||
|
|
0fa63d564b | ||
|
|
e5dd805d19 | ||
|
|
0f3ed09233 | ||
|
|
1d9f509b25 | ||
|
|
feb39b11d5 | ||
|
|
e66148a75c | ||
|
|
badf5b3571 |
No files matched your search
@@ -6,18 +6,25 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
name: ruff + pyright + pytest
|
name: ruff + pyright + pytest
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.12", "3.13"]
|
||||||
steps:
|
steps:
|
||||||
- name: 检出代码
|
- name: 检出代码
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: 安装 uv 与 Python 3.13
|
- name: 安装 uv 与 Python ${{ matrix.python-version }}
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: ${{ matrix.python-version }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|
||||||
- name: 同步依赖(含 dev 组)
|
- name: 同步依赖(含 dev 组)
|
||||||
@@ -26,8 +33,32 @@ jobs:
|
|||||||
- name: ruff 静态检查
|
- name: ruff 静态检查
|
||||||
run: uv run ruff check src tests examples
|
run: uv run ruff check src tests examples
|
||||||
|
|
||||||
|
- name: ruff 格式检查
|
||||||
|
run: uv run ruff format --check src tests examples
|
||||||
|
|
||||||
- name: pyright 类型检查
|
- name: pyright 类型检查
|
||||||
run: uv run pyright
|
run: uv run pyright
|
||||||
|
|
||||||
- name: pytest 单元测试
|
- name: pytest 单元测试
|
||||||
run: uv run pytest
|
run: uv run pytest
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: 构建发行包
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: 检出代码
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 安装 uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
|
||||||
|
- name: 构建 sdist 与 wheel
|
||||||
|
run: uv build
|
||||||
|
|
||||||
|
- name: 上传构建产物
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist/
|
||||||
@@ -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
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
name: 部署文档(GitHub Pages)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "index.html"
|
||||||
|
- "_sidebar.md"
|
||||||
|
- "README.md"
|
||||||
|
- "docs/**"
|
||||||
|
- "examples/**"
|
||||||
|
- "CHANGELOG.md"
|
||||||
|
- ".github/workflows/pages.yml"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pages
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: 组装纯文档并部署
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- name: 检出代码
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# 站点是零构建的 docsify 静态文件:从仓库挑出文档相关文件组装后直接部署。
|
||||||
|
# CNAME 必须随每次部署带上,否则 Actions 部署会把自定义域名设置抹掉。
|
||||||
|
- name: 组装纯文档站点
|
||||||
|
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
|
||||||
|
echo "qqbotsdk.4i.hk" > site/CNAME
|
||||||
|
find site -type d -name __pycache__ -exec rm -rf {} +
|
||||||
|
|
||||||
|
- name: 配置 Pages
|
||||||
|
uses: actions/configure-pages@v5
|
||||||
|
|
||||||
|
- name: 上传站点产物
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: site
|
||||||
|
|
||||||
|
- name: 部署
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
name: 发布到 PyPI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: 构建并发布
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
# Trusted Publishing(OIDC)需要显式声明 id-token 写权限
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- name: 检出代码
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 安装 uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
|
||||||
|
- name: 构建 sdist 与 wheel
|
||||||
|
run: uv build
|
||||||
|
|
||||||
|
# 走 Trusted Publishing:无 token,发布身份由 GitHub OIDC 签发。
|
||||||
|
# 前置条件(一次性):pypi.org → qqbotsdk-py → Publishing →
|
||||||
|
# Add pending publisher(owner: snowy-yang,repo: qqbotsdk,
|
||||||
|
# workflow: publish.yml)
|
||||||
|
- name: 发布
|
||||||
|
run: uv publish
|
||||||
+13
-1
@@ -5,8 +5,15 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [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` 注入实例
|
||||||
|
- **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 徽章
|
||||||
|
- **文档站迁移 GitHub Pages(Actions 部署)**:不再同步 `docs` 孤儿分支(已删除),`pages.yml` 组装纯文档产物经 `deploy-pages` 直部署;弃用 Cloudflare Pages 托管,自定义域名 qqbotsdk.4i.hk 改由 CNAME 指向 GitHub Pages
|
||||||
- **旧版(频道侧)OpenAPI 对齐(对照旧版 wiki 全集核对)**:私信(`create_dms` 建会话、`post_dms_message` 发信、`withdraw_dms_message` 撤回);身份组增删改与成员授予/收回;频道成员列表/踢出(可拉黑+撤回历史消息)/身份组成员列表/子频道在线成员数;全员与批量成员禁言;子频道用户/身份组权限查改;频道消息频率设置;接口权限列表与授权链接申请;新增 `audio` 域(播放控制、上/下麦)与 `forum` 域(帖子列表/详情/发帖/删帖)组合进 `BotApi`(`AUDIO_STATUS_*`、`FORUM_FORMAT_*` 常量随模块导出)
|
- **旧版(频道侧)OpenAPI 对齐(对照旧版 wiki 全集核对)**:私信(`create_dms` 建会话、`post_dms_message` 发信、`withdraw_dms_message` 撤回);身份组增删改与成员授予/收回;频道成员列表/踢出(可拉黑+撤回历史消息)/身份组成员列表/子频道在线成员数;全员与批量成员禁言;子频道用户/身份组权限查改;频道消息频率设置;接口权限列表与授权链接申请;新增 `audio` 域(播放控制、上/下麦)与 `forum` 域(帖子列表/详情/发帖/删帖)组合进 `BotApi`(`AUDIO_STATUS_*`、`FORUM_FORMAT_*` 常量随模块导出)
|
||||||
- **事件全量对齐**:新增 `GROUP_MEMBER_ADD`/`GROUP_MEMBER_REMOVE`(共用 `GroupMemberChange`)、`GROUP_JOIN_REQUEST`(入群申请,含验证问答与自动审批命中信息,仅机器人是群管理员时推送)、`SUBSCRIBE_MESSAGE_STATUS`(订阅消息授权状态)四个 payload;`Intent` 枚举与 intents.toml 补上此前缺失的 `GROUP_MEMBER_EVENT (1<<24)` 分组(默认 false,需订阅请手动开启)
|
- **事件全量对齐**:新增 `GROUP_MEMBER_ADD`/`GROUP_MEMBER_REMOVE`(共用 `GroupMemberChange`)、`GROUP_JOIN_REQUEST`(入群申请,含验证问答与自动审批命中信息,仅机器人是群管理员时推送)、`SUBSCRIBE_MESSAGE_STATUS`(订阅消息授权状态)四个 payload;`Intent` 枚举与 intents.toml 补上此前缺失的 `GROUP_MEMBER_EVENT (1<<24)` 分组(默认 false,需订阅请手动开启)
|
||||||
- **v2 OpenAPI 全量对齐(对照官方 2026-09 新版文档站逐页核对)**:新增 `group` 域方法集组合进 `BotApi`——群信息/成员列表/成员详情/机器人群内状态、入群申请列表与审批、群黑名单增删查、群成员批量移除、群禁言状态查询、入群自动审批策略(增删改查/执行/白名单,机器人维度全局配置)
|
- **v2 OpenAPI 全量对齐(对照官方 2026-09 新版文档站逐页核对)**:新增 `group` 域方法集组合进 `BotApi`——群信息/成员列表/成员详情/机器人群内状态、入群申请列表与审批、群黑名单增删查、群成员批量移除、群禁言状态查询、入群自动审批策略(增删改查/执行/白名单,机器人维度全局配置)
|
||||||
@@ -25,6 +32,7 @@
|
|||||||
|
|
||||||
### 变更
|
### 变更
|
||||||
|
|
||||||
|
- **Python 基线降至 3.12**:`requires-python = ">=3.12"`(PEP 695 `type` 语句、`typing.override` 等既有写法本就 3.12 可用,代码无需改动);ruff/pyright 目标版本同步 py312;CI 改为 3.12/3.13 双版本矩阵并补 `ruff format --check` 格式检查
|
||||||
- **移除 dishka 依赖,改为内置参数注入**:handler 形参直接标注组件类型(`api: BotApi`)即注入,不再需要 `Inject[T]` 包装;组件改在 `run_loop` 显式装配并按类型登记到 `emitter.services`(自定义组件同样登记即可注入)。破坏性变更:`Inject`、`make_container`、`use_container` 随之移除,原 `api: Inject[BotApi]` 写法改为 `api: BotApi`
|
- **移除 dishka 依赖,改为内置参数注入**:handler 形参直接标注组件类型(`api: BotApi`)即注入,不再需要 `Inject[T]` 包装;组件改在 `run_loop` 显式装配并按类型登记到 `emitter.services`(自定义组件同样登记即可注入)。破坏性变更:`Inject`、`make_container`、`use_container` 随之移除,原 `api: Inject[BotApi]` 写法改为 `api: BotApi`
|
||||||
- `EventEmitter` 的 `queue` 参数改为可选:不传自动创建队列,`EventEmitter()` 即可直接注册 handler;需多处共享队列时仍可显式传 `EventQueue`(`ee.queue` 取实例)。文档与示例统一改为无参构造
|
- `EventEmitter` 的 `queue` 参数改为可选:不传自动创建队列,`EventEmitter()` 即可直接注册 handler;需多处共享队列时仍可显式传 `EventQueue`(`ee.queue` 取实例)。文档与示例统一改为无参构造
|
||||||
- **`api.py` 拆分为 `api/` 包**:`core.py` 请求内核(鉴权/限流重试/错误归一)+ `v2.py`(群/单聊与按钮构造)、`channel.py`(频道消息)、`guild.py`(频道管理与查询)三个域方法集组合成 `BotApi`;方法名与导入路径不变(`from qqbotsdk.api import BotApi, button, keyboard` 照旧),新增域加模块即可
|
- **`api.py` 拆分为 `api/` 包**:`core.py` 请求内核(鉴权/限流重试/错误归一)+ `v2.py`(群/单聊与按钮构造)、`channel.py`(频道消息)、`guild.py`(频道管理与查询)三个域方法集组合成 `BotApi`;方法名与导入路径不变(`from qqbotsdk.api import BotApi, button, keyboard` 照旧),新增域加模块即可
|
||||||
@@ -46,6 +54,7 @@
|
|||||||
|
|
||||||
### 移除
|
### 移除
|
||||||
|
|
||||||
|
- **`main()` / `run_loop()` 与 `qqbotsdk` 控制台脚本入口**(破坏性变更):启动方式统一为 `Bot` 门面——原 `ee = EventEmitter()` + `main(ee)` 改为 `bot = Bot()` + `@bot.on(...)` + `bot.run()`;原 `await run_loop(ee)` 嵌入场景改用 `await bot.start()`。pyproject 的 `[project.scripts]` 随之删除
|
||||||
- **`Event` 的字段名映射属性 `user_id`/`group_id`/`content`/`message_id`**(含旧版群消息 payload 顶层 `from_user_id` 字段的回退):`Event` 收敛为推送信封封装,只保留 `.raw`/`.data`/`.typed`/`.type`/`.event_id`;事件体字段一律标注 payload dataclass(如 `msg: GroupAtMessage`)获取解析结果,或经 `.data`/`.raw` 自取。破坏性变更:原 `event.group_id` 改为 typed payload 的 `msg.group_openid`,`event.message_id` 改为 `msg.id`,以此类推
|
- **`Event` 的字段名映射属性 `user_id`/`group_id`/`content`/`message_id`**(含旧版群消息 payload 顶层 `from_user_id` 字段的回退):`Event` 收敛为推送信封封装,只保留 `.raw`/`.data`/`.typed`/`.type`/`.event_id`;事件体字段一律标注 payload dataclass(如 `msg: GroupAtMessage`)获取解析结果,或经 `.data`/`.raw` 自取。破坏性变更:原 `event.group_id` 改为 typed payload 的 `msg.group_openid`,`event.message_id` 改为 `msg.id`,以此类推
|
||||||
- `EventEmitter.register_protocol()`、`EventEmitter.handle()` 与 `emitter` 持有协议层的机制(协议处理器改为由 connecter 持有并逐帧调用,见上文"变更")
|
- `EventEmitter.register_protocol()`、`EventEmitter.handle()` 与 `emitter` 持有协议层的机制(协议处理器改为由 connecter 持有并逐帧调用,见上文"变更")
|
||||||
- apscheduler 依赖(心跳迁入 connecter 后不再需要调度器)
|
- apscheduler 依赖(心跳迁入 connecter 后不再需要调度器)
|
||||||
@@ -58,6 +67,7 @@
|
|||||||
- token 请求失败时抛出 `TokenError`(携带 QQ 返回的 `code`/`message`),不再抛出掩盖真实原因的 `KeyError: 'access_token'`
|
- token 请求失败时抛出 `TokenError`(携带 QQ 返回的 `code`/`message`),不再抛出掩盖真实原因的 `KeyError: 'access_token'`
|
||||||
- 修正被动回复 `event_id` 的指引:官方要求取网关帧最外层的 id(`Event.event_id`),事件体里的裸 UUID(`Interaction.id` 等)不能作 `event_id`,此前文档与 docstring 的说法有误
|
- 修正被动回复 `event_id` 的指引:官方要求取网关帧最外层的 id(`Event.event_id`),事件体里的裸 UUID(`Interaction.id` 等)不能作 `event_id`,此前文档与 docstring 的说法有误
|
||||||
- 响应 `Content-Type` 非 JSON 时不再抛 `ContentTypeError`(个别接口如回应互动返回 200 text/plain + `{}` body);JSON 解析统一切换 ujson
|
- 响应 `Content-Type` 非 JSON 时不再抛 `ContentTypeError`(个别接口如回应互动返回 200 text/plain + `{}` body);JSON 解析统一切换 ujson
|
||||||
|
- 文档站点子页面左侧目录不加载:docsify 进入子目录页面会先请求 `<目录>/_sidebar.md`、请求失败才回退根目录,而 Cloudflare Pages 对缺失路径回退返回 `index.html`(状态 200),侧栏被当作 Markdown 解析成空白;`index.html` 的 alias 现将 `docs/`、`examples/` 的 `_sidebar.md` 请求映射回根目录
|
||||||
|
|
||||||
## [0.1.0] - 2026-09-14
|
## [0.1.0] - 2026-09-14
|
||||||
|
|
||||||
@@ -74,4 +84,6 @@
|
|||||||
- **文档**:使用指南、API 参考、架构说明与可运行示例(`examples/`)
|
- **文档**:使用指南、API 参考、架构说明与可运行示例(`examples/`)
|
||||||
- **测试**:纯逻辑单测 + 假网关 WebSocket 端到端 + Webhook 验签/去重端到端
|
- **测试**:纯逻辑单测 + 假网关 WebSocket 端到端 + Webhook 验签/去重端到端
|
||||||
|
|
||||||
[0.1.0]: https://gitea.4i.hk/hhhge/qqbotsdk/releases/tag/v0.1.0
|
[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
|
||||||
@@ -1,8 +1,60 @@
|
|||||||
# qqbotsdk
|
# qqbotsdk
|
||||||
|
|
||||||
QQ 官方机器人 Python SDK(Python 3.13 / asyncio / aiohttp),支持 WebSocket 与 Webhook 两种接入方式。
|
[](https://github.com/snowy-yang/qqbotsdk/actions/workflows/ci.yml)
|
||||||
|
[](https://pypi.org/project/qqbotsdk-py/)
|
||||||
|
[](https://pypi.org/project/qqbotsdk-py/)
|
||||||
|
[](https://pypistats.org/packages/qqbotsdk-py)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
[使用指南](docs/GUIDE.md) · [API 参考](docs/API.md) · [架构](docs/ARCHITECTURE.md) · [示例](examples/) · [更新日志](CHANGELOG.md)
|
QQ 官方机器人 Python SDK(Python 3.12+ / asyncio / aiohttp),支持 WebSocket 与 Webhook 两种接入方式。
|
||||||
|
|
||||||
|
**📖 在线文档:<https://qqbotsdk.4i.hk/>**
|
||||||
|
|
||||||
|
[快速开始](docs/guide/start.md) · [使用指南](docs/guide/handlers.md) · [API 参考](docs/api/core.md) · [架构](docs/ARCHITECTURE.md) · [示例](examples/) · [更新日志](CHANGELOG.md)
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install qqbotsdk-py # 或 uv add qqbotsdk-py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 五分钟上手
|
||||||
|
|
||||||
|
`.env` 配好 `APPID`/`APPSECRET`,`intents.toml` 开启 `GROUP_AND_C2C_EVENT = true`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from qqbotsdk import Bot
|
||||||
|
from qqbotsdk.api import BotApi
|
||||||
|
from qqbotsdk.payloads import GroupAtMessage
|
||||||
|
|
||||||
|
bot = Bot()
|
||||||
|
|
||||||
|
|
||||||
|
# handler 形参按标注注入 payload 与 BotApi 等组件
|
||||||
|
@bot.on("GROUP_AT_MESSAGE_CREATE")
|
||||||
|
async def on_group_message(msg: GroupAtMessage, api: BotApi):
|
||||||
|
# 被动回复带 msg_id(5 分钟内有效,多次回复递增 msg_seq);失败抛 ApiError
|
||||||
|
await api.post_group_message(
|
||||||
|
msg.group_openid,
|
||||||
|
content=f"你说:{msg.content}",
|
||||||
|
msg_id=msg.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
bot.run()
|
||||||
|
```
|
||||||
|
|
||||||
|
Webhook 接入:`.env` 中 `CONNECTER` 改为 `webhook`,再把回调地址配置到 QQ 开放平台(仅支持 80/443/8080/8443 端口,需公网 HTTPS),handler 写法完全一致。
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- `Bot` 门面:`bot.run()` 一行启动,`bot.call_api` 通用 REST,`bot.api` 类型化 API 面
|
||||||
|
- 断线自动重连(指数退避 + 心跳看门狗),重连时自动 RESUME 续传
|
||||||
|
- Webhook 接入自带 Ed25519 验签、op=13 验证应答与事件去重
|
||||||
|
- 事件订阅由项目根目录的 `intents.toml` 按大类开关
|
||||||
|
- Markdown 与内嵌按钮一等支持,`respond_interaction()` 回应按钮回调
|
||||||
|
- 组件按类型注入 handler(自定义组件同样可注入),全 SDK 共享一个 HTTP 连接池
|
||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
@@ -22,7 +74,7 @@ flowchart TB
|
|||||||
end
|
end
|
||||||
subgraph 业务层
|
subgraph 业务层
|
||||||
API["BotApi(REST + ApiError)"]
|
API["BotApi(REST + ApiError)"]
|
||||||
USER["用户 handler @ee.on(...)"]
|
USER["用户 handler @bot.on(...)"]
|
||||||
end
|
end
|
||||||
QQ["QQ 开放平台"]
|
QQ["QQ 开放平台"]
|
||||||
|
|
||||||
@@ -37,90 +89,7 @@ flowchart TB
|
|||||||
WS --> QQ
|
WS --> QQ
|
||||||
```
|
```
|
||||||
|
|
||||||
组件在 `run_loop` 显式装配,并按类型登记到 `emitter.services` 供 handler 参数注入(无全局单例);协议帧由连接适配器交给各自的协议处理器就地处理,只有 op=0 的业务事件经队列进入分发——依赖关系与设计决策详见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
|
组件在 `Bot.start()` 中装配,依赖关系与设计决策详见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
|
||||||
|
|
||||||
## 特性
|
|
||||||
|
|
||||||
- 内置协议处理器(`ws_protocol.py` / `webhook_protocol.py`):HELLO 鉴权(IDENTIFY/RESUME 自动切换)、READY 会话捕获、INVALID_SESSION 会话失效处理;心跳由连接器按连接生命周期驱动
|
|
||||||
- 断线自动重连(指数退避 + 心跳看门狗),重连时自动 RESUME 续传
|
|
||||||
- Webhook 接入自带 Ed25519 验签、op=13 验证应答与 60s TTL 事件去重
|
|
||||||
- 事件订阅由项目根目录的 `intents.toml` 控制,按大类(Intents 分组)开关,将对应分组改为 `true` 即可
|
|
||||||
- Markdown 与内嵌按钮一等支持:`post_group_message(..., markdown=..., keyboard=...)` 自动置 msg_type=2,`respond_interaction()` 回应按钮回调
|
|
||||||
- 组件在 `run_loop` 显式装配(无 DI 框架依赖),全 SDK 共享一个 HTTP 连接池
|
|
||||||
|
|
||||||
## 配置
|
|
||||||
|
|
||||||
`.env`(`APPID`/`APPSECRET` 必填,缺失启动即报错):
|
|
||||||
|
|
||||||
```ini
|
|
||||||
APPID=你的AppID
|
|
||||||
APPSECRET=你的AppSecret
|
|
||||||
BASE_URL=https://api.bot.qq.com # 可选,默认官方地址
|
|
||||||
TIMEOUT=5000 # 可选,毫秒
|
|
||||||
CONNECTER=websocket # websocket 或 webhook
|
|
||||||
|
|
||||||
# 仅 webhook 接入时
|
|
||||||
WEBHOOK_HOST=0.0.0.0
|
|
||||||
WEBHOOK_PORT=8080
|
|
||||||
WEBHOOK_PATH=/qqbot/webhook
|
|
||||||
```
|
|
||||||
|
|
||||||
## 使用
|
|
||||||
|
|
||||||
编写入口(如 `main.py`),把自己构造的 `EventEmitter` 传给 `main()`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from qqbotsdk import EventEmitter, main
|
|
||||||
|
|
||||||
ee = EventEmitter()
|
|
||||||
|
|
||||||
# 方式一:裸参数(d 为原始事件数据)
|
|
||||||
@ee.on("GROUP_AT_MESSAGE_CREATE")
|
|
||||||
async def on_group_message(d):
|
|
||||||
print("收到群消息:", d)
|
|
||||||
|
|
||||||
# 方式二:类型化 payload(dataclass,见 qqbotsdk.payloads)
|
|
||||||
from qqbotsdk.payloads import GroupAtMessage
|
|
||||||
|
|
||||||
@ee.on("GROUP_AT_MESSAGE_CREATE")
|
|
||||||
async def on_group_message(msg: GroupAtMessage):
|
|
||||||
print(msg.group_openid, msg.user_openid, msg.content)
|
|
||||||
|
|
||||||
# 方式三:参数注入(按形参标注装配,payload 与 `BotApi` 等组件可任意组合)
|
|
||||||
from qqbotsdk.api import BotApi
|
|
||||||
from qqbotsdk.events import Event
|
|
||||||
from qqbotsdk.payloads import GroupAtMessage
|
|
||||||
|
|
||||||
@ee.on("GROUP_AT_MESSAGE_CREATE")
|
|
||||||
async def handle(msg: GroupAtMessage, event: Event, api: BotApi):
|
|
||||||
print(event.event_id) # 信封信息(op/t/d/id)经 Event 取用,事件字段走 payload
|
|
||||||
# 被动回复:带 msg_id(群聊 5 分钟/单聊 60 分钟内有效,最多分别回复 5/4 次);
|
|
||||||
# 同一条消息多次回复递增 msg_seq
|
|
||||||
await api.post_group_message(
|
|
||||||
msg.group_openid,
|
|
||||||
content=f"你说:{msg.content}",
|
|
||||||
msg_id=msg.id,
|
|
||||||
) # 失败抛 ApiError;富媒体先 upload_group_file 拿 file_info
|
|
||||||
|
|
||||||
# 业务事件(op=0)才进分发,返回值不回流,回复请显式调 BotApi;
|
|
||||||
# 协议帧(HELLO/READY/INVALID_SESSION/op=13)由各接入方式的协议处理器
|
|
||||||
# 在连接器内就地处理,不进队列、也不会触达 handler。
|
|
||||||
# 单个 handler 抛异常不会影响其他 handler 和主循环,异常会被记录。
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main(ee)
|
|
||||||
```
|
|
||||||
|
|
||||||
运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run python main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Webhook 接入:将 `.env` 中 `CONNECTER` 改为 `webhook`,运行后把
|
|
||||||
`http://<你的域名>:8080/qqbot/webhook` 配置到 QQ 开放平台回调地址(仅支持 80/443/8080/8443 端口,需公网 HTTPS)。
|
|
||||||
|
|
||||||
完整可运行示例见 [examples/](examples/):群机器人(文本/图片回复、入群欢迎、单聊回复)与 Webhook 最小接入。
|
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
@@ -129,16 +98,10 @@ uv run pytest # 单元测试
|
|||||||
uv run ruff check src tests examples && uv run pyright
|
uv run ruff check src tests examples && uv run pyright
|
||||||
```
|
```
|
||||||
|
|
||||||
推送到 main 与 Pull Request 会由 Gitea Actions 自动执行同样的 ruff + pyright 检查([.gitea/workflows/ci.yml](.gitea/workflows/ci.yml))。
|
推送与 Pull Request 由 GitHub Actions 自动执行同样的检查([.github/workflows/ci.yml](.github/workflows/ci.yml));打 `v*` 标签经 [publish.yml](.github/workflows/publish.yml) 以 Trusted Publishing 发布到 PyPI。
|
||||||
|
|
||||||
文档站点基于 [docsify](https://docsify.js.org/)(无需构建,仓库根 `index.html` + Markdown),本地预览:
|
文档站点基于 [docsify](https://docsify.js.org/),文档变更时自动部署到 GitHub Pages([.github/workflows/pages.yml](.github/workflows/pages.yml),自定义域名 qqbotsdk.4i.hk);本地预览:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx docsify-cli serve # 打开 http://localhost:3000
|
npx docsify-cli serve
|
||||||
```
|
```
|
||||||
|
|
||||||
线上部署:文档文件变更时 GitHub Actions 自动把纯文档组装进 `docs` 分支([.github/workflows/deploy-docs.yml](.github/workflows/deploy-docs.yml),与源码历史无关的孤儿分支);Cloudflare Pages 连接本仓库、生产分支选 `docs`、构建命令留空、输出目录填 `/` 即完成接入,站点地址 `https://<项目名>.pages.dev`。
|
|
||||||
|
|
||||||
## 待完善
|
|
||||||
|
|
||||||
- 更多 OpenAPI 封装(按需在 `api.py` 的 `BotApi` 上加薄封装即可)
|
|
||||||
+14
-2
@@ -1,6 +1,18 @@
|
|||||||
- [首页](/README.md)
|
- [首页](/README.md)
|
||||||
- [使用指南](/docs/GUIDE.md)
|
- 使用指南
|
||||||
- [API 参考](/docs/API.md)
|
- [快速开始](/docs/guide/start.md)
|
||||||
|
- [配置与订阅](/docs/guide/config.md)
|
||||||
|
- [事件处理](/docs/guide/handlers.md)
|
||||||
|
- [收发消息](/docs/guide/messaging.md)
|
||||||
|
- [部署与常见问题](/docs/guide/ops.md)
|
||||||
|
- API 参考
|
||||||
|
- [核心框架](/docs/api/core.md)
|
||||||
|
- [群聊与单聊(v2)](/docs/api/v2.md)
|
||||||
|
- [群管理](/docs/api/group.md)
|
||||||
|
- [频道](/docs/api/guild.md)
|
||||||
|
- [指令面板与菜单](/docs/api/panel.md)
|
||||||
|
- [音频与论坛](/docs/api/audio-forum.md)
|
||||||
|
- [事件 payload](/docs/api/events.md)
|
||||||
- [架构设计](/docs/ARCHITECTURE.md)
|
- [架构设计](/docs/ARCHITECTURE.md)
|
||||||
- [示例](/examples/README.md)
|
- [示例](/examples/README.md)
|
||||||
- [更新日志](/CHANGELOG.md)
|
- [更新日志](/CHANGELOG.md)
|
||||||
-323
@@ -1,323 +0,0 @@
|
|||||||
# qqbotsdk API 参考
|
|
||||||
|
|
||||||
按公开使用面组织。上手流程见 [GUIDE.md](GUIDE.md),分层与装配见 [ARCHITECTURE.md](ARCHITECTURE.md)。
|
|
||||||
|
|
||||||
## 顶层导出
|
|
||||||
|
|
||||||
```python
|
|
||||||
from qqbotsdk import (
|
|
||||||
main, run_loop, # 入口(组件在 run_loop 显式装配)
|
|
||||||
EventEmitter, EventQueue, # 分发层
|
|
||||||
BaseProtocol, # 协议处理器接口(自定义接入方式时继承)
|
|
||||||
Config, Connecter, # 配置与连接器接口
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
| 名称 | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| `main(emitter=None)` | 同步入口:装配组件并阻塞运行,Ctrl+C 优雅退出 |
|
|
||||||
| `run_loop(emitter=None)` | 异步入口:`asyncio.run(run_loop(ee))` 的内部实现,可自行 await |
|
|
||||||
| `EventEmitter(queue=None)` | 分发主体,`@ee.on(..., background=True)` 注册 handler(可选后台并发);`queue` 不传自动创建 |
|
|
||||||
| `ee.services` | `dict[type, Any]`,按类型登记可注入组件;`run_loop` 装配 SDK 内置组件,也可登记自定义类型 |
|
|
||||||
| `EventQueue()` | 业务事件通道(连接层 → 分发层,单向);`EventEmitter` 默认自建,需多处共享时手动传入(`ee.queue` 取实例) |
|
|
||||||
| `Config` | frozen dataclass,环境变量一次性读齐(见下文) |
|
|
||||||
| `Connecter` | 连接适配器抽象基类(ABC),按 `CONNECTER` 自动选择实现 |
|
|
||||||
| `BaseProtocol` | 协议处理器抽象基类(`protocol.py`),自定义接入方式时继承并实现 `on_frame(payload)` |
|
|
||||||
|
|
||||||
子包按需导入:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from qqbotsdk.api import BotApi, ApiError
|
|
||||||
from qqbotsdk.payloads import GroupAtMessage, EVENT_TYPES, parse_event
|
|
||||||
from qqbotsdk.events import Event
|
|
||||||
```
|
|
||||||
|
|
||||||
## EventEmitter
|
|
||||||
|
|
||||||
```python
|
|
||||||
ee = EventEmitter()
|
|
||||||
|
|
||||||
@ee.on("GROUP_AT_MESSAGE_CREATE") # 业务事件用 t 名(仅 op=0 会分发到这里)
|
|
||||||
async def handler(...): ...
|
|
||||||
|
|
||||||
@ee.on("GROUP_AT_MESSAGE_CREATE", background=True) # 后台并发执行,不阻塞事件流
|
|
||||||
async def slow(...): ...
|
|
||||||
```
|
|
||||||
|
|
||||||
- **只分发业务事件**:`EventEmitter` 只处理 op=0(DISPATCH)事件,按 payload 的 `t` 名路由。协议帧(HELLO/READY/INVALID_SESSION/op=13 等)由各接入方式的协议处理器(`WebsocketProtocol`/`WebhookProtocol`)在连接器内就地处理,不进事件队列、也不会触达你的 handler。
|
|
||||||
- **注册与顺序**:同一函数对同一事件重复注册只生效一次;handler 按注册顺序串行执行(`background=True` 的除外)。
|
|
||||||
- **异常隔离**:handler 异常就地记录(loguru),不影响同事件其他 handler 与主循环。
|
|
||||||
- **后台并发**:`background=True` 的 handler 进后台任务,emit 不等它;同一事件内失去先后保证,仅用于业务 handler。`run_loop` 退出时统一取消在飞后台任务(`ee.close()`)。
|
|
||||||
- 业务 handler 的返回值不回流;回复消息请在 handler 里显式调 `BotApi`。
|
|
||||||
|
|
||||||
### handler 参数注入约定
|
|
||||||
|
|
||||||
按参数类型标注解析,可任意混用:
|
|
||||||
|
|
||||||
| 标注 | 得到 | 未命中时 |
|
|
||||||
|---|---|---|
|
|
||||||
| payload dataclass(`payloads` 中的类型) | `Event.typed` 解析对象 | 回退传原始 d |
|
|
||||||
| `Event` | 事件封装对象 | — |
|
|
||||||
| 组件类型(`BotApi`/`Session`/`Config` 等已登记进 `ee.services` 的类型) | 登记的实例 | 回退传原始 d |
|
|
||||||
| 无标注 | 原始 d(业务事件即事件 dict;协议事件可能是标量,如 `INVALID_SESSION` 的 `resumable: bool`) | — |
|
|
||||||
|
|
||||||
## Event
|
|
||||||
|
|
||||||
`from qqbotsdk.events import Event`,业务事件推送信封(op/t/d/id)的轻量封装,只封装信封语义、不做字段名映射;事件体字段请标注对应 payload dataclass(见下文)获取解析结果:
|
|
||||||
|
|
||||||
| 属性 | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| `.raw` | 完整线上 payload(含 op/t/d) |
|
|
||||||
| `.data` | `d` 的 dict 拷贝 |
|
|
||||||
| `.typed` | `d` 的 dataclass 解析结果(未知事件类型为 dict) |
|
|
||||||
| `.type` | 事件 t 名 |
|
|
||||||
| `.event_id` | 推送信封顶层的事件 id(与 op/t/d 平级,形如 `INTERACTION_CREATE:uuid`);非消息事件(`INTERACTION_CREATE`/`GROUP_ADD_ROBOT` 等)的被动回复凭据只有这里能拿到,事件体里的裸 UUID 不行(官方点名的常见坑);消息事件的被动回复凭据是事件体的 `d.id`(payload 的 `.id` 字段,即 msg_id),不用这里 |
|
|
||||||
|
|
||||||
## Config
|
|
||||||
|
|
||||||
`Config.load()` 从环境变量一次性读齐(frozen,启动后不变):
|
|
||||||
|
|
||||||
| 字段 | 环境变量 | 默认 | 说明 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `app_id` | `APPID` | 必填 | 缺失启动即 ValueError |
|
|
||||||
| `app_secret` | `APPSECRET` | 必填 | 同上 |
|
|
||||||
| `connecter` | `CONNECTER` | `websocket` | 仅允许 `websocket`/`webhook` |
|
|
||||||
| `base_url` | `BASE_URL` | `https://api.bot.qq.com` | 官方 API 根地址 |
|
|
||||||
| `timeout` | `TIMEOUT` | `5000`(毫秒) | 转为 aiohttp `ClientTimeout(total=…)` |
|
|
||||||
| `intents_file` | `INTENTS_FILE` | `intents.toml` | 事件订阅配置路径 |
|
|
||||||
| `webhook_host` | `WEBHOOK_HOST` | `0.0.0.0` | webhook 监听地址 |
|
|
||||||
| `webhook_port` | `WEBHOOK_PORT` | `8080` | webhook 监听端口 |
|
|
||||||
| `webhook_path` | `WEBHOOK_PATH` | `/qqbot/webhook` | webhook 回调路径 |
|
|
||||||
|
|
||||||
## BotApi
|
|
||||||
|
|
||||||
`from qqbotsdk.api import BotApi`。经参数注入(`api: BotApi`)。自动附带 `Authorization: QQBot <token>` 鉴权头并刷新 token;HTTP ≥400 抛 `ApiError`。
|
|
||||||
|
|
||||||
实现按域拆在 `qqbotsdk/api/` 包内(`core` 请求内核 + `v2`/`channel`/`guild`/`panel`/`group`/`audio`/`forum` 方法集组合成 `BotApi`),方法名与下表一致;429 限流自动重试(最多 3 次,优先 `Retry-After`,退避封顶 30s),401 强制刷新 token 后重试一次。
|
|
||||||
|
|
||||||
### 通用
|
|
||||||
|
|
||||||
| 方法 | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| `request(method, path, **kwargs)` | 通用请求,kwargs 透传 aiohttp(`json=`/`params=` 等) |
|
|
||||||
| `get` / `post` / `delete` / `put` / `patch`(path, **kwargs) | `request` 快捷方式 |
|
|
||||||
| `me()` | 机器人自身信息(GET /users/@me) |
|
|
||||||
| `generate_url_link(callback_data=None)` | 生成机器人分享链接(POST /v2/generate_url_link),返回 `{"data": {"url": …}}`;用户经链接添加时 `callback_data`(≤32 字符)透传给开发者 |
|
|
||||||
| `respond_interaction(interaction_id, code=0)` | 回应互动事件(PUT /interactions/{id});按钮点击(INTERACTION_CREATE type=11)收到后必须回应,否则客户端持续 loading,同一 id 仅可回应一次 |
|
|
||||||
|
|
||||||
常量:`MSG_TYPE_TEXT=0`、`MSG_TYPE_MARKDOWN=2`、`MSG_TYPE_MEDIA=7`;`FILE_TYPE_IMAGE=1`、`FILE_TYPE_VIDEO=2`、`FILE_TYPE_VOICE=3`、`FILE_TYPE_FILE=4`;流式消息 `STREAM_INPUT_APPEND`/`STREAM_INPUT_REPLACE`、`STREAM_GENERATING=1`/`STREAM_FINISHED=10`、`STREAM_CONTENT_TEXT`/`STREAM_CONTENT_MARKDOWN`;音频 `AUDIO_STATUS_START=0`/`PAUSE=1`/`RESUME=2`/`STOP=3`;论坛 `FORUM_FORMAT_TEXT=1`/`HTML=2`/`MARKDOWN=3`/`JSON=4`。
|
|
||||||
|
|
||||||
按钮构造:`button(label, data, *, type=1, permission=2, style=1)` 生成单个按钮 dict,`keyboard(*rows)` 组装为 `keyboard=` 参数(单按钮独占一行,按钮列表同行并排)。点击回调经 `INTERACTION_CREATE` 事件处理:`Interaction.button_data` 取被点按钮的 `data`,`Interaction.id` 用于 `respond_interaction`,**被动回复的 `event_id` 用 `Event.event_id`(网关帧最外层 id),不能用 `Interaction.id`**。
|
|
||||||
|
|
||||||
### v2 群聊(群 openid)
|
|
||||||
|
|
||||||
| 方法 | 端点 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `post_group_message(group_openid, content="", msg_id=None, msg_seq=1, media=None, event_id=None, markdown=None, keyboard=None, **extra)` | POST /v2/groups/{group_openid}/messages | `msg_id`/`event_id` 二选一即被动回复(`event_id` 支持 GROUP_ADD_ROBOT / INTERACTION_CREATE 等事件);`media` 传上传返回的 `{"file_info": …}`(自动 msg_type=7);`markdown` 为字符串或完整 dict(自动 msg_type=2,与 content 互斥);`keyboard` 为内嵌键盘 `{"content": {"rows": [...]}}` 或 `{"id": …}`;其余字段经 `extra` 透传 |
|
|
||||||
| `upload_group_file(group_openid, file_type, url, srv_send_msg=False)` | POST /v2/groups/{group_openid}/files | 富媒体上传,返回含 `file_info`;`srv_send_msg=True` 上传即发送 |
|
|
||||||
| `withdraw_group_message(group_openid, message_id)` | DELETE /v2/groups/{group_openid}/messages/{message_id} | 发出 2 分钟内可撤回 |
|
|
||||||
| `mute_group_member(group_openid, mute_expire_at)` | POST /v2/groups/{group_openid}/restrict_chat_setting | `mute_expire_at` 为 RFC3339 到期时间;传早于当前的时间即解禁 |
|
|
||||||
| `prepare_group_upload(group_openid, file_type, file_size, file_name, md5, sha1, md5_10m)` | POST /v2/groups/{group_openid}/upload_prepare | 大文件分片预上传,返回 `upload_id`/`block_size`/各分片预签名 `parts`;逐片 PUT 后调 `finish_group_upload_part`,全部完成后携 `upload_id` 调 `upload_group_file` 合并;`md5_10m` 为文件前约 10MB 的 MD5 |
|
|
||||||
| `finish_group_upload_part(group_openid, upload_id, part_index, block_size=None, md5=None)` | POST …/upload_part_finish | 每个分片 PUT 成功后调用确认 |
|
|
||||||
|
|
||||||
### v2 单聊(用户 openid)
|
|
||||||
|
|
||||||
| 方法 | 端点 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `post_c2c_message(openid, content="", msg_id=None, msg_seq=1, media=None, event_id=None, markdown=None, keyboard=None, **extra)` | POST /v2/users/{openid}/messages | 同 `post_group_message` |
|
|
||||||
| `upload_c2c_file(openid, file_type, url, srv_send_msg=False)` | POST /v2/users/{openid}/files | 同 `upload_group_file` |
|
|
||||||
| `withdraw_c2c_message(openid, message_id)` | DELETE /v2/users/{openid}/messages/{message_id} | 2 分钟内可撤回 |
|
|
||||||
| `post_c2c_stream_message(openid, content_raw="", *, stream_msg_id=None, index=0, input_state=STREAM_GENERATING, input_mode=None, content_type=STREAM_CONTENT_MARKDOWN, msg_id=None, event_id=None, msg_seq=None, is_wakeup=None)` | POST /v2/users/{openid}/stream_messages | 流式分批发送(AI 回复逐段下发):首片不传 `stream_msg_id`,响应 `id` 即后续分片要携带的值,`index` 从 0 递增,以 `input_state=STREAM_FINISHED` 收尾;`input_mode="replace"` 时 `content_raw` 须以上游已下发前缀开头;`is_wakeup=True` 为召回消息,不校验被动回复有效期 |
|
|
||||||
| `prepare_c2c_upload(openid, …)` / `finish_c2c_upload_part(openid, …)` | POST /v2/users/{openid}/upload_prepare · upload_part_finish | 单聊分片上传,用法同群聊 `prepare_group_upload`/`finish_group_upload_part` |
|
|
||||||
|
|
||||||
### 群管理(群 openid)
|
|
||||||
|
|
||||||
多数接口要求机器人拥有群管理员身份;成员/黑名单/批量移除等能力官方标注"内邀接入中",未开通时服务端返回业务码 11253。常量:`APPROVE`/`DECLINE`(审批动作)、`OP_ADD`/`OP_DEL`(黑名单/白名单操作)。
|
|
||||||
|
|
||||||
| 方法 | 端点 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `get_group_info(group_openid)` | GET /v2/groups/{group_openid}/info | 群名称/简介/分类/标签/成员数 |
|
|
||||||
| `get_group_members(group_openid, cursor=None)` | GET …/members | 成员列表,每页最多 30 条,`next_cursor` 翻页(空串=末页) |
|
|
||||||
| `get_group_member(group_openid, member_openid)` | GET …/members/{member_openid} | 成员昵称/角色(member/owner/admin)/入群时间 |
|
|
||||||
| `get_group_bot_state(group_openid)` | GET …/bot_state | 机器人自身状态:入群时间、是否允许主动推送、群内消息接收设置、角色 |
|
|
||||||
| `remove_group_members(group_openid, member_openids, add_to_member_blacklist=False)` | POST …/batch_remove_members | 批量移除,单次最多 20 个,可选拉黑 |
|
|
||||||
| `get_group_join_requests(group_openid, cursor=None, limit=None)` | GET …/join_request_list | 入群申请列表(含验证问答),需群管理员;limit 默认 20 最大 50 |
|
|
||||||
| `review_group_join_request(group_openid, member_openid, op, join_request_id=None, reject_reason=None, add_to_member_blacklist=None)` | POST …/approval_join_request/{member_openid} | `op`:`APPROVE`/`DECLINE`;拒绝时可附理由并拉黑 |
|
|
||||||
| `get_group_mute_state(group_openid)` | GET …/restrict_chat_setting | 禁言状态:全员禁言规则(定时/周期)与被禁言成员列表 |
|
|
||||||
| `get_group_blacklist(group_openid, cursor=None, limit=None)` | GET …/member_blacklist | 黑名单列表,limit 默认 20 最大 100 |
|
|
||||||
| `update_group_blacklist(group_openid, op, member_openids)` | POST …/member_blacklist | `op`:`OP_ADD`/`OP_DEL`,单次最多 20 个;成员在群中时无法拉黑 |
|
|
||||||
| `get_join_approval_strategies(cursor=None, limit=None)` | GET /v2/groups/join_approval_strategy | 自动审批策略列表(机器人维度全局配置),按创建时间倒序 |
|
|
||||||
| `create_join_approval_strategy(*, group_openids=None, group_ids=None, is_enable=None, expire_at=None, remark=None)` | POST /v2/groups/join_approval_strategy | 关联群两种标识二选一(互斥,最多 100 个);`is_enable` "on"/"off";默认一年过期;仅机器人有群管理员身份时生效;返回 `strategy_id` |
|
|
||||||
| `update_join_approval_strategy(strategy_id, *, is_enable=None, expire_at=None, group_action=None, remark=None)` | PATCH …/{strategy_id} | 只传变更项;`group_action` 为 `{"op": "add"/"del", "group_openids": […]}`,群标识形式须与创建时一致 |
|
|
||||||
| `delete_join_approval_strategy(strategy_id)` | DELETE …/{strategy_id} | 删除策略 |
|
|
||||||
| `execute_join_approval_strategy(strategy_id)` | POST …/{strategy_id}/execute | 全量扫描关联群并自动通过白名单申请;异步约 10 分钟 |
|
|
||||||
| `update_join_approval_strategy_whitelist(strategy_id, op, whitelist_users)` | POST …/{strategy_id}/whitelist_users | QQ 号码字符串列表,单次最多 10000、上限 10 万 |
|
|
||||||
|
|
||||||
### 频道消息(channel_id / guild_id)
|
|
||||||
|
|
||||||
| 方法 | 端点 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `post_channel_message(channel_id, content="", msg_id=None, msg_seq=1, image=None, **extra)` | POST /channels/{channel_id}/messages | `image` 为图片 URL;embed/ark/markdown 等经 `extra` 透传 |
|
|
||||||
| `withdraw_channel_message(channel_id, message_id, hide_tip=False)` | DELETE /channels/{channel_id}/messages/{message_id} | **仅私域机器人**;`hide_tip` 对应官方 `hidetip` |
|
|
||||||
| `put_channel_reaction(channel_id, message_id, emoji_type, emoji_id)` | PUT …/reactions/{type}/{emoji_id} | `emoji_type=1` 系统表情,`emoji_id` 为序号字符串 |
|
|
||||||
| `delete_channel_reaction(channel_id, message_id, emoji_type, emoji_id)` | DELETE …/reactions/{type}/{emoji_id} | 删除自己的表态 |
|
|
||||||
| `create_dms(recipient_id, source_guild_id)` | POST /users/@me/dms | 创建私信会话(需与用户同频道),返回私信的 `guild_id`/`channel_id` |
|
|
||||||
| `post_dms_message(guild_id, content="", msg_id=None, msg_seq=1, image=None, **extra)` | POST /dms/{guild_id}/messages | 发私信,参数同 `post_channel_message`;主动消息每天单用户 2 条、累计 200 条,被动不限 |
|
|
||||||
| `withdraw_dms_message(guild_id, message_id, hide_tip=False)` | DELETE /dms/{guild_id}/messages/{message_id} | 撤回自己发的私信,**仅私域** |
|
|
||||||
| `get_channel_permissions(channel_id, user_id)` / `get_channel_role_permissions(channel_id, role_id)` | GET …/members/{user_id}/permissions · …/roles/{role_id}/permissions | 子频道权限位图(字符串) |
|
|
||||||
| `update_channel_permissions(channel_id, user_id, *, add=None, remove=None)` / `update_channel_role_permissions(channel_id, role_id, …)` | PUT 同路径 | `add`/`remove` 为字符串权限位图,至少传一个,需管理员权限 |
|
|
||||||
|
|
||||||
### 频道管理
|
|
||||||
|
|
||||||
| 方法 | 端点 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `create_guild_announce(guild_id, channel_id, message_id, announces_type=0, **extra)` | POST /guilds/{guild_id}/announces | `announces_type`:0 成员公告 / 1 欢迎公告;推荐频道公告经 extra(与消息公告互斥) |
|
|
||||||
| `create_channel(guild_id, name, **fields)` | POST /guilds/{guild_id}/channels | 创建子频道,**私域**需管理员权限;`fields` 透传 type/sub_type/position/parent_id/private_type/speak_permission 等;触发 CHANNEL_CREATE |
|
|
||||||
| `update_channel(channel_id, **fields)` | PATCH /channels/{channel_id} | 修改子频道,**私域**;只传变更项;触发 CHANNEL_UPDATE |
|
|
||||||
| `delete_channel(channel_id)` | DELETE /channels/{channel_id} | 删除子频道,**私域**;不可恢复;触发 CHANNEL_DELETE |
|
|
||||||
| `delete_guild_announce(guild_id, message_id)` | DELETE /guilds/{guild_id}/announces/{message_id} | 推荐频道公告传 `message_id="all"` |
|
|
||||||
| `pin_channel_message(channel_id, message_id)` | PUT /channels/{channel_id}/pins/{message_id} | 添加精华消息 |
|
|
||||||
| `unpin_channel_message(channel_id, message_id)` | DELETE /channels/{channel_id}/pins/{message_id} | 移出精华消息 |
|
|
||||||
| `get_channel_pins(channel_id)` | GET /channels/{channel_id}/pins | 精华消息列表 |
|
|
||||||
| `get_channel_schedules(channel_id, since=None)` | GET /channels/{channel_id}/schedules | 默认当天日程;`since` 毫秒时间戳 |
|
|
||||||
| `get_channel_schedule(channel_id, schedule_id)` | GET /channels/{channel_id}/schedules/{schedule_id} | 单个日程 |
|
|
||||||
| `create_channel_schedule(channel_id, name, start_timestamp, end_timestamp, jump_channel_id, remind_type="0", description="")` | POST /channels/{channel_id}/schedules | 时间为毫秒时间戳字符串;`remind_type` "0" 不提醒 / "1" 开始时;请求体自动包在 `schedule` 字段;单管理员每天限 10 次 |
|
|
||||||
| `update_channel_schedule(channel_id, schedule_id, **fields)` | PATCH /channels/{channel_id}/schedules/{schedule_id} | `fields` 为 schedule 内字段透传 |
|
|
||||||
| `delete_channel_schedule(channel_id, schedule_id)` | DELETE /channels/{channel_id}/schedules/{schedule_id} | 删除日程 |
|
|
||||||
| `create_guild_role(guild_id, name="", color=None, hoist=None)` | POST /guilds/{guild_id}/roles | 创建身份组,返回 `{"role_id", "role"}`;`color` 为 ARGB 十进制 |
|
|
||||||
| `update_guild_role(guild_id, role_id, name=None, color=None, hoist=None)` | PATCH /guilds/{guild_id}/roles/{role_id} | 只传变更项;系统默认身份组不可改 |
|
|
||||||
| `delete_guild_role(guild_id, role_id)` | DELETE /guilds/{guild_id}/roles/{role_id} | 只能删除自建身份组 |
|
|
||||||
| `add_guild_member_role(guild_id, user_id, role_id)` / `remove_guild_member_role(…)` | PUT / DELETE /guilds/{guild_id}/members/{user_id}/roles/{role_id} | 授予/收回成员身份组 |
|
|
||||||
| `remove_guild_member(guild_id, user_id, add_blacklist=False, delete_history_msg_days=0)` | DELETE /guilds/{guild_id}/members/{user_id} | 踢人,**私域**需踢人权限;撤回消息仅支持 3/7/15/30 或 -1 全部 |
|
|
||||||
| `mute_guild(guild_id, mute_end_timestamp=None, mute_seconds=None)` | PATCH /guilds/{guild_id}/mute | 全员禁言,两个时间字段二选一;解除都传 "0" |
|
|
||||||
| `mute_guild_members(guild_id, user_ids, …)` | PATCH /guilds/{guild_id}/mute | 批量禁言(带 `user_ids`),单次最多 20 人 |
|
|
||||||
| `create_api_permission_demand(guild_id, channel_id, path, method, desc)` | POST /guilds/{guild_id}/api_permission/demand | 生成接口权限授权链接发到子频道,由频道管理员授权 |
|
|
||||||
| `mute_guild_member(guild_id, user_id, mute_seconds=0)` | PATCH /guilds/{guild_id}/members/{user_id}/mute | 禁言秒数(上限 28 天),0 解禁 |
|
|
||||||
|
|
||||||
### 频道查询
|
|
||||||
|
|
||||||
| 方法 | 端点 |
|
|
||||||
|---|---|
|
|
||||||
| `get_guild(guild_id)` | GET /guilds/{guild_id} |
|
|
||||||
| `get_me_guilds(before=None, after=None, limit=None)` | GET /users/@me/guilds |
|
|
||||||
| `get_guild_channels(guild_id)` | GET /channels/{guild_id}/channels |
|
|
||||||
| `get_guild_members(guild_id)` | GET /guilds/{guild_id}/members |
|
|
||||||
| `get_role_members(guild_id, role_id)` | GET /guilds/{guild_id}/roles/{role_id}/members |
|
|
||||||
| `get_channel_online_nums(channel_id)` | GET /channels/{channel_id}/online_nums |
|
|
||||||
| `get_guild_message_setting(guild_id)` | GET /guilds/{guild_id}/message/setting |
|
|
||||||
| `get_guild_api_permissions(guild_id)` | GET /guilds/{guild_id}/api_permission |
|
|
||||||
| `get_channel(channel_id)` | GET /channels/{channel_id} |
|
|
||||||
| `get_guild_roles(guild_id)` | GET /guilds/{guild_id}/roles |
|
|
||||||
| `get_guild_member(guild_id, user_id)` | GET /guilds/{guild_id}/members/{user_id} |
|
|
||||||
|
|
||||||
### 指令面板(斜线指令)与全局菜单
|
|
||||||
|
|
||||||
斜线指令面板即用户在聊天输入框输入 `/` 唤起的指令列表,支持 c2c(单聊)、group(群聊)、channel(文字子频道)、dm(频道私信)四种场景;c2c/group 支持 `target_type="specific"` 按指定对象生效(关联对象每次最多 20 个,详情最多查 1000 个),channel/dm 仅全局生效。一个机器人最多 20 个面板,每面板最多 20 个元素(`name` ≤14 字符、`desc` ≤30 字符)。常量:`SCOPE_C2C`/`SCOPE_GROUP`/`SCOPE_CHANNEL`/`SCOPE_DM`、`TARGET_ALL`/`TARGET_SPECIFIC`、`ITEM_COMMAND`/`ITEM_LINK`、`TARGET_OP_ADD`/`TARGET_OP_DEL`。
|
|
||||||
|
|
||||||
| 方法 | 端点 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `create_panel(scope, panel, *, target_type=None, user_openids=None, group_openids=None, **extra)` | POST /v2/panels | 返回 `{"panel_id": …}`;`panel` 为 `{"items": [{"type": "command"/"link", "name", "desc", "only_admin", "link"}], "remark"}`,command 类型点击后 `name` 填入输入框,link 类型点击跳转 |
|
|
||||||
| `get_panels(scope, cursor=None, limit=None)` | GET /v2/panels | 分页列表(按设置时间倒序),返回 `records`/`next_cursor`(空串=末页)/`is_end`;`limit` 默认 20、最大 50 |
|
|
||||||
| `get_panel(panel_id)` | GET /v2/panels/{panel_id} | 面板详情,specific 面板另带关联的 `user_openids`/`group_openids` |
|
|
||||||
| `update_panel(panel_id, panel)` | PUT /v2/panels/{panel_id} | 整体覆盖元素与备注,不影响已关联对象;返回 `{"version": …}` |
|
|
||||||
| `delete_panel(panel_id)` | DELETE /v2/panels/{panel_id} | 删除面板,删除后对所有对象失效 |
|
|
||||||
| `update_panel_targets(panel_id, op, user_openids=None, group_openids=None)` | PUT /v2/panels/{panel_id}/target | `op`:`"add"`/`"del"`;`user_openids` 仅 c2c、`group_openids` 仅 group;channel/dm 与全局面板不支持 |
|
|
||||||
| `get_menu()` | GET /v2/menu | 查询全局自定义菜单(仅 C2C、对所有用户生效),返回 `{"version", "menu"}`;未设置过时 `menu` 为空 |
|
|
||||||
| `update_menu(menu=None)` | PUT /v2/menu | 整体覆盖菜单(≤10 项,子菜单 ≤5 且不可再嵌套);`send_message` 类型点击后把文本(如 `/help`)填入输入框,常与斜线指令配合 |
|
|
||||||
|
|
||||||
### 音频与论坛(私域)
|
|
||||||
|
|
||||||
音频接口仅音频类机器人可用(需联系平台开通);论坛仅论坛子频道,发帖/删帖为异步任务(返回 `task_id`,结果经 FORUM_* 事件推送)。
|
|
||||||
|
|
||||||
| 方法 | 端点 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `control_channel_audio(channel_id, audio_url=None, text=None, status=AUDIO_STATUS_START)` | POST /channels/{channel_id}/audio | 播放控制(START/PAUSE/RESUME/STOP);`audio_url`/`text` 仅 START 时传 |
|
|
||||||
| `put_channel_mic(channel_id)` / `delete_channel_mic(channel_id)` | PUT / DELETE /channels/{channel_id}/mic | 机器人上麦 / 下麦 |
|
|
||||||
| `get_channel_threads(channel_id)` | GET /channels/{channel_id}/threads | 帖子列表 `{"threads", "is_finish"}` |
|
|
||||||
| `get_channel_thread(channel_id, thread_id)` | GET …/threads/{thread_id} | 帖子详情 |
|
|
||||||
| `put_channel_thread(channel_id, title, content, format=FORUM_FORMAT_TEXT)` | PUT /channels/{channel_id}/threads | 发帖,`format` 为 FORUM_FORMAT_* 常量;返回 `task_id` |
|
|
||||||
| `delete_channel_thread(channel_id, thread_id)` | DELETE …/threads/{thread_id} | 删帖,返回 `task_id` |
|
|
||||||
|
|
||||||
### ApiError
|
|
||||||
|
|
||||||
`ApiError(RuntimeError)`,重试后仍失败(HTTP ≥400)时抛出:
|
|
||||||
|
|
||||||
| 属性 | 说明 |
|
|
||||||
|---|---|
|
|
||||||
| `.status` | HTTP 状态码 |
|
|
||||||
| `.code` | 官方业务 code(响应体中的 `code`) |
|
|
||||||
| `.message` | 官方错误描述 |
|
|
||||||
|
|
||||||
鉴权阶段(换取 access_token)的失败抛 `TokenError`(`from qqbotsdk.token import TokenError`),与 `ApiError` 同构(`RuntimeError` 子类,同样携带 `.status`/`.code`/`.message`)——appid/secret 配置错误在这里暴露 QQ 返回的真实原因。
|
|
||||||
|
|
||||||
## 事件 payload
|
|
||||||
|
|
||||||
`qqbotsdk.payloads` 定义事件 `d` 的 dataclass。handler 标注类型即得解析对象;未知事件类型回退原始 dict。所有字段均可为 None(线上缺省即 None),嵌套 `author` 自动解析为 `Author`。
|
|
||||||
|
|
||||||
### 事件名 → dataclass 对照
|
|
||||||
|
|
||||||
下表左列是 intents.toml 的订阅分组(订阅粒度即分组,组内事件无法单独开关),中列是同时属于该分组、会被一起推送的事件 `t` 名:
|
|
||||||
|
|
||||||
| intents 分组 | 事件 t 名 | dataclass |
|
|
||||||
|---|---|---|
|
|
||||||
| GROUP_AND_C2C_EVENT | `C2C_MESSAGE_CREATE` | `C2CMessage` |
|
|
||||||
| | `GROUP_AT_MESSAGE_CREATE` / `GROUP_MESSAGE_CREATE` | `GroupAtMessage` |
|
|
||||||
| | `FRIEND_ADD` / `FRIEND_DEL` | `FriendAdd` / `FriendDel` |
|
|
||||||
| | `GROUP_ADD_ROBOT` / `GROUP_DEL_ROBOT` | `GroupAddRobot` / `GroupDelRobot` |
|
|
||||||
| | `C2C_MSG_REJECT` / `C2C_MSG_RECEIVE` | `C2CMsgReject` / `C2CMsgReceive` |
|
|
||||||
| | `GROUP_MSG_REJECT` / `GROUP_MSG_RECEIVE` | `GroupMsgReject` / `GroupMsgReceive` |
|
|
||||||
| | `SUBSCRIBE_MESSAGE_STATUS` | `SubscribeMessageStatus` |
|
|
||||||
| GROUP_MEMBER_EVENT | `GROUP_MEMBER_ADD` / `GROUP_MEMBER_REMOVE` | `GroupMemberChange` |
|
|
||||||
| | `GROUP_JOIN_REQUEST`(仅机器人是群管理员时推送) | `GroupJoinRequest` |
|
|
||||||
| PUBLIC_GUILD_MESSAGES | `AT_MESSAGE_CREATE` | `AtMessage` |
|
|
||||||
| | `PUBLIC_MESSAGE_DELETE` | `MessageDelete` |
|
|
||||||
| GUILD_MESSAGES(私域) | `MESSAGE_DELETE` | `MessageDelete` |
|
|
||||||
| DIRECT_MESSAGE | `DIRECT_MESSAGE_CREATE` | `DirectMessage` |
|
|
||||||
| GUILDS | `GUILD_CREATE` / `GUILD_UPDATE` / `GUILD_DELETE` | `Guild` |
|
|
||||||
| | `CHANNEL_CREATE` / `CHANNEL_UPDATE` / `CHANNEL_DELETE` | `Channel` |
|
|
||||||
| GUILD_MESSAGE_REACTIONS | `MESSAGE_REACTION_ADD` / `…_REMOVE` | `MessageReaction` |
|
|
||||||
| MESSAGE_AUDIT | `MESSAGE_AUDIT_PASS` / `…_REJECT` | `MessageAudit` |
|
|
||||||
| INTERACTION | `INTERACTION_CREATE` | `Interaction` |
|
|
||||||
| FORUMS_EVENT(私域) | `FORUM_THREAD_CREATE` / `…_UPDATE` / `…_DELETE` | `ForumThread` |
|
|
||||||
| | `FORUM_POST_CREATE` / `…_DELETE` | `ForumPost` |
|
|
||||||
| | `FORUM_REPLY_CREATE` / `…_DELETE` | `ForumReply` |
|
|
||||||
| AUDIO_ACTION | `AUDIO_START` / `AUDIO_FINISH` / `AUDIO_ON_MIC` / `AUDIO_OFF_MIC` | `AudioAction` |
|
|
||||||
| —(DISPATCH 协议事件) | `READY` | `Ready` |
|
|
||||||
|
|
||||||
未收录的事件(如私域 `MESSAGE_CREATE`、`DIRECT_MESSAGE_DELETE`、`GUILD_MEMBER_*`)handler 收到原始 dict;新增事件在 `payloads.py` 加 dataclass 并登记 `EVENT_TYPES` 即可。
|
|
||||||
|
|
||||||
`GROUP_MESSAGE_CREATE` 是群消息**全量模式**:需在开放平台开通"接收所有消息"权限,群里每条消息(不限 @机器人)都推送此事件,与 `GROUP_AT_MESSAGE_CREATE` 共用 `GroupAtMessage`。官方提示同一 msg_id 可能重复推送,业务侧需按 `id` 去重。
|
|
||||||
|
|
||||||
### 常用 dataclass 字段
|
|
||||||
|
|
||||||
```python
|
|
||||||
Author # id, user_openid, member_openid, username, bot
|
|
||||||
GroupAtMessage # id, content, group_openid, author, timestamp,
|
|
||||||
# message_type, message_scene, attachments, mentions, ark_data, msg_elements
|
|
||||||
C2CMessage # id, content, author, attachments, timestamp
|
|
||||||
AtMessage # id, content, channel_id, guild_id, author, member, timestamp
|
|
||||||
DirectMessage # 同 AtMessage
|
|
||||||
GroupJoinRequest # group_openid, join_request_id, member_openid, username, risk_tips,
|
|
||||||
# apply_at, apply_source, invited_by, verify_info, auto_approved, …
|
|
||||||
GroupMemberChange # timestamp, group_openid, member_openid, user_openid
|
|
||||||
```
|
|
||||||
|
|
||||||
- `GroupAtMessage.user_openid` / `C2CMessage.user_openid`:便利属性,从 `author.member_openid`/`author.user_openid` 取发言人标识。
|
|
||||||
- 群/单聊场景拿不到用户真实 QQ 号,只有 openid;频道场景 `Author.id`/`username` 有效。
|
|
||||||
- 其余事件 dataclass 为 2~5 个平铺字段的同构结构(openid/时间戳/信息 dict),字段名即官方文档字段。
|
|
||||||
|
|
||||||
`parse_event(t, d)`:把事件 `d` 解析为对应 dataclass(`Event.typed` 的内部实现),`EVENT_TYPES` 为事件名 → 类的完整登记表。
|
|
||||||
|
|
||||||
## 内部模块索引
|
|
||||||
|
|
||||||
维护者视角的分层与装配见 [ARCHITECTURE.md](ARCHITECTURE.md):
|
|
||||||
|
|
||||||
| 模块 | 职责 |
|
|
||||||
|---|---|
|
|
||||||
| `connecter.py` | `WebsocketConnecter` / `WebhookConnecter`:接入归一为业务事件流入、出站帧流出 |
|
|
||||||
| `protocol.py` | `BaseProtocol` 协议处理器接口(`on_frame`,由连接适配器逐帧调用) |
|
|
||||||
| `ws_protocol.py` | IDENTIFY/RESUME 鉴权、会话语义与 `s` 序列号(心跳在 connecter 内) |
|
|
||||||
| `webhook_protocol.py` | op=13 验证应答 |
|
|
||||||
| `queue.py` / `session.py` / `token.py` / `crypto.py` | 业务事件通道、ws 会话状态、token 缓存、Ed25519 签名 |
|
|
||||||
| `model.py` | Opcode、payload 类型、Intent 位掩码 |
|
|
||||||
| `__init__.py` | `main`/`run_loop` 入口与组件显式装配 |
|
|
||||||
@@ -98,11 +98,11 @@ sequenceDiagram
|
|||||||
|
|
||||||
## 生命周期与依赖装配
|
## 生命周期与依赖装配
|
||||||
|
|
||||||
- 全部组件在 `run_loop`(`__init__.py`)显式构造装配,无 DI 框架,也无任何全局单例。
|
- 用户入口是 `Bot` 门面(`bot.py`):`bot = Bot()` 构造即读 .env 校验配置,`@bot.on` 注册 handler,`run()` 同步启动 / `start()` 嵌入外部 asyncio 应用;装配细节不外露。
|
||||||
- 组件按类型登记进 `emitter.services`,handler 形参标注类型即得实例;用户自定义组件同样登记即可注入。
|
- 全部组件在 `Bot.start()` → `_assemble()` 显式构造装配,无 DI 框架,也无任何全局单例。
|
||||||
- 唯一的 `ClientSession` 在 `run_loop` 构造,`finally` 中先 `emitter.close()` 收尾后台 handler、再统一释放连接池(全 SDK 共享一个连接池)。
|
- 组件按类型登记进 `emitter.services`(含 `Bot` 自身,handler 标注 `bot: Bot` 即得实例),handler 形参标注类型即得实例;用户自定义组件经 `bot.services` 登记即可注入。
|
||||||
- 接入方式的选择收敛在 `run_loop` 的 `match config.connecter` 装配分支:每种接入方式构造**自己的协议处理器**并注入对应的 connecter(主体不再持有协议层);`WebhookConnecter` 经协议处理器的 `on_frame` 就地取得 op=13 应答,不感知 `EventEmitter`。两个 connecter 的依赖面一致(config + queue + 各自所需的窄依赖 + `BaseProtocol`)。
|
- 唯一的 `ClientSession` 在 `_assemble` 构造,`start()` 的 `finally` 中先 `emitter.close()` 收尾后台 handler、再统一释放连接池(全 SDK 共享一个连接池)。
|
||||||
- 用户在 `main(ee)` 之前构造的 `EventEmitter` 会被复用(连同它的队列),保证 handler 注册在主体实际使用的实例上。
|
- 接入方式的选择收敛在 `_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 响应体。
|
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 续传。
|
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()` 统一取消。
|
4. **handler 异常就地隔离**:`_invoke` 捕获记日志,`dispatch` 主循环另有兜底;单条事件的单个 handler 失败不影响进程。`background=True` 的 handler 进后台任务(emit 不等它),任务由 emitter 持强引用、`close()` 统一取消。
|
||||||
5. **注入约定**:handler 参数按形参标注解析——`payloads.py` 里的 dataclass → 解析后的 payload 对象;`Event` → 事件封装;登记进 `emitter.services` 的组件类型(如 `BotApi`)→ 实例;未标注或未登记 → 回退传**原始 d**。解析是纯同步查表,无框架、无运行时魔法:每个 handler 的 `get_type_hints` + 形参名只解析一次并缓存,但 `services` 命中在每条事件时实时判定,保证"先注册 handler、后登记组件"的装配顺序可用。
|
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` 的除外)。
|
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 请求都要验签)。都是"值为输入的纯函数、结果与输入一一对应",缓存安全且收益明确;不追求极致速度,只去掉明显的重复计算。
|
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` 上。
|
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)。
|
- **新增业务 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 的全部事件组(消息、成员变动、审核、表态、互动、论坛、音频)。
|
- **新增事件类型 dataclass**:在 `payloads.py` 定义 dataclass 并登记 `EVENT_TYPES`,handler 即可按类型标注拿解析结果;未收录事件回退传 dict。当前已覆盖 intents.toml 的全部事件组(消息、成员变动、审核、表态、互动、论坛、音频)。
|
||||||
|
|
||||||
|
|||||||
-243
@@ -1,243 +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.13。
|
|
||||||
|
|
||||||
## 配置
|
|
||||||
|
|
||||||
在项目根目录放 `.env`(SDK 启动时自动加载,`APPID`/`APPSECRET` 缺失启动即报错):
|
|
||||||
|
|
||||||
```ini
|
|
||||||
APPID=你的AppID
|
|
||||||
APPSECRET=你的AppSecret
|
|
||||||
CONNECTER=websocket # websocket 或 webhook,默认 websocket
|
|
||||||
|
|
||||||
BASE_URL=https://api.bot.qq.com # 可选,官方 API 地址
|
|
||||||
TIMEOUT=5000 # 可选,HTTP 请求超时(毫秒)
|
|
||||||
INTENTS_FILE=intents.toml # 可选,事件订阅配置文件路径
|
|
||||||
|
|
||||||
WEBHOOK_HOST=0.0.0.0 # 以下仅 webhook 接入时生效
|
|
||||||
WEBHOOK_PORT=8080
|
|
||||||
WEBHOOK_PATH=/qqbot/webhook
|
|
||||||
```
|
|
||||||
|
|
||||||
## 事件订阅(intents.toml)
|
|
||||||
|
|
||||||
SDK 启动时读取 `intents.toml` 计算订阅掩码。订阅粒度就是下面表格里的大类(Intents 分组),把某组设为 `true` 即订阅该组下的全部事件:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
GROUP_AND_C2C_EVENT = true # 群/单聊消息与关注事件
|
|
||||||
INTERACTION = true # 互动事件(卡片按钮回调)
|
|
||||||
GUILDS = false # 频道/子频道增删改
|
|
||||||
```
|
|
||||||
|
|
||||||
QQ 网关只认分组位掩码,**组内单个事件无法单独开关**——能收到哪些事件由订阅了哪些分组决定(各组包含哪些事件见 [API.md](API.md#事件名--dataclass-对照))。分组与特权要求:
|
|
||||||
|
|
||||||
| 分组 | 位掩码 | 说明 |
|
|
||||||
|---|---|---|
|
|
||||||
| `GUILDS` | 1 << 0 | 频道/子频道增删改 |
|
|
||||||
| `GUILD_MEMBERS` | 1 << 1 | 成员增删改 |
|
|
||||||
| `GUILD_MESSAGES` | 1 << 9 | 频道全量消息,**仅私域机器人** |
|
|
||||||
| `GUILD_MESSAGE_REACTIONS` | 1 << 10 | 频道表情表态 |
|
|
||||||
| `DIRECT_MESSAGE` | 1 << 12 | 频道私信 |
|
|
||||||
| `GROUP_AND_C2C_EVENT` | 1 << 25 | 群/单聊消息与关注事件(群机器人核心) |
|
|
||||||
| `INTERACTION` | 1 << 26 | 互动事件 |
|
|
||||||
| `MESSAGE_AUDIT` | 1 << 27 | 消息审核结果 |
|
|
||||||
| `FORUMS_EVENT` | 1 << 28 | 论坛事件,**仅私域机器人** |
|
|
||||||
| `AUDIO_ACTION` | 1 << 29 | 音频播放/上麦下麦 |
|
|
||||||
| `PUBLIC_GUILD_MESSAGES` | 1 << 30 | 频道公域消息(AT_MESSAGE_CREATE) |
|
|
||||||
|
|
||||||
分组名即 `Intent` 枚举成员名。配置只接受 `分组名 = true/false`;拼错的分组名、非布尔值,以及旧版"表内逐事件开关"的写法都会在启动时直接抛 `ValueError`(与其静默少订阅,不如启动即报错)。文件缺失则告警并按不订阅任何事件启动。
|
|
||||||
|
|
||||||
## 接入方式选择
|
|
||||||
|
|
||||||
| | WebSocket | Webhook |
|
|
||||||
|---|---|---|
|
|
||||||
| 网络要求 | 能访问外网即可,本地开发友好 | 需公网可达 + HTTPS,仅支持 80/443/8080/8443 端口 |
|
|
||||||
| 配置 | 无额外配置 | 平台回调地址填 `https://<域名>:<端口><WEBHOOK_PATH>` |
|
|
||||||
| 连接维护 | SDK 自动心跳、断线指数退避重连并 RESUME 续传;服务端约 1 小时会断开连接,SDK 会在临到期前(约 50 分钟)主动重连续传 | SDK 处理 Ed25519 验签、op=13 验证应答、60s TTL 事件去重 |
|
|
||||||
| 适用 | 长驻进程、需要会话与重连保障 | 无状态部署、回调型服务 |
|
|
||||||
|
|
||||||
两种方式下 handler 的写法完全一致,切换只需改 `CONNECTER`。
|
|
||||||
|
|
||||||
## 编写 handler
|
|
||||||
|
|
||||||
用装饰器把 handler 注册到 `EventEmitter`,事件名即线上 payload 的 `t` 名(如 `GROUP_AT_MESSAGE_CREATE`)。三种写法可混用:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from qqbotsdk import EventEmitter
|
|
||||||
|
|
||||||
ee = EventEmitter()
|
|
||||||
|
|
||||||
# 方式一:裸参数,d 为原始事件 dict
|
|
||||||
@ee.on("GROUP_AT_MESSAGE_CREATE")
|
|
||||||
async def raw(d):
|
|
||||||
print(d)
|
|
||||||
|
|
||||||
# 方式二:标注 payload dataclass,拿到解析后的对象
|
|
||||||
from qqbotsdk.payloads import GroupAtMessage
|
|
||||||
|
|
||||||
@ee.on("GROUP_AT_MESSAGE_CREATE")
|
|
||||||
async def typed(msg: GroupAtMessage):
|
|
||||||
print(msg.group_openid, msg.user_openid, msg.content)
|
|
||||||
|
|
||||||
# 方式三:参数注入,标注组件类型即拿到实例(payload 与组件可任意组合)
|
|
||||||
from qqbotsdk.api import BotApi
|
|
||||||
from qqbotsdk.payloads import GroupAtMessage
|
|
||||||
|
|
||||||
@ee.on("GROUP_AT_MESSAGE_CREATE")
|
|
||||||
async def di(msg: GroupAtMessage, api: BotApi):
|
|
||||||
await api.post_group_message(msg.group_openid, content="收到", msg_id=msg.id)
|
|
||||||
```
|
|
||||||
|
|
||||||
handler 参数按标注解析的完整规则:
|
|
||||||
|
|
||||||
| 参数标注 | 得到 |
|
|
||||||
|---|---|
|
|
||||||
| payload dataclass(如 `GroupAtMessage`) | 解析后的 dataclass 对象;未收录事件回退原始 dict |
|
|
||||||
| `Event` | 事件封装对象,只含信封语义(`.raw`/`.data`/`.typed`/`.type`/`.event_id`);事件体字段请标注 payload dataclass 获取 |
|
|
||||||
| 组件类型(`BotApi`/`Session`/`Config` 等) | `emitter.services` 中按类型登记的实例;未登记回退传原始 d |
|
|
||||||
| 无标注 | 原始 d(即事件 dict) |
|
|
||||||
|
|
||||||
注意:
|
|
||||||
|
|
||||||
- 只有**业务事件(op=0)**会分发给你的 handler;协议帧(HELLO/READY/INVALID_SESSION/op=13)由 SDK 的协议处理器就地处理,不触达 handler(`Ready` 是例外,它既是握手结果也是可监听的业务事件)。
|
|
||||||
- 业务事件处理器的**返回值不会回流**,回复消息请在 handler 里显式调 API。
|
|
||||||
- 单个 handler 抛异常只记录日志,不影响同事件其他 handler 和主循环。
|
|
||||||
- 所在分组未订阅(intents.toml 中为 `false`)的事件不会到达 handler。
|
|
||||||
- 自定义组件也能注入:`ee.services[MyService] = MyService(...)` 登记后,handler 形参标注 `MyService` 即可拿到实例。
|
|
||||||
|
|
||||||
### 后台并发 handler
|
|
||||||
|
|
||||||
默认同一事件的所有 handler 串行执行,某个慢 handler 会延迟后续事件。调用密集、不关心先后顺序的 handler 可加 `background=True` 丢进后台任务,事件流不再等它:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@ee.on("GROUP_AT_MESSAGE_CREATE", background=True)
|
|
||||||
async def slow_work(msg: GroupAtMessage, api: BotApi):
|
|
||||||
... # 耗时处理,不阻塞其他事件的分发
|
|
||||||
```
|
|
||||||
|
|
||||||
代价是同一事件内 handler 间失去先后保证,且返回值不回流(本就只对协议事件回流),因此仅用于业务 handler。进程退出时 `run_loop` 会统一取消在飞的后台任务。
|
|
||||||
|
|
||||||
## 发消息
|
|
||||||
|
|
||||||
### 被动回复(最常用)
|
|
||||||
|
|
||||||
携带 `msg_id`(回复某条消息)或 `event_id`(响应某类事件,二选一)即为被动回复,**不需要任何消息频率白名单**。两个 id 来源不同:`msg_id` 是消息事件体里的 `d.id`;`event_id` 是**网关推送最外层**的 id(`event.event_id`,形如 `INTERACTION_CREATE:uuid`),不是事件体里的裸 UUID:
|
|
||||||
|
|
||||||
- 群聊:收到消息后 **5 分钟**内可回复,同一 `msg_id` 最多 **5 次**;
|
|
||||||
- 单聊:**60 分钟**内可回复,同一 `msg_id` 最多 **4 次**;
|
|
||||||
- 同一条消息多次回复必须递增 `msg_seq`(相同 `msg_id`+`msg_seq` 会发送失败);
|
|
||||||
- 被动回复同样受内容审核。
|
|
||||||
|
|
||||||
```python
|
|
||||||
await api.post_group_message(
|
|
||||||
group_openid, content="你好", msg_id=msg.id, msg_seq=1,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 主动消息
|
|
||||||
|
|
||||||
不带 `msg_id`/`event_id` 即主动消息,有额外频控(群/单聊每日每对象 1000 条),且默认关闭,需在开放平台申请。用户可在资料卡关闭主动推送(对应 `C2C_MSG_REJECT`/`GROUP_MSG_REJECT` 事件,关闭后发送会失败)。
|
|
||||||
|
|
||||||
### 富媒体(图片/视频/语音/文件)
|
|
||||||
|
|
||||||
先上传拿 `file_info`,再作为 `media` 发送(此时 SDK 自动把 `msg_type` 置为 7,`content` 作媒体下方的文字):
|
|
||||||
|
|
||||||
```python
|
|
||||||
upload = await api.upload_group_file(
|
|
||||||
group_openid, file_type=1, url="https://example.com/pic.png",
|
|
||||||
)
|
|
||||||
await api.post_group_message(
|
|
||||||
group_openid, content="看图", msg_id=msg.id, media=upload,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
`file_type`:1 图片、2 视频、3 语音、4 文件。也可在 `upload_*_file` 传 `srv_send_msg=True` 让上传即发送。
|
|
||||||
|
|
||||||
### Markdown 等其他类型
|
|
||||||
|
|
||||||
`post_group_message`/`post_c2c_message` 支持 `**extra` 透传官方 body 字段,例如 markdown(会覆盖默认的 `msg_type`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
await api.post_group_message(group_openid, content="# 标题\n正文",
|
|
||||||
msg_id=msg.id, msg_type=2)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Markdown 与按钮(msg_type=2)
|
|
||||||
|
|
||||||
`button()`/`keyboard()`(`from qqbotsdk.api import button, keyboard`)构造内嵌按钮,配合 `markdown=` 一起发(msg_type 自动置 2):
|
|
||||||
|
|
||||||
```python
|
|
||||||
await api.post_group_message(
|
|
||||||
group_openid,
|
|
||||||
markdown="## 标题\n**加粗**正文", # 与 content 互斥
|
|
||||||
keyboard=keyboard(
|
|
||||||
button("点我", data="我的按钮数据"), # 单按钮独占一行
|
|
||||||
[button("赞", data="like"), button("踩", data="dislike")], # 列表同行并排
|
|
||||||
),
|
|
||||||
msg_id=msg.id,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
- `button(label, data, *, type=1, permission=2, style=1)`:`type` 1 回调(点击推 `INTERACTION_CREATE`)、0 跳转链接(data 为 url)、2 指令(往输入框插入 data);`permission` 2 所有人 / 1 管理员 / 0 指定用户;`label` 官方限 10 字以内;要补嵌套字段(如 `visited_label`、`prompt`)直接修改返回的 dict。
|
|
||||||
|
|
||||||
**处理按钮点击**:点击回调推 `INTERACTION_CREATE` 事件,`inter.button_data` 即被点按钮的 `data`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@ee.on("INTERACTION_CREATE")
|
|
||||||
async def on_button(inter: Interaction, event: Event, api: BotApi):
|
|
||||||
# 必须应答,否则用户端按钮一直 loading(inter.id 是事件体里的互动 id)
|
|
||||||
await api.respond_interaction(inter.id)
|
|
||||||
await api.post_group_message(
|
|
||||||
inter.group_openid, content=f"你点了:{inter.button_data}",
|
|
||||||
event_id=event.event_id,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
- **`event_id` 要取网关帧最外层的 id(`event.event_id`,形如 `INTERACTION_CREATE:uuid`),不能用事件体里的裸 UUID `inter.id`**——`inter.id` 只用于 `respond_interaction`,这是官方文档点名的常见坑;
|
|
||||||
- 需要在 intents.toml 开启 `INTERACTION` 分组;
|
|
||||||
- Markdown 有平台权限要求,无权限时发送会抛 `ApiError`(业务 code 304036/40034127),按需回退纯文本。
|
|
||||||
|
|
||||||
### 撤回
|
|
||||||
|
|
||||||
- 群/单聊(v2):发出 **2 分钟**内可撤回(`withdraw_group_message`/`withdraw_c2c_message`);
|
|
||||||
- 频道:仅私域机器人可撤回(`withdraw_channel_message`,`hide_tip=True` 隐藏撤回小灰条)。
|
|
||||||
|
|
||||||
## 运行与部署
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run python main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
- **WebSocket**:进程常驻即可;SDK 自动处理鉴权(IDENTIFY,断线自动 RESUME)、心跳与重连。
|
|
||||||
- **Webhook**:平台回调地址要求公网 HTTPS,本地调试建议用反向代理或内网穿透;开放平台会先发 op=13 验证请求,SDK 自动完成签名应答。
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
- **启动后频繁重连 / READY 收不到**:QQ 网关接口有频率限制(code 100017),连续重启需间隔约 1 分钟;SDK 已内置退避,等即可。
|
|
||||||
- **`ApiError`**:HTTP ≥400 时抛出,`e.status`/`e.code`/`e.message` 可用于排查(如被动回复超时、msg_seq 重复、内容审核不通过)。
|
|
||||||
- **日志出现 `TokenError: 获取 access_token 失败`**:appid/secret 配置错误或被平台拒绝,`code`/`message` 就是 QQ 返回的真实原因,对照官方错误码排查即可。
|
|
||||||
- **handler 没被触发**:检查 intents.toml 中该事件所属分组是否为 `true`;群/单聊事件需开通对应能力;频道消息事件区分公私域。
|
|
||||||
- **想收原始数据**:handler 裸参数或标 `Event`,`event.raw` 是完整线上 payload(含 op/t/d)。
|
|
||||||
- **日志**:SDK 用 loguru 输出中文日志,连接、重连、API 失败、handler 异常均有记录,可直接用 loguru 配置格式与落盘。
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# 音频与论坛 API(私域)
|
||||||
|
|
||||||
|
音频接口仅音频类机器人可用(需联系平台开通);论坛仅论坛子频道,发帖/删帖为异步任务(返回 `task_id`,结果经 FORUM_* 事件推送,见[事件 payload](/docs/api/events.md))。
|
||||||
|
|
||||||
|
常量:`AUDIO_STATUS_START=0`/`PAUSE=1`/`RESUME=2`/`STOP=3`;`FORUM_FORMAT_TEXT=1`/`HTML=2`/`MARKDOWN=3`/`JSON=4`。
|
||||||
|
|
||||||
|
| 方法 | 端点 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `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` |
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# 核心框架 API
|
||||||
|
|
||||||
|
`Bot` 门面、分发层(`EventEmitter`)、事件封装(`Event`)与错误体系。
|
||||||
|
上手流程见[快速开始](/docs/guide/start.md),分层与装配见[架构设计](/docs/ARCHITECTURE.md)。
|
||||||
|
|
||||||
|
## 顶层导出
|
||||||
|
|
||||||
|
```python
|
||||||
|
from qqbotsdk import (
|
||||||
|
Bot, # 门面:on/run/start/call_api/api/services
|
||||||
|
EventEmitter, EventQueue, # 分发层
|
||||||
|
Intent, # 订阅分组位掩码(IntFlag,可 OR 出掩码传 Bot(intents=…))
|
||||||
|
BaseProtocol, # 协议处理器接口(自定义接入方式时继承)
|
||||||
|
Config, Connecter, # 配置与连接器接口
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 名称 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `Bot(config=None)` | 门面类,见下节 |
|
||||||
|
| `EventEmitter(queue=None)` | 分发主体,`Bot` 内部持有;`queue` 不传自动创建 |
|
||||||
|
| `EventQueue()` | 业务事件通道(连接层 → 分发层,单向);需多处共享时手动传入(`bot.queue` 取实例) |
|
||||||
|
| `Config` | frozen dataclass,环境变量一次性读齐(字段表见[配置与订阅](/docs/guide/config.md#env-配置)) |
|
||||||
|
| `Connecter` | 连接适配器接口(`typing.Protocol`),按 `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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bot
|
||||||
|
|
||||||
|
```python
|
||||||
|
bot = Bot() # 构造即读 .env 校验配置(fail fast)
|
||||||
|
|
||||||
|
@bot.on("GROUP_AT_MESSAGE_CREATE") # 注册 handler,语义与 EventEmitter.on 一致
|
||||||
|
async def handler(msg, api): ...
|
||||||
|
|
||||||
|
bot.run() # 同步入口:阻塞运行,Ctrl+C 优雅退出
|
||||||
|
```
|
||||||
|
|
||||||
|
| 成员 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `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.run()` | 同步入口:`asyncio.run(start())`,`KeyboardInterrupt` 记日志退出 |
|
||||||
|
| `await bot.start()` | 异步入口:装配组件并常驻运行,供嵌入外部 asyncio 应用 |
|
||||||
|
| `bot.call_api(method, path, **kwargs)` | 通用 REST 入口:带鉴权头、429 退避重试、401 刷新 token 重试;kwargs 透传 aiohttp(`json=`/`params=` 等) |
|
||||||
|
| `bot.api` | 类型化 API 门面(`BotApi`,97 个方法),启动(run/start)后可用,之前抛 `RuntimeError` |
|
||||||
|
| `bot.services` | `dict[type, Any]` 组件登记表;SDK 启动登记 8 项内置组件,也可登记自定义类型(见[事件处理](/docs/guide/handlers.md#自定义组件注入)) |
|
||||||
|
| `bot.queue` | 事件队列实例 |
|
||||||
|
| `bot.config` | `Config` 实例 |
|
||||||
|
|
||||||
|
## EventEmitter
|
||||||
|
|
||||||
|
`Bot` 内部的分发主体(`bot._emitter`),独立导入用于测试或自定义装配:
|
||||||
|
|
||||||
|
```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。`bot.run()` 退出时统一取消在飞后台任务。
|
||||||
|
- 业务 handler 的返回值不回流;回复消息请在 handler 里显式调 `BotApi`。
|
||||||
|
|
||||||
|
handler 参数注入约定表见[事件处理](/docs/guide/handlers.md)。
|
||||||
|
|
||||||
|
## Event
|
||||||
|
|
||||||
|
`from qqbotsdk.events import Event`,业务事件推送信封(op/t/d/id)的轻量封装,只封装信封语义、不做字段名映射;事件体字段请标注对应 payload dataclass(见[事件 payload](/docs/api/events.md))获取解析结果:
|
||||||
|
|
||||||
|
| 属性 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `.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),不用这里 |
|
||||||
|
|
||||||
|
## 错误体系
|
||||||
|
|
||||||
|
`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 返回的真实原因。
|
||||||
|
|
||||||
|
`BotApi` 的请求内核(`api/core.py`):自动附带 `Authorization: QQBot <token>` 鉴权头并刷新 token;429 限流自动重试(最多 3 次,优先 `Retry-After`,退避封顶 30s);401 强制刷新 token 后重试一次。`bot.call_api` 与全部类型化方法共享该内核。
|
||||||
|
|
||||||
|
## 内部模块索引
|
||||||
|
|
||||||
|
维护者视角的分层与装配见[架构设计](/docs/ARCHITECTURE.md):
|
||||||
|
|
||||||
|
| 模块 | 职责 |
|
||||||
|
|---|---|
|
||||||
|
| `bot.py` | `Bot` 门面:组件装配、`run`/`start` 生命周期 |
|
||||||
|
| `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 位掩码 |
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# 事件 payload
|
||||||
|
|
||||||
|
`qqbotsdk.payloads` 定义事件 `d` 的 dataclass。handler 标注类型即得解析对象;未知事件类型回退原始 dict。所有字段均可为 None(线上缺省即 None),嵌套 `author` 自动解析为 `Author`。事件对照表只在本文。
|
||||||
|
|
||||||
|
## 事件名 → dataclass 对照
|
||||||
|
|
||||||
|
下表左列是 intents.toml 的订阅分组(订阅粒度即分组,组内事件无法单独开关,见[配置与订阅](/docs/guide/config.md#事件订阅intentstoml)),中列是同时属于该分组、会被一起推送的事件 `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` 为事件名 → 类的完整登记表。
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 群管理 API
|
||||||
|
|
||||||
|
`from qqbotsdk.api import BotApi`。多数接口要求机器人拥有群管理员身份;成员/黑名单/批量移除等能力官方标注"内邀接入中",未开通时服务端返回业务码 11253。
|
||||||
|
|
||||||
|
入群申请的审批结果也会以事件推送(`GROUP_JOIN_REQUEST` / `GROUP_MEMBER_ADD` 等,见[事件 payload](/docs/api/events.md))。
|
||||||
|
|
||||||
|
常量:`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 万 |
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# 频道 API
|
||||||
|
|
||||||
|
`from qqbotsdk.api import BotApi`。频道(guild)/子频道(channel)场景的旧版接口,按消息、管理、查询三组列出。标"私域"的接口仅频道私域机器人可用。
|
||||||
|
|
||||||
|
频道消息的被动回复规则与群聊不同:公域机器人发消息必须带 `msg_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} |
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# 指令面板与菜单 API
|
||||||
|
|
||||||
|
斜线指令面板即用户在聊天输入框输入 `/` 唤起的指令列表,支持 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`)填入输入框,常与斜线指令配合 |
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 群聊与单聊 API(v2)
|
||||||
|
|
||||||
|
`from qqbotsdk.api import BotApi`。经 handler 参数注入(`api: BotApi`)或 `bot.api` 使用;未封装的接口走 `bot.call_api(method, path, **kwargs)`。
|
||||||
|
|
||||||
|
消息规则(被动回复时限、msg_seq、富媒体流程)见[收发消息](/docs/guide/messaging.md)。
|
||||||
|
|
||||||
|
## 群聊(群 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 成功后调用确认 |
|
||||||
|
|
||||||
|
## 单聊(用户 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 | 流式分批发送,见下节 |
|
||||||
|
| `prepare_c2c_upload(openid, …)` / `finish_c2c_upload_part(openid, …)` | POST /v2/users/{openid}/upload_prepare · upload_part_finish | 单聊分片上传,用法同群聊 |
|
||||||
|
|
||||||
|
## 流式消息
|
||||||
|
|
||||||
|
把一条回复拆成多片依次下发(LLM 逐段输出的标准玩法):
|
||||||
|
|
||||||
|
- 首片不传 `stream_msg_id`,响应 `id` 即后续分片要携带的值;`index` 从 0 递增;
|
||||||
|
- 以 `input_state=STREAM_FINISHED` 收尾;
|
||||||
|
- `input_mode="replace"` 时 `content_raw` 须以上游已下发前缀开头;
|
||||||
|
- `is_wakeup=True` 为召回消息,不校验被动回复有效期。
|
||||||
|
|
||||||
|
## 通用方法与常量
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `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});按钮点击收到后必须回应,否则客户端持续 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`。
|
||||||
|
|
||||||
|
按钮构造:`button(label, data, *, type=1, permission=2, style=1)` 生成单个按钮 dict,`keyboard(*rows)` 组装为 `keyboard=` 参数(单按钮独占一行,按钮列表同行并排)。点击回调经 `INTERACTION_CREATE` 事件处理,见[收发消息](/docs/guide/messaging.md#markdown-与按钮msg_type2)。
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# 配置与订阅
|
||||||
|
|
||||||
|
`.env` 环境变量、`intents.toml` 事件订阅与接入方式选择。完整字段参考只在本文。
|
||||||
|
|
||||||
|
## .env 配置
|
||||||
|
|
||||||
|
SDK 启动时自动加载项目根目录的 `.env`,`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 # 可选,事件订阅配置文件路径
|
||||||
|
INTENTS= # 可选,订阅掩码直传(十进制或 0x 十六进制),优先于 intents.toml
|
||||||
|
|
||||||
|
WEBHOOK_HOST=0.0.0.0 # 以下仅 webhook 接入时生效
|
||||||
|
WEBHOOK_PORT=8080
|
||||||
|
WEBHOOK_PATH=/qqbot/webhook
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 环境变量 | 默认 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `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` | 事件订阅配置路径 |
|
||||||
|
| `intents` | `INTENTS` | 空 | 订阅掩码直传,见[掩码直传](#掩码直传) |
|
||||||
|
| `webhook_host` | `WEBHOOK_HOST` | `0.0.0.0` | webhook 监听地址 |
|
||||||
|
| `webhook_port` | `WEBHOOK_PORT` | `8080` | webhook 监听端口 |
|
||||||
|
| `webhook_path` | `WEBHOOK_PATH` | `/qqbot/webhook` | webhook 回调路径 |
|
||||||
|
|
||||||
|
`Config` 是 frozen dataclass,`Bot()` 构造时一次性读齐;handler 可标注 `config: Config` 注入。
|
||||||
|
|
||||||
|
## 事件订阅(intents.toml)
|
||||||
|
|
||||||
|
SDK 启动时读取 `intents.toml` 计算订阅掩码。订阅粒度就是下面表格里的大类(Intents 分组),把某组设为 `true` 即订阅该组下的全部事件:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
GROUP_AND_C2C_EVENT = true # 群/单聊消息与关注事件
|
||||||
|
INTERACTION = true # 互动事件(卡片按钮回调)
|
||||||
|
GUILDS = false # 频道/子频道增删改
|
||||||
|
```
|
||||||
|
|
||||||
|
QQ 网关只认分组位掩码,**组内单个事件无法单独开关**——能收到哪些事件由订阅了哪些分组决定(各组包含哪些事件见[事件 payload 对照](/docs/api/events.md))。分组与特权要求:
|
||||||
|
|
||||||
|
| 分组 | 位掩码 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `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`(与其静默少订阅,不如启动即报错)。文件缺失则告警并按不订阅任何事件启动。
|
||||||
|
|
||||||
|
## 掩码直传
|
||||||
|
|
||||||
|
不想用配置文件的,可以直接传订阅掩码,三种入口按优先级取先命中者:
|
||||||
|
|
||||||
|
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 |
|
||||||
|
|---|---|---|
|
||||||
|
| 网络要求 | 能访问外网即可,本地开发友好 | 需公网可达 + HTTPS,仅支持 80/443/8080/8443 端口 |
|
||||||
|
| 配置 | 无额外配置 | 平台回调地址填 `https://<域名>:<端口><WEBHOOK_PATH>` |
|
||||||
|
| 连接维护 | SDK 自动心跳、断线指数退避重连并 RESUME 续传;服务端约 1 小时会断开连接,SDK 会在临到期前(约 50 分钟)主动重连续传 | SDK 处理 Ed25519 验签、op=13 验证应答、60s TTL 事件去重 |
|
||||||
|
| 适用 | 长驻进程、需要会话与重连保障 | 无状态部署、回调型服务 |
|
||||||
|
|
||||||
|
两种方式下 handler 的写法完全一致,切换只需改 `CONNECTER`。
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# 事件处理
|
||||||
|
|
||||||
|
handler 的注册、参数注入约定与后台并发。注入约定表只在本文。
|
||||||
|
|
||||||
|
## 注册
|
||||||
|
|
||||||
|
用装饰器把 handler 注册到 `Bot`,事件名即线上 payload 的 `t` 名(如 `GROUP_AT_MESSAGE_CREATE`)。参数按类型标注注入,可任意混用:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from qqbotsdk import Bot
|
||||||
|
from qqbotsdk.api import BotApi
|
||||||
|
from qqbotsdk.events import Event
|
||||||
|
from qqbotsdk.payloads import GroupAtMessage, Interaction
|
||||||
|
|
||||||
|
bot = Bot()
|
||||||
|
|
||||||
|
|
||||||
|
# 标注 payload dataclass,拿到解析后的对象
|
||||||
|
@bot.on("GROUP_AT_MESSAGE_CREATE")
|
||||||
|
async def typed(msg: GroupAtMessage):
|
||||||
|
print(msg.group_openid, msg.user_openid, msg.content)
|
||||||
|
|
||||||
|
|
||||||
|
# payload 与组件任意组合;Event 拿完整信封(如按钮回调要 event_id)
|
||||||
|
@bot.on("INTERACTION_CREATE")
|
||||||
|
async def full(inter: Interaction, event: Event, api: BotApi):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
handler 参数按标注解析的完整规则:
|
||||||
|
|
||||||
|
| 参数标注 | 得到 |
|
||||||
|
|---|---|
|
||||||
|
| payload dataclass(如 `GroupAtMessage`,全部类型见[事件 payload](/docs/api/events.md)) | 解析后的 dataclass 对象 |
|
||||||
|
| `Event` | 事件封装对象,只含信封语义(`.raw`/`.data`/`.typed`/`.type`/`.event_id`);事件体字段请标注 payload dataclass 获取 |
|
||||||
|
| 组件类型(`BotApi`/`Bot`/`Session`/`Config` 等已登记进 `bot.services` 的类型) | 按类型登记的实例 |
|
||||||
|
|
||||||
|
无标注或标注以上三者皆非的形参会在分发时抛 `ValueError`(记日志、按 handler 隔离)——SDK 不注入原始数据:要事件体字段就标注 payload dataclass,要完整信封就标注 `Event`。
|
||||||
|
|
||||||
|
## 注册与分发语义
|
||||||
|
|
||||||
|
- 只有**业务事件(op=0)**会分发给你的 handler;协议帧(HELLO/READY/INVALID_SESSION/op=13)由 SDK 的协议处理器就地处理,不触达 handler(`Ready` 是例外,它既是握手结果也是可监听的业务事件)。
|
||||||
|
- 同一函数对同一事件重复注册只生效一次;handler 按注册顺序串行执行(`background=True` 的除外)。
|
||||||
|
- 业务事件处理器的**返回值不会回流**,回复消息请在 handler 里显式调 API。
|
||||||
|
- 单个 handler 抛异常只记录日志,不影响同事件其他 handler 和主循环。
|
||||||
|
- 所在分组未订阅(intents.toml 中为 `false`)的事件不会到达 handler(见[配置与订阅](/docs/guide/config.md))。
|
||||||
|
|
||||||
|
## 后台并发 handler
|
||||||
|
|
||||||
|
默认同一事件的所有 handler 串行执行,某个慢 handler 会延迟后续事件。调用密集、不关心先后顺序的 handler 可加 `background=True` 丢进后台任务,事件流不再等它:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@bot.on("GROUP_AT_MESSAGE_CREATE", background=True)
|
||||||
|
async def slow_work(msg: GroupAtMessage, api: BotApi):
|
||||||
|
... # 耗时处理,不阻塞其他事件的分发
|
||||||
|
```
|
||||||
|
|
||||||
|
代价是同一事件内 handler 间失去先后保证,因此仅用于业务 handler。`bot.run()` 退出时统一取消在飞的后台任务。
|
||||||
|
|
||||||
|
## 自定义组件注入
|
||||||
|
|
||||||
|
`bot.services` 是 `dict[type, Any]` 组件登记表:SDK 启动时登记内置组件(`Bot`/`EventEmitter`/`EventQueue`/`Config`/`Session`/`ClientSession`/`AccessToken`/`BotApi`),你也可以登记自定义类型:
|
||||||
|
|
||||||
|
```python
|
||||||
|
bot = Bot()
|
||||||
|
bot.services[MyService] = MyService(...) # 任意时机登记(启动前后皆可)
|
||||||
|
|
||||||
|
|
||||||
|
@bot.on("GROUP_AT_MESSAGE_CREATE")
|
||||||
|
async def with_service(msg: GroupAtMessage, svc: MyService):
|
||||||
|
... # 形参标注 MyService 即拿到实例
|
||||||
|
```
|
||||||
|
|
||||||
|
services 命中在每条事件分发时实时判定,"先注册 handler、后登记组件"的顺序可用。
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# 收发消息
|
||||||
|
|
||||||
|
被动回复规则、主动消息、富媒体、Markdown 按钮与撤回。各 API 的完整参数见 [API 参考](/docs/api/v2.md)。
|
||||||
|
|
||||||
|
## 被动回复(最常用)
|
||||||
|
|
||||||
|
携带 `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,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
非消息事件(入群 `GROUP_ADD_ROBOT`、按钮回调 `INTERACTION_CREATE` 等)的被动回复凭据只能取 `event.event_id`(信封顶层 id):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@bot.on("GROUP_ADD_ROBOT")
|
||||||
|
async def on_added(added: GroupAddRobot, event: Event, api: BotApi):
|
||||||
|
await api.post_group_message(
|
||||||
|
added.group_openid, content="大家好~", event_id=event.event_id,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 主动消息
|
||||||
|
|
||||||
|
不带 `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 与按钮(msg_type=2)
|
||||||
|
|
||||||
|
`post_group_message`/`post_c2c_message` 直接支持 `markdown=`(字符串或完整 dict,自动 `msg_type=2`,与 `content` 互斥);`button()`/`keyboard()`(`from qqbotsdk.api import button, keyboard`)构造内嵌按钮:
|
||||||
|
|
||||||
|
```python
|
||||||
|
await api.post_group_message(
|
||||||
|
group_openid,
|
||||||
|
markdown="## 标题\n**加粗**正文",
|
||||||
|
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。
|
||||||
|
- 其他 body 字段可经 `**extra` 透传(如原生 `msg_type=6` 输入中状态)。
|
||||||
|
|
||||||
|
**处理按钮点击**:点击回调推 `INTERACTION_CREATE` 事件,`inter.button_data` 即被点按钮的 `data`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@bot.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`),不能用事件体里的裸 UUID `inter.id`**——`inter.id` 只用于 `respond_interaction`,这是官方文档点名的常见坑;
|
||||||
|
- 需要在 intents.toml 开启 `INTERACTION` 分组;
|
||||||
|
- Markdown 有平台权限要求,无权限时发送会抛 `ApiError`(业务 code 304036/40034127),按需回退纯文本。
|
||||||
|
|
||||||
|
## 流式消息(AI 逐段下发)
|
||||||
|
|
||||||
|
`post_c2c_stream_message` 把回复拆成多片依次下发(适合 LLM 逐段输出):首片不传 `stream_msg_id`,响应里的 `id` 即后续分片要携带的值,`index` 从 0 递增,最后以 `input_state=STREAM_FINISHED` 收尾。参数与常量见 [v2 API](/docs/api/v2.md#流式消息)。
|
||||||
|
|
||||||
|
## 撤回
|
||||||
|
|
||||||
|
- 群/单聊(v2):发出 **2 分钟**内可撤回(`withdraw_group_message`/`withdraw_c2c_message`);
|
||||||
|
- 频道:仅私域机器人可撤回(`withdraw_channel_message`,`hide_tip=True` 隐藏撤回小灰条)。
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 部署与常见问题
|
||||||
|
|
||||||
|
## 启动方式
|
||||||
|
|
||||||
|
普通脚本一个 `bot.run()` 阻塞运行(Ctrl+C 优雅退出):
|
||||||
|
|
||||||
|
```python
|
||||||
|
bot = Bot()
|
||||||
|
# ... @bot.on(...) 注册 handler
|
||||||
|
bot.run()
|
||||||
|
```
|
||||||
|
|
||||||
|
要嵌入自己的 asyncio 应用(如先起自定义组件再跑 SDK),用异步入口:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def amain():
|
||||||
|
async with ClientSession() as http: # 你的前置装配
|
||||||
|
register_handlers(bot, http)
|
||||||
|
await bot.start() # 装配 SDK 并常驻,随外层协程退出
|
||||||
|
```
|
||||||
|
|
||||||
|
- **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 配置格式与落盘。
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# 快速开始
|
||||||
|
|
||||||
|
从零开始接入 QQ 官方机器人:装包、配置、跑起第一个回声机器人。
|
||||||
|
事件处理细节见[事件处理](/docs/guide/handlers.md),收发消息的规则见[收发消息](/docs/guide/messaging.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`(全量消息)与论坛事件,可主动发消息、撤回消息。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install qqbotsdk-py # 或
|
||||||
|
uv add qqbotsdk-py
|
||||||
|
```
|
||||||
|
|
||||||
|
导入名是 `qqbotsdk`(发行包名带 `-py` 后缀是因为 PyPI 上 `qqbotsdk` 与已有项目撞名)。要求 Python ≥ 3.12。
|
||||||
|
|
||||||
|
## 最小可跑示例
|
||||||
|
|
||||||
|
项目根目录放两个文件。
|
||||||
|
|
||||||
|
`.env`(SDK 启动时自动加载,缺失 `APPID`/`APPSECRET` 启动即报错):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
APPID=你的AppID
|
||||||
|
APPSECRET=你的AppSecret
|
||||||
|
```
|
||||||
|
|
||||||
|
`intents.toml`(事件订阅按分组开关,详见[配置与订阅](/docs/guide/config.md)):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
GROUP_AND_C2C_EVENT = true
|
||||||
|
```
|
||||||
|
|
||||||
|
`main.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from qqbotsdk import Bot
|
||||||
|
from qqbotsdk.api import BotApi
|
||||||
|
from qqbotsdk.payloads import GroupAtMessage
|
||||||
|
|
||||||
|
bot = Bot()
|
||||||
|
|
||||||
|
|
||||||
|
# handler 形参按标注注入:payload dataclass 与 BotApi 等组件可任意组合
|
||||||
|
@bot.on("GROUP_AT_MESSAGE_CREATE")
|
||||||
|
async def on_group_message(msg: GroupAtMessage, api: BotApi):
|
||||||
|
# 被动回复带 msg_id(5 分钟内有效,多次回复递增 msg_seq);失败抛 ApiError
|
||||||
|
await api.post_group_message(
|
||||||
|
msg.group_openid,
|
||||||
|
content=f"你说:{msg.content}",
|
||||||
|
msg_id=msg.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
bot.run()
|
||||||
|
```
|
||||||
|
|
||||||
|
运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
把机器人拉进测试群,@它说句话,就会收到回声。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- 两种接入方式(WebSocket / Webhook)怎么选:[配置与订阅](/docs/guide/config.md)
|
||||||
|
- handler 参数注入、后台并发、自定义组件:[事件处理](/docs/guide/handlers.md)
|
||||||
|
- 富媒体、Markdown 按钮、被动回复时限等消息规则:[收发消息](/docs/guide/messaging.md)
|
||||||
|
- 完整示例(按钮卡片、入群欢迎、单聊、webhook):[examples/](https://github.com/snowy-yang/qqbotsdk/tree/main/examples)
|
||||||
+1
-1
@@ -22,4 +22,4 @@ uv run python examples/webhook_bot/main.py
|
|||||||
|
|
||||||
Webhook 示例启动后,把 `https://<你的域名>:<端口>/qqbot/webhook` 填到开放平台回调地址;保存时平台先发 op=13 验证请求,SDK 自动完成签名应答。
|
Webhook 示例启动后,把 `https://<你的域名>:<端口>/qqbot/webhook` 填到开放平台回调地址;保存时平台先发 op=13 验证请求,SDK 自动完成签名应答。
|
||||||
|
|
||||||
接入方式切换与 handler 写法详见 [使用指南](docs/GUIDE.md#接入方式选择)。
|
接入方式切换见[配置与订阅](../docs/guide/config.md#接入方式选择),handler 写法见[事件处理](../docs/guide/handlers.md)。
|
||||||
+28
-12
@@ -10,19 +10,35 @@
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from qqbotsdk import EventEmitter, main
|
from qqbotsdk import Bot
|
||||||
from qqbotsdk.api import BotApi, button, keyboard
|
from qqbotsdk.api import BotApi, button, keyboard
|
||||||
from qqbotsdk.events import Event
|
from qqbotsdk.events import Event
|
||||||
from qqbotsdk.payloads import C2CMessage, GroupAddRobot, GroupAtMessage, Interaction
|
from qqbotsdk.payloads import (
|
||||||
|
C2CMessage,
|
||||||
|
FriendAdd,
|
||||||
|
GroupAddRobot,
|
||||||
|
GroupAtMessage,
|
||||||
|
Interaction,
|
||||||
|
)
|
||||||
|
|
||||||
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:
|
async def on_group_message(msg: GroupAtMessage, api: BotApi, bot: Bot) -> None:
|
||||||
if not (msg.group_openid and msg.id):
|
if not (msg.group_openid and msg.id):
|
||||||
return
|
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)
|
# "/img <图片url>":上传富媒体后随消息发出(msg_type 自动置为 7)
|
||||||
if (msg.content or "").startswith("/img "):
|
if (msg.content or "").startswith("/img "):
|
||||||
url = (msg.content or "").removeprefix("/img ").strip()
|
url = (msg.content or "").removeprefix("/img ").strip()
|
||||||
@@ -58,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:
|
async def on_button_click(inter: Interaction, event: Event, api: BotApi) -> None:
|
||||||
if inter.chat_type != 1 or not inter.group_openid:
|
if inter.chat_type != 1 or not inter.group_openid:
|
||||||
return # 本示例只处理群聊的按钮回调
|
return # 本示例只处理群聊的按钮回调
|
||||||
@@ -73,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:
|
async def on_robot_added(added: GroupAddRobot, event: Event, api: BotApi) -> None:
|
||||||
# 入群是事件而非消息:用信封顶层的事件 id 作 event_id 被动回复(拿不到则跳过)
|
# 入群是事件而非消息:用信封顶层的事件 id 作 event_id 被动回复(拿不到则跳过)
|
||||||
if not (added.group_openid and event.event_id):
|
if not (added.group_openid and event.event_id):
|
||||||
@@ -86,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:
|
async def on_c2c_message(msg: C2CMessage, api: BotApi) -> None:
|
||||||
user_openid = msg.user_openid
|
user_openid = msg.user_openid
|
||||||
if not (user_openid and msg.id):
|
if not (user_openid and msg.id):
|
||||||
@@ -97,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(d: dict) -> None:
|
async def on_friend_add(added: FriendAdd) -> None:
|
||||||
logger.info(f"新增用户:{d.get('user_openid')}")
|
logger.info(f"新增用户:{added.openid}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main(ee)
|
bot.run()
|
||||||
@@ -11,14 +11,14 @@
|
|||||||
运行:uv run python examples/webhook_bot/main.py
|
运行:uv run python examples/webhook_bot/main.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from qqbotsdk import EventEmitter, main
|
from qqbotsdk import Bot
|
||||||
from qqbotsdk.api import BotApi
|
from qqbotsdk.api import BotApi
|
||||||
from qqbotsdk.payloads import GroupAtMessage
|
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:
|
async def on_group_message(msg: GroupAtMessage, api: BotApi) -> None:
|
||||||
if not (msg.group_openid and msg.id):
|
if not (msg.group_openid and msg.id):
|
||||||
return
|
return
|
||||||
@@ -30,4 +30,4 @@ async def on_group_message(msg: GroupAtMessage, api: BotApi) -> None:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main(ee)
|
bot.run()
|
||||||
+12
@@ -28,6 +28,8 @@
|
|||||||
// 相对链接按当前页面所在目录解析(与 GitHub 行为一致),docs/*.md 的互链才能正确跳转
|
// 相对链接按当前页面所在目录解析(与 GitHub 行为一致),docs/*.md 的互链才能正确跳转
|
||||||
relativePath: true,
|
relativePath: true,
|
||||||
loadSidebar: true,
|
loadSidebar: true,
|
||||||
|
// 侧边栏分组("使用指南"/"API 参考")默认展开一级
|
||||||
|
sidebarDisplayLevel: 1,
|
||||||
subMaxLevel: 3,
|
subMaxLevel: 3,
|
||||||
auto2top: true,
|
auto2top: true,
|
||||||
themeColor: '#0f8bff',
|
themeColor: '#0f8bff',
|
||||||
@@ -50,6 +52,14 @@
|
|||||||
alias: {
|
alias: {
|
||||||
'/examples': '/examples/README.md',
|
'/examples': '/examples/README.md',
|
||||||
'/examples/': '/examples/README.md',
|
'/examples/': '/examples/README.md',
|
||||||
|
// 子目录页面的 _sidebar.md 请求映射到根目录的:
|
||||||
|
// docsify 会先请求 <当前目录>/_sidebar.md,而 Cloudflare Pages 对
|
||||||
|
// 缺失文件回退 index.html 且状态 200,docsify 误当作侧栏解析导致目录空白
|
||||||
|
'/docs/_sidebar.md': '/_sidebar.md',
|
||||||
|
'/examples/_sidebar.md': '/_sidebar.md',
|
||||||
|
// 文档模块化重构(GUIDE/API 拆为 guide/ 与 api/ 多页)后的旧链接映射
|
||||||
|
'/docs/GUIDE.md': '/docs/guide/start.md',
|
||||||
|
'/docs/API.md': '/docs/api/core.md',
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
// 把 ```mermaid 代码块替换为 div 后交给 mermaid 渲染(架构图/时序图)
|
// 把 ```mermaid 代码块替换为 div 后交给 mermaid 渲染(架构图/时序图)
|
||||||
@@ -72,6 +82,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/docsify@4/lib/docsify.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/docsify@4/lib/docsify.min.js"></script>
|
||||||
|
<!-- 侧边栏分组折叠(需在 docsify 之后引入) -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/docsify-sidebar-collapse@1.3.5/dist/docsify-sidebar-collapse.min.js"></script>
|
||||||
<!-- prism 语言包需在 docsify 之后引入(docsify 会重建 window.Prism,先加载会被覆盖) -->
|
<!-- prism 语言包需在 docsify 之后引入(docsify 会重建 window.Prism,先加载会被覆盖) -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-python.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-python.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-toml.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-toml.min.js"></script>
|
||||||
|
|||||||
+24
-7
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "qqbotsdk"
|
name = "qqbotsdk-py"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
description = "QQ 官方机器人 Python SDK(WebSocket / Webhook)"
|
description = "QQ 官方机器人 Python SDK(WebSocket / Webhook)"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -8,7 +8,18 @@ readme = "README.md"
|
|||||||
authors = [
|
authors = [
|
||||||
{ name = "snowy-yang", email = "102860110+snowy-yang@users.noreply.github.com" },
|
{ name = "snowy-yang", email = "102860110+snowy-yang@users.noreply.github.com" },
|
||||||
]
|
]
|
||||||
requires-python = ">=3.13"
|
keywords = ["qq", "qqbot", "bot", "sdk", "websocket", "webhook", "机器人"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"Natural Language :: Chinese (Simplified)",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
|
]
|
||||||
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp>=3.14.3",
|
"aiohttp>=3.14.3",
|
||||||
"cryptography>=50.0.1",
|
"cryptography>=50.0.1",
|
||||||
@@ -17,13 +28,19 @@ dependencies = [
|
|||||||
"ujson>=6.0.0",
|
"ujson>=6.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.urls]
|
||||||
qqbotsdk = "qqbotsdk:main"
|
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"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["uv_build>=0.12.7,<0.13.0"]
|
requires = ["uv_build>=0.12.7,<0.13.0"]
|
||||||
build-backend = "uv_build"
|
build-backend = "uv_build"
|
||||||
|
|
||||||
|
[tool.uv.build-backend]
|
||||||
|
module-name = "qqbotsdk"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = ["pyright>=1.1.411", "pytest>=8.3.0", "pytest-asyncio>=0.24.0", "ruff>=0.16.5"]
|
dev = ["pyright>=1.1.411", "pytest>=8.3.0", "pytest-asyncio>=0.24.0", "ruff>=0.16.5"]
|
||||||
|
|
||||||
@@ -33,7 +50,7 @@ testpaths = ["tests"]
|
|||||||
|
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py313"
|
target-version = "py312"
|
||||||
line-length = 88
|
line-length = 88
|
||||||
indent-width = 4
|
indent-width = 4
|
||||||
|
|
||||||
@@ -71,7 +88,7 @@ line-ending = "auto"
|
|||||||
|
|
||||||
[tool.pyright]
|
[tool.pyright]
|
||||||
typeCheckingMode = "standard"
|
typeCheckingMode = "standard"
|
||||||
pythonVersion = "3.13"
|
pythonVersion = "3.12"
|
||||||
include = ["src"]
|
include = ["src"]
|
||||||
exclude = ["**/__pycache__", "**/.*", "**/node_modules", ".venv"]
|
exclude = ["**/__pycache__", "**/.*", "**/node_modules", ".venv"]
|
||||||
reportMissingImports = true
|
reportMissingImports = true
|
||||||
|
|||||||
+25
-67
@@ -1,83 +1,41 @@
|
|||||||
"""qqbotsdk:QQ 官方机器人 SDK 入口。
|
"""qqbotsdk:QQ 官方机器人 SDK 入口。
|
||||||
|
|
||||||
用法:构造 `EventEmitter()` 注册 handler,交给 `main(ee)`
|
用法:`bot = Bot()` → `@bot.on(...)` 注册 handler → `bot.run()` 启动;
|
||||||
启动;连接方式由 .env 的 CONNECTER 决定(websocket / webhook)。
|
连接方式由 .env 的 CONNECTER 决定(websocket / webhook)。
|
||||||
架构详情见 docs/ARCHITECTURE.md。
|
架构详情见 docs/ARCHITECTURE.md。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from aiohttp import ClientSession
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from .api import BotApi
|
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 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
|
from .session import Session as Session
|
||||||
from .token import AccessToken
|
from .token import AccessToken as AccessToken
|
||||||
from .webhook_protocol import WebhookProtocol
|
from .webhook_protocol import WebhookProtocol as WebhookProtocol
|
||||||
from .ws_protocol import WebsocketProtocol
|
from .ws_protocol import WebsocketProtocol as WebsocketProtocol
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
async def run_loop(emitter: EventEmitter | None = None) -> None:
|
"AccessToken",
|
||||||
"""启动 SDK。可传入外部构造的 EventEmitter 以注册 handler;不传时自建。
|
"BaseProtocol",
|
||||||
|
"Bot",
|
||||||
全部组件在此显式装配,并按类型登记到 emitter.services 供 handler
|
"BotApi",
|
||||||
参数注入;共享的 HTTP 连接池随 run_loop 结束释放。
|
"Config",
|
||||||
"""
|
"Connecter",
|
||||||
if emitter is None:
|
"EventEmitter",
|
||||||
emitter = EventEmitter()
|
"EventQueue",
|
||||||
|
"Intent",
|
||||||
config = Config.load()
|
"Session",
|
||||||
http = ClientSession(timeout=config.timeout)
|
"WebhookConnecter",
|
||||||
session = Session()
|
"WebhookProtocol",
|
||||||
token = AccessToken(config, http)
|
"WebsocketConnecter",
|
||||||
api = BotApi(config, http, token)
|
"WebsocketProtocol",
|
||||||
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 已退出")
|
|
||||||
@@ -87,9 +87,7 @@ class ChannelApi(BaseApi):
|
|||||||
"""
|
"""
|
||||||
return await self.get(f"/channels/{channel_id}/members/{user_id}/permissions")
|
return await self.get(f"/channels/{channel_id}/members/{user_id}/permissions")
|
||||||
|
|
||||||
async def get_channel_role_permissions(
|
async def get_channel_role_permissions(self, channel_id: str, role_id: str) -> dict:
|
||||||
self, channel_id: str, role_id: str
|
|
||||||
) -> dict:
|
|
||||||
"""获取子频道指定身份组的权限(GET .../roles/{role_id}/permissions)。"""
|
"""获取子频道指定身份组的权限(GET .../roles/{role_id}/permissions)。"""
|
||||||
return await self.get(f"/channels/{channel_id}/roles/{role_id}/permissions")
|
return await self.get(f"/channels/{channel_id}/roles/{role_id}/permissions")
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ class GroupApi(BaseApi):
|
|||||||
"""
|
"""
|
||||||
return await self.get(f"/v2/groups/{group_openid}/info")
|
return await self.get(f"/v2/groups/{group_openid}/info")
|
||||||
|
|
||||||
async def get_group_members(self, group_openid: str, cursor: str | None = None) -> dict:
|
async def get_group_members(
|
||||||
|
self, group_openid: str, cursor: str | None = None
|
||||||
|
) -> dict:
|
||||||
"""获取群成员列表(GET /v2/groups/{group_openid}/members)。
|
"""获取群成员列表(GET /v2/groups/{group_openid}/members)。
|
||||||
|
|
||||||
每次最多返回 30 条,翻页透传上次响应的 next_cursor(空串=末页)。
|
每次最多返回 30 条,翻页透传上次响应的 next_cursor(空串=末页)。
|
||||||
@@ -44,9 +46,7 @@ class GroupApi(BaseApi):
|
|||||||
|
|
||||||
返回昵称、角色(member/owner/admin)、入群时间等。
|
返回昵称、角色(member/owner/admin)、入群时间等。
|
||||||
"""
|
"""
|
||||||
return await self.get(
|
return await self.get(f"/v2/groups/{group_openid}/members/{member_openid}")
|
||||||
f"/v2/groups/{group_openid}/members/{member_openid}"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_group_bot_state(self, group_openid: str) -> dict:
|
async def get_group_bot_state(self, group_openid: str) -> dict:
|
||||||
"""获取机器人在指定群内的状态(GET .../bot_state)。
|
"""获取机器人在指定群内的状态(GET .../bot_state)。
|
||||||
@@ -247,7 +247,9 @@ class GroupApi(BaseApi):
|
|||||||
对策略关联的全部群发起全量扫描,命中白名单号码的入群申请自动
|
对策略关联的全部群发起全量扫描,命中白名单号码的入群申请自动
|
||||||
审批通过;异步执行,约 10 分钟完成。
|
审批通过;异步执行,约 10 分钟完成。
|
||||||
"""
|
"""
|
||||||
return await self.post(f"/v2/groups/join_approval_strategy/{strategy_id}/execute")
|
return await self.post(
|
||||||
|
f"/v2/groups/join_approval_strategy/{strategy_id}/execute"
|
||||||
|
)
|
||||||
|
|
||||||
async def update_join_approval_strategy_whitelist(
|
async def update_join_approval_strategy_whitelist(
|
||||||
self, strategy_id: str, op: str, whitelist_users: list[str]
|
self, strategy_id: str, op: str, whitelist_users: list[str]
|
||||||
|
|||||||
@@ -247,9 +247,7 @@ class GuildApi(BaseApi):
|
|||||||
|
|
||||||
需要管理员权限(同身份组的成员也可操作)。
|
需要管理员权限(同身份组的成员也可操作)。
|
||||||
"""
|
"""
|
||||||
return await self.put(
|
return await self.put(f"/guilds/{guild_id}/members/{user_id}/roles/{role_id}")
|
||||||
f"/guilds/{guild_id}/members/{user_id}/roles/{role_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def remove_guild_member_role(
|
async def remove_guild_member_role(
|
||||||
self, guild_id: str, user_id: str, role_id: str
|
self, guild_id: str, user_id: str, role_id: str
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Bot 门面:组件装配收进门面,用户侧只接触实例本身。
|
||||||
|
|
||||||
|
`Bot()` 构造即读 .env 校验配置;`@bot.on` 注册 handler(转发
|
||||||
|
EventEmitter,语义不变);`run()` 同步启动,`start()` 供嵌入外部
|
||||||
|
asyncio 应用。连接池/token/API/services 登记与连接器选型全部内聚在
|
||||||
|
start() 的装配里,handler 经形参标注拿所需组件(含 Bot 自身)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import replace
|
||||||
|
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, *, intents: int | None = None
|
||||||
|
) -> None:
|
||||||
|
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._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 已退出")
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
@@ -16,9 +16,8 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from time import monotonic
|
from time import monotonic
|
||||||
from typing import cast
|
from typing import Protocol, cast
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import ujson
|
import ujson
|
||||||
@@ -34,10 +33,9 @@ from .session import Session
|
|||||||
from .token import AccessToken
|
from .token import AccessToken
|
||||||
|
|
||||||
|
|
||||||
class Connecter(ABC):
|
class Connecter(Protocol):
|
||||||
"""连接适配器接口:两种接入方式都实现 `run()` 常驻运行。"""
|
"""连接适配器接口:两种接入方式都实现 `run()` 常驻运行。"""
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def run(self) -> None: ...
|
async def run(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
按 t 名路由到 `@on` 注册的 handler;协议帧(HELLO/INVALID_SESSION/op=13 等)
|
按 t 名路由到 `@on` 注册的 handler;协议帧(HELLO/INVALID_SESSION/op=13 等)
|
||||||
不进队列,由连接适配器的协议处理器就地处理(见 protocol.py);
|
不进队列,由连接适配器的协议处理器就地处理(见 protocol.py);
|
||||||
- handler 参数按形参标注注入:payload dataclass → 解析对象,`Event` →
|
- handler 参数按形参标注注入:payload dataclass → 解析对象,`Event` →
|
||||||
事件封装,`services` 里登记的组件类型 → 实例,其余直传原始 d
|
事件封装,`services` 里登记的组件类型 → 实例;无标注或标注三者皆非
|
||||||
(可能是 dict、标量甚至 None);
|
则解析即抛错(经异常隔离记日志)——不注入原始 d;
|
||||||
- 业务 handler 返回值不回流;handler 异常就地隔离,不影响其他 handler。
|
- 业务 handler 返回值不回流;handler 异常就地隔离,不影响其他 handler。
|
||||||
- `on(..., background=True)` 把 handler 放进后台任务并发执行:
|
- `on(..., background=True)` 把 handler 放进后台任务并发执行:
|
||||||
不阻塞事件流,但同事件 handler 间失去先后保证;
|
不阻塞事件流,但同事件 handler 间失去先后保证;
|
||||||
@@ -144,9 +144,8 @@ class EventEmitter:
|
|||||||
self, fn: Callable[..., Awaitable[Any]], event: Event
|
self, fn: Callable[..., Awaitable[Any]], event: Event
|
||||||
) -> list[Any]:
|
) -> list[Any]:
|
||||||
"""按 handler 形参标注解析:payload dataclass → 解析对象,`Event` → 事件
|
"""按 handler 形参标注解析:payload dataclass → 解析对象,`Event` → 事件
|
||||||
封装,`services` 登记的组件类型 → 实例,其余(含无标注)→ 原始 d
|
封装,`services` 登记的组件类型 → 实例;无标注或标注三者皆非则抛
|
||||||
(Event.data 是 dict 化视图,标量 d 如 INVALID_SESSION 的 true/false
|
ValueError——原始 d 不注入,静默传 dict 比报错更难排查。"""
|
||||||
必须走这里才能拿到原值)。"""
|
|
||||||
cached = self._signatures.get(fn)
|
cached = self._signatures.get(fn)
|
||||||
if cached is None:
|
if cached is None:
|
||||||
cached = (
|
cached = (
|
||||||
@@ -166,5 +165,9 @@ class EventEmitter:
|
|||||||
elif hint in self.services:
|
elif hint in self.services:
|
||||||
args.append(self.services[hint])
|
args.append(self.services[hint])
|
||||||
else:
|
else:
|
||||||
args.append(event.raw.get("d"))
|
raise ValueError(
|
||||||
|
f"事件处理器 {getattr(fn, '__qualname__', fn)} 的形参 {name} "
|
||||||
|
f"不可注入(标注 {hint!r} 只能是 payload dataclass、Event "
|
||||||
|
"或已登记进 services 的组件类型)"
|
||||||
|
)
|
||||||
return args
|
return args
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
"""事件 payload 的 dataclass 定义与解析。
|
"""事件 payload 的 dataclass 定义与解析。
|
||||||
|
|
||||||
handler 直接标注具体类型即可拿到解析后的对象(如
|
handler 直接标注具体类型即可拿到解析后的对象(如
|
||||||
`async def on_msg(msg: GroupAtMessage)`);未收录的事件类型
|
`async def on_msg(msg: GroupAtMessage)`);未收录的事件类型没有可标注的
|
||||||
由 emitter 回退传原始 dict,新增事件只需在这里加 dataclass
|
dataclass,只能经 `Event.typed` 拿到原始 dict——新增事件只需在这里加
|
||||||
并登记到 EVENT_TYPES。
|
dataclass 并登记到 EVENT_TYPES。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass, fields
|
from dataclasses import dataclass, fields
|
||||||
|
|||||||
@@ -7,19 +7,18 @@ HELLO/READY/INVALID_SESSION/op=13 这类协议帧在适配器内就地处理,
|
|||||||
与 `webhook_protocol.py`。
|
与 `webhook_protocol.py`。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from typing import Any, Protocol
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from .model import Payload
|
from .model import Payload
|
||||||
|
|
||||||
|
|
||||||
class BaseProtocol(ABC):
|
class BaseProtocol(Protocol):
|
||||||
"""适配器专有的协议处理器:实现方返回需要回送的响应体。"""
|
"""适配器专有的协议处理器:实现方返回需要回送的响应体。"""
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def on_frame(self, payload: Payload) -> Payload | dict[str, Any] | None:
|
async def on_frame(self, payload: Payload) -> Payload | dict[str, Any] | None:
|
||||||
"""处理一条帧,返回需回送的响应体(无需回送则 None)。
|
"""处理一条帧,返回需回送的响应体(无需回送则 None)。
|
||||||
|
|
||||||
IDENTIFY/RESUME 等握手应答、op=13 验证应答都经返回值回送;业务事件
|
IDENTIFY/RESUME 等握手应答、op=13 验证应答都经返回值回送;业务事件
|
||||||
(op=0)不必处理,连接器会照常入队分发。
|
(op=0)不必处理,连接器会照常入队分发。
|
||||||
"""
|
"""
|
||||||
|
...
|
||||||
@@ -41,10 +41,6 @@ def get_intents(path: str) -> int:
|
|||||||
intent = INTENT_GROUPS.get(group)
|
intent = INTENT_GROUPS.get(group)
|
||||||
if intent is None:
|
if intent is None:
|
||||||
raise ValueError(f"未知的 intents 分组: {group}")
|
raise ValueError(f"未知的 intents 分组: {group}")
|
||||||
if isinstance(enabled, dict):
|
|
||||||
raise ValueError(
|
|
||||||
f"intents 分组 {group} 不支持组内事件开关,请改为 {group} = true/false"
|
|
||||||
)
|
|
||||||
if not isinstance(enabled, bool):
|
if not isinstance(enabled, bool):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"intents 分组 {group} 的值应为 true/false,实际为 {enabled!r}"
|
f"intents 分组 {group} 的值应为 true/false,实际为 {enabled!r}"
|
||||||
@@ -54,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
|
||||||
@@ -91,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": {},
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-8
@@ -298,7 +298,9 @@ async def test_chunked_upload_group_and_c2c():
|
|||||||
await api.prepare_c2c_upload("U1", **common)
|
await api.prepare_c2c_upload("U1", **common)
|
||||||
assert cap["call"][:2] == ("POST", "/v2/users/U1/upload_prepare")
|
assert cap["call"][:2] == ("POST", "/v2/users/U1/upload_prepare")
|
||||||
|
|
||||||
await api.finish_c2c_upload_part("U1", "up1", part_index=1, block_size=4096, md5="p1")
|
await api.finish_c2c_upload_part(
|
||||||
|
"U1", "up1", part_index=1, block_size=4096, md5="p1"
|
||||||
|
)
|
||||||
method, path, kwargs = cap["call"]
|
method, path, kwargs = cap["call"]
|
||||||
assert (method, path) == ("POST", "/v2/users/U1/upload_part_finish")
|
assert (method, path) == ("POST", "/v2/users/U1/upload_part_finish")
|
||||||
assert kwargs["json"] == {
|
assert kwargs["json"] == {
|
||||||
@@ -377,16 +379,18 @@ async def test_group_join_request_flow():
|
|||||||
assert (method, path) == ("GET", "/v2/groups/G1/join_request_list")
|
assert (method, path) == ("GET", "/v2/groups/G1/join_request_list")
|
||||||
assert kwargs["params"] == {"limit": 10}
|
assert kwargs["params"] == {"limit": 10}
|
||||||
|
|
||||||
await api.review_group_join_request(
|
await api.review_group_join_request("G1", "M1", "approve", join_request_id="j1")
|
||||||
"G1", "M1", "approve", join_request_id="j1"
|
|
||||||
)
|
|
||||||
method, path, kwargs = cap["call"]
|
method, path, kwargs = cap["call"]
|
||||||
assert (method, path) == ("POST", "/v2/groups/G1/approval_join_request/M1")
|
assert (method, path) == ("POST", "/v2/groups/G1/approval_join_request/M1")
|
||||||
assert kwargs["json"] == {"op": "approve", "join_request_id": "j1"}
|
assert kwargs["json"] == {"op": "approve", "join_request_id": "j1"}
|
||||||
|
|
||||||
await api.review_group_join_request(
|
await api.review_group_join_request(
|
||||||
"G1", "M1", "decline", join_request_id="j1",
|
"G1",
|
||||||
reject_reason="广告", add_to_member_blacklist=True,
|
"M1",
|
||||||
|
"decline",
|
||||||
|
join_request_id="j1",
|
||||||
|
reject_reason="广告",
|
||||||
|
add_to_member_blacklist=True,
|
||||||
)
|
)
|
||||||
body = cap["call"][2]["json"]
|
body = cap["call"][2]["json"]
|
||||||
assert body == {
|
assert body == {
|
||||||
@@ -689,7 +693,9 @@ async def test_guild_members_mutes_and_api_permission():
|
|||||||
await api.get_guild_members("GID")
|
await api.get_guild_members("GID")
|
||||||
assert cap["call"][:2] == ("GET", "/guilds/GID/members")
|
assert cap["call"][:2] == ("GET", "/guilds/GID/members")
|
||||||
|
|
||||||
await api.remove_guild_member("GID", "U1", add_blacklist=True, delete_history_msg_days=7)
|
await api.remove_guild_member(
|
||||||
|
"GID", "U1", add_blacklist=True, delete_history_msg_days=7
|
||||||
|
)
|
||||||
method, path, kwargs = cap["call"]
|
method, path, kwargs = cap["call"]
|
||||||
assert (method, path) == ("DELETE", "/guilds/GID/members/U1")
|
assert (method, path) == ("DELETE", "/guilds/GID/members/U1")
|
||||||
assert kwargs["json"] == {"add_blacklist": True, "delete_history_msg_days": 7}
|
assert kwargs["json"] == {"add_blacklist": True, "delete_history_msg_days": 7}
|
||||||
@@ -717,7 +723,9 @@ async def test_guild_members_mutes_and_api_permission():
|
|||||||
await api.get_guild_api_permissions("GID")
|
await api.get_guild_api_permissions("GID")
|
||||||
assert cap["call"][:2] == ("GET", "/guilds/GID/api_permission")
|
assert cap["call"][:2] == ("GET", "/guilds/GID/api_permission")
|
||||||
|
|
||||||
await api.create_api_permission_demand("GID", "C1", "/channels/x/messages", "POST", "d")
|
await api.create_api_permission_demand(
|
||||||
|
"GID", "C1", "/channels/x/messages", "POST", "d"
|
||||||
|
)
|
||||||
method, path, kwargs = cap["call"]
|
method, path, kwargs = cap["call"]
|
||||||
assert (method, path) == ("POST", "/guilds/GID/api_permission/demand")
|
assert (method, path) == ("POST", "/guilds/GID/api_permission/demand")
|
||||||
assert kwargs["json"] == {
|
assert kwargs["json"] == {
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""Bot 门面:构造校验、注册转发、注入约定与装配产物。"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from aiohttp import ClientSession
|
||||||
|
|
||||||
|
from qqbotsdk import (
|
||||||
|
AccessToken,
|
||||||
|
Bot,
|
||||||
|
BotApi,
|
||||||
|
Config,
|
||||||
|
EventEmitter,
|
||||||
|
EventQueue,
|
||||||
|
Intent,
|
||||||
|
Session,
|
||||||
|
WebhookConnecter,
|
||||||
|
WebsocketConnecter,
|
||||||
|
)
|
||||||
|
from qqbotsdk.payloads import GroupAtMessage
|
||||||
|
|
||||||
|
intent_mask = (1 << 25) | (1 << 26)
|
||||||
|
|
||||||
|
|
||||||
|
@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_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:
|
||||||
|
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"}})]
|
||||||
+19
-18
@@ -26,7 +26,7 @@ async def test_same_handler_registered_twice_runs_once():
|
|||||||
emitter = EventEmitter()
|
emitter = EventEmitter()
|
||||||
calls: list[str] = []
|
calls: list[str] = []
|
||||||
|
|
||||||
async def handler(_):
|
async def handler():
|
||||||
calls.append("hit")
|
calls.append("hit")
|
||||||
|
|
||||||
emitter.on("READY", handler)
|
emitter.on("READY", handler)
|
||||||
@@ -41,11 +41,11 @@ async def test_handlers_run_in_registration_order():
|
|||||||
order: list[int] = []
|
order: list[int] = []
|
||||||
|
|
||||||
@emitter.on("READY")
|
@emitter.on("READY")
|
||||||
async def first(_):
|
async def first():
|
||||||
order.append(1)
|
order.append(1)
|
||||||
|
|
||||||
@emitter.on("READY")
|
@emitter.on("READY")
|
||||||
async def second(_):
|
async def second():
|
||||||
order.append(2)
|
order.append(2)
|
||||||
|
|
||||||
await emitter.emit({"op": Opcode.DISPATCH, "t": "READY", "d": {}})
|
await emitter.emit({"op": Opcode.DISPATCH, "t": "READY", "d": {}})
|
||||||
@@ -59,11 +59,11 @@ async def test_handler_exception_is_isolated():
|
|||||||
called: list[str] = []
|
called: list[str] = []
|
||||||
|
|
||||||
@emitter.on("READY")
|
@emitter.on("READY")
|
||||||
async def bad(_):
|
async def bad():
|
||||||
raise RuntimeError("boom")
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
@emitter.on("READY")
|
@emitter.on("READY")
|
||||||
async def good(_):
|
async def good():
|
||||||
called.append("ok")
|
called.append("ok")
|
||||||
|
|
||||||
await emitter.emit({"op": Opcode.DISPATCH, "t": "READY", "d": {}})
|
await emitter.emit({"op": Opcode.DISPATCH, "t": "READY", "d": {}})
|
||||||
@@ -77,8 +77,8 @@ async def test_protocol_frame_never_reaches_business_handler():
|
|||||||
called: list[object] = []
|
called: list[object] = []
|
||||||
|
|
||||||
@emitter.on(Opcode.HEARTBEAT)
|
@emitter.on(Opcode.HEARTBEAT)
|
||||||
async def beat(d):
|
async def beat():
|
||||||
called.append(d)
|
called.append("hit")
|
||||||
|
|
||||||
await emitter.emit({"op": Opcode.HEARTBEAT, "d": 0})
|
await emitter.emit({"op": Opcode.HEARTBEAT, "d": 0})
|
||||||
assert called == []
|
assert called == []
|
||||||
@@ -94,11 +94,11 @@ async def test_service_annotation_resolves_from_services():
|
|||||||
|
|
||||||
store = Store()
|
store = Store()
|
||||||
emitter.services[Store] = store
|
emitter.services[Store] = store
|
||||||
received: list[tuple[GroupAtMessage, Store, dict]] = []
|
received: list[tuple[GroupAtMessage, Store]] = []
|
||||||
|
|
||||||
@emitter.on("GROUP_AT_MESSAGE_CREATE")
|
@emitter.on("GROUP_AT_MESSAGE_CREATE")
|
||||||
async def handler(msg: GroupAtMessage, st: Store, d):
|
async def handler(msg: GroupAtMessage, st: Store):
|
||||||
received.append((msg, st, d))
|
received.append((msg, st))
|
||||||
|
|
||||||
await emitter.emit(
|
await emitter.emit(
|
||||||
{
|
{
|
||||||
@@ -108,24 +108,25 @@ async def test_service_annotation_resolves_from_services():
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
assert len(received) == 1
|
assert len(received) == 1
|
||||||
msg, got, d = received[0]
|
msg, got = received[0]
|
||||||
assert got is store
|
assert got is store
|
||||||
assert isinstance(msg, GroupAtMessage) and msg.content == "hi"
|
assert isinstance(msg, GroupAtMessage) and msg.content == "hi"
|
||||||
assert d == {"content": "hi", "group_openid": "G1"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_unregistered_annotation_falls_back_to_raw_dict():
|
async def test_unresolvable_param_raises_instead_of_raw_dict():
|
||||||
queue = EventQueue()
|
"""原始 d 不再注入:无标注或标注不可解析的形参直接抛错(由异常隔离记日志)。"""
|
||||||
emitter = EventEmitter(queue)
|
emitter = EventEmitter()
|
||||||
received: list[dict] = []
|
received: list[object] = []
|
||||||
|
|
||||||
@emitter.on("READY")
|
@emitter.on("READY")
|
||||||
async def handler(dep: Session): # Session 未登记进 services
|
async def handler(dep: Session): # Session 未登记进 services
|
||||||
received.append(dep)
|
received.append(dep)
|
||||||
|
|
||||||
await emitter.emit({"op": Opcode.DISPATCH, "t": "READY", "d": {"session_id": "s1"}})
|
event = Event({"op": Opcode.DISPATCH, "t": "READY", "d": {"session_id": "s1"}})
|
||||||
assert received == [{"session_id": "s1"}]
|
with pytest.raises(ValueError, match="不可注入"):
|
||||||
|
emitter._resolve_params(handler, event)
|
||||||
|
assert received == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
+51
-2
@@ -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)
|
||||||
@@ -51,7 +51,7 @@ def test_non_bool_value_raises(tmp_path):
|
|||||||
|
|
||||||
def test_legacy_per_event_switches_are_rejected(tmp_path):
|
def test_legacy_per_event_switches_are_rejected(tmp_path):
|
||||||
path = write(tmp_path, "[GROUP_AND_C2C_EVENT]\nC2C_MESSAGE_CREATE = true\n")
|
path = write(tmp_path, "[GROUP_AND_C2C_EVENT]\nC2C_MESSAGE_CREATE = true\n")
|
||||||
with pytest.raises(ValueError, match="组内事件开关"):
|
with pytest.raises(ValueError, match="true/false"):
|
||||||
get_intents(path)
|
get_intents(path)
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -179,21 +179,6 @@ async def test_typed_annotation_receives_dataclass():
|
|||||||
assert received[0].group_openid == "G1"
|
assert received[0].group_openid == "G1"
|
||||||
|
|
||||||
|
|
||||||
async def test_untyped_annotation_still_receives_raw_dict():
|
|
||||||
queue = EventQueue()
|
|
||||||
emitter = EventEmitter(queue)
|
|
||||||
received: list[dict] = []
|
|
||||||
|
|
||||||
@emitter.on("GROUP_AT_MESSAGE_CREATE")
|
|
||||||
async def handler(d):
|
|
||||||
received.append(d)
|
|
||||||
|
|
||||||
await emitter.emit(
|
|
||||||
{"op": Opcode.DISPATCH, "t": "GROUP_AT_MESSAGE_CREATE", "d": {"content": "x"}}
|
|
||||||
)
|
|
||||||
assert received == [{"content": "x"}]
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_group_member_and_join_request_events():
|
def test_parse_group_member_and_join_request_events():
|
||||||
from qqbotsdk.payloads import (
|
from qqbotsdk.payloads import (
|
||||||
GroupJoinRequest,
|
GroupJoinRequest,
|
||||||
|
|||||||
+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()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiohappyeyeballs"
|
name = "aiohappyeyeballs"
|
||||||
@@ -22,10 +22,29 @@ dependencies = [
|
|||||||
{ name = "frozenlist" },
|
{ name = "frozenlist" },
|
||||||
{ name = "multidict" },
|
{ name = "multidict" },
|
||||||
{ name = "propcache" },
|
{ name = "propcache" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
{ name = "yarl" },
|
{ name = "yarl" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" },
|
{ url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" },
|
{ url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" },
|
||||||
@@ -98,6 +117,7 @@ version = "1.4.0"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "frozenlist" },
|
{ name = "frozenlist" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -122,6 +142,18 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
|
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
|
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
|
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
|
||||||
@@ -251,6 +283,22 @@ version = "1.8.0"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" },
|
{ url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" },
|
{ url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" },
|
||||||
@@ -355,6 +403,24 @@ version = "6.7.1"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" },
|
{ url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" },
|
{ url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" },
|
{ url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" },
|
||||||
@@ -463,6 +529,23 @@ version = "0.5.2"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" },
|
{ url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" },
|
{ url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" },
|
||||||
@@ -587,6 +670,7 @@ version = "1.4.0"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -603,8 +687,8 @@ wheels = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qqbotsdk"
|
name = "qqbotsdk-py"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
@@ -679,6 +763,19 @@ version = "6.0.0"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/64/7c/e1fa3fb70b53192436d751b5cb671f0ee960baa188b8351a7fec735223d3/ujson-6.0.0.tar.gz", hash = "sha256:80e23393feb707582e0ad495c397a4477b646d08094d2df64f7316f9fafd8aae", size = 7169158, upload-time = "2026-09-04T03:55:42.983Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/64/7c/e1fa3fb70b53192436d751b5cb671f0ee960baa188b8351a7fec735223d3/ujson-6.0.0.tar.gz", hash = "sha256:80e23393feb707582e0ad495c397a4477b646d08094d2df64f7316f9fafd8aae", size = 7169158, upload-time = "2026-09-04T03:55:42.983Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/d1/6a5526d896ead68746997a95f0ae9077e5a438c1574ecb6ea031e72cb75b/ujson-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02148bd4706f42b063bb95f6cc309e16554fb4c250db4683688c0a3eb83048ad", size = 55112, upload-time = "2026-09-04T03:53:39.569Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/b0/454f4a6aea48fd580eaaceb9d692cfa219b41e197c6bbfd534ee8945dad9/ujson-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b305657e2ddc29a50b333053e7c7f431a8c24c92b7dcbbf7a420f2330152b486", size = 54240, upload-time = "2026-09-04T03:53:40.597Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/39/4536f25a8c47fb78a12b5253929ce4b41a121fcd4e1b34d02634b6939e4d/ujson-6.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:a054959ec07f2fd63b6e8a63019a6879262c4f1983a100545c5a0206eefe993e", size = 58494, upload-time = "2026-09-04T03:53:41.561Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/e7/870f261071662573c6e0d74f28ed3e6d4ed8c604ced85df3a1e404c679d8/ujson-6.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c51915961a51e37403fd94114e293d580dd916ddd1961b229217a87193d2454e", size = 52376, upload-time = "2026-09-04T03:53:42.67Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/7d/cd5139cbdab193562713851e751249e2981c90c275c57584403351d7d70c/ujson-6.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa03ac78c7806c6a391c037e0a63552e11532210b719bc062cddc00671a7577f", size = 53714, upload-time = "2026-09-04T03:53:43.89Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/3d/066537298a91738f2f598ebea58baf0a612a65fad92a522ddac31d8191fd/ujson-6.0.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aa247eb50a52bb2190871ca8c2e0a96f8190bfdb1ebd68c70d1bf422f640b73", size = 57184, upload-time = "2026-09-04T03:53:44.999Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/be/4ddd61b3d4beb21eced10d0d100a4dca26f13303d4952e48fb5e89167869/ujson-6.0.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e9359bfd0efd12593f0db40ccb2d1497284401da207f1d6a1783718313201b21", size = 56411, upload-time = "2026-09-04T03:53:46.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/18/8835fce508f89f20409b1ba336ffe5cde18e4ef66889aeafaf3e9a73ce4d/ujson-6.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:921408c159b01d39d70e90252b8ab17f16594fc91f229e6f881642fb0ed24ae7", size = 1037444, upload-time = "2026-09-04T03:53:47.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/b1/58a77bf3939317a679474d9e0cca62ce87f0c299689aadf2940ba6a5acc5/ujson-6.0.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8141cade37dabc5f090eb5e6a267eabb6b193078becdc82aaf10433196715c33", size = 1196452, upload-time = "2026-09-04T03:53:48.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/8d/39cd22f388524daa0155e78ab64232711e7331fe31ae3074f9e6593b7685/ujson-6.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dd55ca435d6c3c7e4cb6d8a0a98a133d4fd1b67d9abf90449442d9f5a728a9ff", size = 1088719, upload-time = "2026-09-04T03:53:50.032Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/82/0380bc0636ff366ea8666fd072cd31dbfd10947cab34f4eb06b2f299c93d/ujson-6.0.0-cp312-cp312-win32.whl", hash = "sha256:2a09d4ea9ee60c023220195b229ce2688479dbdcf51630acdd54ee75b27c0c00", size = 229127, upload-time = "2026-09-04T03:53:51.465Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/32/03a2ad4b3c6ebddaee2dc157f8342a9147dbafba0817c3452252749b0478/ujson-6.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8cd9f7203c0b2aaed66809edf7e66aa3ab0fe3402e87b69a43b9dfd8d33125ab", size = 227516, upload-time = "2026-09-04T03:53:52.629Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/78/50dac7c077e60c1277615bce2b25eddd6312e769d425a00e6fe3cf603fe5/ujson-6.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:9b59ead8dd9a96399cc38994d19720443a3cc626b730cbb4f414fb768b3e2816", size = 400243, upload-time = "2026-09-04T03:53:54.05Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/32/c67df85215ba0ebcdca8f8b1b3a856fd2434da87f84f9757e275ef99bd40/ujson-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fb37ec7d7542e2f23fd7ca8fd034c8db7221c5e86d6a6a3a170711f993eecf15", size = 55113, upload-time = "2026-09-04T03:53:55.285Z" },
|
{ url = "https://files.pythonhosted.org/packages/bd/32/c67df85215ba0ebcdca8f8b1b3a856fd2434da87f84f9757e275ef99bd40/ujson-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fb37ec7d7542e2f23fd7ca8fd034c8db7221c5e86d6a6a3a170711f993eecf15", size = 55113, upload-time = "2026-09-04T03:53:55.285Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/a0/e5c7ae933fab41be06f0ff7976e3483519cbbf9e2492a217a5550af95c18/ujson-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ad11c9153c775087d261634410da7cfaac2743d79bc9ab573177d9e3398f00c6", size = 54247, upload-time = "2026-09-04T03:53:56.383Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/a0/e5c7ae933fab41be06f0ff7976e3483519cbbf9e2492a217a5550af95c18/ujson-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ad11c9153c775087d261634410da7cfaac2743d79bc9ab573177d9e3398f00c6", size = 54247, upload-time = "2026-09-04T03:53:56.383Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9a/10/0993497a08f9fcff34cbcdcd6da46491f415e711a459c5d81f594e89769c/ujson-6.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:2dbe0b6d417b458164ccf1f59e081d6bd65c1fb2f626e0daeb6fb88c436f9643", size = 58497, upload-time = "2026-09-04T03:53:57.791Z" },
|
{ url = "https://files.pythonhosted.org/packages/9a/10/0993497a08f9fcff34cbcdcd6da46491f415e711a459c5d81f594e89769c/ujson-6.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:2dbe0b6d417b458164ccf1f59e081d6bd65c1fb2f626e0daeb6fb88c436f9643", size = 58497, upload-time = "2026-09-04T03:53:57.791Z" },
|
||||||
@@ -744,6 +841,12 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/c2/ce/baf673bd0ebe6135abb5bee5a4dcf162d2a79608bdd18fbd0e47bb7371ce/ujson-6.0.0-cp315-cp315t-win32.whl", hash = "sha256:54ab6b66fa6f67dfa8234e109df132074e155af3b299ad83aab13ba4b6db9b3f", size = 236549, upload-time = "2026-09-04T03:55:14.153Z" },
|
{ url = "https://files.pythonhosted.org/packages/c2/ce/baf673bd0ebe6135abb5bee5a4dcf162d2a79608bdd18fbd0e47bb7371ce/ujson-6.0.0-cp315-cp315t-win32.whl", hash = "sha256:54ab6b66fa6f67dfa8234e109df132074e155af3b299ad83aab13ba4b6db9b3f", size = 236549, upload-time = "2026-09-04T03:55:14.153Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/e8/39a55080f06270c7fb9a9e6384a2cc8a9d24094ffe90c6447fea6724f346/ujson-6.0.0-cp315-cp315t-win_amd64.whl", hash = "sha256:801ff407fda799f4ff98d960342128b065a14113eaccfc116b50092342636861", size = 236190, upload-time = "2026-09-04T03:55:16.018Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/e8/39a55080f06270c7fb9a9e6384a2cc8a9d24094ffe90c6447fea6724f346/ujson-6.0.0-cp315-cp315t-win_amd64.whl", hash = "sha256:801ff407fda799f4ff98d960342128b065a14113eaccfc116b50092342636861", size = 236190, upload-time = "2026-09-04T03:55:16.018Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/40/76/ccb45390fb2bab53b69c7a49c0cec655a93727eeae0b3912132fa7150649/ujson-6.0.0-cp315-cp315t-win_arm64.whl", hash = "sha256:a2e699d5f290f81829f42638f8bc6582e3e73452d8607edf749ad3e1843946fa", size = 415495, upload-time = "2026-09-04T03:55:17.279Z" },
|
{ url = "https://files.pythonhosted.org/packages/40/76/ccb45390fb2bab53b69c7a49c0cec655a93727eeae0b3912132fa7150649/ujson-6.0.0-cp315-cp315t-win_arm64.whl", hash = "sha256:a2e699d5f290f81829f42638f8bc6582e3e73452d8607edf749ad3e1843946fa", size = 415495, upload-time = "2026-09-04T03:55:17.279Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/04/46d0146a9b4185aaee6b8b5d4fb58df59ec62e9557d6f59b0fdcd7c84b9a/ujson-6.0.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:28ac884b58c62eacdb6ac67284475b3f19b8160dbacb723956e67a0c11e45014", size = 58103, upload-time = "2026-09-04T03:55:18.697Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/ea/2175d546216366acddc5f677d8a2eb27fb1a24b53871c8e31f5109237ec8/ujson-6.0.0-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e0652b2110fc374c766cdfca4fad61f9d13a0ad60c5b335ef3fed509374557bc", size = 53902, upload-time = "2026-09-04T03:55:19.806Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/ca/caa8d28fc2507443fd4c32fb7e415f6d8fc70aa332160de36e253d8b083c/ujson-6.0.0-graalpy312-graalpy250_312_native-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8604968307105c3229ce0170e70bf3f172cf96f73c978b1afbc3d0ec8bdfcf86", size = 55419, upload-time = "2026-09-04T03:55:21.046Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/a9/7fcaed6aa33a50a9dfb2c2e3259a2fe9075b66bba44bef77bc597c05a263/ujson-6.0.0-graalpy312-graalpy250_312_native-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15aa57f6d0dafccd20f282f46f6a8d721d46c73fd9474f5ba996e9adc48d3177", size = 54396, upload-time = "2026-09-04T03:55:22.274Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/59/7b0b75c19cc394436fe8143a3f88a39a9c2015469e540e236c6046773e42/ujson-6.0.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:868856ea75794d952c773c506bb638e2a692bc5a8095cefebdcd98f43c79e772", size = 55497, upload-time = "2026-09-04T03:55:24.238Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/07/1ddd75170364566389db80faf7845950f79bf71989bf9194e7a331d585ed/ujson-6.0.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:0d6e29b91a0934ed9d22ee48aa91518523cd2ce1c6caee2810b439fb371b8439", size = 228249, upload-time = "2026-09-04T03:55:25.436Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -766,6 +869,23 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" },
|
{ url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" },
|
{ url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" },
|
||||||
|
|||||||
Reference in new issue
Block a user