Compare commits

...
19 Commits
Author SHA1 Message Date
snowy-yang 706a39b07b build: 发布 0.2.1 2026-10-03 21:58:02 +08:00
snowy-yang 6235f9e393 fix: 群成员禁言对齐官方 members/op 结构——mute_group_members 批量透传、mute_group_member 签名补 member_openid、新增 unmute_group_member 2026-10-03 21:58:02 +08:00
snowy-yang 321ab70b35 build: 发布 0.2.0(Unreleased 收口) 2026-10-03 20:07:56 +08:00
snowy-yang 7e8794bf5f feat: intents 掩码直传——Bot(intents=) 参数与 INTENTS 环境变量,优先于 intents.toml
Intent 枚举(IntFlag)提升到顶层导出可直接 OR;掩码含未收录位仅告警不拦截。
2026-10-03 20:07:56 +08:00
snowy-yang bcd69099b6 docs: 文档模块化重构(guide/api 多页),文档站迁移 GitHub Pages Actions 部署
GUIDE/API 拆为 docs/guide/ 五页与 docs/api/ 七页,侧边栏分组折叠,
旧链接 alias 映射;安装改为 pip install qqbotsdk-py,README 加徽章;
删除 docs 分支同步工作流,pages.yml 直部署(自定义域名 CNAME 随产物带上)。
2026-10-03 19:12:11 +08:00
snowy-yang 652a142e86 ci: 加并发取消与构建 job,新增 tag 触发的 PyPI 发布工作流(Trusted Publishing) 2026-10-03 19:12:11 +08:00
snowy-yang 0515c6e4c0 refactor!: Bot 门面接管启动,移除 main/run_loop 与控制台脚本入口
bot.run() 同步启动、bot.start() 嵌入外部 asyncio 应用;
bot.call_api 通用 REST、bot.api 类型化 API 面、bot.services 自定义组件注入;
services 登记扩到 8 项(含 Bot 自身,可标注注入)。
2026-10-03 19:12:06 +08:00
snowy-yang 1f91b1acca build: PyPI 发行名改为 qqbotsdk-py,模块名保持 qqbotsdk 2026-10-03 18:40:23 +08:00
snowy-yang afe36c2a84 build: 补充 PyPI 发布元数据(urls/classifiers/keywords) 2026-10-03 18:33:34 +08:00
snowy-yang febeb95d7c build: Python 基线降至 3.12,CI 改 3.12/3.13 双版本矩阵
PEP 695 type 语句、typing.override 等既有写法本就 3.12 可用,代码零
改动;pyproject/ruff/pyright 目标版本同步 py312,README 与 GUIDE 的
版本要求更新,uv.lock 随 requires-python 重新解析。
2026-10-01 23:05:13 +08:00
snowy-yang 0fa63d564b style: ruff format 统一 4 处签名换行漂移,CI 补格式检查
channel/group/guild/test_api 的函数签名换行偏离 ruff format(CI 只跑
ruff check 拦不住格式漂移),先归一再补 ruff format --check 步骤防回潮。
2026-10-01 23:05:01 +08:00
snowy-yang e5dd805d19 fix: 示例与文档跟上 handler 注入规则——FRIEND_ADD 改标注 FriendAdd
0f3ed09 后 dict 标注不可注入,示例 handler 每次触发都被异常隔离吞掉;
官方 FRIEND_ADD 字段名为 openid,原 d.get('user_openid') 写法在重构
前也取不到值。payloads 模块 docstring 同步改为现行语义(未知事件只能
经 Event.typed 拿 dict)。
2026-10-01 23:04:49 +08:00
snowy-yang 0f3ed09233 refactor!: handler 参数只按模型/Event/组件注入,不再回退传原始 d
解析不到的形参(无标注或标注三者皆非)在分发时抛 ValueError,经异常
隔离记日志;原始数据经 Event 的 .raw/.typed 自取。同步四处文档与测试。
2026-09-24 22:40:37 +08:00
snowy-yang 1d9f509b25 refactor: BaseProtocol/Connecter 由 abc 改为 typing.Protocol,统一接口风格 2026-09-24 22:40:29 +08:00
snowy-yang feb39b11d5 refactor: 删去 intents 组内事件开关的专属校验(dict 本就被布尔校验拦下) 2026-09-24 22:40:29 +08:00
snowy-yang e66148a75c fix: 文档站子页面左侧目录不加载
docsify 进子目录路由会先请求 <目录>/_sidebar.md,失败(404)才回退根
目录;Cloudflare Pages 无 404.html 时对缺失路径 SPA 回退返回 index.html
且状态 200,docsify 把 HTML 当侧栏 Markdown 解析,左侧目录卡在加载中。
alias 将 docs/、examples/ 的 _sidebar.md 请求映射回根目录 _sidebar.md。
2026-09-24 21:51:40 +08:00
snowy-yang badf5b3571 docs: README 瘦身——顶部加在线文档链接,删解释性内容与过时部署/CI 描述 2026-09-24 21:51:39 +08:00
snowy-yang 7663ca610d fix: 移除误入的 dist 目录(早期组装演练残留,撤销 gitignore 后被 add -A 收走) 2026-09-24 21:20:20 +08:00
snowy-yang 7603db7fd9 feat!: 文档部署改为 docs 分支自动同步,移除 Gitea 工作流与 wrangler 脚本
Gitea 已配置为 GitHub 的镜像,主仓库落在 GitHub:

- 删除 .gitea/(runner 已弃用,六轮实跑不稳定)与
  scripts/deploy_docs.sh(wrangler Direct Upload 路径随之退役,
  CLOUDFLARE_* secrets 不再被 CI 需要)
- deploy-docs.yml 重写:main 的文档文件变更时自动把纯文档组装进
  docs 孤儿分支并强推——Cloudflare Pages 直连 GitHub、生产分支选
  docs、构建命令留空、输出目录 / 即完成零构建接入
- index.html 仓库链接与 README/GUIDE 安装命令补 GitHub 地址;
  CHANGELOG 合并原先两条 CI/部署条目
2026-09-24 21:19:15 +08:00
51 changed files with 1652 additions and 1080 deletions

No files matched your search

