qqbotsdk-base-py 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
qqbotsdk/errors.py ADDED
@@ -0,0 +1,92 @@
1
+ """统一异常体系: 业务层, 凭证层与网关层三级分类.
2
+
3
+ 层次::
4
+
5
+ QQBotError SDK 所有异常的基类
6
+ +-- QQBotAPIError OpenAPI 返回的业务/HTTP 错误
7
+ +-- QQBotAuthError 凭证缺失或换取 access_token 失败
8
+ +-- QQBotGatewayError WebSocket 网关层错误
9
+
10
+ 调用方 ``except QQBotError`` 可以兜住全部错误, 也可以按层分别处理.
11
+ """
12
+
13
+ __all__ = [
14
+ "QQBotAPIError",
15
+ "QQBotAuthError",
16
+ "QQBotError",
17
+ "QQBotGatewayError",
18
+ ]
19
+
20
+
21
+ class QQBotError(Exception):
22
+ """SDK 所有异常的基类."""
23
+
24
+
25
+ class QQBotAPIError(QQBotError):
26
+ """OpenAPI 返回的业务错误.
27
+
28
+ Attributes:
29
+ status: HTTP 状态码.
30
+ err_code: 业务错误码 (body 中的 ``err_code`` 或 ``code``), 0 表示未知.
31
+ message: 服务端返回的错误描述.
32
+ trace_id: 服务端追踪 ID, 提交工单时需要提供.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ status: int,
38
+ err_code: int = 0,
39
+ message: str = "",
40
+ trace_id: str | None = None,
41
+ ) -> None:
42
+ self.status = status
43
+ self.err_code = err_code
44
+ self.message = message
45
+ self.trace_id = trace_id
46
+ super().__init__(status, err_code, message, trace_id)
47
+
48
+ def __str__(self) -> str:
49
+ parts = [
50
+ f"status={self.status}",
51
+ f"err_code={self.err_code}",
52
+ f"message={self.message!r}",
53
+ ]
54
+ if self.trace_id:
55
+ parts.append(f"trace_id={self.trace_id}")
56
+ return f"QQBotAPIError({', '.join(parts)})"
57
+
58
+
59
+ class QQBotAuthError(QQBotError):
60
+ """凭证缺失或换取 access_token 失败.
61
+
62
+ Attributes:
63
+ message: 失败原因, 换取失败时包含服务端返回的 ``code`` 与 ``message``.
64
+ code: 服务端业务码, 本地凭证缺失时为 None.
65
+ """
66
+
67
+ def __init__(self, message: str = "", *, code: int | None = None) -> None:
68
+ self.message = message
69
+ self.code = code
70
+ super().__init__(message)
71
+
72
+ def __str__(self) -> str:
73
+ return f"QQBotAuthError({self.message})"
74
+
75
+
76
+ class QQBotGatewayError(QQBotError):
77
+ """WebSocket 网关层错误.
78
+
79
+ Attributes:
80
+ reason: 错误原因描述.
81
+ code: 网关返回的错误码 (op 9 无效会话或 close code), 未知时为 None.
82
+ """
83
+
84
+ def __init__(self, reason: str = "", code: int | None = None) -> None:
85
+ self.reason = reason
86
+ self.code = code
87
+ super().__init__(code, reason)
88
+
89
+ def __str__(self) -> str:
90
+ if self.code is None:
91
+ return f"QQBotGatewayError({self.reason})"
92
+ return f"QQBotGatewayError(code={self.code}, reason={self.reason})"
@@ -0,0 +1,48 @@
1
+ # agent.md — qqbotsdk-base/model(基类与共享子模型)
2
+
3
+ 适用范围:`packages/qqbotsdk-base/src/qqbotsdk/model/**`(`core.py`、`sub/messages.py`、`sub/upload.py`)。
4
+
5
+ ## MUST(必须做)
6
+
7
+ - MUST 让所有模型继承 `QQBotBaseModel`(已配置 `validate_by_name`、`serialize_by_alias`、`extra="allow"`);禁止直接继承 `pydantic.BaseModel`。
8
+ - MUST 用「字段名 + 多行 `Field`」的固定写法,尾逗号:
9
+
10
+ ```python
11
+ msg_type: Literal[0, 2, 7, 8] | None = Field(
12
+ default=None,
13
+ description="消息类型:0=纯文本,2=Markdown,7=富媒体,8=图文卡片",
14
+ )
15
+ required_name: str = Field(
16
+ ...,
17
+ description="必填字段用省略号",
18
+ )
19
+ ```
20
+
21
+ - MUST 可选字段写成 `Field(default=None, ...)` 而不是 `Field(None, ...)`:pydantic 2.14 + pyright 会把后者当成必填,导致用户侧任何「部分构造」(如 `SendGroupMessagesRequest(content="x")`)都报 `reportCallIssue`。
22
+ - MUST 严格照文档定必填性:「必填=是」→ `Field(...)`;「必填=否」→ `Field(default=None, ...)`。
23
+ - MUST 让 `description` 用中文、单句、**不加结尾句号**,并尽量沿用文档原词。
24
+ - MUST 在收紧取值时用 `Literal[...]`,在有明确上下限时加 `ge`/`le`/`max_length`/`min_length`。
25
+ - MUST 保持向后兼容:只允许新增字段、放宽类型、改描述;删除/改名/收窄公开字段前必须取得许可。
26
+
27
+ ## SHOULD(应当做)
28
+
29
+ - SHOULD 把「多业务域共用」的结构放这里(键盘、Markdown、引用、富媒体、卡片、分片上传);单一业务域专有的模型放各自包。
30
+ - SHOULD 用 `list[X] | None` 而不是 `list[X] = []`;需要可变默认值时用 `Field(default_factory=list)`。
31
+ - SHOULD 用 `RootModel` 表达「裸数组」响应,用普通模型表达对象响应。
32
+
33
+ ## MUST NOT(禁止做)
34
+
35
+ - MUST NOT 建模已废弃/不可用字段:`Keyboard.id`(内嵌键盘模板)、群/单聊 `MessageMarkdown.template_id` / `custom_template_id`、`Action.click_limit`、`Action.at_bot_show_channel_list`。
36
+ - MUST NOT 按「码点」加中文字符数上限——文档说「一个中文汉字算 2 个字符」时,把限制写进 `description`,不要加 `max_length`(会比文档更严,误拒合法输入)。
37
+ - MUST NOT 在这里 import 任何业务包(`qqbotsdk.model.group` / `guild` / `event` 等)。
38
+ - MUST NOT 为同一个结构重复造多个类;共享子模型只有一个定义。
39
+
40
+ ## 自检
41
+
42
+ ```powershell
43
+ cd D:\QQBotSDK
44
+ .venv\Scripts\ruff.exe check packages\qqbotsdk-base\src\qqbotsdk\model
45
+ .venv\Scripts\pyright.exe
46
+ ```
47
+
48
+ 改动的字段会影响 `qqbotsdk-py` 与 `qqbotsdk-guild-py` 的所有请求模型,改完必须全仓跑质量门。
qqbotsdk/model/core.py ADDED
@@ -0,0 +1,30 @@
1
+ from pydantic import BaseModel, ConfigDict
2
+
3
+
4
+ class QQBotBaseModel(BaseModel):
5
+ """响应/事件模型基类: 宽容未知字段, 服务端新增字段不会导致解析失败."""
6
+
7
+ model_config = ConfigDict(
8
+ validate_by_name=True,
9
+ serialize_by_alias=True,
10
+ extra="allow",
11
+ )
12
+
13
+
14
+ class QQBotRequestModel(QQBotBaseModel):
15
+ """请求体基类: 禁止未知字段.
16
+
17
+ 字段名拼错时会立刻抛 ``ValidationError``, 而不是把错误字段静默发给服务端.
18
+ """
19
+
20
+ model_config = ConfigDict(
21
+ validate_by_name=True,
22
+ serialize_by_alias=True,
23
+ extra="forbid",
24
+ )
25
+
26
+
27
+ __all__ = [
28
+ "QQBotBaseModel",
29
+ "QQBotRequestModel",
30
+ ]
@@ -0,0 +1,209 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import Field
4
+
5
+ from qqbotsdk.model.core import QQBotBaseModel
6
+
7
+
8
+ class Permission(QQBotBaseModel):
9
+ type: Literal[0, 1, 2, 3] | None = Field(
10
+ default=None,
11
+ description="0=指定用户, 1=管理员, 2=所有人, 3=指定身份组(仅频道可用)",
12
+ )
13
+ specify_user_ids: list[str] | None = Field(
14
+ default=None,
15
+ description="有权限的用户 id 的列表",
16
+ )
17
+ specify_role_ids: list[str] | None = Field(
18
+ default=None,
19
+ description="有权限的身份组 id 的列表(仅频道可用)",
20
+ )
21
+
22
+
23
+ class Modal(QQBotBaseModel):
24
+ content: str | None = Field(
25
+ default=None,
26
+ max_length=40,
27
+ description="二次确认的提示文本,不为空则进行二次确认,最多 40 个字符,不能有 URL",
28
+ )
29
+ confirm_text: str | None = Field(
30
+ default=None,
31
+ max_length=4,
32
+ description='二次确认提示确认按钮文字,默认为"确认",最多 4 个字符',
33
+ )
34
+ cancel_text: str | None = Field(
35
+ default=None,
36
+ max_length=4,
37
+ description='二次确认提示取消按钮文字,默认为"取消",最多 4 个字符',
38
+ )
39
+
40
+
41
+ class Action(QQBotBaseModel):
42
+ type: Literal[0, 1, 2] | None = Field(
43
+ default=None,
44
+ description="0=跳转按钮,1=回调按钮,2=指令按钮",
45
+ )
46
+ permission: Permission | None = Field(
47
+ default=None,
48
+ description="操作权限",
49
+ )
50
+ data: str | None = Field(
51
+ default=None,
52
+ description="回调数据。type=1/2 时必填",
53
+ )
54
+ unsupport_tips: str | None = Field(
55
+ default=None,
56
+ description="版本过低时提示文案",
57
+ )
58
+ enter: bool | None = Field(
59
+ default=None,
60
+ description="指令按钮可用,点击按钮后直接自动发送 data,仅单聊可用,默认 false",
61
+ )
62
+ reply: bool | None = Field(
63
+ default=None,
64
+ description="指令按钮可用,指令是否带引用回复本消息,默认 false",
65
+ )
66
+ anchor: int | None = Field(
67
+ default=None,
68
+ description="仅指令按钮下有效,设置后忽略 action.enter,设置为 1 时点击按钮自动唤起手Q选图器",
69
+ )
70
+ modal: Modal | None = Field(
71
+ default=None,
72
+ description="用户点击二次确认操作",
73
+ )
74
+
75
+
76
+ class RenderData(QQBotBaseModel):
77
+ label: str | None = Field(
78
+ default=None,
79
+ max_length=10,
80
+ description="按钮文字,最多 10 字符",
81
+ )
82
+ visited_label: str | None = Field(
83
+ default=None,
84
+ description="点击后文字,不传则保持不变",
85
+ )
86
+ style: Literal[0, 1, 3, 4] | None = Field(
87
+ default=None,
88
+ description="0=灰色线框,1=蓝色线框,3=白色背景+红色字体,4=蓝色背景+白色字体",
89
+ )
90
+
91
+
92
+ class Button(QQBotBaseModel):
93
+ id: str | None = Field(
94
+ default=None,
95
+ description="按钮 ID。同一键盘内唯一",
96
+ )
97
+ render_data: RenderData | None = Field(
98
+ default=None,
99
+ description="按钮渲染",
100
+ )
101
+ action: Action | None = Field(
102
+ default=None,
103
+ description="按钮点击行为",
104
+ )
105
+ group_id: str | None = Field(
106
+ default=None,
107
+ description="分组ID,同一分组内有一个按钮操作后其它按钮变灰不可点击,仅当 action.type = 1 时有效",
108
+ )
109
+
110
+
111
+ class Row(QQBotBaseModel):
112
+ buttons: list[Button] | None = Field(
113
+ default=None,
114
+ description="行内按钮,从左到右排列",
115
+ )
116
+
117
+
118
+ class KeyboardContent(QQBotBaseModel):
119
+ rows: list[Row] | None = Field(
120
+ default=None,
121
+ description="按钮行列表",
122
+ )
123
+
124
+
125
+ class Keyboard(QQBotBaseModel):
126
+ content: KeyboardContent | None = Field(
127
+ default=None,
128
+ description="自定义键盘布局,用于自定义按钮",
129
+ )
130
+
131
+
132
+ class MessageMarkdown(QQBotBaseModel):
133
+ content: str | None = Field(
134
+ default=None,
135
+ description="Markdown 内容",
136
+ )
137
+ force_verify_image_resource: bool | None = Field(
138
+ default=None,
139
+ description="是否校验图片转存结果,为 true 时图片转存失败会返回错误且消息不发送,默认 false",
140
+ )
141
+
142
+
143
+ class MediaInfo(QQBotBaseModel):
144
+ file_info: str | None = Field(
145
+ default=None,
146
+ description="文件数据。来自文件上传接口返回值",
147
+ )
148
+
149
+
150
+ class MessageReference(QQBotBaseModel):
151
+ message_id: str | None = Field(
152
+ default=None,
153
+ description="被引用消息 ID,例如 REFIDX_xxxxxx",
154
+ )
155
+
156
+
157
+ class InputNotify(QQBotBaseModel):
158
+ input_type: Literal[1] | None = Field(
159
+ default=None,
160
+ description="填 1",
161
+ )
162
+ input_second: int | None = Field(
163
+ default=None,
164
+ le=60,
165
+ description="状态持续时间,最长 60s",
166
+ )
167
+
168
+
169
+ class MessageExtInfo(QQBotBaseModel):
170
+ ref_idx: str | None = Field(
171
+ default=None,
172
+ description="引用消息索引。对应消息时间 ext 里的 msg_idx 与 ref_msg_idx",
173
+ )
174
+
175
+
176
+ class MessageCardContent(QQBotBaseModel):
177
+ title: str | None = Field(
178
+ default=None,
179
+ min_length=1,
180
+ max_length=50,
181
+ description="卡片标题,1-50 字符",
182
+ )
183
+ description: str | None = Field(
184
+ default=None,
185
+ min_length=1,
186
+ max_length=200,
187
+ description="卡片描述,1-200 字符",
188
+ )
189
+ url: str | None = Field(
190
+ default=None,
191
+ max_length=1024,
192
+ description="跳转链接,必须为 https,长度 <= 1024 字符",
193
+ )
194
+ pic_url: str | None = Field(
195
+ default=None,
196
+ max_length=1024,
197
+ description="封面图链接,必须为 https,长度 <= 1024 字符",
198
+ )
199
+
200
+
201
+ class MessageCard(QQBotBaseModel):
202
+ type: Literal["tuwen"] | None = Field(
203
+ default=None,
204
+ description="卡片类型:tuwen=图文卡片",
205
+ )
206
+ content: MessageCardContent | None = Field(
207
+ default=None,
208
+ description="卡片元素;type=tuwen 时必须包含 title/description/url/pic_url",
209
+ )
@@ -0,0 +1,100 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import Field
4
+
5
+ from qqbotsdk.model.core import QQBotBaseModel, QQBotRequestModel
6
+
7
+
8
+ class UploadPart(QQBotBaseModel):
9
+ index: int | None = Field(
10
+ default=None,
11
+ description="分片序号,从 0 开始",
12
+ )
13
+ presigned_url: str | None = Field(
14
+ default=None,
15
+ description="预签名上传 URL,客户端通过 HTTP PUT 将分片数据上传到此 URL",
16
+ )
17
+ block_size: str | None = Field(
18
+ default=None,
19
+ description="该分块的大小(字节)",
20
+ )
21
+
22
+
23
+ class UploadConfig(QQBotBaseModel):
24
+ concurrency: int | None = Field(
25
+ default=None,
26
+ description="上传并发数,默认 1",
27
+ )
28
+ retry_timeout: int | None = Field(
29
+ default=None,
30
+ description="重试超时时间(秒),默认 300(5 分钟)",
31
+ )
32
+ retry_delay: int | None = Field(
33
+ default=None,
34
+ description="重试延迟(秒),默认 1",
35
+ )
36
+
37
+
38
+ class UploadPrepareResponse(QQBotBaseModel):
39
+ upload_id: str | None = Field(
40
+ default=None,
41
+ description="上传任务 ID,后续分片上传和完成合并时需携带",
42
+ )
43
+ block_size: str | None = Field(
44
+ default=None,
45
+ description="分块大小(字节),默认 5MB",
46
+ )
47
+ parts: list[UploadPart] | None = Field(
48
+ default=None,
49
+ description="分片列表,每个分片包含一个预签名上传 URL",
50
+ )
51
+ upload_config: UploadConfig | None = Field(
52
+ default=None,
53
+ description="上传配置,控制客户端上传行为",
54
+ )
55
+
56
+
57
+ class UploadPartFinishRequest(QQBotRequestModel):
58
+ upload_id: str | None = Field(
59
+ default=None,
60
+ description="上传任务 ID,来自预上传响应",
61
+ )
62
+ part_index: int | None = Field(
63
+ default=None,
64
+ description="分片序号,对应 UploadPart.index",
65
+ )
66
+ block_size: str | None = Field(
67
+ default=None,
68
+ description="该分块的实际大小(字节)",
69
+ )
70
+ md5: str | None = Field(
71
+ default=None,
72
+ description="该分片的 MD5 校验值",
73
+ )
74
+
75
+
76
+ class UploadPrepareRequest(QQBotRequestModel):
77
+ file_type: Literal[1, 2, 3, 4] = Field(
78
+ ...,
79
+ description="业务类型:1=图片,2=视频,3=语音,4=文件,超过软限制会降级为文件类型",
80
+ )
81
+ file_size: str = Field(
82
+ ...,
83
+ description="文件大小(字节),字符串形式",
84
+ )
85
+ file_name: str = Field(
86
+ ...,
87
+ description="文件名",
88
+ )
89
+ md5: str = Field(
90
+ ...,
91
+ description="整个文件的 MD5 校验值",
92
+ )
93
+ sha1: str = Field(
94
+ ...,
95
+ description="整个文件的 SHA1 校验值",
96
+ )
97
+ md5_10m: str = Field(
98
+ ...,
99
+ description="文件前 10002432 字节(约 10MB)的 MD5 校验值",
100
+ )
@@ -0,0 +1,22 @@
1
+ """网关 WebSocket 客户端.
2
+
3
+ 对外只需要几个名字: ``Intents``(事件订阅位), ``DEFAULT_INTENTS``(默认订阅),
4
+ ``GatewayPayload``(事件数据包), ``ReadyContent``(READY 事件体),
5
+ ``QQBotWebSocket``(连接客户端), ``WILDCARD_EVENT``(通配事件名) 与 ``Handler``(回调类型).
6
+ """
7
+
8
+ from qqbotsdk.ws.client import WILDCARD_EVENT, Handler, QQBotWebSocket
9
+ from qqbotsdk.ws.intents import DEFAULT_INTENTS, Intents
10
+ from qqbotsdk.ws.payload import GatewayPayload, OpCode, ReadyContent, ReadyUser
11
+
12
+ __all__ = [
13
+ "DEFAULT_INTENTS",
14
+ "WILDCARD_EVENT",
15
+ "GatewayPayload",
16
+ "Handler",
17
+ "Intents",
18
+ "OpCode",
19
+ "QQBotWebSocket",
20
+ "ReadyContent",
21
+ "ReadyUser",
22
+ ]
qqbotsdk/ws/agent.md ADDED
@@ -0,0 +1,39 @@
1
+ # agent.md — qqbotsdk-base/ws(网关 WebSocket)
2
+
3
+ 适用范围:`packages/qqbotsdk-base/src/qqbotsdk/ws/**`(`intents.py`、`payload.py`、`client.py`、`__init__.py`)。
4
+
5
+ 职责:网关长连接的状态机——握手、心跳、重连、Resume、事件分发。**不含任何业务模型**。
6
+
7
+ ## MUST(必须做)
8
+
9
+ - MUST 按官方顺序驱动状态机:等 `op=10 Hello` → 取 `heartbeat_interval` → 启动心跳 Task → 有 `session_id` 且 `seq` 非空发 `op=6 Resume`,否则发 `op=2 Identify`。
10
+ - MUST 在 `Identify` 里固定上报 `"shard": [0, 1]`(本 SDK 不做分片)。
11
+ - MUST 让心跳携带**最近一次收到的 `s`**(首次为 `None`),并处理 `op=11` ACK。
12
+ - MUST 按关闭码分三类处理:`4009` 保留会话走 Resume;`4001`/`4002`/`4010~4014`/`4914`/`4915` 直接终止不再重连;其余(含 `4006`/`4007`/`4900~4913`/`None`)清空会话重新 Identify。
13
+ - MUST 重连使用指数退避(`min(base * 2**(n-1), max)`,指数封顶避免溢出),并在 READY/RESUMED 成功后归零。
14
+ - MUST 让 `close()` 幂等:停心跳、关 ws、取消后台任务、投递结束哨兵;`async for` 在停止后以 `StopAsyncIteration` 结束。
15
+ - MUST 保持 `__aiter__` 只产出 `op=0` 的 Dispatch 帧(READY / RESUMED 也产出)。
16
+
17
+ ## SHOULD(应当做)
18
+
19
+ - SHOULD 把无法解析或校验失败的帧记 `warning` 后忽略,而不是让整个连接崩掉。
20
+ - SHOULD 让 `wait_ready(timeout=...)` 同时等待「就绪」与「已停止」两个事件,避免无限挂起。
21
+ - SHOULD 复用 `qqbotsdk.api.core` 的 `session.client` 与 `qqbotsdk.api.auth.get_access_token()`,不要自建 HTTP 会话。
22
+
23
+ ## MUST NOT(禁止做)
24
+
25
+ - MUST NOT 打印或记录 `access_token`(包括拼好的 `QQBot <token>`);调试时只允许打印长度或事件名。
26
+ - MUST NOT 实现分片/负载均衡:不接 `shard` 参数、不封装 `GET /gateway/bot`。
27
+ - MUST NOT 在 `ws/` 里 import 业务包模型;事件体由业务包自己的 `parse_event` 解析。
28
+ - MUST NOT 依赖 aiohttp 的内建心跳(`ws_connect(heartbeat=...)` 必须为 `None`),心跳由本模块自己发。
29
+ - MUST NOT 让异常静默死亡:不可重试的终止必须留下 `error` 级日志。
30
+
31
+ ## 自检
32
+
33
+ ```powershell
34
+ cd D:\QQBotSDK
35
+ .venv\Scripts\ruff.exe check packages\qqbotsdk-base\src\qqbotsdk\ws
36
+ .venv\Scripts\pyright.exe
37
+ ```
38
+
39
+ 回归方式:用 `aiohttp.web` 起本地假网关(Hello → Identify → READY → 心跳/ACK → Dispatch → `close(4009)` → Resume → RESUMED),断言事件顺序、`Identify` 的 `shard == [0, 1]`、Resume 携带的 `session_id`/`seq`。真机联调用 `get_gateway()` 拿地址后连一次,确认能收到 READY 后正常关闭。