qqbotsdk-base-py 2.1.0__tar.gz → 2.2.0__tar.gz

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.
Files changed (19) hide show
  1. {qqbotsdk_base_py-2.1.0 → qqbotsdk_base_py-2.2.0}/PKG-INFO +1 -1
  2. {qqbotsdk_base_py-2.1.0 → qqbotsdk_base_py-2.2.0}/pyproject.toml +1 -1
  3. {qqbotsdk_base_py-2.1.0 → qqbotsdk_base_py-2.2.0}/pyproject.toml.orig +1 -1
  4. {qqbotsdk_base_py-2.1.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/api/core.py +18 -2
  5. qqbotsdk_base_py-2.1.0/src/qqbotsdk/errors.py → qqbotsdk_base_py-2.2.0/src/qqbotsdk/errors/__init__.py +10 -0
  6. qqbotsdk_base_py-2.2.0/src/qqbotsdk/errors/codes.py +129 -0
  7. {qqbotsdk_base_py-2.1.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/model/sub/messages.py +69 -5
  8. {qqbotsdk_base_py-2.1.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/ws/__init__.py +5 -3
  9. {qqbotsdk_base_py-2.1.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/ws/client.py +25 -1
  10. {qqbotsdk_base_py-2.1.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/ws/intents.py +14 -0
  11. {qqbotsdk_base_py-2.1.0 → qqbotsdk_base_py-2.2.0}/README.md +0 -0
  12. {qqbotsdk_base_py-2.1.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/api/agent.md +0 -0
  13. {qqbotsdk_base_py-2.1.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/api/auth.py +0 -0
  14. {qqbotsdk_base_py-2.1.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/api/gateway.py +0 -0
  15. {qqbotsdk_base_py-2.1.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/model/agent.md +0 -0
  16. {qqbotsdk_base_py-2.1.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/model/core.py +0 -0
  17. {qqbotsdk_base_py-2.1.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/model/sub/upload.py +0 -0
  18. {qqbotsdk_base_py-2.1.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/ws/agent.md +0 -0
  19. {qqbotsdk_base_py-2.1.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/ws/payload.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: qqbotsdk-base-py
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: QQ 官方机器人 SDK 基座包:模型基类、共享消息模型与 HTTP 会话
5
5
  Author: snowy-yang
6
6
  Author-email: snowy-yang <102860110+snowy-yang@users.noreply.github.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qqbotsdk-base-py"
3
- version = "2.1.0"
3
+ version = "2.2.0"
4
4
  description = "QQ 官方机器人 SDK 基座包:模型基类、共享消息模型与 HTTP 会话"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qqbotsdk-base-py"
3
- version = "2.1.0"
3
+ version = "2.2.0"
4
4
  description = "QQ 官方机器人 SDK 基座包:模型基类、共享消息模型与 HTTP 会话"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -415,8 +415,24 @@ class QQBotSession:
415
415
  return self._ensure()
416
416
 
417
417
  async def request(self, method: str, url: str, **kwargs: Any) -> ClientResponse:
418
- """返回已读取完成的响应, 便于需要自行处理状态码的场景."""
419
- return await self._ensure().request(method, url, **kwargs)
418
+ """通用 verb 请求: 与 ``get`` / ``post`` 等完全同构, 同样走 429 重试.
419
+
420
+ ``method`` 可以是任意 HTTP verb, 便于按配置分发。行为与
421
+ ``get`` / ``post`` / ``put`` / ``patch`` / ``delete`` 一致:
422
+
423
+ - 命中 HTTP 429 时按 ``Retry-After`` / 指数退避自动重试, 最多
424
+ ``MAX_RETRIES`` 次;
425
+ - 非 2xx 抛 :class:`qqbotsdk.errors.QQBotAPIError`(OpenAPI 域名下);
426
+ - 返回的响应**未**读取 body, 调用方自己 ``await resp.json()`` 或
427
+ ``await resp.read()``, 用完由本方法的上下文管理器释放连接::
428
+
429
+ async with session.request("GET", "/users/@me") as resp:
430
+ data = await resp.json()
431
+
432
+ 注意: 直接 ``await session.request(...)`` 拿到的响应不会自动释放,
433
+ 与 aiohttp 原生语义一致, 建议一律用 ``async with``.
434
+ """
435
+ return await self._request_context(method, url, **kwargs)
420
436
 
421
437
  @property
422
438
  def closed(self) -> bool:
@@ -8,13 +8,23 @@
8
8
  +-- QQBotGatewayError WebSocket 网关层错误
9
9
 
10
10
  调用方 ``except QQBotError`` 可以兜住全部错误, 也可以按层分别处理.
11
+
12
+ 业务错误码常量见子模块 :mod:`qqbotsdk.errors.codes`::
13
+
14
+ from qqbotsdk.errors import QQBotAPIError, codes
15
+
16
+ if exc.err_code == codes.MSG_DEDUPLICATED:
17
+ ...
11
18
  """
12
19
 
20
+ from qqbotsdk.errors import codes
21
+
13
22
  __all__ = [
14
23
  "QQBotAPIError",
15
24
  "QQBotAuthError",
16
25
  "QQBotError",
17
26
  "QQBotGatewayError",
27
+ "codes",
18
28
  ]
19
29
 
20
30
 
@@ -0,0 +1,129 @@
1
+ """OpenAPI 业务错误码常量.
2
+
3
+ ``QQBotAPIError.err_code`` 是**业务错误码**, 与 HTTP 状态码分开: HTTP 200 时
4
+ 也可能带非零 ``err_code``. 用这里的常量替代魔法数字::
5
+
6
+ from qqbotsdk.errors import QQBotAPIError, codes
7
+
8
+ try:
9
+ await ctx.reply("...")
10
+ except QQBotAPIError as exc:
11
+ if exc.err_code == codes.MSG_MSG_ID_EXPIRED:
12
+ ...
13
+
14
+ 命名规范: 前缀是错误码所属的域(``MSG_`` 消息、``APP_`` 应用权限、
15
+ ``FILE_`` 文件), 名字取官方错误表里英文标识的大写形式; 官方只给了中文描述的,
16
+ 按描述意译.
17
+
18
+ 来源: 官方 [API 调用指南 · 公共错误码] 与各接口页的「错误码」表. 标注为
19
+ 「实践观察」的几条未收录在官方公共错误表中, 来自真实 bot 的调用日志, 平台可能
20
+ 调整, 不要把它们当成稳定契约.
21
+ """
22
+
23
+ from typing import Final
24
+
25
+ # ---------------------------------------------------------------- 消息收发
26
+
27
+ MSG_DEDUPLICATED: Final = 40054005
28
+ """相同 msg_id + msg_seq 重复发送被去重. 递增 msg_seq 即可."""
29
+
30
+ MSG_ID_EXPIRED: Final = 40034005
31
+ """回复消息的 msg_id 已过期, 请在收到消息后尽快回复."""
32
+
33
+ EVENT_ID_INVALID: Final = 40034025
34
+ """请求参数 event_id 无效或越权."""
35
+
36
+ EVENT_ID_EXPIRED: Final = 40034026
37
+ """请求参数 event_id 已过期, 请在收到事件后尽快回复."""
38
+
39
+ EVENT_NOT_REPLYABLE: Final = 40034027
40
+ """该事件不支持回复消息. 例如 GROUP_MEMBER_ADD 只能发主动消息."""
41
+
42
+ MSG_TYPE_MISMATCH: Final = 22006
43
+ """消息类型与内容不匹配(例如 msg_type=2 却只传了 content)."""
44
+
45
+ MSG_TYPE_INVALID: Final = 340069
46
+ """消息类型无效, 检查 msg_type 取值."""
47
+
48
+ MSG_CONTENT_INVALID: Final = 304061
49
+ """消息内容无效, 检查消息格式."""
50
+
51
+ MSG_LENGTH_EXCEEDED: Final = 40054007
52
+ """消息长度超限."""
53
+
54
+ MSG_URL_NOT_ALLOWED: Final = 40054010
55
+ """不允许发送 URL."""
56
+
57
+ MSG_PASSIVE_LIMIT: Final = 40034128
58
+ """被动回复时间或次数超限(群聊 5 次 / 单聊 4 次)."""
59
+
60
+ MSG_ACTIVE_RATE_LIMITED: Final = 40034100
61
+ """主动消息发送超过频控限制."""
62
+
63
+ BOT_NOT_IN_GROUP: Final = 40054003
64
+ """机器人不是群成员."""
65
+
66
+ BOT_MUTED: Final = 40054002
67
+ """机器人被禁言, 等解禁后再发送."""
68
+
69
+ BOT_OFFLINE: Final = 40054016
70
+ """机器人已下线, 检查网关连接状态."""
71
+
72
+ MEDIA_TRANSFER_FAILED: Final = 40034004
73
+ """富媒体信息转存失败, 可重试."""
74
+
75
+ FILE_INFO_INVALID: Final = 304080
76
+ """文件信息无效, 检查 file_info 格式."""
77
+
78
+ MSG_ID_EXPIRED_ALT: Final = 304103
79
+ """消息 ID 已过期, 不能回复."""
80
+
81
+ # ---------------------------------------------------------------- 应用与权限
82
+
83
+ APP_PRIVILEGE_NOT_PASS: Final = 11253
84
+ """应用权限不通过: 该机器人未获得调用该接口的权限, 需要向平台申请."""
85
+
86
+ APP_PRIVILEGE_CHECK_FAILED: Final = 11252
87
+ """检查应用权限失败(系统错误), 通常重试一次即可."""
88
+
89
+ APP_INTERFACE_FORBIDDEN: Final = 11254
90
+ """应用接口被封禁: 有该接口权限但被封禁."""
91
+
92
+ BOT_BANNED: Final = 11265
93
+ """机器人已经被封禁."""
94
+
95
+ # ---------------------------------------------------------------- 实践观察
96
+ # 以下未收录在官方公共错误码表中, 来自真实 bot 的调用日志, 平台可能调整.
97
+
98
+ GROUP_FILE_NO_PERMISSION: Final = 40093005
99
+ """群文件无权限(实践观察). 常见于机器人缺少群文件读写权限时."""
100
+
101
+ QQ_STREAM_FETCH_FAILED: Final = 40093007
102
+ """QQ 抓流失败(实践观察). 常见于语音 / 视频链接平台侧拉取失败."""
103
+
104
+ __all__ = [
105
+ "APP_INTERFACE_FORBIDDEN",
106
+ "APP_PRIVILEGE_CHECK_FAILED",
107
+ "APP_PRIVILEGE_NOT_PASS",
108
+ "BOT_BANNED",
109
+ "BOT_MUTED",
110
+ "BOT_NOT_IN_GROUP",
111
+ "BOT_OFFLINE",
112
+ "EVENT_ID_EXPIRED",
113
+ "EVENT_ID_INVALID",
114
+ "EVENT_NOT_REPLYABLE",
115
+ "FILE_INFO_INVALID",
116
+ "GROUP_FILE_NO_PERMISSION",
117
+ "MEDIA_TRANSFER_FAILED",
118
+ "MSG_ACTIVE_RATE_LIMITED",
119
+ "MSG_CONTENT_INVALID",
120
+ "MSG_DEDUPLICATED",
121
+ "MSG_ID_EXPIRED",
122
+ "MSG_ID_EXPIRED_ALT",
123
+ "MSG_LENGTH_EXCEEDED",
124
+ "MSG_PASSIVE_LIMIT",
125
+ "MSG_TYPE_INVALID",
126
+ "MSG_TYPE_MISMATCH",
127
+ "MSG_URL_NOT_ALLOWED",
128
+ "QQ_STREAM_FETCH_FAILED",
129
+ ]
@@ -1,8 +1,8 @@
1
- from typing import Literal
1
+ from typing import Any, ClassVar, Literal, cast
2
2
 
3
- from pydantic import Field
3
+ from pydantic import Field, model_validator
4
4
 
5
- from qqbotsdk.model.core import QQBotBaseModel
5
+ from qqbotsdk.model.core import QQBotBaseModel, QQBotRequestModel
6
6
 
7
7
 
8
8
  class Permission(QQBotBaseModel):
@@ -76,8 +76,10 @@ class Action(QQBotBaseModel):
76
76
  class RenderData(QQBotBaseModel):
77
77
  label: str | None = Field(
78
78
  default=None,
79
- max_length=10,
80
- description="按钮文字,最多 10 字符",
79
+ description=(
80
+ "按钮文字。平台限制为最多 10 字符,超出部分由平台自行裁剪;"
81
+ "SDK 不在此处校验长度,避免用热词/标题等业务数据时被 ValidationError 卡住"
82
+ ),
81
83
  )
82
84
  visited_label: str | None = Field(
83
85
  default=None,
@@ -207,3 +209,65 @@ class MessageCard(QQBotBaseModel):
207
209
  default=None,
208
210
  description="卡片元素;type=tuwen 时必须包含 title/description/url/pic_url",
209
211
  )
212
+
213
+
214
+ class MessageSendRequestBase(QQBotRequestModel):
215
+ """发送消息请求的公共校验: 按内容字段推导 ``msg_type``, 并校验内容互斥.
216
+
217
+ 平台的 ``msg_type`` 与内容字段是一一对应的(``content``→0、``markdown``→2、
218
+ ``input_notify``→6、``media``→7、``card``→8), 但两个字段都是可选的: 只填
219
+ ``markdown`` 而不填 ``msg_type`` 时, 请求体会缺 ``msg_type`` 而只剩内容字段,
220
+ 语义全靠平台默认推断. 这里在**校验前**按填入的字段补齐 ``msg_type``, 让
221
+ ``SendXxxMessagesRequest(markdown=...)`` 直接就是一个合法的请求.
222
+
223
+ 同时把原来只写在 docstring 里的互斥关系变成真校验:
224
+
225
+ - ``markdown`` / ``media`` / ``card`` / ``input_notify`` 四者至多填一个;
226
+ - ``content`` 与 ``markdown`` 互斥(官方: 传了 markdown 后 content 必须为空);
227
+ - ``content`` 可以与 ``media`` 共存, 作为富媒体的附带文本.
228
+
229
+ 显式传了 ``msg_type`` 但与内容字段对不上时直接报错, 而不是把矛盾发给服务端.
230
+ """
231
+
232
+ # 内容字段 -> 该字段对应的 msg_type
233
+ _MSG_TYPE_BY_FIELD: ClassVar[dict[str, int]] = {
234
+ "markdown": 2,
235
+ "media": 7,
236
+ "card": 8,
237
+ "input_notify": 6,
238
+ }
239
+
240
+ @model_validator(mode="before")
241
+ @classmethod
242
+ def _infer_msg_type(cls, data: Any) -> Any:
243
+ """补齐 ``msg_type`` 并校验内容字段互斥."""
244
+ if not isinstance(data, dict):
245
+ return data
246
+ raw = cast("dict[str, Any]", data)
247
+ filled = [name for name in cls._MSG_TYPE_BY_FIELD if raw.get(name) is not None]
248
+ if len(filled) > 1:
249
+ raise ValueError(f"消息内容字段互斥,不能同时传 {filled}")
250
+ has_content = raw.get("content") is not None
251
+ if has_content and "markdown" in filled:
252
+ raise ValueError(
253
+ "content 与 markdown 互斥:传了 markdown 时 content 必须为空"
254
+ )
255
+
256
+ if filled:
257
+ inferred = cls._MSG_TYPE_BY_FIELD[filled[0]]
258
+ elif has_content:
259
+ inferred = 0
260
+ else:
261
+ # 没有任何内容字段(例如只带键盘), 保持原样交给平台判断
262
+ return raw
263
+
264
+ declared = raw.get("msg_type")
265
+ if declared is None:
266
+ return {**raw, "msg_type": inferred}
267
+ if declared != inferred:
268
+ source = filled[0] if filled else "content"
269
+ raise ValueError(
270
+ f"msg_type={declared!r} 与内容字段 {source!r} 不匹配,"
271
+ f"按 {source!r} 应为 {inferred}"
272
+ )
273
+ return raw
@@ -1,16 +1,18 @@
1
1
  """网关 WebSocket 客户端.
2
2
 
3
3
  对外只需要几个名字: ``Intents``(事件订阅位), ``DEFAULT_INTENTS``(默认订阅),
4
- ``GatewayPayload``(事件数据包), ``ReadyContent``(READY 事件体),
5
- ``QQBotWebSocket``(连接客户端), ``WILDCARD_EVENT``(通配事件名) 与 ``Handler``(回调类型).
4
+ ``GROUP_BOT_INTENTS``(只做群聊 / 单聊的订阅位), ``GatewayPayload``(事件数据包),
5
+ ``ReadyContent``(READY 事件体), ``QQBotWebSocket``(连接客户端),
6
+ ``WILDCARD_EVENT``(通配事件名) 与 ``Handler``(回调类型).
6
7
  """
7
8
 
8
9
  from qqbotsdk.ws.client import WILDCARD_EVENT, Handler, QQBotWebSocket
9
- from qqbotsdk.ws.intents import DEFAULT_INTENTS, Intents
10
+ from qqbotsdk.ws.intents import DEFAULT_INTENTS, GROUP_BOT_INTENTS, Intents
10
11
  from qqbotsdk.ws.payload import GatewayPayload, OpCode, ReadyContent, ReadyUser
11
12
 
12
13
  __all__ = [
13
14
  "DEFAULT_INTENTS",
15
+ "GROUP_BOT_INTENTS",
14
16
  "WILDCARD_EVENT",
15
17
  "GatewayPayload",
16
18
  "Handler",
@@ -71,6 +71,26 @@ _FATAL_CLOSE_CODES: Final = frozenset(
71
71
  {4001, 4002, 4010, 4011, 4012, 4013, 4014, 4914, 4915}
72
72
  )
73
73
 
74
+ # 不可重连关闭码的可读原因, 让报错直接指向怎么修
75
+ _FATAL_CLOSE_REASONS: Final = {
76
+ 4001: "无效的 opcode",
77
+ 4002: "无效的 payload",
78
+ 4010: "无效的 shard",
79
+ 4011: "连接数超过限制",
80
+ 4012: "无效的 API 版本",
81
+ 4013: (
82
+ "无效的 intents:其中有当前应用不可订阅的事件位;"
83
+ "到开放平台确认该 intents 已在「事件订阅」中开通"
84
+ ),
85
+ 4014: (
86
+ "无权限的 intents:该 intents 未在开放平台开通;"
87
+ "到「开发设置 → 事件订阅」申请对应事件,或改用 "
88
+ "GROUP_BOT_INTENTS 收窄订阅范围"
89
+ ),
90
+ 4914: "机器人已下架, 请到开放平台检查应用状态",
91
+ 4915: "机器人已被封禁",
92
+ }
93
+
74
94
  # 其余关闭码(含 4006, 4007, 4900~4913)一律清空会话后重新 Identify
75
95
 
76
96
 
@@ -509,7 +529,11 @@ class QQBotWebSocket:
509
529
  # 主动关闭, 无需重连
510
530
  return
511
531
  if code in _FATAL_CLOSE_CODES:
512
- error = QQBotGatewayError("网关拒绝连接,不可重连", code=code)
532
+ reason = _FATAL_CLOSE_REASONS.get(code)
533
+ message = "网关拒绝连接,不可重连"
534
+ if reason is not None:
535
+ message = f"{message}:{reason}"
536
+ error = QQBotGatewayError(message, code=code)
513
537
  self._last_error = error
514
538
  raise _StopGateway(str(error))
515
539
  if code in _RESUMABLE_CLOSE_CODES:
@@ -42,7 +42,21 @@ DEFAULT_INTENTS: Final = (
42
42
  | Intents.INTERACTION
43
43
  )
44
44
 
45
+ # 只做群聊 / 单聊的机器人用这一组: 不含任何频道域订阅位
46
+ GROUP_BOT_INTENTS: Final = Intents.GROUP_AND_C2C_EVENT | Intents.INTERACTION
47
+ """群聊 / 单聊机器人的订阅位.
48
+
49
+ ``DEFAULT_INTENTS`` 带两个频道位(``GUILD_MEMBERS`` / ``PUBLIC_GUILD_MESSAGES``,
50
+ 按官方文档它们属于基础事件、默认有权限, 不会导致连接被拒), 只做群聊的机器人用
51
+ 这组更贴合实际订阅范围, 也少一位需要考虑权限的位.
52
+
53
+ 注意 ``INTERACTION`` 属于需要申请的特殊事件: 如果机器人不用消息按钮 / 快捷菜单,
54
+ 可以进一步收窄成 ``Intents.GROUP_AND_C2C_EVENT``. 订阅了没开通的 intent, 网关会用
55
+ 关闭码 4013 / 4014 直接断开.
56
+ """
57
+
45
58
  __all__ = [
46
59
  "DEFAULT_INTENTS",
60
+ "GROUP_BOT_INTENTS",
47
61
  "Intents",
48
62
  ]