From 097bc96d8be7cdf01177d96608ca9c2ec4cc7792 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:42:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8C=89=E9=92=AE=E5=BF=AB=E6=8D=B7?= =?UTF-8?q?=E6=9E=84=E9=80=A0=E3=80=81event=5Fid=20=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E4=B8=8E=20TokenError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++ docs/API.md | 7 +++- docs/GUIDE.md | 36 ++++++++++++------ examples/group_bot/main.py | 42 +++++++++++++++++---- pyproject.toml | 1 + src/qqbotsdk/api.py | 41 ++++++++++++++++++++- src/qqbotsdk/connecter.py | 8 ++-- src/qqbotsdk/events.py | 10 +++++ src/qqbotsdk/model.py | 5 --- src/qqbotsdk/payloads.py | 13 ++++++- src/qqbotsdk/token.py | 28 +++++++++++++- tests/test_api.py | 67 +++++++++++++++++++++++++++++++++- tests/test_events.py | 39 ++++++++++++++++++++ tests/test_payloads.py | 22 +++++++++++ tests/test_token.py | 64 ++++++++++++++++++++++++++++++++ uv.lock | 75 ++++++++++++++++++++++++++++++++++++++ 16 files changed, 431 insertions(+), 35 deletions(-) create mode 100644 tests/test_events.py create mode 100644 tests/test_token.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c08c0a..cece813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### 新增 - Markdown 与内嵌按钮一等支持:`post_group_message`/`post_c2c_message` 新增 `markdown`/`keyboard` 参数(自动置 msg_type=2,与 content 互斥);新增 `respond_interaction()` 回应按钮回调;`Interaction` payload 补齐 `chat_type`/`scene`/`group_openid` 等官方字段 +- `Event` 新增 `event_id` 属性(推送信封顶层的事件 id,与 op/t/d 平级):非消息事件(GROUP_ADD_ROBOT 等)的被动回复不再需要手动 `event.raw["id"]` +- 按钮快捷构造与点击处理:`button()`/`keyboard()` 生成内嵌按钮(免手写嵌套 dict);`Interaction` 新增 `button_data` 属性(被点按钮的 action.data);examples/group_bot 补 `/btn` 卡片与 `INTERACTION_CREATE` 回调示例 ### 变更 @@ -17,6 +19,12 @@ - 音乐插件示例(`plugins/`、根目录 `main.py`)已拆分为独立的点歌 bot 项目(含 Markdown 卡片、按钮回调与内置短网址多线程下载代理),不再随 SDK 仓库分发 +### 修复 + +- 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 + ## [0.1.0] - 2026-09-14 首个公开版本:QQ 官方机器人 Python SDK(Python 3.13 / asyncio / aiohttp)。 diff --git a/docs/API.md b/docs/API.md index 61a6bc0..87334dc 100644 --- a/docs/API.md +++ b/docs/API.md @@ -69,7 +69,8 @@ async def handler(...): ... | `.user_id` | 用户标识(兼容 `user_id`/`from_user_id`) | | `.group_id` | `group_openid` | | `.content` | 消息文本 | -| `.message_id` | `d["id"]`,被动回复用 | +| `.message_id` | `d["id"]`,消息事件的被动回复凭据(msg_id) | +| `.event_id` | 推送信封顶层的事件 id(与 op/t/d 平级,形如 `INTERACTION_CREATE:uuid`);非消息事件(`INTERACTION_CREATE`/`GROUP_ADD_ROBOT` 等)的被动回复凭据只有这里能拿到,事件体里的裸 UUID 不行(官方点名的常见坑) | ## Config @@ -102,6 +103,8 @@ async def handler(...): ... 常量:`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`。 +按钮构造:`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) | 方法 | 端点 | 说明 | @@ -164,6 +167,8 @@ async def handler(...): ... | `.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`。 diff --git a/docs/GUIDE.md b/docs/GUIDE.md index f67b00d..572f08b 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -106,7 +106,7 @@ handler 参数按标注解析的完整规则: | 参数标注 | 得到 | |---|---| | payload dataclass(如 `GroupAtMessage`) | 解析后的 dataclass 对象;未收录事件回退原始 dict | -| `Event` | 事件封装对象(`.raw`/`.data`/`.typed`/`.user_id`/`.group_id`/`.content`/`.message_id`) | +| `Event` | 事件封装对象(`.raw`/`.data`/`.typed`/`.user_id`/`.group_id`/`.content`/`.message_id`/`.event_id`) | | 组件类型(`BotApi`/`Session`/`Config` 等) | `emitter.services` 中按类型登记的实例;未登记回退传原始 dict | | 无标注 | 原始事件 dict | @@ -121,7 +121,7 @@ handler 参数按标注解析的完整规则: ### 被动回复(最常用) -携带 `msg_id`(回复某条消息)或 `event_id`(响应某类事件,二选一)即为被动回复,**不需要任何消息频率白名单**: +携带 `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 次**; @@ -164,23 +164,36 @@ await api.post_group_message(group_openid, content="# 标题\n正文", ### Markdown 与按钮(msg_type=2) -`post_group_message`/`post_c2c_message` 原生支持 Markdown 与内嵌按钮(keyboard): +`button()`/`keyboard()`(`from qqbotsdk.api import button, keyboard`)构造内嵌按钮,配合 `markdown=` 一起发(msg_type 自动置 2): ```python await api.post_group_message( group_openid, - markdown="## 标题\n**加粗**正文", # msg_type 自动置 2,与 content 互斥 - keyboard={"content": {"rows": [{"buttons": [{ - "id": "btn1", - "render_data": {"label": "点我", "style": 1}, - "action": {"type": 1, "permission": {"type": 2}, "data": "我的按钮数据"}, - }]}}]}, + markdown="## 标题\n**加粗**正文", # 与 content 互斥 + keyboard=keyboard( + button("点我", data="我的按钮数据"), # 单按钮独占一行 + [button("赞", data="like"), button("踩", data="dislike")], # 列表同行并排 + ), msg_id=msg.id, ) ``` -- `action.type`:1 回调(点击推 `INTERACTION_CREATE` 事件,`data.resolved.button_data` 携带按钮 data)、2 指令(往输入框插入 data)、0 跳转链接; -- 收到 `INTERACTION_CREATE` 后必须调 `api.respond_interaction(interaction_id)` 应答,否则用户端按钮一直 loading;事件体的 `id` 可作 `event_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),按需回退纯文本。 @@ -202,6 +215,7 @@ uv run python main.py - **启动后频繁重连 / 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 配置格式与落盘。 diff --git a/examples/group_bot/main.py b/examples/group_bot/main.py index b9130e2..b966f91 100644 --- a/examples/group_bot/main.py +++ b/examples/group_bot/main.py @@ -1,8 +1,8 @@ -"""群机器人示例:@消息回复、富媒体图片回复、入群欢迎、单聊回复。 +"""群机器人示例:@消息回复、富媒体图片、按钮卡片与点击回调、入群欢迎、单聊回复。 运行前提: 1. 项目根目录 .env 配好 APPID/APPSECRET(CONNECTER 用默认 websocket); -2. intents.toml 开启 GROUP_AND_C2C_EVENT 分组的事件; +2. intents.toml 开启 GROUP_AND_C2C_EVENT 与 INTERACTION 分组的事件; 3. 机器人已在开放平台开通群聊能力,并被拉入测试群。 运行:uv run python examples/group_bot/main.py @@ -11,9 +11,9 @@ from loguru import logger from qqbotsdk import EventEmitter, EventQueue, main -from qqbotsdk.api import BotApi +from qqbotsdk.api import BotApi, button, keyboard from qqbotsdk.events import Event -from qqbotsdk.payloads import C2CMessage, GroupAtMessage +from qqbotsdk.payloads import C2CMessage, GroupAtMessage, Interaction ee = EventEmitter(EventQueue()) @@ -34,19 +34,45 @@ async def on_group_message(msg: GroupAtMessage, api: BotApi) -> None: ) return + # "/btn":发一张带回调按钮的 Markdown 卡片 + if (msg.content or "").strip() == "/btn": + await api.post_group_message( + msg.group_openid, + markdown="## 按钮示例\n点下面的按钮试试:", + keyboard=keyboard( + button("打个招呼", data="btn|hello"), + [button("赞", data="btn|like"), button("踩", data="btn|dislike")], + ), + msg_id=msg.id, + ) + return + # 普通文本:带 msg_id 被动回复(5 分钟内有效,同一条消息多次回复递增 msg_seq) await api.post_group_message( msg.group_openid, content=f"你说:{msg.content}", msg_id=msg.id, ) +@ee.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 # 本示例只处理群聊的按钮回调 + # 必须应答,否则用户端按钮一直 loading(这里的 id 是事件体里的互动 id) + await api.respond_interaction(inter.id) + # 被动回复的 event_id 是网关帧最外层的 id(形如 INTERACTION_CREATE:uuid), + # 不是事件体里的裸 UUID——官方文档点名的常见坑 + await api.post_group_message( + inter.group_openid, content=f"你点了:{inter.button_data}", + event_id=event.event_id, + ) + + @ee.on("GROUP_ADD_ROBOT") async def on_robot_added(event: Event, api: BotApi) -> None: group_openid = event.group_id - # 入群是事件而非消息:事件 ID 在推送 envelope 顶层 id 字段, - # 作 event_id 即可在 5 分钟内被动回复欢迎语(拿不到则跳过) - event_id = event.raw.get("id") - if not (group_openid and isinstance(event_id, str) and event_id): + # 入群是事件而非消息:用信封顶层的事件 id 作 event_id 被动回复(拿不到则跳过) + event_id = event.event_id + if not (group_openid and event_id): logger.info(f"机器人加入群聊 {group_openid}(无事件 ID,跳过欢迎语)") return await api.post_group_message( diff --git a/pyproject.toml b/pyproject.toml index 6872baf..7f7ce72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "loguru>=0.7.3", "pyee>=14.0.0", "python-dotenv>=1.2.3", + "ujson>=6.0.0", ] [project.scripts] diff --git a/src/qqbotsdk/api.py b/src/qqbotsdk/api.py index d067a34..5401fd2 100644 --- a/src/qqbotsdk/api.py +++ b/src/qqbotsdk/api.py @@ -8,7 +8,9 @@ """ from typing import Any +from uuid import uuid4 +import ujson from aiohttp import ClientSession from loguru import logger @@ -26,6 +28,41 @@ FILE_TYPE_VOICE = 3 FILE_TYPE_FILE = 4 +def button( + label: str, + data: str = "", + *, + type: int = 1, + permission: int = 2, + style: int = 1, + id: str | None = None, +) -> dict: + """构造单个内嵌按钮 dict,供 keyboard() 组装后传入 keyboard= 参数。 + + type: 1 回调(点击推 INTERACTION_CREATE)/ 0 跳转链接(data 为 url)/ + 2 指令(data 插入输入框)。permission: 2 所有人 / 1 管理员 / 0 指定 + 用户。label 官方限 10 字以内;要补嵌套字段(如 visited_label、 + prompt)直接修改返回的 dict。 + """ + return { + "id": id or uuid4().hex[:8], + "render_data": {"label": label, "style": style}, + "action": {"type": type, "permission": {"type": permission}, "data": data}, + } + + +def keyboard(*rows: dict | list[dict]) -> dict: + """组装内嵌键盘(keyboard= 参数):每个位置参数为一行, + 单个按钮独占一行,按钮列表同行并排。""" + return { + "content": { + "rows": [ + {"buttons": row if isinstance(row, list) else [row]} for row in rows + ] + } + } + + class ApiError(RuntimeError): """OpenAPI 调用失败:HTTP 非 2xx 或业务 code 非 0。""" @@ -53,7 +90,9 @@ class BotApi: headers=headers, **kwargs, ) as resp: - data = await resp.json() + # 个别接口 200 响应的 Content-Type 不是 JSON(body 仍是 JSON, + # 如回应互动返回 200 text/plain + {}),跳过标头校验 + data = await resp.json(loads=ujson.loads, content_type=None) if resp.status >= 400: logger.warning(f"{method} {path} 失败: {data}") code = data.get("code") if isinstance(data, dict) else None diff --git a/src/qqbotsdk/connecter.py b/src/qqbotsdk/connecter.py index ae9f46c..3c0e5ca 100644 --- a/src/qqbotsdk/connecter.py +++ b/src/qqbotsdk/connecter.py @@ -8,11 +8,11 @@ """ import asyncio -import json from time import monotonic from typing import Protocol import aiohttp +import ujson from aiohttp import WSMsgType, web from loguru import logger @@ -74,7 +74,7 @@ class WebsocketConnecter: async with self._http.get( f"{self._config.base_url}/gateway", headers=headers ) as resp: - data = await resp.json() + data = await resp.json(loads=ujson.loads) if data.get("code") == 100017: raise RateLimitError @@ -101,7 +101,7 @@ class WebsocketConnecter: if msg.type != WSMsgType.TEXT: logger.warning(f"WebSocket 收到非文本帧,连接终止: {msg.type}") break - data: Payload = msg.json() + data: Payload = msg.json(loads=ujson.loads) op = data.get("op") if op == Opcode.HELLO: d = data.get("d") @@ -169,7 +169,7 @@ class WebhookConnecter: async def _handle(self, request: web.Request) -> web.Response: body = await request.read() - payload: Payload = json.loads(body) + payload: Payload = ujson.loads(body) if payload.get("op") == Opcode.VALIDATION: logger.info("收到 Webhook 验证请求") diff --git a/src/qqbotsdk/events.py b/src/qqbotsdk/events.py index 35f6c40..cbcfd21 100644 --- a/src/qqbotsdk/events.py +++ b/src/qqbotsdk/events.py @@ -48,5 +48,15 @@ class Event: def message_id(self) -> str | None: return self._d.get("id") + @property + def event_id(self) -> str | None: + """推送信封顶层的事件 id(与 op/t/d 平级)。 + + 非消息事件(GROUP_ADD_ROBOT 等)事件体里没有 id,被动回复的 + event_id 只有这里能拿到;消息事件用 `.message_id`(即 msg_id)。 + """ + id_ = self._payload.get("id") + return id_ if isinstance(id_, str) else None + def __repr__(self) -> str: return f"" diff --git a/src/qqbotsdk/model.py b/src/qqbotsdk/model.py index 8fa516d..04550df 100644 --- a/src/qqbotsdk/model.py +++ b/src/qqbotsdk/model.py @@ -73,11 +73,6 @@ class ValidationData(TypedDict): event_ts: str -class AccessTokenResponse(TypedDict): - access_token: str - expires_in: int - - class Intent(IntFlag): GUILDS = 1 << 0 GUILD_MEMBERS = 1 << 1 diff --git a/src/qqbotsdk/payloads.py b/src/qqbotsdk/payloads.py index 7c8e334..387e74a 100644 --- a/src/qqbotsdk/payloads.py +++ b/src/qqbotsdk/payloads.py @@ -206,8 +206,10 @@ class Interaction: """互动(INTERACTION_CREATE)。 按钮点击(type=11)时 data.resolved.button_data 携带按钮的 - action.data;id 可作 event_id 被动回复,并需 PUT - /interactions/{id} 回应(见 BotApi.respond_interaction)。 + action.data;id 用于 PUT /interactions/{id} 回应(见 + BotApi.respond_interaction)。注意被动回复的 event_id 不取这里的 + id(它是裸 UUID),要取网关帧最外层的 id(Event.event_id,形如 + INTERACTION_CREATE:uuid)——官方文档点名的常见坑。 """ id: str | None = None @@ -224,6 +226,13 @@ class Interaction: application_id: str | None = None timestamp: str | None = None + @property + def button_data(self) -> str: + """被点击按钮的 action.data(即 data.resolved.button_data)。""" + resolved = (self.data or {}).get("resolved") + value = resolved.get("button_data") if isinstance(resolved, dict) else None + return value if isinstance(value, str) else "" + @dataclass(slots=True) class ForumThread: diff --git a/src/qqbotsdk/token.py b/src/qqbotsdk/token.py index c029265..37e7169 100644 --- a/src/qqbotsdk/token.py +++ b/src/qqbotsdk/token.py @@ -1,11 +1,28 @@ """access_token 获取与缓存:到期前直接复用,过期自动重新换取。""" import time +from typing import Any +import ujson from aiohttp import ClientSession from .config import Config -from .model import AccessTokenResponse + + +class TokenError(RuntimeError): + """换取 access_token 失败:携带 QQ 返回的 code/message。 + + 与 ApiError 同构(appid/secret 错误等鉴权失败在此暴露真实原因, + 而不是 KeyError: 'access_token')。 + """ + + def __init__(self, status: int, code: Any, message: Any) -> None: + self.status = status + self.code = code + self.message = message + super().__init__( + f"获取 access_token 失败: HTTP {status}, code={code}, message={message}" + ) class AccessToken: @@ -28,7 +45,14 @@ class AccessToken: f"{self._config.base_url}/app/getAppAccessToken", json=payload, ) as resp: - data: AccessTokenResponse = await resp.json() + # 与 request() 同理:个别接口以非 JSON Content-Type 返回 JSON body + data = await resp.json(loads=ujson.loads, content_type=None) + # 失败时 QQ 返回 code/message 且 HTTP 可能仍是 200, + # 以 access_token 是否在为准判断成败,抛出真实原因 + if not isinstance(data, dict) or "access_token" not in data: + code = data.get("code") if isinstance(data, dict) else None + message = data.get("message") if isinstance(data, dict) else data + raise TokenError(resp.status, code, message) self._access_token = data["access_token"] self._expires_at = time.time() + int(data["expires_in"]) diff --git a/tests/test_api.py b/tests/test_api.py index dd812ec..d38c346 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,4 +1,9 @@ -from qqbotsdk.api import BotApi +from aiohttp import ClientSession, ClientTimeout, web +from aiohttp.test_utils import TestServer + +from qqbotsdk.api import BotApi, button, keyboard +from qqbotsdk.config import Config +from qqbotsdk.token import AccessToken def make_api() -> tuple[BotApi, dict]: @@ -163,3 +168,63 @@ async def test_respond_interaction(): method, path, kwargs = cap["call"] assert (method, path) == ("PUT", "/interactions/I1") assert kwargs["json"] == {"code": 0} + + +def test_button_defaults_and_overrides(): + btn = button("点我", data="d1") + assert btn["render_data"] == {"label": "点我", "style": 1} + assert btn["action"] == {"type": 1, "permission": {"type": 2}, "data": "d1"} + assert len(btn["id"]) == 8 # 未指定 id 时自动生成短 id + + btn2 = button("跳转", data="https://x", type=0, permission=1, style=0, id="b2") + assert btn2["id"] == "b2" + assert btn2["action"]["type"] == 0 + assert btn2["action"]["permission"] == {"type": 1} + assert btn2["render_data"]["style"] == 0 + + +def test_keyboard_rows(): + b1, b2, b3 = button("a", data="1"), button("b", data="2"), button("c", data="3") + kb = keyboard(b1, [b2, b3]) + rows = kb["content"]["rows"] + assert rows[0]["buttons"] == [b1] # 单按钮独占一行 + assert rows[1]["buttons"] == [b2, b3] # 列表同行并排 + assert len(rows) == 2 + + +async def test_request_tolerates_non_json_content_type(): + """回应互动等接口 200 响应的 Content-Type 是 text/plain(body 为 JSON), + request 不应抛 ContentTypeError(真实 HTTP 栈回归)。""" + + async def handler(request): + return web.Response(text="{}", content_type="text/plain") + + app = web.Application() + app.router.add_route("PUT", "/interactions/I1", handler) + server = TestServer(app) + await server.start_server() + try: + config = Config( + app_id="id", + app_secret="s", + base_url=f"http://{server.host}:{server.port}", + timeout=ClientTimeout(total=5), + connecter="websocket", + intents_file="intents.toml", + webhook_host="0.0.0.0", + webhook_port=8080, + webhook_path="/qqbot/webhook", + ) + + class FixedToken(AccessToken): + async def get_access_token(self) -> str: + return "test-token" + + http = ClientSession() + try: + api = BotApi(config, http, FixedToken(config, http)) + assert await api.respond_interaction("I1") == {} + finally: + await http.close() + finally: + await server.close() diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..8128a2e --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,39 @@ +"""Event 封装属性:message_id 取自事件体 d.id,event_id 取自信封顶层 id。""" + +from qqbotsdk.events import Event + + +def make_event() -> Event: + return Event( + { + "op": 0, + "id": "evt-1", # 信封顶层,与 op/t/d 平级 + "t": "GROUP_ADD_ROBOT", + "d": {"group_openid": "G1", "op_member_openid": "M1"}, + } + ) + + +def test_event_id_from_envelope(): + event = make_event() + assert event.event_id == "evt-1" + assert event.group_id == "G1" + + +def test_message_id_from_event_body(): + event = Event( + { + "op": 0, + "id": "evt-2", + "t": "GROUP_AT_MESSAGE_CREATE", + "d": {"id": "msgid-1", "group_openid": "G1", "content": "hi"}, + } + ) + assert event.message_id == "msgid-1" # 消息事件回复用 msg_id + assert event.event_id == "evt-2" # 信封顶层 id 另有其值 + + +def test_event_id_defaults_to_none(): + event = Event({"op": 0, "t": "READY", "d": {}}) + assert event.event_id is None + assert event.message_id is None diff --git a/tests/test_payloads.py b/tests/test_payloads.py index d03aa05..3a69214 100644 --- a/tests/test_payloads.py +++ b/tests/test_payloads.py @@ -3,6 +3,7 @@ from qqbotsdk.model import Opcode from qqbotsdk.payloads import ( C2CMessage, GroupAtMessage, + Interaction, Ready, parse_event, ) @@ -62,6 +63,27 @@ def test_parse_non_dict_d_returns_dict(): assert parse_event("GROUP_AT_MESSAGE_CREATE", d) == 42 +def test_interaction_button_data(): + inter = parse_event( + "INTERACTION_CREATE", + { + "id": "i1", + "chat_type": 1, + "group_openid": "G1", + "data": {"resolved": {"button_data": "btn|like"}}, + }, + ) + assert isinstance(inter, Interaction) + assert inter.button_data == "btn|like" + + +def test_interaction_button_data_defaults_to_empty(): + inter = Interaction(id="i2", data=None) + assert inter.button_data == "" + inter = Interaction(id="i3", data={"resolved": None}) + assert inter.button_data == "" + + async def test_typed_annotation_receives_dataclass(): queue = EventQueue() emitter = EventEmitter(queue) diff --git a/tests/test_token.py b/tests/test_token.py new file mode 100644 index 0000000..59bdd6b --- /dev/null +++ b/tests/test_token.py @@ -0,0 +1,64 @@ +"""AccessToken:成功换取与失败时抛 TokenError(带 QQ 的 code/message)。""" + +import pytest +from aiohttp import ClientTimeout + +from qqbotsdk.config import Config +from qqbotsdk.token import AccessToken, TokenError + + +def make_token(payload) -> AccessToken: + class FakeResponse: + status = 200 + + def __init__(self) -> None: + self._payload = payload + + async def json(self, **kwargs): + return self._payload + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + class FakeSession: + def post(self, *args, **kwargs): + return FakeResponse() + + config = Config( + app_id="id", + app_secret="s", + base_url="https://x", + timeout=ClientTimeout(total=5), + connecter="websocket", + intents_file="intents.toml", + webhook_host="0.0.0.0", + webhook_port=8080, + webhook_path="/qqbot/webhook", + ) + return AccessToken(config, FakeSession()) # type: ignore[arg-type] + + +async def test_success_caches_token(): + token = make_token({"access_token": "t1", "expires_in": 7200}) + assert await token.get_access_token() == "t1" + # 缓存期内直接复用,不再发请求(FakeSession 也没有第二次响应的意义) + assert await token.get_access_token() == "t1" + + +async def test_error_payload_raises_token_error(): + token = make_token({"code": 11253, "message": "invalid appid or secret"}) + with pytest.raises(TokenError) as exc_info: + await token.get_access_token() + assert exc_info.value.code == 11253 + assert exc_info.value.message == "invalid appid or secret" + assert "invalid appid or secret" in str(exc_info.value) + + +async def test_non_dict_payload_raises_token_error(): + token = make_token("gateway busy") + with pytest.raises(TokenError) as exc_info: + await token.get_access_token() + assert exc_info.value.message == "gateway busy" diff --git a/uv.lock b/uv.lock index 1d03a4f..b53bc2a 100644 --- a/uv.lock +++ b/uv.lock @@ -637,6 +637,7 @@ dependencies = [ { name = "loguru" }, { name = "pyee" }, { name = "python-dotenv" }, + { name = "ujson" }, ] [package.dev-dependencies] @@ -655,6 +656,7 @@ requires-dist = [ { name = "loguru", specifier = ">=0.7.3" }, { name = "pyee", specifier = ">=14.0.0" }, { name = "python-dotenv", specifier = ">=1.2.3" }, + { name = "ujson", specifier = ">=6.0.0" }, ] [package.metadata.requires-dev] @@ -720,6 +722,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15", size = 18115, upload-time = "2026-06-29T08:03:38.666Z" }, ] +[[package]] +name = "ujson" +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/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" }, + { url = "https://files.pythonhosted.org/packages/70/55/06a578dd00551b10bc94d68889387e5de11977ee92cc53921eb02713146f/ujson-6.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:455e6ae6c925eca6358110e665a31e5bbcf0a93dfe9822a26b954c9351de2c3f", size = 52377, upload-time = "2026-09-04T03:53:58.859Z" }, + { url = "https://files.pythonhosted.org/packages/06/9a/cc0d306e93d1a8f1d48d54f52cb2cae39b56a4c990e2cd8cf328697bbc80/ujson-6.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5376a8c14d0eaf80789bdb10e21ae12582cdf526eb921a47f57053ef08c63f8c", size = 53718, upload-time = "2026-09-04T03:53:59.962Z" }, + { url = "https://files.pythonhosted.org/packages/15/48/0462149003b03afe83450f6bdad3ebff9ac9aee315690eb0670bf2a6e342/ujson-6.0.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8bd6743ad58fe6067ea1677d5df4674bd7de143b038bcd4129c3a6ced483ae8", size = 57191, upload-time = "2026-09-04T03:54:01.005Z" }, + { url = "https://files.pythonhosted.org/packages/45/ad/26f40cdffaebd1158b1083d6c89efd9e056badd706022386a0e9567ae499/ujson-6.0.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3967550c8952bc516c79c40726a54313aceeb3162a8d5cc655362ab83d0957c", size = 56413, upload-time = "2026-09-04T03:54:02.019Z" }, + { url = "https://files.pythonhosted.org/packages/62/60/7f0d5da6198fcad7037dafc68e6c76aebb6536b323c07a2d1f82bf692099/ujson-6.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:619b2152aa77c57a535e3e7eaf88ec8e25beac6d380378b2ade10362cce50f75", size = 1037442, upload-time = "2026-09-04T03:54:03.104Z" }, + { url = "https://files.pythonhosted.org/packages/83/bf/21cd9110b8b33ff1530d854f38b7bd2b8d2be41af67590fccffb418fb29f/ujson-6.0.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2e36269e715c8deea036d263557042e2598e79d52110233c1a623ed9e7c1cf0a", size = 1196458, upload-time = "2026-09-04T03:54:04.39Z" }, + { url = "https://files.pythonhosted.org/packages/ed/51/2e3b3a19b36862f72300a306051fb7a265ba0f5a72d6e2eebd30f2722b44/ujson-6.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c626f68524a19f50d9a9babc17f9c379d1b2a9f2a3da5ac3c40a205cc736259f", size = 1088718, upload-time = "2026-09-04T03:54:05.787Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f5/faeb3439f844e61040dc4bd2744c58745541ec1a94e68f08994fe4f41ee1/ujson-6.0.0-cp313-cp313-win32.whl", hash = "sha256:cea0a63173e4ae98cd960f484096233da76a62550ac10c53312a69ad9f3545b1", size = 229128, upload-time = "2026-09-04T03:54:07.5Z" }, + { url = "https://files.pythonhosted.org/packages/05/19/55a89733b9078a88605f5884763e0457409fd8d04d2e47d4d761052e28d6/ujson-6.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:88b237680c705fd37bacbaaa335106fecb234a47e1df0737d949b8e32c7eb5f9", size = 227518, upload-time = "2026-09-04T03:54:08.653Z" }, + { url = "https://files.pythonhosted.org/packages/d5/cb/807314a66fb495d600718b11f7639af07138a25ea301b91234a18a43af59/ujson-6.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:ec570979304a529a8be1bf9ea28889742a2ff5de9af1c6734584dfe1645da3e6", size = 400244, upload-time = "2026-09-04T03:54:10.006Z" }, + { url = "https://files.pythonhosted.org/packages/7c/40/c22e49f786f5a0a71bae6323d0e6fa9a4a47b7b68c9f00631fdd78f147f7/ujson-6.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:63eefaa34abbe14167493710619b840d3fc167ba86e5fbe0c4a5eb01686aa3a0", size = 55208, upload-time = "2026-09-04T03:54:11.403Z" }, + { url = "https://files.pythonhosted.org/packages/86/40/90a47580ae4246134a080b0f76637e038476271461d7ab227c1c4431822d/ujson-6.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af85ae40c71d422fad944aa8666d59374e4fa92f77899fce34b984037db41420", size = 54303, upload-time = "2026-09-04T03:54:12.467Z" }, + { url = "https://files.pythonhosted.org/packages/62/63/a275e218f7c5f49c0e31b446e9eb581267b7d3393d4dfbc25f397967e51b/ujson-6.0.0-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:2145005321a4b175486dd890946b036bb8730e4e8e17744f5abce23ea014e024", size = 58509, upload-time = "2026-09-04T03:54:13.472Z" }, + { url = "https://files.pythonhosted.org/packages/cd/3b/11fc8994c579a325c5c2075f03c70c967849d2c63cf97197507f31aaa739/ujson-6.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2c670cd7aaad2a3bff450addb32b26aa831f82a8b6c2c875ec19bb282a6c45d", size = 52422, upload-time = "2026-09-04T03:54:14.475Z" }, + { url = "https://files.pythonhosted.org/packages/37/73/a7ecfa39bb08cfe57d35064b4be21712fe671bae47574a4c9901a9a1ad2a/ujson-6.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:63b56e3fcccc339e2c1332e75adc779bd145964e1a47a39a229fa01b2e25618a", size = 53707, upload-time = "2026-09-04T03:54:15.657Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c3/e6d76ff353d179dd0dca2e5df6afdd170874031eb73284acb9a327dd7f52/ujson-6.0.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab7b316bba31be494635dcc5db87e429f2478073d15d2c54925c32fd9e1947f4", size = 57234, upload-time = "2026-09-04T03:54:16.793Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b4/c52aa5b797b76a2ca10da513010120809d70bb12acdfc27ad7875a652fee/ujson-6.0.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9d26982045b28db1937ac60682a9940fdb72f9cab3421a5d56c03f2207c99e9", size = 56411, upload-time = "2026-09-04T03:54:17.979Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ad/5e2dd3fbbadee85811279e57dee23f346d8cc099809c14f7bb01d1a5a879/ujson-6.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fc115cca04dbdfd98a67ec89ba5ffd8a87f3201171af54980cfd550997611c41", size = 1037485, upload-time = "2026-09-04T03:54:19.215Z" }, + { url = "https://files.pythonhosted.org/packages/b8/61/73d5ef4020716e08de4992519d090785908bf229a3d464abf0a067f06c21/ujson-6.0.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90f766c5f8e55de2fe65e4241e3e2e46ed7528e7931255a7ed0dfcb5ce622b15", size = 1196525, upload-time = "2026-09-04T03:54:20.759Z" }, + { url = "https://files.pythonhosted.org/packages/ed/4d/d63aafdf83ecb52a76ab46b0450e5431462b713e0b2576539a1b80ed6afb/ujson-6.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dfceda99f3105e9e6fce8dfd157f80894ad20247dc9ffce368c8b7883e7a2aac", size = 1088718, upload-time = "2026-09-04T03:54:22.056Z" }, + { url = "https://files.pythonhosted.org/packages/c5/92/504ccce4f8b56612dd5ebb1f221b5cb6435bc33d357dafbdedfe5b0b691f/ujson-6.0.0-cp314-cp314-win32.whl", hash = "sha256:22eafdd4f8ee6fe2db0737285c75b15f7486dc53c07b09a4b3699c92c407c3e5", size = 235844, upload-time = "2026-09-04T03:54:23.399Z" }, + { url = "https://files.pythonhosted.org/packages/e0/11/c897b08e00d9778a0dea895d5e88031180812941e3cd7fc63fa26034767a/ujson-6.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:9d522e95bffac7338178757a7931b81639b9e0f2a3ee6e8c7ffdf867f2bfed36", size = 235353, upload-time = "2026-09-04T03:54:24.587Z" }, + { url = "https://files.pythonhosted.org/packages/99/cc/69a625656d73634af2e7bb8854b05f0d47a4650954e0876e28515965a522/ujson-6.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:bc6df52a60b521c7b7d69de0c14856397d3cce1e39aa22cfe439c350d6f52524", size = 415010, upload-time = "2026-09-04T03:54:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/bd/53/cdc879e035a9b67e50fa34aa13d2d9160a826801a5fcf2993f48b9768944/ujson-6.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:222389a616f6407eb40e1efa80a35c1ba468903e50a305faf425c26e3c32bdb9", size = 55660, upload-time = "2026-09-04T03:54:26.996Z" }, + { url = "https://files.pythonhosted.org/packages/51/29/33891cfee86cc13e00a1de6fa326378a637dad786078aaf56ab6336c60cf/ujson-6.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:593acfa0f36ada24e89c07147441fe364081fa1631db73ee55f40893c196e0b9", size = 54730, upload-time = "2026-09-04T03:54:28.106Z" }, + { url = "https://files.pythonhosted.org/packages/16/f6/2d4bd6fb364f8ded5840854bdda58032c8cd11614d70ce0c125a52dfb7a9/ujson-6.0.0-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5b3afbe992e2d1b8c1e4e7a0da2c77da23f29545e5ba695a4a9241702234f20e", size = 59301, upload-time = "2026-09-04T03:54:29.215Z" }, + { url = "https://files.pythonhosted.org/packages/61/fd/7baf38f591fd964558891a1798e9b49078558346a24020b7c27945389130/ujson-6.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:65e0e0c21ead4d0087c9c65a82eb2446c4bd51d36388d41035ce773517e7a3bf", size = 53364, upload-time = "2026-09-04T03:54:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/63/c0/640ed28e4443c81e3ed9cbec2b216f4c3943388f4f45b703e8e0993c4f8f/ujson-6.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0f3eff1f93d9d1f0bd5eee35883b9c71ad9befcfcd0ddc7cd5862c69fba21cf6", size = 54447, upload-time = "2026-09-04T03:54:31.527Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f7/3688adc11a3e22e4b26563256404c6557682efe62510f988bf7dfc09d8b1/ujson-6.0.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4579b8c96824f65888d4a615463c2dc2b7db6c6f0c7f83ece2a58714fd1a8123", size = 58263, upload-time = "2026-09-04T03:54:32.587Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c9/9ab8d5ab9ca362381d0fcc8c6e6a831e96385a908792b2378db6282a374e/ujson-6.0.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8af54166141d5c8ebeebc044c3569ef10edfcdf6fd8ecb487a2bf33c776ebc8f", size = 57154, upload-time = "2026-09-04T03:54:33.698Z" }, + { url = "https://files.pythonhosted.org/packages/23/01/82ed9b5594d770f6490334ce78af22c754b91b8de12efd3ddfaa1d23da9a/ujson-6.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ad8bdad17cfc64aefb049e53687ff8730a72e2c3d99edcb36001683122597846", size = 1038449, upload-time = "2026-09-04T03:54:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/5f/dc/3cea633a17cb79d8b642e06b6c07f21ac31072a4b3043cc41df74db54fa5/ujson-6.0.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0dd8981828f6b515ba5e9f2473f433aa59bebe4784182b48695b71af52033b4f", size = 1197332, upload-time = "2026-09-04T03:54:36.441Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1d/3fcc1ae871d8cd6ee40ec7e92556d9641fdf248875656780c4feea33c793/ujson-6.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:97caee7e4c3e20dff9e6adca0b7443c3cf9d7546ed5d0750954c5bb5456bad86", size = 1089519, upload-time = "2026-09-04T03:54:38.866Z" }, + { url = "https://files.pythonhosted.org/packages/85/86/af921b0c127f2c2d953836abfd277178bcbdfdf72318f26b4793d04a0c9d/ujson-6.0.0-cp314-cp314t-win32.whl", hash = "sha256:3bd770b553bebc408b49d6fdb46efb1dc568368d949ac7813a07fcccaea044ae", size = 236539, upload-time = "2026-09-04T03:54:40.163Z" }, + { url = "https://files.pythonhosted.org/packages/79/12/bb371cd75bb779d3282e5c1efaeb5e20bada1faecba6d83cabdd36756031/ujson-6.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:683501475e3dfa935574bfd2b3d26f7393b4a880a745aeab63cc3d013027bba0", size = 236201, upload-time = "2026-09-04T03:54:41.568Z" }, + { url = "https://files.pythonhosted.org/packages/68/82/f301c155669dd0bec9e569ecd5013b61e88528b7587bec2457b96b7fce23/ujson-6.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e1fa46cb8ddbfba2adf8277b8225e2ebf5bae435e2251c730c17bc0020f63c5e", size = 415482, upload-time = "2026-09-04T03:54:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/c2/2b/020feca4cc502b274029cd1514d428908e334a17386761d157da32447fa6/ujson-6.0.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:b2ab962524adb39dbad565fd259e15a1c26b8944fa978c24ed6dea5ab1eeefd0", size = 55210, upload-time = "2026-09-04T03:54:44.1Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/1cd913419d17260f6d4c9869ab1208132b1281f4db00d3f07b664712ffdf/ujson-6.0.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:dae3765f731779faa947715485f6794bc5984802be4584478a3e9e5143dd62e1", size = 54370, upload-time = "2026-09-04T03:54:45.249Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0e/876719d6f04bb48560806bb508a038550f6a8184558f0a7274bc3015e1fa/ujson-6.0.0-cp315-cp315-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:34c0403b485d8ddd86bd29d879cc9f72223579b57188b0a2bc07a8b06f8cfbdf", size = 55682, upload-time = "2026-09-04T03:54:46.34Z" }, + { url = "https://files.pythonhosted.org/packages/99/92/b59b4827a9c6ba0d12939b0d6e790b8629946873b7a655ff5a06735bd173/ujson-6.0.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d56340493496d50ccc41b460610c1ce6a197aac710733b5f36910e8c9f3ba6d", size = 52648, upload-time = "2026-09-04T03:54:47.641Z" }, + { url = "https://files.pythonhosted.org/packages/6c/49/3d702afd9beb434f5140b12ffdf88198c144c1de5bd17a2a3a6fd7872b22/ujson-6.0.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a38a21efd05384fb82d35bed81fac0ff6056ea39c3dee3c293885ce910879dd0", size = 54007, upload-time = "2026-09-04T03:54:48.714Z" }, + { url = "https://files.pythonhosted.org/packages/52/fb/4dd3f307f62f0b22f33b9d760efbfa7c7890a76591e6867c72bd27070966/ujson-6.0.0-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee87d8c4a4ebbef1c7cb2cf251a1d77726ef06a1597ed04d3dce92709b8fe0f1", size = 57412, upload-time = "2026-09-04T03:54:49.753Z" }, + { url = "https://files.pythonhosted.org/packages/f2/12/03ef04cde2e056f9ec699046f78bf2e8c1339ebfe0f29486098bf965e9ca/ujson-6.0.0-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:928d83b72808dc73a5df530b7fc27101052be1baf013a5dd75a1535de6cf107e", size = 56439, upload-time = "2026-09-04T03:54:50.795Z" }, + { url = "https://files.pythonhosted.org/packages/26/d6/5cd07dc0732de702101e2360b07f2841ba50a77d2d758b0042623caae049/ujson-6.0.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cd835565b660ca125f5895105981d691c708c15367b88a69fa4d92ddbe24504a", size = 1037774, upload-time = "2026-09-04T03:54:52.087Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a3/59bcf91336ceebeb6a54716987c7069f9ddf99579488e513252300beb6ba/ujson-6.0.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:e6926204905e1a2f278bacf92ff2fe31343bcc7fb9ff08fdd42be66b3a217ef0", size = 1193839, upload-time = "2026-09-04T03:54:53.548Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1c/fb168acf568b8d1312cfb3b079ae4a91ff95130219551ce2a3fd5edf71a4/ujson-6.0.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:7a1472649bc9ef3b9ce3ab279e9e812368bfac25210b7ec96bd544767c019577", size = 1088955, upload-time = "2026-09-04T03:54:55.287Z" }, + { url = "https://files.pythonhosted.org/packages/49/c2/6fe4524ff1edc26234f67b1dc9077e05c70dd59cdc736297dea18b171bd5/ujson-6.0.0-cp315-cp315-win32.whl", hash = "sha256:aea27aa0927b0423a0cfb167bd505c2dc59d1df65c66372204e43ba94fc964a8", size = 235843, upload-time = "2026-09-04T03:54:56.797Z" }, + { url = "https://files.pythonhosted.org/packages/26/bc/1a118013f92236150444d6ff931e78e698bf45f2bb9e9688d625970f3557/ujson-6.0.0-cp315-cp315-win_amd64.whl", hash = "sha256:102ddbb1677540f0cae80cc36f5db9663a626c7b3bf872ed10f10fe72343a3c9", size = 235351, upload-time = "2026-09-04T03:54:58.02Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/001d7bd04cde9cd35fb0635239026ad0ae8b57cf12e770db3427dfc85217/ujson-6.0.0-cp315-cp315-win_arm64.whl", hash = "sha256:9ef1920b423effe2837351d19a2278d7a516404a07200cca30b881077a2d7877", size = 415004, upload-time = "2026-09-04T03:54:59.741Z" }, + { url = "https://files.pythonhosted.org/packages/e9/60/5c91a9e9e7f0b433dd782c57c388f7e764f162a1de433545f30fe93f48c6/ujson-6.0.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:7168df25a051fd2a60f8d123b2123b60ead7c1f22cdd467ab7c2bba0fad0aec1", size = 55668, upload-time = "2026-09-04T03:55:01.139Z" }, + { url = "https://files.pythonhosted.org/packages/09/dd/1dddba1b0f74092f433e6a26ce4cb0f419a7a93575b54fcbb0c6d64e616d/ujson-6.0.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:987e191700873419cc23d94d4212e57a85df24eebbe9a33785907b0c99a5a57a", size = 54828, upload-time = "2026-09-04T03:55:02.353Z" }, + { url = "https://files.pythonhosted.org/packages/70/2d/6e65a3a336717d65cd8035ff870ad507b5a6375b8bc992718e744d620891/ujson-6.0.0-cp315-cp315t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:0eeef12ef46e129278b50ca4c66c6b35c318f2fd09346bacddf218ed378cc0bb", size = 56651, upload-time = "2026-09-04T03:55:03.39Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a4/89d2bfc97fd073a3fe44c90beea19eb402c48101f83d46626d4b4d32c9d4/ujson-6.0.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68d623416ad997666bd8ea899b15554462b6250e803f4ce084c7dfd06a775314", size = 53687, upload-time = "2026-09-04T03:55:04.587Z" }, + { url = "https://files.pythonhosted.org/packages/02/5b/ff1227377dbd1b1bb5834d59e3410ff27ef9c1eac1125fbb610e220f0e47/ujson-6.0.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7253ae5cac107d2940226a113165738630a98c19cdeaec1e6d6d6c3a7c307b95", size = 54873, upload-time = "2026-09-04T03:55:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/55/49/f80678f440126a3bd20253b91cf5bb200f1233bc5822f90024c7c94cfcd0/ujson-6.0.0-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b6494d29f7103a97d930cbd25f23fdc4d77e145a931e743660d697a200fd831", size = 58581, upload-time = "2026-09-04T03:55:07.009Z" }, + { url = "https://files.pythonhosted.org/packages/4c/2d/742897add5ea6b4ac9262208fba4bcb463e3f1b60606b6d79f05c7f27f17/ujson-6.0.0-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d56d408ccfb9b0e5c2b4ea687396df30ca42ebe2aedac88362069620ce65402", size = 57408, upload-time = "2026-09-04T03:55:08.105Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b1/8747b3acf29d6219b042e8983f840fd4866dd9c65e5da9457311d4fb4fa1/ujson-6.0.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:add6b3827cbd6ce068ad70b1b890d44271801386a726e2bafe5bced784466642", size = 1038904, upload-time = "2026-09-04T03:55:09.313Z" }, + { url = "https://files.pythonhosted.org/packages/3f/21/deab9b41b6a8737210cd2460054e915f10b878bfda824e2dccc3e5f0db5f/ujson-6.0.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:1cda9f81e58120675dbaba7b254849ee59698e5dee83c4383a3c1a96ca92a679", size = 1194845, upload-time = "2026-09-04T03:55:10.988Z" }, + { url = "https://files.pythonhosted.org/packages/31/40/b25a5f2b7bb6a5940692dc9d10bd89dad0c1e7d5af64c473d7391ec94513/ujson-6.0.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:d7945560fc6ce687ea83aa0bc375aa8a1101d9eee1fcbd085c5e0a5b6c6ac8ad", size = 1089861, upload-time = "2026-09-04T03:55:12.564Z" }, + { 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" }, +] + [[package]] name = "win32-setctime" version = "1.2.0"