-59
View File
@@ -1,59 +0,0 @@
name: 代码检测
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
lint:
name: ruff + pyright
runs-on: ubuntu-latest
# 固定官方 python 镜像:保证 python3.13 + pip 在场(runner 默认镜像
# 工具链不明,实测任务曾 7s 即死);runner 不支持 container 时回落
# 宿主机 python,此时 uv 经下方镜像变量自行下载 CPython
container: python:3.13-slim
steps:
# 不用 actions/checkout、不用 git:runner 对 github 连通性不稳
# (probe.yml 实测),改走 Gitea archive 接口拉源码 tarball;每步
# 结果写进 commit status(日志匿名不可读,status 远程可读)
- name: 拉源码、装依赖并执行检测
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
UV_PYTHON_INSTALL_MIRROR: https://registry.npmmirror.com/-/binary/python-build-standalone
run: |
set -e
SERVER="${GITHUB_SERVER_URL:-https://gitea.4i.hk}"
note() {
curl -s -m 10 -X POST \
-H "Authorization: token $TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"state\":\"$3\",\"context\":\"ci/$1\",\"description\":\"$2\"}" \
"$SERVER/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \
-o /dev/null
}
trap 'note error "步骤意外失败,详见 Actions 日志" failure' ERR
curl -sL "$SERVER/api/v1/repos/$GITHUB_REPOSITORY/archive/$GITHUB_SHA.tar.gz" -o src.tgz \
&& mkdir src && tar -xzf src.tgz -C src --strip-components=1 && cd src \
|| { note fetch "archive 拉取失败" failure; exit 1; }
note fetch ok success
pip install uv >/dev/null 2>&1 \
|| { note pip "pip 安装 uv 失败" failure; exit 1; }
note pip ok success
uv sync >/dev/null 2>&1 \
|| { note sync "uv sync 失败" failure; exit 1; }
note sync ok success
uv run ruff check src tests examples \
|| { note ruff "ruff check 未通过" failure; exit 1; }
note ruff ok success
# pyright 需要 node:镜像里没有就装 Debian 的(保底 pyright 自带下载)
command -v node >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq nodejs; } || true
uv run pyright \
|| { note pyright "pyright 未通过" failure; exit 1; }
note pyright ok success
-51
View File
@@ -1,51 +0,0 @@
name: 部署文档(Cloudflare Pages)
on:
push:
branches: [main]
paths:
- "index.html"
- "_sidebar.md"
- "README.md"
- "docs/**"
- "examples/**"
- "CHANGELOG.md"
- "scripts/deploy_docs.sh"
- ".gitea/workflows/deploy-docs.yml"
workflow_dispatch:
jobs:
deploy-docs:
name: Cloudflare Pages
runs-on: ubuntu-latest
steps:
# 不用 actions/checkout、不用 git(runner 对 github 连通性不稳,见
# probe.yml):改走 Gitea archive 接口拉源码 tarball
- name: 拉源码
run: |
SERVER="${GITHUB_SERVER_URL:-https://gitea.4i.hk}"
curl -sL "$SERVER/api/v1/repos/$GITHUB_REPOSITORY/archive/$GITHUB_SHA.tar.gz" -o src.tgz \
&& mkdir src && tar -xzf src.tgz -C src --strip-components=1 \
&& mv src/* . && rm -rf src src.tgz
# 日志匿名不可读:把 secrets 在场情况写进 commit status 便于远程
# 排查(只报 set/MISSING,不泄露值)
- name: 探测 secrets 是否配置
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CF_ACCOUNT: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
SERVER="${GITHUB_SERVER_URL:-https://gitea.4i.hk}"
s() { [ -n "$2" ] && echo set || echo MISSING; }
curl -s -m 10 -X POST \
-H "Authorization: token $TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"state\":\"success\",\"context\":\"deploy/secrets\",\"description\":\"CLOUDFLARE_API_TOKEN=$(s x "$CF_TOKEN") CLOUDFLARE_ACCOUNT_ID=$(s x "$CF_ACCOUNT")\"}" \
"$SERVER/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" -o /dev/null
- name: 组装并部署(wrangler Direct Upload)
run: bash scripts/deploy_docs.sh
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
-57
View File
@@ -1,57 +0,0 @@
name: 网络探测
# runner 环境盘点:出网连通性 + 基础工具链 + clone/装 uv 迷你实测,
# 结果写入 commit status(公开仓库匿名可读)。
# 改动本文件推送即触发。
on:
push:
paths: ['.gitea/workflows/probe.yml']
workflow_dispatch:
jobs:
probe:
name: runner 环境探测
runs-on: ubuntu-latest
steps:
- name: 工具链与连通性探测
run: |
SERVER="${GITHUB_SERVER_URL:-https://gitea.4i.hk}"
post() {
curl -s -m 10 -X POST \
-H "Authorization: token $TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"state\":\"success\",\"context\":\"probe/$1\",\"description\":\"$2\"}" \
"$SERVER/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \
-o /dev/null -w "probe/$1 = $2 (HTTP %{http_code})\n"
}
inv() { command -v "$1" >/dev/null 2>&1 && echo yes || echo no; }
code() { curl -s -m 8 -o /dev/null -w '%{http_code}' -L "$1"; }
post python3 "$(python3 -V 2>/dev/null || echo absent)"
post pip "$(inv pip)"
post pip3 "$(inv pip3)"
post uv "$(inv uv)"
post git "$(inv git)"
post npx "$(inv npx)"
post pypi "$(code https://pypi.org/simple/)"
post npmmirror "$(code https://registry.npmmirror.com)"
rm -rf /tmp/src
if git clone --depth=1 "$SERVER/$GITHUB_REPOSITORY.git" /tmp/src >/dev/null 2>&1; then
post clone ok
else
post clone fail
fi
if cd /tmp/src 2>/dev/null \
&& { pip install uv >/dev/null 2>&1 \
|| pip3 install uv >/dev/null 2>&1 \
|| python3 -m pip install uv >/dev/null 2>&1; }; then
post pip-install-uv ok
else
post pip-install-uv fail
fi
UV_BIN="$(command -v uv || echo "$HOME/.local/bin/uv")"
post uv-version "$("$UV_BIN" --version 2>/dev/null || echo none)"
+33 -2
View File
@@ -6,18 +6,25 @@ on:
pull_request:
workflow_dispatch:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: ruff + pyright + pytest
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.12", "3.13"]
steps:
- name: 检出代码
uses: actions/checkout@v4
- name: 安装 uv 与 Python 3.13
- name: 安装 uv 与 Python ${{ matrix.python-version }}
uses: astral-sh/setup-uv@v5
with:
python-version: "3.13"
python-version: ${{ matrix.python-version }}
enable-cache: true
- name: 同步依赖(含 dev 组)
@@ -26,8 +33,32 @@ jobs:
- name: ruff 静态检查
run: uv run ruff check src tests examples
- name: ruff 格式检查
run: uv run ruff format --check src tests examples
- name: pyright 类型检查
run: uv run pyright
- name: 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/
-29
View File
@@ -1,29 +0,0 @@
name: 部署文档(Cloudflare Pages)
on:
push:
branches: [main]
paths:
- "index.html"
- "_sidebar.md"
- "README.md"
- "docs/**"
- "examples/**"
- "CHANGELOG.md"
- "scripts/deploy_docs.sh"
- ".github/workflows/deploy-docs.yml"
workflow_dispatch:
jobs:
deploy-docs:
name: Cloudflare Pages
runs-on: ubuntu-latest
steps:
- name: 检出代码
uses: actions/checkout@v4
- name: 组装并部署(wrangler Direct Upload)
run: bash scripts/deploy_docs.sh
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+57
View File
@@ -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
+31
View File
@@ -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
-4
View File
@@ -10,10 +10,6 @@ __pycache__/
.ruff_cache/
.pytest_cache/
# 文档部署产物(scripts/deploy_docs.sh 组装)
dist/
.wrangler/
# 编辑器/IDE
.vscode/
.zcode/
+20 -3
View File
@@ -5,8 +5,21 @@
## [Unreleased]
## [0.2.1] - 2026-10-03
### 修复
- **群成员禁言对齐官方请求结构**:`POST /v2/groups/{group_openid}/restrict_chat_setting` 的请求体实为 `{"members": [{"op": "add"/"update"/"del", "member_openid": …, "mute_expire_at": RFC3339}]}`(单次最多 20 人、最长 30 天、仅可操作普通成员、需群管理员身份、60 QPM),0.2.0 误发顶层 `{"mute_expire_at": …}` 且签名无法指定被禁成员。签名变更:`mute_group_member(group_openid, member_openid, mute_expire_at)`(op=add 单人禁言);新增 `mute_group_members(group_openid, members)` 批量透传与 `unmute_group_member(group_openid, member_openid)`(op=del 解禁,解禁不再走"传过去时间")、`MUTE_OP_ADD`/`MUTE_OP_UPDATE`/`MUTE_OP_DEL` 常量
## [0.2.0] - 2026-10-03
### 新增
- **intents 掩码直传**:`Bot(intents=…)` 参数(推荐用顶层导出的 `Intent` 枚举 OR 出掩码)与 `INTENTS` 环境变量(十进制/0x 十六进制),优先级 Bot 参数 > 环境变量 > intents.toml;掩码含未收录位仅告警不拦截
- **`Bot` 门面类(新启动方式)**:`bot = Bot()` 构造即读 .env 校验配置,`@bot.on(...)` 注册 handler,`bot.run()` 同步启动;`await bot.start()` 供嵌入外部 asyncio 应用。`bot.call_api(method, path, **kwargs)` 通用 REST 入口(带鉴权/429 重试/401 自愈),`bot.api` 为类型化 API 面,`bot.services` 登记自定义组件;handler 可标注 `bot: Bot` 注入实例
- **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_*` 常量随模块导出)
- **事件全量对齐**:新增 `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`——群信息/成员列表/成员详情/机器人群内状态、入群申请列表与审批、群黑名单增删查、群成员批量移除、群禁言状态查询、入群自动审批策略(增删改查/执行/白名单,机器人维度全局配置)
@@ -20,12 +33,12 @@
- OpenAPI 限流与鉴权自愈:`BotApi.request` 对 429 自动重试(最多 3 次,优先 `Retry-After`,脏值容错解析,退避封顶 30s);401 强制刷新 token 后重试一次,token 被服务端提前作废不再直接抛错
- 群消息全量模式:新增 `GROUP_MESSAGE_CREATE` 事件接收,与 `GROUP_AT_MESSAGE_CREATE` 共用 `GroupAtMessage`(需在开放平台开通"接收所有消息"权限,不限 @机器人);`GroupAtMessage` 补齐官方新增的 `message_type`/`message_scene`/`attachments`/`mentions`/`ark_data`/`msg_elements` 字段
- **连接寿命主动重连**:服务端约 1 小时后会无预警断开连接(实测),故连接存活到 `_SERVER_LIFETIME - _RECONNECT_MARGIN`(默认 60min − 10min = 50min)时由 SDK 主动收尾重连——走正常 RESUME 续传、期间消息由网关补发,而不是被服务端掐断后由看门狗判成异常断开;收帧超时在"静默阈值"与"寿命余量"两个截止里取先到者,静默仍归看门狗处理
- GitHub Actions:push/PR 自动执行 `ruff check`、`pyright` 与 pytest;文档文件变更时自动把纯文档组装进 `docs` 分支(与源码历史无关的孤儿分支),供 Cloudflare Pages 直连 GitHub 仓库部署(生产分支 `docs`,零构建)
- 文档站点:基于 docsify(侧边栏、全文搜索、代码复制、mermaid 图渲染、分页),`npx docsify-cli serve` 本地预览;新增 `examples/README.md` 示例索引
- Gitea Actions CI:push(main)/Pull Request/手动触发时自动执行 `ruff check` 与 `pyright`(`.gitea/workflows/ci.yml`,uv 同步依赖后运行与本地开发一致的检查命令)
- 文档站线上部署:`scripts/deploy_docs.sh` 组装纯文档目录(不携带源码)并经 wrangler 上传 Cloudflare Pages(Direct Upload,零构建);`.gitea/workflows/deploy-docs.yml` 在 main 的文档文件变更时自动部署
### 变更
- **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`
- `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` 照旧),新增域加模块即可
@@ -47,6 +60,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`,以此类推
- `EventEmitter.register_protocol()`、`EventEmitter.handle()` 与 `emitter` 持有协议层的机制(协议处理器改为由 connecter 持有并逐帧调用,见上文"变更")
- apscheduler 依赖(心跳迁入 connecter 后不再需要调度器)
@@ -59,6 +73,7 @@
- token 请求失败时抛出 `TokenError`(携带 QQ 返回的 `code`/`message`),不再抛出掩盖真实原因的 `KeyError: 'access_token'`
- 修正被动回复 `event_id` 的指引:官方要求取网关帧最外层的 id(`Event.event_id`),事件体里的裸 UUID(`Interaction.id` 等)不能作 `event_id`,此前文档与 docstring 的说法有误
- 响应 `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
@@ -75,4 +90,6 @@
- **文档**:使用指南、API 参考、架构说明与可运行示例(`examples/`)
- **测试**:纯逻辑单测 + 假网关 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
+59 -96
View File
@@ -1,8 +1,60 @@
# qqbotsdk
QQ 官方机器人 Python SDK(Python 3.13 / asyncio / aiohttp),支持 WebSocket 与 Webhook 两种接入方式。
[![CI](https://github.com/snowy-yang/qqbotsdk/actions/workflows/ci.yml/badge.svg)](https://github.com/snowy-yang/qqbotsdk/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/qqbotsdk-py.svg)](https://pypi.org/project/qqbotsdk-py/)
[![Python](https://img.shields.io/pypi/pyversions/qqbotsdk-py.svg)](https://pypi.org/project/qqbotsdk-py/)
[![Downloads](https://img.shields.io/pypi/dm/qqbotsdk-py.svg)](https://pypistats.org/packages/qqbotsdk-py)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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
subgraph 业务层
API["BotApi(REST + ApiError)"]
USER["用户 handler @ee.on(...)"]
USER["用户 handler @bot.on(...)"]
end
QQ["QQ 开放平台"]
@@ -37,90 +89,7 @@ flowchart TB
WS --> QQ
```
组件在 `run_loop` 显式装配,并按类型登记到 `emitter.services` 供 handler 参数注入(无全局单例);协议帧由连接适配器交给各自的协议处理器就地处理,只有 op=0 的业务事件经队列进入分发——依赖关系与设计决策详见 [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 最小接入。
组件在 `Bot.start()` 中装配,依赖关系与设计决策详见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
## 开发
@@ -129,16 +98,10 @@ uv run pytest # 单元测试
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
npx docsify-cli serve # 打开 http://localhost:3000
npx docsify-cli serve
```
线上部署在 Cloudflare Pages(Direct Upload,Gitea 无法走 Pages 的 Git 直连):`bash scripts/deploy_docs.sh` 组装 `dist/` 并经 wrangler 上传,站点地址 `https://<项目名>.pages.dev`;项目名默认 `qqbotsdk-docs`,可传参或 `PROJECT_NAME` 覆盖。CI 自动部署:Gitea 仓库配置 `CLOUDFLARE_API_TOKEN`(权限 Account → Cloudflare Pages → Edit)与 `CLOUDFLARE_ACCOUNT_ID` 两个 secrets 后,main 上文档文件变更即自动部署([.gitea/workflows/deploy-docs.yml](.gitea/workflows/deploy-docs.yml))。
## 待完善
- 更多 OpenAPI 封装(按需在 `api.py` 的 `BotApi` 上加薄封装即可)
+14 -2
View File
@@ -1,6 +1,18 @@
- [首页](/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)
- [示例](/examples/README.md)
- [更新日志](/CHANGELOG.md)
-323
View File
@@ -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` 入口与组件显式装配 |
+7 -7
View File
@@ -98,11 +98,11 @@ sequenceDiagram
## 生命周期与依赖装配
- 全部组件在 `run_loop`(`__init__.py`)显式构造装配,无 DI 框架,也无任何全局单例。
- 组件按类型登记进 `emitter.services`,handler 形参标注类型即得实例;用户自定义组件同样登记即可注入。
- 唯一的 `ClientSession` 在 `run_loop` 构造,`finally` 中先 `emitter.close()` 收尾后台 handler、再统一释放连接池(全 SDK 共享一个连接池)。
- 接入方式的选择收敛在 `run_loop` 的 `match config.connecter` 装配分支:每种接入方式构造**自己的协议处理器**并注入对应的 connecter(主体不再持有协议层);`WebhookConnecter` 经协议处理器的 `on_frame` 就地取得 op=13 应答,不感知 `EventEmitter`。两个 connecter 的依赖面一致(config + queue + 各自所需的窄依赖 + `BaseProtocol`)。
- 用户在 `main(ee)` 之前构造的 `EventEmitter` 会被复用(连同它的队列),保证 handler 注册在主体实际使用的实例上。
- 用户入口是 `Bot` 门面(`bot.py`):`bot = Bot()` 构造即读 .env 校验配置,`@bot.on` 注册 handler,`run()` 同步启动 / `start()` 嵌入外部 asyncio 应用;装配细节不外露。
- 全部组件在 `Bot.start()` → `_assemble()` 显式构造装配,无 DI 框架,也无任何全局单例。
- 组件按类型登记进 `emitter.services`(含 `Bot` 自身,handler 标注 `bot: Bot` 即得实例),handler 形参标注类型即得实例;用户自定义组件经 `bot.services` 登记即可注入。
- 唯一的 `ClientSession` 在 `_assemble` 构造,`start()` 的 `finally` 中先 `emitter.close()` 收尾后台 handler、再统一释放连接池(全 SDK 共享一个连接池)。
- 接入方式的选择收敛在 `_assemble` 的 `match config.connecter` 装配分支:每种接入方式构造**自己的协议处理器**并注入对应的 connecter(主体不再持有协议层);`WebhookConnecter` 经协议处理器的 `on_frame` 就地取得 op=13 应答,不感知 `EventEmitter`。两个 connecter 的依赖面一致(config + queue + 各自所需的窄依赖 + `BaseProtocol`)。
## 关键设计决策
@@ -110,14 +110,14 @@ sequenceDiagram
2. **协议层只管会话语义,心跳归传输层**:`WebsocketProtocol` 负责 IDENTIFY/RESUME/序列号(说什么),`WebsocketConnecter` 负责连接与心跳(怎么保活)——心跳任务随连接生灭(HELLO 给出间隔后启动,断线即取消),断线期间不在出站缓冲堆积过期心跳,也免去了调度器的生命周期管理。协议处理器的应答经 `on_frame` 返回值交给 connecter:ws 与心跳一起进 connecter 私有的出站缓冲、由单任务发送(维持 ws 单写者不变量),webhook 则直接写进 HTTP 响应体。
3. **看门狗两层互补 + 连接寿命主动重连**(见 `connecter.py` 模块注释):静默超时抓连接彻底失联,ACK 期限检查抓"有事件流但心跳已死"。阈值 `_SILENCE_FACTOR=1.1`、`_ACK_FACTOR=1.2` 是按 ACK 自然节奏校准的,**不能压到 1× 整**,否则正常抖动会周期性误杀(误杀由 RESUME 兜底,不丢消息)。重连退避只在连接稳定存活(`_STABLE_SECONDS`)后才归一,抖动的服务端不会被 1s 间隔反复探测。此外服务端约 1 小时会**无预警**断开连接(实测),故连接存活到 `_SERVER_LIFETIME - _RECONNECT_MARGIN`(默认 50 分钟)时 SDK 主动收尾重连——收帧超时取"静默阈值"与"寿命余量"两个截止中的先到者,静默仍交给看门狗,寿命到点则正常结束本轮连接、由 `run()` 判定为健康断开后立刻 RESUME 续传。
4. **handler 异常就地隔离**:`_invoke` 捕获记日志,`dispatch` 主循环另有兜底;单条事件的单个 handler 失败不影响进程。`background=True` 的 handler 进后台任务(emit 不等它),任务由 emitter 持强引用、`close()` 统一取消。
5. **注入约定**:handler 参数按形参标注解析——`payloads.py` 里的 dataclass → 解析后的 payload 对象;`Event` → 事件封装;登记进 `emitter.services` 的组件类型(如 `BotApi`)→ 实例;未标注或未登记 → 回退传**原始 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` 的除外)。
7. **解析计划与派生密钥都预计算**:`payloads._parse_plan` 把 dataclass 的"字段名 + 是否需解析成 Author"按类缓存一次(不再每次事件重跑 `dataclasses.fields` 与联合类型解包),解析时未收录字段一律忽略——线上 payload 会带新增/未声明字段,透传会让构造报错。`crypto` 按 AppSecret 缓存派生出的 Ed25519 私钥(派生约 40µs,而每个 webhook 请求都要验签)。都是"值为输入的纯函数、结果与输入一一对应",缓存安全且收益明确;不追求极致速度,只去掉明显的重复计算。
8. **OpenAPI 请求内核自治**:`api/core.py` 统一处理鉴权头、429 限流重试(优先 `Retry-After`,脏值容错,退避封顶)与 401 刷新 token 重试一次;v2/channel/guild 三个域方法集继承内核组合成 `BotApi`,新增域加模块即可,方法对用户始终平铺在 `BotApi` 上。
## 扩展点
- **新增接入方式**(如轮询/其他平台):继承 `Connecter` 实现 `run()`、继承 `BaseProtocol` 实现 `on_frame()`,在 `run_loop` 的 `match` 装配分支加一个 `case`(构造该方式的协议处理器并注入 connecter),`Config.load` 的白名单加一个名字。
- **新增接入方式**(如轮询/其他平台):继承 `Connecter` 实现 `run()`、继承 `BaseProtocol` 实现 `on_frame()`,在 `Bot._assemble` 的 `match` 装配分支加一个 `case`(构造该方式的协议处理器并注入 connecter),`Config.load` 的白名单加一个名字。
- **新增业务 API**:在 `api/` 对应域模块(`v2`/`channel`/`guild`)加方法即自动平铺到 `BotApi`,全部获得鉴权、限流重试与错误处理;新增域则新建模块并组合进 `api/__init__.py` 的 `BotApi`。群/单聊消息经 `_post_message` 统一处理 msg_id/msg_seq/media 语义;已有方法覆盖:单聊/群聊消息与富媒体上传、频道消息/撤回/表态、公告、精华消息、日程、禁言、频道信息查询(guild/channels/roles/member)。
- **新增事件类型 dataclass**:在 `payloads.py` 定义 dataclass 并登记 `EVENT_TYPES`,handler 即可按类型标注拿解析结果;未收录事件回退传 dict。当前已覆盖 intents.toml 的全部事件组(消息、成员变动、审核、表态、互动、论坛、音频)。
-242
View File
@@ -1,242 +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://gitea.4i.hk/hhhge/qqbotsdk # 从仓库安装
uv add /path/to/qqbotsdk # 本地路径安装
```
要求 Python ≥ 3.13。
## 配置
在项目根目录放 `.env`(SDK 启动时自动加载,`APPID`/`APPSECRET` 缺失启动即报错):
```ini
APPID=你的AppID
APPSECRET=你的AppSecret
CONNECTER=websocket # websocket 或 webhook,默认 websocket
BASE_URL=https://api.bot.qq.com # 可选,官方 API 地址
TIMEOUT=5000 # 可选,HTTP 请求超时(毫秒)
INTENTS_FILE=intents.toml # 可选,事件订阅配置文件路径
WEBHOOK_HOST=0.0.0.0 # 以下仅 webhook 接入时生效
WEBHOOK_PORT=8080
WEBHOOK_PATH=/qqbot/webhook
```
## 事件订阅(intents.toml)
SDK 启动时读取 `intents.toml` 计算订阅掩码。订阅粒度就是下面表格里的大类(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 配置格式与落盘。
+14
View File
@@ -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` |
+118
View File
@@ -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 位掩码 |
+57
View File
@@ -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` 为事件名 → 类的完整登记表。
+26
View File
@@ -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 万 |
+62
View File
@@ -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} |
+16
View File
@@ -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`)填入输入框,常与斜线指令配合 |
+49
View File
@@ -0,0 +1,49 @@
# 群聊与单聊 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_members(group_openid, members)` | POST /v2/groups/{group_openid}/restrict_chat_setting | 批量禁言/解禁;`members` 每项为 `{"op": "add"/"update"/"del", "member_openid": …, "mute_expire_at": …}`(RFC3339):add 新增禁言、update 更新到期时间、del 解禁(expire 可传空串)。单次最多 20 人、最长禁言 30 天、仅可操作普通成员(群主/管理员/机器人不可);需群管理员身份,60 QPM |
| `mute_group_member(group_openid, member_openid, mute_expire_at)` | 同上 | 单人禁言封装(op=add) |
| `unmute_group_member(group_openid, member_openid)` | 同上 | 单人解禁封装(op=del) |
| `prepare_group_upload(group_openid, file_type, file_size, file_name, md5, sha1, md5_10m)` | POST /v2/groups/{group_openid}/upload_prepare | 大文件分片预上传,返回 `upload_id`/`block_size`/各分片预签名 `parts`;逐片 PUT 后调 `finish_group_upload_part`,全部完成后携 `upload_id` 调 `upload_group_file` 合并;`md5_10m` 为文件前约 10MB 的 MD5 |
| `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`;禁言 `MUTE_OP_ADD`/`MUTE_OP_UPDATE`/`MUTE_OP_DEL`。
按钮构造:`button(label, data, *, type=1, permission=2, style=1)` 生成单个按钮 dict,`keyboard(*rows)` 组装为 `keyboard=` 参数(单按钮独占一行,按钮列表同行并排)。点击回调经 `INTERACTION_CREATE` 事件处理,见[收发消息](/docs/guide/messaging.md#markdown-与按钮msg_type2)。
+94
View File
@@ -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`。
+74
View File
@@ -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、后登记组件"的顺序可用。
+92
View File
@@ -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` 隐藏撤回小灰条)。
+32
View File
@@ -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 配置格式与落盘。
+78
View File
@@ -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
View File
@@ -22,4 +22,4 @@ uv run python examples/webhook_bot/main.py
Webhook 示例启动后,把 `https://<你的域名>:<端口>/qqbot/webhook` 填到开放平台回调地址;保存时平台先发 op=13 验证请求,SDK 自动完成签名应答。
接入方式切换与 handler 写法详见 [使用指南](docs/GUIDE.md#接入方式选择)。
接入方式切换见[配置与订阅](../docs/guide/config.md#接入方式选择),handler 写法见[事件处理](../docs/guide/handlers.md)。
+28 -12
View File
@@ -10,19 +10,35 @@
from loguru import logger
from qqbotsdk import EventEmitter, main
from qqbotsdk import Bot
from qqbotsdk.api import BotApi, button, keyboard
from qqbotsdk.events import Event
from qqbotsdk.payloads import C2CMessage, GroupAddRobot, GroupAtMessage, Interaction
from qqbotsdk.payloads import (
C2CMessage,
FriendAdd,
GroupAddRobot,
GroupAtMessage,
Interaction,
)
ee = EventEmitter()
bot = Bot()
@ee.on("GROUP_AT_MESSAGE_CREATE")
async def on_group_message(msg: GroupAtMessage, api: BotApi) -> None:
@bot.on("GROUP_AT_MESSAGE_CREATE")
async def on_group_message(msg: GroupAtMessage, api: BotApi, bot: Bot) -> None:
if not (msg.group_openid and msg.id):
return
# "/me":经 bot.call_api 调通用 REST 接口(未封装接口也走这里)
if (msg.content or "").strip() == "/me":
me = await bot.call_api("GET", "/users/@me")
await api.post_group_message(
msg.group_openid,
content=f"我是 {me.get('username', '机器人')}",
msg_id=msg.id,
)
return
# "/img <图片url>":上传富媒体后随消息发出(msg_type 自动置为 7)
if (msg.content or "").startswith("/img "):
url = (msg.content or "").removeprefix("/img ").strip()
@@ -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:
if inter.chat_type != 1 or not inter.group_openid:
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:
# 入群是事件而非消息:用信封顶层的事件 id 作 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:
user_openid = msg.user_openid
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")
async def on_friend_add(d: dict) -> None:
logger.info(f"新增用户:{d.get('user_openid')}")
@bot.on("FRIEND_ADD")
async def on_friend_add(added: FriendAdd) -> None:
logger.info(f"新增用户:{added.openid}")
if __name__ == "__main__":
main(ee)
bot.run()
+4 -4
View File
@@ -11,14 +11,14 @@
运行:uv run python examples/webhook_bot/main.py
"""
from qqbotsdk import EventEmitter, main
from qqbotsdk import Bot
from qqbotsdk.api import BotApi
from qqbotsdk.payloads import GroupAtMessage
ee = EventEmitter()
bot = Bot()
@ee.on("GROUP_AT_MESSAGE_CREATE")
@bot.on("GROUP_AT_MESSAGE_CREATE")
async def on_group_message(msg: GroupAtMessage, api: BotApi) -> None:
if not (msg.group_openid and msg.id):
return
@@ -30,4 +30,4 @@ async def on_group_message(msg: GroupAtMessage, api: BotApi) -> None:
if __name__ == "__main__":
main(ee)
bot.run()
+13 -1
View File
@@ -24,10 +24,12 @@
<script>
window.$docsify = {
name: 'qqbotsdk',
repo: 'https://gitea.4i.hk/hhhge/qqbotsdk',
repo: 'https://github.com/snowy-yang/qqbotsdk',
// 相对链接按当前页面所在目录解析(与 GitHub 行为一致),docs/*.md 的互链才能正确跳转
relativePath: true,
loadSidebar: true,
// 侧边栏分组("使用指南"/"API 参考")默认展开一级
sidebarDisplayLevel: 1,
subMaxLevel: 3,
auto2top: true,
themeColor: '#0f8bff',
@@ -50,6 +52,14 @@
alias: {
'/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: [
// 把 ```mermaid 代码块替换为 div 后交给 mermaid 渲染(架构图/时序图)
@@ -72,6 +82,8 @@
</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,先加载会被覆盖) -->
<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>
+24 -7
View File
@@ -1,6 +1,6 @@
[project]
name = "qqbotsdk"
version = "0.1.0"
name = "qqbotsdk-py"
version = "0.2.1"
description = "QQ 官方机器人 Python SDK(WebSocket / Webhook)"
license = "MIT"
license-files = ["LICENSE"]
@@ -8,7 +8,18 @@ readme = "README.md"
authors = [
{ 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 = [
"aiohttp>=3.14.3",
"cryptography>=50.0.1",
@@ -17,13 +28,19 @@ dependencies = [
"ujson>=6.0.0",
]
[project.scripts]
qqbotsdk = "qqbotsdk:main"
[project.urls]
Homepage = "https://github.com/snowy-yang/qqbotsdk"
Documentation = "https://qqbotsdk.4i.hk"
Repository = "https://github.com/snowy-yang/qqbotsdk"
Changelog = "https://github.com/snowy-yang/qqbotsdk/blob/main/CHANGELOG.md"
[build-system]
requires = ["uv_build>=0.12.7,<0.13.0"]
build-backend = "uv_build"
[tool.uv.build-backend]
module-name = "qqbotsdk"
[dependency-groups]
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]
target-version = "py313"
target-version = "py312"
line-length = 88
indent-width = 4
@@ -71,7 +88,7 @@ line-ending = "auto"
[tool.pyright]
typeCheckingMode = "standard"
pythonVersion = "3.13"
pythonVersion = "3.12"
include = ["src"]
exclude = ["**/__pycache__", "**/.*", "**/node_modules", ".venv"]
reportMissingImports = true
-26
View File
@@ -1,26 +0,0 @@
#!/usr/bin/env bash
# 组装纯文档目录并部署到 Cloudflare Pages(docsify 零构建,Direct Upload)。
#
# 用法:scripts/deploy_docs.sh [项目名] # 项目名默认取 PROJECT_NAME,再默认 qqbotsdk-docs
# 本地首次运行会自动唤起 wrangler 浏览器授权(或预先 export CLOUDFLARE_API_TOKEN);
# 项目不存在时 wrangler 会自动创建,站点地址 https://<项目名>.pages.dev。
# CI(.gitea/workflows/deploy-docs.yml)经 CLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_ID 鉴权调用本脚本。
set -euo pipefail
cd "$(dirname "$0")/.."
PROJECT_NAME="${1:-${PROJECT_NAME:-qqbotsdk-docs}}"
rm -rf dist
mkdir dist
# docsify 站点只由这些 markdown 和 index.html 组成,静态资源全部走 CDN——
# 只挑文档文件,不把源码仓库整个传上站点
cp index.html README.md _sidebar.md CHANGELOG.md dist/
cp -r docs examples dist/
cp .nojekyll dist/ 2>/dev/null || true
# examples/ 里的 Python 字节码缓存不该上站点
find dist -type d -name __pycache__ -exec rm -rf {} +
# 新版 wrangler 的 pages deploy 不再自动建项目:先幂等创建(已存在则忽略报错)
npx wrangler pages project create "$PROJECT_NAME" --production-branch=main >/dev/null 2>&1 || true
npx wrangler pages deploy dist --project-name="$PROJECT_NAME" --branch=main --commit-dirty=true
+25 -67
View File
@@ -1,83 +1,41 @@
"""qqbotsdk:QQ 官方机器人 SDK 入口。
用法:构造 `EventEmitter()` 注册 handler,交给 `main(ee)`
启动;连接方式由 .env 的 CONNECTER 决定(websocket / webhook)。
用法:`bot = Bot()` → `@bot.on(...)` 注册 handler → `bot.run()` 启动;
连接方式由 .env 的 CONNECTER 决定(websocket / webhook)。
架构详情见 docs/ARCHITECTURE.md。
"""
import asyncio
from aiohttp import ClientSession
from dotenv import load_dotenv
from loguru import logger
from .api import BotApi
from .config import Config
from .bot import Bot as Bot
from .config import Config as Config
from .connecter import Connecter as Connecter
from .connecter import WebhookConnecter, WebsocketConnecter
from .emitter import EventEmitter as EventEmitter
from .model import Intent as Intent
from .protocol import BaseProtocol as BaseProtocol
from .queue import EventQueue as EventQueue
from .session import Session
from .token import AccessToken
from .webhook_protocol import WebhookProtocol
from .ws_protocol import WebsocketProtocol
from .session import Session as Session
from .token import AccessToken as AccessToken
from .webhook_protocol import WebhookProtocol as WebhookProtocol
from .ws_protocol import WebsocketProtocol as WebsocketProtocol
load_dotenv()
async def run_loop(emitter: EventEmitter | None = None) -> None:
"""启动 SDK。可传入外部构造的 EventEmitter 以注册 handler;不传时自建。
全部组件在此显式装配,并按类型登记到 emitter.services 供 handler
参数注入;共享的 HTTP 连接池随 run_loop 结束释放。
"""
if emitter is None:
emitter = EventEmitter()
config = Config.load()
http = ClientSession(timeout=config.timeout)
session = Session()
token = AccessToken(config, http)
api = BotApi(config, http, token)
emitter.services.update(
{
EventEmitter: emitter,
EventQueue: emitter.queue,
Config: config,
Session: session,
ClientSession: http,
AccessToken: token,
BotApi: api,
}
)
# 各接入方式配自己的协议处理器:协议帧由适配器就近处理,只有业务事件
# (op=0)入队交给 emitter 分发(见 protocol.py / connecter.py)
match config.connecter:
case "websocket":
protocol = WebsocketProtocol(config, session, token)
connecter = WebsocketConnecter(
config, http, token, session, emitter.queue, protocol
)
case "webhook":
protocol = WebhookProtocol(config)
connecter = WebhookConnecter(config, emitter.queue, protocol)
case unknown:
raise ValueError(f"未知的 CONNECTER: {unknown}")
logger.info(f"qqbotsdk 启动,连接方式: {config.connecter}")
try:
await asyncio.gather(emitter.dispatch(), connecter.run())
finally:
# 在飞的后台 handler 先于连接池收尾,避免用到已关闭的 ClientSession
await emitter.close()
# 全 SDK 共享一个连接池,统一在此释放
await http.close()
def main(emitter: EventEmitter | None = None) -> None:
try:
asyncio.run(run_loop(emitter))
except KeyboardInterrupt:
logger.info("qqbotsdk 已退出")
__all__ = [
"AccessToken",
"BaseProtocol",
"Bot",
"BotApi",
"Config",
"Connecter",
"EventEmitter",
"EventQueue",
"Intent",
"Session",
"WebhookConnecter",
"WebhookProtocol",
"WebsocketConnecter",
"WebsocketProtocol",
]
+1 -3
View File
@@ -87,9 +87,7 @@ class ChannelApi(BaseApi):
"""
return await self.get(f"/channels/{channel_id}/members/{user_id}/permissions")
async def get_channel_role_permissions(
self, channel_id: str, role_id: str
) -> dict:
async def get_channel_role_permissions(self, channel_id: str, role_id: str) -> dict:
"""获取子频道指定身份组的权限(GET .../roles/{role_id}/permissions)。"""
return await self.get(f"/channels/{channel_id}/roles/{role_id}/permissions")
+7 -5
View File
@@ -31,7 +31,9 @@ class GroupApi(BaseApi):
"""
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)。
每次最多返回 30 条,翻页透传上次响应的 next_cursor(空串=末页)。
@@ -44,9 +46,7 @@ class GroupApi(BaseApi):
返回昵称、角色(member/owner/admin)、入群时间等。
"""
return await self.get(
f"/v2/groups/{group_openid}/members/{member_openid}"
)
return await self.get(f"/v2/groups/{group_openid}/members/{member_openid}")
async def get_group_bot_state(self, group_openid: str) -> dict:
"""获取机器人在指定群内的状态(GET .../bot_state)。
@@ -247,7 +247,9 @@ class GroupApi(BaseApi):
对策略关联的全部群发起全量扫描,命中白名单号码的入群申请自动
审批通过;异步执行,约 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(
self, strategy_id: str, op: str, whitelist_users: list[str]
+1 -3
View File
@@ -247,9 +247,7 @@ class GuildApi(BaseApi):
需要管理员权限(同身份组的成员也可操作)。
"""
return await self.put(
f"/guilds/{guild_id}/members/{user_id}/roles/{role_id}"
)
return await self.put(f"/guilds/{guild_id}/members/{user_id}/roles/{role_id}")
async def remove_guild_member_role(
self, guild_id: str, user_id: str, role_id: str
+35 -5
View File
@@ -28,6 +28,10 @@ STREAM_GENERATING = 1 # input_state:生成中
STREAM_FINISHED = 10 # input_state:生成结束
STREAM_CONTENT_TEXT = "text"
STREAM_CONTENT_MARKDOWN = "markdown"
# 群禁言 members[].op 取值(restrict_chat_setting)
MUTE_OP_ADD = "add"
MUTE_OP_UPDATE = "update"
MUTE_OP_DEL = "del"
def button(
@@ -384,15 +388,41 @@ class V2Api(BaseApi):
"""撤回单聊消息(DELETE /v2/users/{openid}/messages/{message_id})。"""
return await self._withdraw_v2_message(f"/v2/users/{openid}", message_id)
async def mute_group_member(self, group_openid: str, mute_expire_at: str) -> dict:
"""群成员禁言(POST /v2/groups/{group_openid}/restrict_chat_setting)。
async def mute_group_members(
self, group_openid: str, members: list[dict[str, Any]]
) -> dict:
"""批量设置群成员禁言/解禁(POST .../restrict_chat_setting)。
mute_expire_at 为禁言到期时间(RFC3339 格式,如
"2026-01-01T12:00:00+08:00");传当前时间之前即解除禁言。
members 单次最多 20 项,每项为 {"op": MUTE_OP_ADD/MUTE_OP_UPDATE/
MUTE_OP_DEL, "member_openid": ..., "mute_expire_at": RFC3339 时间}:
add 新增禁言、update 更新到期时间、del 解除禁言(expire 可传
空串)。最长禁言 30 天;仅可操作普通成员(群主/管理员/机器人
不可);需群管理员身份,60 QPM。
"""
return await self.post(
f"/v2/groups/{group_openid}/restrict_chat_setting",
json={"mute_expire_at": mute_expire_at},
json={"members": members},
)
async def mute_group_member(
self, group_openid: str, member_openid: str, mute_expire_at: str
) -> dict:
"""禁言单个群成员(mute_group_members 的 op=add 封装)。"""
return await self.mute_group_members(
group_openid,
[
{
"op": MUTE_OP_ADD,
"member_openid": member_openid,
"mute_expire_at": mute_expire_at,
}
],
)
async def unmute_group_member(self, group_openid: str, member_openid: str) -> dict:
"""解除单个群成员禁言(mute_group_members 的 op=del 封装)。"""
return await self.mute_group_members(
group_openid, [{"op": MUTE_OP_DEL, "member_openid": member_openid}]
)
async def generate_url_link(self, callback_data: str | None = None) -> dict:
+144
View File
@@ -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 已退出")
+12
View File
@@ -26,6 +26,7 @@ class Config:
webhook_host: str
webhook_port: int
webhook_path: str
intents: int | None = None
@classmethod
def load(cls) -> "Config":
@@ -34,6 +35,16 @@ class Config:
raise ValueError(f"未知的接入方式: {connecter}")
# TIMEOUT 以毫秒配置
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(
app_id=_required_env("APPID"),
app_secret=_required_env("APPSECRET"),
@@ -44,4 +55,5 @@ class Config:
webhook_host=os.getenv("WEBHOOK_HOST", "0.0.0.0"),
webhook_port=int(os.getenv("WEBHOOK_PORT", "8080")),
webhook_path=os.getenv("WEBHOOK_PATH", "/qqbot/webhook"),
intents=intents,
)
+2 -4
View File
@@ -16,9 +16,8 @@
"""
import asyncio
from abc import ABC, abstractmethod
from time import monotonic
from typing import cast
from typing import Protocol, cast
import aiohttp
import ujson
@@ -34,10 +33,9 @@ from .session import Session
from .token import AccessToken
class Connecter(ABC):
class Connecter(Protocol):
"""连接适配器接口:两种接入方式都实现 `run()` 常驻运行。"""
@abstractmethod
async def run(self) -> None: ...
+9 -6
View File
@@ -4,8 +4,8 @@
按 t 名路由到 `@on` 注册的 handler;协议帧(HELLO/INVALID_SESSION/op=13 等)
不进队列,由连接适配器的协议处理器就地处理(见 protocol.py);
- handler 参数按形参标注注入:payload dataclass → 解析对象,`Event` →
事件封装,`services` 里登记的组件类型 → 实例,其余直传原始 d
(可能是 dict、标量甚至 None);
事件封装,`services` 里登记的组件类型 → 实例;无标注或标注三者皆非
则解析即抛错(经异常隔离记日志)——不注入原始 d;
- 业务 handler 返回值不回流;handler 异常就地隔离,不影响其他 handler。
- `on(..., background=True)` 把 handler 放进后台任务并发执行:
不阻塞事件流,但同事件 handler 间失去先后保证;
@@ -144,9 +144,8 @@ class EventEmitter:
self, fn: Callable[..., Awaitable[Any]], event: Event
) -> list[Any]:
"""按 handler 形参标注解析:payload dataclass → 解析对象,`Event` → 事件
封装,`services` 登记的组件类型 → 实例,其余(含无标注)→ 原始 d
(Event.data 是 dict 化视图,标量 d 如 INVALID_SESSION 的 true/false
必须走这里才能拿到原值)。"""
封装,`services` 登记的组件类型 → 实例;无标注或标注三者皆非则抛
ValueError——原始 d 不注入,静默传 dict 比报错更难排查。"""
cached = self._signatures.get(fn)
if cached is None:
cached = (
@@ -166,5 +165,9 @@ class EventEmitter:
elif hint in self.services:
args.append(self.services[hint])
else:
args.append(event.raw.get("d"))
raise ValueError(
f"事件处理器 {getattr(fn, '__qualname__', fn)} 的形参 {name} "
f"不可注入(标注 {hint!r} 只能是 payload dataclass、Event "
"或已登记进 services 的组件类型)"
)
return args
+3 -3
View File
@@ -1,9 +1,9 @@
"""事件 payload 的 dataclass 定义与解析。
handler 直接标注具体类型即可拿到解析后的对象(如
`async def on_msg(msg: GroupAtMessage)`);未收录的事件类型
由 emitter 回退传原始 dict,新增事件只需在这里加 dataclass
并登记到 EVENT_TYPES。
`async def on_msg(msg: GroupAtMessage)`);未收录的事件类型没有可标注的
dataclass,只能经 `Event.typed` 拿到原始 dict——新增事件只需在这里加
dataclass 并登记到 EVENT_TYPES。
"""
from dataclasses import dataclass, fields
+3 -4
View File
@@ -7,19 +7,18 @@ HELLO/READY/INVALID_SESSION/op=13 这类协议帧在适配器内就地处理,
与 `webhook_protocol.py`。
"""
from abc import ABC, abstractmethod
from typing import Any
from typing import Any, Protocol
from .model import Payload
class BaseProtocol(ABC):
class BaseProtocol(Protocol):
"""适配器专有的协议处理器:实现方返回需要回送的响应体。"""
@abstractmethod
async def on_frame(self, payload: Payload) -> Payload | dict[str, Any] | None:
"""处理一条帧,返回需回送的响应体(无需回送则 None)。
IDENTIFY/RESUME 等握手应答、op=13 验证应答都经返回值回送;业务事件
(op=0)不必处理,连接器会照常入队分发。
"""
...
+18 -5
View File
@@ -41,10 +41,6 @@ def get_intents(path: str) -> int:
intent = INTENT_GROUPS.get(group)
if intent is None:
raise ValueError(f"未知的 intents 分组: {group}")
if isinstance(enabled, dict):
raise ValueError(
f"intents 分组 {group} 不支持组内事件开关,请改为 {group} = true/false"
)
if not isinstance(enabled, bool):
raise ValueError(
f"intents 分组 {group} 的值应为 true/false,实际为 {enabled!r}"
@@ -54,6 +50,23 @@ def get_intents(path: str) -> int:
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):
def __init__(self, config: Config, session: Session, token: AccessToken) -> None:
self._config = config
@@ -91,7 +104,7 @@ class WebsocketProtocol(BaseProtocol):
op = Opcode.IDENTIFY
d = {
"token": f"QQBot {access_token}",
"intents": get_intents(self._config.intents_file),
"intents": resolve_intents(self._config),
"shard": (0, 1),
"properties": {},
}
+36 -10
View File
@@ -116,10 +116,28 @@ async def test_mute_and_schedule_official_field_names():
assert info["remind_type"] == "0"
assert "schedule_info" not in kwargs["json"]
await api.mute_group_member("G1", "2026-01-01T12:00:00+08:00")
await api.mute_group_member("G1", "M1", "2026-01-01T12:00:00+08:00")
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/groups/G1/restrict_chat_setting")
assert kwargs["json"] == {"mute_expire_at": "2026-01-01T12:00:00+08:00"}
assert kwargs["json"] == {
"members": [
{
"op": "add",
"member_openid": "M1",
"mute_expire_at": "2026-01-01T12:00:00+08:00",
}
]
}
await api.unmute_group_member("G1", "M1")
assert cap["call"][2]["json"] == {"members": [{"op": "del", "member_openid": "M1"}]}
members = [
{"op": "add", "member_openid": "M1", "mute_expire_at": "t1"},
{"op": "update", "member_openid": "M2", "mute_expire_at": "t2"},
]
await api.mute_group_members("G1", members)
assert cap["call"][2]["json"] == {"members": members}
async def test_me():
@@ -298,7 +316,9 @@ async def test_chunked_upload_group_and_c2c():
await api.prepare_c2c_upload("U1", **common)
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"]
assert (method, path) == ("POST", "/v2/users/U1/upload_part_finish")
assert kwargs["json"] == {
@@ -377,16 +397,18 @@ async def test_group_join_request_flow():
assert (method, path) == ("GET", "/v2/groups/G1/join_request_list")
assert kwargs["params"] == {"limit": 10}
await api.review_group_join_request(
"G1", "M1", "approve", join_request_id="j1"
)
await api.review_group_join_request("G1", "M1", "approve", join_request_id="j1")
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/groups/G1/approval_join_request/M1")
assert kwargs["json"] == {"op": "approve", "join_request_id": "j1"}
await api.review_group_join_request(
"G1", "M1", "decline", join_request_id="j1",
reject_reason="广告", add_to_member_blacklist=True,
"G1",
"M1",
"decline",
join_request_id="j1",
reject_reason="广告",
add_to_member_blacklist=True,
)
body = cap["call"][2]["json"]
assert body == {
@@ -689,7 +711,9 @@ async def test_guild_members_mutes_and_api_permission():
await api.get_guild_members("GID")
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"]
assert (method, path) == ("DELETE", "/guilds/GID/members/U1")
assert kwargs["json"] == {"add_blacklist": True, "delete_history_msg_days": 7}
@@ -717,7 +741,9 @@ async def test_guild_members_mutes_and_api_permission():
await api.get_guild_api_permissions("GID")
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"]
assert (method, path) == ("POST", "/guilds/GID/api_permission/demand")
assert kwargs["json"] == {
+146
View File
@@ -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
View File
@@ -26,7 +26,7 @@ async def test_same_handler_registered_twice_runs_once():
emitter = EventEmitter()
calls: list[str] = []
async def handler(_):
async def handler():
calls.append("hit")
emitter.on("READY", handler)
@@ -41,11 +41,11 @@ async def test_handlers_run_in_registration_order():
order: list[int] = []
@emitter.on("READY")
async def first(_):
async def first():
order.append(1)
@emitter.on("READY")
async def second(_):
async def second():
order.append(2)
await emitter.emit({"op": Opcode.DISPATCH, "t": "READY", "d": {}})
@@ -59,11 +59,11 @@ async def test_handler_exception_is_isolated():
called: list[str] = []
@emitter.on("READY")
async def bad(_):
async def bad():
raise RuntimeError("boom")
@emitter.on("READY")
async def good(_):
async def good():
called.append("ok")
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] = []
@emitter.on(Opcode.HEARTBEAT)
async def beat(d):
called.append(d)
async def beat():
called.append("hit")
await emitter.emit({"op": Opcode.HEARTBEAT, "d": 0})
assert called == []
@@ -94,11 +94,11 @@ async def test_service_annotation_resolves_from_services():
store = Store()
emitter.services[Store] = store
received: list[tuple[GroupAtMessage, Store, dict]] = []
received: list[tuple[GroupAtMessage, Store]] = []
@emitter.on("GROUP_AT_MESSAGE_CREATE")
async def handler(msg: GroupAtMessage, st: Store, d):
received.append((msg, st, d))
async def handler(msg: GroupAtMessage, st: Store):
received.append((msg, st))
await emitter.emit(
{
@@ -108,24 +108,25 @@ async def test_service_annotation_resolves_from_services():
}
)
assert len(received) == 1
msg, got, d = received[0]
msg, got = received[0]
assert got is store
assert isinstance(msg, GroupAtMessage) and msg.content == "hi"
assert d == {"content": "hi", "group_openid": "G1"}
@pytest.mark.asyncio
async def test_unregistered_annotation_falls_back_to_raw_dict():
queue = EventQueue()
emitter = EventEmitter(queue)
received: list[dict] = []
async def test_unresolvable_param_raises_instead_of_raw_dict():
"""原始 d 不再注入:无标注或标注不可解析的形参直接抛错(由异常隔离记日志)。"""
emitter = EventEmitter()
received: list[object] = []
@emitter.on("READY")
async def handler(dep: Session): # Session 未登记进 services
received.append(dep)
await emitter.emit({"op": Opcode.DISPATCH, "t": "READY", "d": {"session_id": "s1"}})
assert received == [{"session_id": "s1"}]
event = Event({"op": Opcode.DISPATCH, "t": "READY", "d": {"session_id": "s1"}})
with pytest.raises(ValueError, match="不可注入"):
emitter._resolve_params(handler, event)
assert received == []
@pytest.mark.asyncio
+51 -2
View File
@@ -1,7 +1,7 @@
import pytest
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)
@@ -51,7 +51,7 @@ def test_non_bool_value_raises(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")
with pytest.raises(ValueError, match="组内事件开关"):
with pytest.raises(ValueError, match="true/false"):
get_intents(path)
@@ -74,3 +74,52 @@ def test_config_rejects_unknown_connecter(monkeypatch):
monkeypatch.setenv("CONNECTER", "carrier-pigeon")
with pytest.raises(ValueError, match="carrier-pigeon"):
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
-15
View File
@@ -179,21 +179,6 @@ async def test_typed_annotation_receives_dataclass():
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():
from qqbotsdk.payloads import (
GroupJoinRequest,
+14 -1
View File
@@ -17,7 +17,9 @@ class FakeToken:
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()
config = Config(
app_id="app",
@@ -29,6 +31,7 @@ def make_protocol(session: Session | None = None) -> tuple[WebsocketProtocol, Se
webhook_host="0.0.0.0",
webhook_port=8080,
webhook_path="/x",
intents=intents,
)
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)
@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
async def test_hello_with_session_resumes():
protocol, session = make_protocol()
Generated
+123 -3
View File
@@ -1,6 +1,6 @@
version = 1
revision = 3
requires-python = ">=3.13"
requires-python = ">=3.12"
[[package]]
name = "aiohappyeyeballs"
@@ -22,10 +22,29 @@ dependencies = [
{ name = "frozenlist" },
{ name = "multidict" },
{ name = "propcache" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ 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" }
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/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" },
@@ -98,6 +117,7 @@ version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ 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" }
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" }
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/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" },
@@ -251,6 +283,22 @@ version = "1.8.0"
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" }
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/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" },
@@ -355,6 +403,24 @@ version = "6.7.1"
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" }
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/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" },
@@ -463,6 +529,23 @@ version = "0.5.2"
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" }
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/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" },
@@ -587,6 +670,7 @@ version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ 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" }
wheels = [
@@ -603,8 +687,8 @@ wheels = [
]
[[package]]
name = "qqbotsdk"
version = "0.1.0"
name = "qqbotsdk-py"
version = "0.2.1"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -679,6 +763,19 @@ version = "6.0.0"
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" }
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/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" },
@@ -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/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/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]]
@@ -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" }
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/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" },