qqbotsdk-base-py 2.0.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 (20) hide show
  1. {qqbotsdk_base_py-2.0.0 → qqbotsdk_base_py-2.2.0}/PKG-INFO +1 -1
  2. {qqbotsdk_base_py-2.0.0 → qqbotsdk_base_py-2.2.0}/pyproject.toml +1 -1
  3. {qqbotsdk_base_py-2.0.0 → qqbotsdk_base_py-2.2.0}/pyproject.toml.orig +1 -1
  4. {qqbotsdk_base_py-2.0.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/api/core.py +117 -3
  5. qqbotsdk_base_py-2.0.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.0.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/model/sub/messages.py +69 -5
  8. {qqbotsdk_base_py-2.0.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/ws/__init__.py +5 -3
  9. {qqbotsdk_base_py-2.0.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/ws/client.py +25 -1
  10. qqbotsdk_base_py-2.2.0/src/qqbotsdk/ws/intents.py +62 -0
  11. qqbotsdk_base_py-2.0.0/src/qqbotsdk/ws/intents.py +0 -42
  12. {qqbotsdk_base_py-2.0.0 → qqbotsdk_base_py-2.2.0}/README.md +0 -0
  13. {qqbotsdk_base_py-2.0.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/api/agent.md +0 -0
  14. {qqbotsdk_base_py-2.0.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/api/auth.py +0 -0
  15. {qqbotsdk_base_py-2.0.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/api/gateway.py +0 -0
  16. {qqbotsdk_base_py-2.0.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/model/agent.md +0 -0
  17. {qqbotsdk_base_py-2.0.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/model/core.py +0 -0
  18. {qqbotsdk_base_py-2.0.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/model/sub/upload.py +0 -0
  19. {qqbotsdk_base_py-2.0.0 → qqbotsdk_base_py-2.2.0}/src/qqbotsdk/ws/agent.md +0 -0
  20. {qqbotsdk_base_py-2.0.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.0.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.0.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.0.0"
3
+ version = "2.2.0"
4
4
  description = "QQ 官方机器人 SDK 基座包:模型基类、共享消息模型与 HTTP 会话"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -22,6 +22,7 @@ import asyncio
22
22
  import os
23
23
  import random
24
24
  from collections.abc import Awaitable, Callable, Generator, Mapping
25
+ from contextlib import contextmanager
25
26
  from types import TracebackType
26
27
  from typing import Any, Final, cast
27
28
  from urllib.parse import urlparse
@@ -414,8 +415,24 @@ class QQBotSession:
414
415
  return self._ensure()
415
416
 
416
417
  async def request(self, method: str, url: str, **kwargs: Any) -> ClientResponse:
417
- """返回已读取完成的响应, 便于需要自行处理状态码的场景."""
418
- 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)
419
436
 
420
437
  @property
421
438
  def closed(self) -> bool:
@@ -434,7 +451,100 @@ class QQBotSession:
434
451
  await self.close()
435
452
 
436
453
 
437
- session = QQBotSession()
454
+ # 默认会话: 未注入时所有请求都走它
455
+ _DEFAULT_SESSION: Final = QQBotSession()
456
+
457
+ # 当前注入的会话; None 表示用默认会话
458
+ _injected_session: QQBotSession | None = None
459
+
460
+
461
+ class _SessionRouter:
462
+ """``session`` 的转发实现: 每次调用都重新解析当前生效的会话.
463
+
464
+ 模块级 ``session`` 必须是**稳定的转发层**而不是会话本身: 业务模块的
465
+ ``from ... import session`` 在 import 期就把名字绑死了, 如果 ``session`` 是会话
466
+ 实例, 注入就要去改各模块的模块属性(打补丁), 测起来很脆. 这里让名字稳定不变,
467
+ 真正的会话由 :func:`set_session` 换, 所有 import 过它的模块自动看到新会话.
468
+ """
469
+
470
+ __slots__ = ()
471
+
472
+ def __getattr__(self, name: str) -> Any:
473
+ """未显式定义的属性一律转发到当前生效的会话.
474
+
475
+ 注意这是「转发引用」: ``session.some_attr = x`` 只会写到本代理上, 不会写到
476
+ 真正的会话. 需要改内部状态请用 :func:`get_session` 拿真实会话.
477
+ """
478
+ return getattr(resolve_session(), name)
479
+
480
+ def _ensure(self) -> ClientSession:
481
+ """确保当前会话的连接池已创建, 返回底层 ``ClientSession``."""
482
+ session = resolve_session()
483
+ return session._ensure() # pyright: ignore[reportPrivateUsage]
484
+
485
+ async def __aenter__(self) -> ClientSession:
486
+ return self._ensure()
487
+
488
+ async def __aexit__(self, *_exc: object) -> None:
489
+ return None
490
+
491
+
492
+ def resolve_session() -> QQBotSession:
493
+ """返回当前生效的会话对象: 注入了就用注入的, 否则用模块级默认单例."""
494
+ return _DEFAULT_SESSION if _injected_session is None else _injected_session
495
+
496
+
497
+ def get_session() -> QQBotSession:
498
+ """返回当前生效的会话对象, 供需要改内部状态(例如测试)的场景使用."""
499
+ return resolve_session()
500
+
501
+
502
+ @contextmanager
503
+ def use_session(session: QQBotSession) -> Generator[QQBotSession]:
504
+ """在 ``with`` 块内让所有 ``qqbotsdk`` 请求改走指定会话, 退出后还原.
505
+
506
+ 用于单测: 构造一个指向本地假服务端的会话传进来, 业务模块与 ``ctx.api`` 都会
507
+ 自动用它, 不会误连真实域名.
508
+
509
+ Args:
510
+ session: 要临时启用的会话对象.
511
+
512
+ Yields:
513
+ 传入的会话对象本身.
514
+ """
515
+ set_session(session)
516
+ try:
517
+ yield session
518
+ finally:
519
+ reset_session()
520
+
521
+
522
+ def set_session(session: QQBotSession | None) -> None:
523
+ """替换全局会话(传 None 表示恢复默认单例).
524
+
525
+ Args:
526
+ session: 新的会话对象, 或 None.
527
+
528
+ Notes:
529
+ 被替换掉的会话不会被自动关闭, 调用方自己决定何时 ``await it.close()``.
530
+ """
531
+ global _injected_session
532
+ _injected_session = session
533
+
534
+
535
+ def reset_session() -> None:
536
+ """恢复默认单例, 供测试夹具收尾使用(不关闭任何会话)."""
537
+ global _injected_session
538
+ _injected_session = None
539
+
540
+
541
+ session = _SessionRouter()
542
+ """当前生效会话的转发入口, 用法与 ``aiohttp.ClientSession`` 一致.
543
+
544
+ ``from qqbotsdk.api.core import session`` 拿到的就是这个对象; 它本身不持有连接,
545
+ 真正的会话由 :func:`get_session` / :func:`set_session` 决定, 因此测试里可以整体
546
+ 换成假服务端上的会话.
547
+ """
438
548
 
439
549
  __all__ = [
440
550
  "BASE_URL",
@@ -445,5 +555,9 @@ __all__ = [
445
555
  "RETRY_TOTAL_WAIT_SECONDS",
446
556
  "QQBotResponse",
447
557
  "QQBotSession",
558
+ "get_session",
559
+ "reset_session",
448
560
  "session",
561
+ "set_session",
562
+ "use_session",
449
563
  ]
@@ -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:
@@ -0,0 +1,62 @@
1
+ """网关事件订阅位(Intents).
2
+
3
+ Intents 是一个位掩码, 在 Identify 时上报给网关, 声明机器人希望订阅哪些事件;
4
+ 未订阅的事件不会下发. 用按位或组合多个位::
5
+
6
+ from qqbotsdk.ws import Intents
7
+
8
+ intents = Intents.GROUP_AND_C2C_EVENT | Intents.PUBLIC_GUILD_MESSAGES
9
+
10
+ ``GROUP_MEMBER_EVENT`` (1<<24) 没有出现在官方「事件订阅与通知」的总表里, 但
11
+ ``GROUP_MEMBER_ADD`` 与 ``GROUP_JOIN_REQUEST`` 两篇事件文档标注的 intent 都是它;
12
+ 官方 Python SDK (botpy) 至今也没有这一位. 需要群成员进出 / 入群申请事件时显式
13
+ 订阅它.
14
+ """
15
+
16
+ from enum import IntFlag
17
+ from typing import Final
18
+
19
+
20
+ class Intents(IntFlag):
21
+ """事件订阅位, 每一位对应一类事件."""
22
+
23
+ GUILDS = 1 << 0
24
+ GUILD_MEMBERS = 1 << 1
25
+ GUILD_MESSAGES = 1 << 9
26
+ GUILD_MESSAGE_REACTIONS = 1 << 10
27
+ DIRECT_MESSAGE = 1 << 12
28
+ GROUP_MEMBER_EVENT = 1 << 24
29
+ GROUP_AND_C2C_EVENT = 1 << 25
30
+ INTERACTION = 1 << 26
31
+ MESSAGE_AUDIT = 1 << 27
32
+ FORUMS_EVENT = 1 << 28
33
+ AUDIO_ACTION = 1 << 29
34
+ PUBLIC_GUILD_MESSAGES = 1 << 30
35
+
36
+
37
+ # 默认订阅: 群与单聊消息, 公域频道消息, 频道成员变更, 交互事件
38
+ DEFAULT_INTENTS: Final = (
39
+ Intents.GROUP_AND_C2C_EVENT
40
+ | Intents.PUBLIC_GUILD_MESSAGES
41
+ | Intents.GUILD_MEMBERS
42
+ | Intents.INTERACTION
43
+ )
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
+
58
+ __all__ = [
59
+ "DEFAULT_INTENTS",
60
+ "GROUP_BOT_INTENTS",
61
+ "Intents",
62
+ ]
@@ -1,42 +0,0 @@
1
- """网关事件订阅位(Intents).
2
-
3
- Intents 是一个位掩码, 在 Identify 时上报给网关, 声明机器人希望订阅哪些事件;
4
- 未订阅的事件不会下发. 用按位或组合多个位::
5
-
6
- from qqbotsdk.ws import Intents
7
-
8
- intents = Intents.GROUP_AND_C2C_EVENT | Intents.PUBLIC_GUILD_MESSAGES
9
- """
10
-
11
- from enum import IntFlag
12
- from typing import Final
13
-
14
-
15
- class Intents(IntFlag):
16
- """事件订阅位, 每一位对应一类事件."""
17
-
18
- GUILDS = 1 << 0
19
- GUILD_MEMBERS = 1 << 1
20
- GUILD_MESSAGES = 1 << 9
21
- GUILD_MESSAGE_REACTIONS = 1 << 10
22
- DIRECT_MESSAGE = 1 << 12
23
- GROUP_AND_C2C_EVENT = 1 << 25
24
- INTERACTION = 1 << 26
25
- MESSAGE_AUDIT = 1 << 27
26
- FORUMS_EVENT = 1 << 28
27
- AUDIO_ACTION = 1 << 29
28
- PUBLIC_GUILD_MESSAGES = 1 << 30
29
-
30
-
31
- # 默认订阅: 群与单聊消息, 公域频道消息, 频道成员变更, 交互事件
32
- DEFAULT_INTENTS: Final = (
33
- Intents.GROUP_AND_C2C_EVENT
34
- | Intents.PUBLIC_GUILD_MESSAGES
35
- | Intents.GUILD_MEMBERS
36
- | Intents.INTERACTION
37
- )
38
-
39
- __all__ = [
40
- "DEFAULT_INTENTS",
41
- "Intents",
42
- ]