mirror of
https://github.com/snowy-yang/qqbotsdk.git
synced 2026-10-21 09:05:21 +00:00
feat: 按钮快捷构造、event_id 语义修正与 TokenError
This commit is contained in:
1 parent
cff0c142c2
commit
097bc96d8b
16 files changed
+431
-35
No files matched your search
@@ -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)。
|
||||
|
||||
+6
-1
@@ -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`。
|
||||
|
||||
+25
-11
@@ -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 配置格式与落盘。
|
||||
@@ -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(
|
||||
|
||||
@@ -16,6 +16,7 @@ dependencies = [
|
||||
"loguru>=0.7.3",
|
||||
"pyee>=14.0.0",
|
||||
"python-dotenv>=1.2.3",
|
||||
"ujson>=6.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
+40
-1
@@ -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
|
||||
|
||||
@@ -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 验证请求")
|
||||
|
||||
@@ -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"<Event {self.type} {self._d}>"
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+26
-2
@@ -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"])
|
||||
|
||||
|
||||
+66
-1
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user