qq-botpy-sdk 2.0.2__tar.gz → 2.0.4__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 (88) hide show
  1. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/PKG-INFO +33 -18
  2. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/README.md +23 -7
  3. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/api.py +46 -11
  4. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/client.py +121 -22
  5. qq_botpy_sdk-2.0.4/botpy/connection.py +452 -0
  6. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/flags.py +15 -3
  7. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/gateway.py +221 -43
  8. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/http.py +38 -89
  9. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/auth.py +109 -62
  10. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/errors.py +12 -0
  11. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/http.py +146 -68
  12. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/reconnect.py +0 -2
  13. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/transport/__init__.py +2 -0
  14. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/transport/webhook.py +70 -39
  15. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/robot.py +10 -3
  16. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/group.py +7 -3
  17. qq_botpy_sdk-2.0.4/pyproject.toml +46 -0
  18. qq_botpy_sdk-2.0.2/pyproject.toml → qq_botpy_sdk-2.0.4/pyproject.toml.orig +13 -9
  19. qq_botpy_sdk-2.0.2/botpy/connection.py +0 -340
  20. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/LICENSE +0 -0
  21. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/__init__.py +0 -0
  22. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/audio.py +0 -0
  23. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/channel.py +0 -0
  24. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/configuration.py +0 -0
  25. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/errors.py +0 -0
  26. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/ext/__init__.py +0 -0
  27. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/ext/channel_jump/__init__.py +0 -0
  28. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/ext/cog_apscheduler/__init__.py +0 -0
  29. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/ext/cog_yaml/__init__.py +0 -0
  30. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/ext/command_util.py +0 -0
  31. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/ext/convert_color/__init__.py +0 -0
  32. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/forum.py +0 -0
  33. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/guild.py +0 -0
  34. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/interaction.py +0 -0
  35. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/logging.py +0 -0
  36. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/manage.py +0 -0
  37. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/message.py +0 -0
  38. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/middleware/__init__.py +0 -0
  39. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/middleware/base.py +0 -0
  40. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/middleware/builtin.py +0 -0
  41. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/middleware/commands.py +0 -0
  42. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/middleware/control.py +0 -0
  43. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/middleware/conversation.py +0 -0
  44. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/middleware/policy.py +0 -0
  45. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/middleware/typing.py +0 -0
  46. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/__init__.py +0 -0
  47. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/audio.py +0 -0
  48. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/events.py +0 -0
  49. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/formatting.py +0 -0
  50. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/image.py +0 -0
  51. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/media.py +0 -0
  52. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/media_utils.py +0 -0
  53. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/message.py +0 -0
  54. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/models.py +0 -0
  55. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/reply.py +0 -0
  56. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/session.py +0 -0
  57. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/streaming.py +0 -0
  58. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/target.py +0 -0
  59. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/text.py +0 -0
  60. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/transport/base.py +0 -0
  61. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/transport/webhook_verify.py +0 -0
  62. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/upload_cache.py +0 -0
  63. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/reaction.py +0 -0
  64. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/storage/__init__.py +0 -0
  65. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/storage/adapters.py +0 -0
  66. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/storage/kv.py +0 -0
  67. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/__init__.py +0 -0
  68. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/announce.py +0 -0
  69. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/audio.py +0 -0
  70. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/channel.py +0 -0
  71. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/emoji.py +0 -0
  72. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/forum.py +0 -0
  73. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/gateway.py +0 -0
  74. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/guild.py +0 -0
  75. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/inline.py +0 -0
  76. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/interaction.py +0 -0
  77. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/menu.py +0 -0
  78. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/message.py +0 -0
  79. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/panel.py +0 -0
  80. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/permission.py +0 -0
  81. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/pins_message.py +0 -0
  82. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/reaction.py +0 -0
  83. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/rich_text.py +0 -0
  84. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/robot.py +0 -0
  85. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/schedule.py +0 -0
  86. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/session.py +0 -0
  87. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/user.py +0 -0
  88. {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/user.py +0 -0
@@ -1,7 +1,8 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.3
2
2
  Name: qq-botpy-sdk
3
- Version: 2.0.2
3
+ Version: 2.0.4
4
4
  Summary: Community-maintained async Python SDK for the QQ Bot Open Platform
5
+ Author: Teahouse Studios
5
6
  License: MIT License
6
7
 
7
8
  Copyright (c) 2021 Tencent
@@ -23,9 +24,6 @@ License: MIT License
23
24
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
25
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
26
  SOFTWARE.
26
- License-File: LICENSE
27
- Author: Teahouse Studios
28
- Requires-Python: >=3.10,<4.0
29
27
  Classifier: Development Status :: 4 - Beta
30
28
  Classifier: Intended Audience :: Developers
31
29
  Classifier: License :: OSI Approved :: MIT License
@@ -35,13 +33,15 @@ Classifier: Programming Language :: Python :: 3.11
35
33
  Classifier: Programming Language :: Python :: 3.12
36
34
  Classifier: Programming Language :: Python :: 3.13
37
35
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
38
- Requires-Dist: APScheduler (>=3.11.3,<4)
39
- Requires-Dist: PyYAML (>=6.0.3,<7)
40
- Requires-Dist: aiohttp (>=3.14.3,<4)
41
- Requires-Dist: cryptography (>=50.0.0,<51)
36
+ Requires-Dist: httpx>=0.28.1,<1
37
+ Requires-Dist: httpx-ws>=0.7.2,<1
38
+ Requires-Dist: apscheduler>=3.11.3,<4
39
+ Requires-Dist: cryptography>=50.0.0,<51
40
+ Requires-Dist: pyyaml>=6.0.3,<7
41
+ Requires-Python: >=3.10, <4.0
42
42
  Project-URL: Homepage, https://github.com/Teahouse-Studios/qq-botpy-sdk
43
- Project-URL: Issues, https://github.com/Teahouse-Studios/qq-botpy-sdk/issues
44
43
  Project-URL: Repository, https://github.com/Teahouse-Studios/qq-botpy-sdk
44
+ Project-URL: Issues, https://github.com/Teahouse-Studios/qq-botpy-sdk/issues
45
45
  Description-Content-Type: text/markdown
46
46
 
47
47
  # qq-botpy-sdk
@@ -89,6 +89,7 @@ Description-Content-Type: text/markdown
89
89
  - 检测 Opcode 11 Heartbeat ACK,超时后主动恢复连接。
90
90
  - 支持 Identify、Resume、服务端重连指令和 Invalid Session。
91
91
  - 按关闭码选择刷新 Token、清理 Session、退避重连或停止。
92
+ - 重连期间暂停消息发送,待所有分片 READY/RESUMED 后继续。
92
93
  - 支持 JSON 文件或自定义 Store 持久化 Gateway Session。
93
94
  - 支持 WebSocket、内置 Webhook 服务和自定义事件传输适配器。
94
95
  - Webhook 支持回调地址验证、Ed25519 验签和事件 ACK。
@@ -100,6 +101,7 @@ Description-Content-Type: text/markdown
100
101
  - 结构化 API、认证、限流和传输异常。
101
102
  - 解析 `Retry-After`,安全方法支持指数退避。
102
103
  - POST/PATCH 默认不自动重试,避免非幂等消息重复发送。
104
+ - C2C/群聊仅带 `msg_id` 的被动回复会对不确定的传输失败重试一次。
103
105
  - 可配置 API 地址、Token 地址、User-Agent 和 SSLContext/私有 CA。
104
106
  - 通过 `client.api.request/get/post/put/patch/delete()` 调用尚未封装的 REST API。
105
107
 
@@ -161,10 +163,10 @@ import botpy
161
163
  pip install "git+https://github.com/Teahouse-Studios/qq-botpy-sdk.git"
162
164
  ```
163
165
 
164
- Poetry 项目可以使用:
166
+ uv 项目可以使用:
165
167
 
166
168
  ```bash
167
- poetry add "git+https://github.com/Teahouse-Studios/qq-botpy-sdk.git"
169
+ uv add "qq-botpy-sdk @ git+https://github.com/Teahouse-Studios/qq-botpy-sdk.git"
168
170
  ```
169
171
 
170
172
  > `qq-botpy-sdk` 是当前独立维护版的发布包名。历史 `qq-botpy` 包属于不同的发布来源,不代表本仓库。
@@ -174,7 +176,7 @@ poetry add "git+https://github.com/Teahouse-Studios/qq-botpy-sdk.git"
174
176
  ```bash
175
177
  git clone https://github.com/Teahouse-Studios/qq-botpy-sdk.git
176
178
  cd qq-botpy-sdk
177
- poetry install
179
+ uv sync
178
180
  ```
179
181
 
180
182
  ## 快速开始
@@ -234,6 +236,19 @@ client = MyClient(
234
236
  进程重启后,客户端会优先尝试 Resume,并从保存的序列号之后补发事件。失效、过期或分片数量不匹配的
235
237
  Session 会被自动清理。
236
238
 
239
+ ### 重连期间的消息发送
240
+
241
+ ```python
242
+ client = MyClient(
243
+ intents=intents,
244
+ gateway_send_timeout=30.0,
245
+ )
246
+ ```
247
+
248
+ WebSocket 模式下,Gateway 正在连接或重连时,消息会等待所有已注册分片恢复,不会立即发出 HTTP
249
+ 请求。`gateway_send_timeout` 默认为 30 秒;设为 `None` 可一直等待,设为 `0` 则立即失败。首次请求前
250
+ 等待超时时,`TransportError.attempts` 为 `0`;若重试等待超时,则保留此前实际请求次数。
251
+
237
252
  ### Webhook 模式
238
253
 
239
254
  ```python
@@ -396,13 +411,13 @@ client = MyClient(
396
411
  安装依赖:
397
412
 
398
413
  ```bash
399
- poetry install
414
+ uv sync
400
415
  ```
401
416
 
402
417
  运行纯本地测试:
403
418
 
404
419
  ```bash
405
- poetry run python -m unittest discover -s tests -p "test_[!a]*.py"
420
+ uv run python -m unittest discover -s tests -p "test_[!a]*.py"
406
421
  ```
407
422
 
408
423
  `tests/test_api.py` 使用真实平台凭证,并包含创建、修改或删除线上资源的 API 测试。除非你明确准备了隔离的
@@ -411,8 +426,9 @@ poetry run python -m unittest discover -s tests -p "test_[!a]*.py"
411
426
  编译和项目元数据检查:
412
427
 
413
428
  ```bash
414
- poetry run python -m compileall -q botpy examples
415
- poetry check
429
+ uv run python -m compileall -q botpy examples
430
+ uv lock --check
431
+ uv build
416
432
  ```
417
433
 
418
434
  ## 当前限制
@@ -429,4 +445,3 @@ poetry check
429
445
  贡献者提供的基础实现。
430
446
 
431
447
  本仓库继续采用 [MIT License](./LICENSE)。原始版权声明保留在许可证文件和相关源文件中。
432
-
@@ -43,6 +43,7 @@
43
43
  - 检测 Opcode 11 Heartbeat ACK,超时后主动恢复连接。
44
44
  - 支持 Identify、Resume、服务端重连指令和 Invalid Session。
45
45
  - 按关闭码选择刷新 Token、清理 Session、退避重连或停止。
46
+ - 重连期间暂停消息发送,待所有分片 READY/RESUMED 后继续。
46
47
  - 支持 JSON 文件或自定义 Store 持久化 Gateway Session。
47
48
  - 支持 WebSocket、内置 Webhook 服务和自定义事件传输适配器。
48
49
  - Webhook 支持回调地址验证、Ed25519 验签和事件 ACK。
@@ -54,6 +55,7 @@
54
55
  - 结构化 API、认证、限流和传输异常。
55
56
  - 解析 `Retry-After`,安全方法支持指数退避。
56
57
  - POST/PATCH 默认不自动重试,避免非幂等消息重复发送。
58
+ - C2C/群聊仅带 `msg_id` 的被动回复会对不确定的传输失败重试一次。
57
59
  - 可配置 API 地址、Token 地址、User-Agent 和 SSLContext/私有 CA。
58
60
  - 通过 `client.api.request/get/post/put/patch/delete()` 调用尚未封装的 REST API。
59
61
 
@@ -115,10 +117,10 @@ import botpy
115
117
  pip install "git+https://github.com/Teahouse-Studios/qq-botpy-sdk.git"
116
118
  ```
117
119
 
118
- Poetry 项目可以使用:
120
+ uv 项目可以使用:
119
121
 
120
122
  ```bash
121
- poetry add "git+https://github.com/Teahouse-Studios/qq-botpy-sdk.git"
123
+ uv add "qq-botpy-sdk @ git+https://github.com/Teahouse-Studios/qq-botpy-sdk.git"
122
124
  ```
123
125
 
124
126
  > `qq-botpy-sdk` 是当前独立维护版的发布包名。历史 `qq-botpy` 包属于不同的发布来源,不代表本仓库。
@@ -128,7 +130,7 @@ poetry add "git+https://github.com/Teahouse-Studios/qq-botpy-sdk.git"
128
130
  ```bash
129
131
  git clone https://github.com/Teahouse-Studios/qq-botpy-sdk.git
130
132
  cd qq-botpy-sdk
131
- poetry install
133
+ uv sync
132
134
  ```
133
135
 
134
136
  ## 快速开始
@@ -188,6 +190,19 @@ client = MyClient(
188
190
  进程重启后,客户端会优先尝试 Resume,并从保存的序列号之后补发事件。失效、过期或分片数量不匹配的
189
191
  Session 会被自动清理。
190
192
 
193
+ ### 重连期间的消息发送
194
+
195
+ ```python
196
+ client = MyClient(
197
+ intents=intents,
198
+ gateway_send_timeout=30.0,
199
+ )
200
+ ```
201
+
202
+ WebSocket 模式下,Gateway 正在连接或重连时,消息会等待所有已注册分片恢复,不会立即发出 HTTP
203
+ 请求。`gateway_send_timeout` 默认为 30 秒;设为 `None` 可一直等待,设为 `0` 则立即失败。首次请求前
204
+ 等待超时时,`TransportError.attempts` 为 `0`;若重试等待超时,则保留此前实际请求次数。
205
+
191
206
  ### Webhook 模式
192
207
 
193
208
  ```python
@@ -350,13 +365,13 @@ client = MyClient(
350
365
  安装依赖:
351
366
 
352
367
  ```bash
353
- poetry install
368
+ uv sync
354
369
  ```
355
370
 
356
371
  运行纯本地测试:
357
372
 
358
373
  ```bash
359
- poetry run python -m unittest discover -s tests -p "test_[!a]*.py"
374
+ uv run python -m unittest discover -s tests -p "test_[!a]*.py"
360
375
  ```
361
376
 
362
377
  `tests/test_api.py` 使用真实平台凭证,并包含创建、修改或删除线上资源的 API 测试。除非你明确准备了隔离的
@@ -365,8 +380,9 @@ poetry run python -m unittest discover -s tests -p "test_[!a]*.py"
365
380
  编译和项目元数据检查:
366
381
 
367
382
  ```bash
368
- poetry run python -m compileall -q botpy examples
369
- poetry check
383
+ uv run python -m compileall -q botpy examples
384
+ uv lock --check
385
+ uv build
370
386
  ```
371
387
 
372
388
  ## 当前限制
@@ -4,7 +4,7 @@
4
4
 
5
5
  from io import BufferedReader
6
6
  from collections.abc import Sequence
7
- from typing import Any, List, Union, BinaryIO, Dict, Mapping, Optional
7
+ from typing import Any, Awaitable, Callable, List, Union, BinaryIO, Dict, Mapping, Optional
8
8
  from urllib.parse import urlsplit
9
9
 
10
10
  from .flags import Permission
@@ -134,8 +134,7 @@ def _validate_menu(value: Mapping[str, Any]) -> Dict[str, Any]:
134
134
  else:
135
135
  sub_items = _bounded_list(item.get("sub_menu_items"), f"{name}.sub_menu_items", 5)
136
136
  item["sub_menu_items"] = [
137
- _validate_sub_menu_item(sub_item, sub_index)
138
- for sub_index, sub_item in enumerate(sub_items)
137
+ _validate_sub_menu_item(sub_item, sub_index) for sub_index, sub_item in enumerate(sub_items)
139
138
  ]
140
139
  normalized.append(item)
141
140
  result["items"] = normalized
@@ -259,12 +258,29 @@ class BotAPI:
259
258
  - API当前返回的所有自定义类型数据为字典数据,通过TypedDict进行类型提示
260
259
  """
261
260
 
262
- def __init__(self, http: BotHttp):
261
+ def __init__(
262
+ self,
263
+ http: BotHttp,
264
+ message_guard: Optional[Callable[[], Awaitable[None]]] = None,
265
+ ):
263
266
  """
264
267
  Args:
265
268
  http (BotHttp): 用于发送请求的 http 客户端。
266
269
  """
267
270
  self._http = http
271
+ self._message_guard = message_guard
272
+
273
+ async def _wait_until_message_send_ready(self) -> None:
274
+ if self._message_guard is not None:
275
+ await self._message_guard()
276
+
277
+ async def _request_message(self, route: Route, **kwargs: Any) -> Any:
278
+ """Run the Gateway guard before the first request and every HTTP retry."""
279
+
280
+ await self._wait_until_message_send_ready()
281
+ if self._message_guard is not None:
282
+ kwargs["before_attempt"] = self._message_guard
283
+ return await self._http.request(route, **kwargs)
268
284
 
269
285
  async def request(
270
286
  self,
@@ -824,7 +840,7 @@ class BotAPI:
824
840
  payload.update(extra)
825
841
  payload = {key: value for key, value in payload.items() if value is not None}
826
842
  route = Route("POST", "/channels/{channel_id}/messages", channel_id=channel_id)
827
- return await self._http.request(route, json=payload)
843
+ return await self._request_message(route, json=payload)
828
844
 
829
845
  async def recall_message(self, channel_id: str, message_id: str, hidetip: bool = False) -> str:
830
846
  """
@@ -904,7 +920,7 @@ class BotAPI:
904
920
  "/channels/{channel_id}/messages",
905
921
  channel_id=channel_id,
906
922
  )
907
- return await self._http.request(route, json=payload)
923
+ return await self._request_message(route, json=payload)
908
924
 
909
925
  async def on_interaction_result(
910
926
  self,
@@ -1044,7 +1060,7 @@ class BotAPI:
1044
1060
  payload.update(extra)
1045
1061
  payload = {key: value for key, value in payload.items() if value is not None}
1046
1062
  route = Route("POST", "/dms/{guild_id}/messages", guild_id=guild_id)
1047
- return await self._http.request(route, json=payload)
1063
+ return await self._request_message(route, json=payload)
1048
1064
 
1049
1065
  # 音频接口
1050
1066
  async def update_audio(self, channel_id: str, audio_control: audio.AudioControl) -> str:
@@ -2151,7 +2167,12 @@ class BotAPI:
2151
2167
  payload.update(extra)
2152
2168
  payload = {key: value for key, value in payload.items() if value is not None}
2153
2169
  route = Route("POST", "/v2/groups/{group_openid}/messages", group_openid=group_openid)
2154
- return await self._http.request(route, json=payload)
2170
+ return await self._request_message(
2171
+ route,
2172
+ retry_time=1,
2173
+ json=payload,
2174
+ retry_ambiguous=bool(msg_id),
2175
+ )
2155
2176
 
2156
2177
  async def post_c2c_message(
2157
2178
  self,
@@ -2211,7 +2232,12 @@ class BotAPI:
2211
2232
  payload.update(extra)
2212
2233
  payload = {key: value for key, value in payload.items() if value is not None}
2213
2234
  route = Route("POST", "/v2/users/{openid}/messages", openid=openid)
2214
- return await self._http.request(route, json=payload)
2235
+ return await self._request_message(
2236
+ route,
2237
+ retry_time=1,
2238
+ json=payload,
2239
+ retry_ambiguous=bool(msg_id),
2240
+ )
2215
2241
 
2216
2242
  async def post_c2c_typing(
2217
2243
  self,
@@ -2230,7 +2256,12 @@ class BotAPI:
2230
2256
  if msg_id:
2231
2257
  payload["msg_id"] = msg_id
2232
2258
  route = Route("POST", "/v2/users/{openid}/messages", openid=openid)
2233
- return await self._http.request(route, json=payload)
2259
+ return await self._request_message(
2260
+ route,
2261
+ retry_time=1,
2262
+ json=payload,
2263
+ retry_ambiguous=bool(msg_id),
2264
+ )
2234
2265
 
2235
2266
  async def post_c2c_stream_message(
2236
2267
  self,
@@ -2261,7 +2292,7 @@ class BotAPI:
2261
2292
  }
2262
2293
  payload = {key: value for key, value in payload.items() if value is not None}
2263
2294
  route = Route("POST", "/v2/users/{openid}/stream_messages", openid=openid)
2264
- return await self._http.request(route, json=payload)
2295
+ return await self._request_message(route, json=payload)
2265
2296
 
2266
2297
  async def post_upload_prepare(
2267
2298
  self,
@@ -2377,6 +2408,8 @@ class BotAPI:
2377
2408
  }
2378
2409
  payload = {key: value for key, value in payload.items() if value is not None}
2379
2410
  route = Route("POST", "/v2/groups/{group_openid}/files", group_openid=group_openid)
2411
+ if srv_send_msg:
2412
+ return await self._request_message(route, json=payload, timeout=120)
2380
2413
  return await self._http.request(route, json=payload, timeout=120)
2381
2414
 
2382
2415
  async def post_c2c_file(
@@ -2408,4 +2441,6 @@ class BotAPI:
2408
2441
  }
2409
2442
  payload = {key: value for key, value in payload.items() if value is not None}
2410
2443
  route = Route("POST", "/v2/users/{openid}/files", openid=openid)
2444
+ if srv_send_msg:
2445
+ return await self._request_message(route, json=payload, timeout=120)
2411
2446
  return await self._http.request(route, json=payload, timeout=120)
@@ -1,6 +1,7 @@
1
1
  import asyncio
2
2
  import base64
3
3
  import inspect
4
+ import math
4
5
  import re
5
6
  import traceback
6
7
  from collections import OrderedDict
@@ -19,6 +20,7 @@ from .gateway import BotWebSocket
19
20
  from .http import BotHttp
20
21
  from .middleware import Middleware, MiddlewareContext, create_middleware_context, run_middleware_chain
21
22
  from .protocol.events import normalize_inbound_message
23
+ from .protocol.errors import TransportError
22
24
  from .protocol.media import ChunkedMediaUploader, ProgressCallback
23
25
  from .protocol.message import (
24
26
  LARGE_MEDIA_THRESHOLD,
@@ -87,6 +89,7 @@ class Client:
87
89
  menu: Optional[Menu] = None,
88
90
  panels: Optional[Iterable[Panel]] = None,
89
91
  config_sync_strict: bool = False,
92
+ gateway_send_timeout: Optional[float] = 30.0,
90
93
  ):
91
94
  """
92
95
  Args:
@@ -106,7 +109,7 @@ class Client:
106
109
  base_url: 自定义开放平台 REST API 根地址,主要用于测试或代理。
107
110
  token_base_url: 自定义 access token 服务根地址。
108
111
  user_agent: HTTP 请求使用的 User-Agent。
109
- ssl: 传给 aiohttp 的 SSLContext、Fingerprint 或布尔值。
112
+ ssl: 传给 httpx/httpx-ws 的 SSLContext 或布尔值。
110
113
  upload_cache: 自定义媒体上传缓存;默认每个 Client 独享一份内存缓存。
111
114
  reply_limiter: 自定义被动回复限制器;默认每条消息每小时最多 4 次。
112
115
  on_message_sent: 平台响应包含 ``ext_info.ref_idx`` 时调用的出站消息钩子。
@@ -114,6 +117,7 @@ class Client:
114
117
  menu: 可选的声明式 C2C 全局菜单;配置后在登录成功时按差异同步。
115
118
  panels: 可选的声明式指令面板集合;使用稳定 key 非破坏性同步。
116
119
  config_sync_strict: 配置同步失败时是否中止客户端启动。
120
+ gateway_send_timeout: Gateway 重连期间等待消息发送恢复的秒数;``None`` 表示一直等待。
117
121
  """
118
122
  self.intents: int = intents.value
119
123
  self.ret_coro: bool = False
@@ -129,7 +133,22 @@ class Client:
129
133
  raise TypeError("markdown_support must be a bool")
130
134
  if not isinstance(config_sync_strict, bool):
131
135
  raise TypeError("config_sync_strict must be a bool")
136
+ gateway_timeout_value = None
137
+ if gateway_send_timeout is not None:
138
+ if (
139
+ isinstance(gateway_send_timeout, bool)
140
+ or not isinstance(gateway_send_timeout, (int, float))
141
+ or gateway_send_timeout < 0
142
+ ):
143
+ raise ValueError("gateway_send_timeout must be a non-negative finite number or None")
144
+ try:
145
+ gateway_timeout_value = float(gateway_send_timeout)
146
+ except OverflowError as exc:
147
+ raise ValueError("gateway_send_timeout must be a non-negative finite number or None") from exc
148
+ if not math.isfinite(gateway_timeout_value):
149
+ raise ValueError("gateway_send_timeout must be a non-negative finite number or None")
132
150
  self._markdown_support = markdown_support
151
+ self._gateway_send_timeout = gateway_timeout_value
133
152
  self._token_base_url = token_base_url
134
153
  self._user_agent = user_agent
135
154
  self._ssl = ssl
@@ -141,7 +160,7 @@ class Client:
141
160
  user_agent=user_agent,
142
161
  ssl=ssl,
143
162
  )
144
- self.api: BotAPI = BotAPI(http=self.http)
163
+ self.api: BotAPI = BotAPI(http=self.http, message_guard=self._wait_for_gateway_ready)
145
164
  self.configuration = ConfigurationManager(
146
165
  self.api,
147
166
  menu=menu,
@@ -150,6 +169,8 @@ class Client:
150
169
  self._config_sync_strict = config_sync_strict
151
170
 
152
171
  self._connection: Optional[ConnectionSession] = None
172
+ self._gateway_starting = False
173
+ self._gateway_connection_created = asyncio.Event()
153
174
  self._closed: bool = False
154
175
  self._listeners: Dict[str, List[Tuple[asyncio.Future, Callable[..., bool]]]] = {}
155
176
  self._ws_ap: Dict = {}
@@ -213,6 +234,58 @@ class Client:
213
234
  raise RuntimeError("机器人尚未登录")
214
235
  return self._robot
215
236
 
237
+ async def _wait_for_gateway_ready(self) -> None:
238
+ """Delay message POSTs until every Gateway shard is ready again."""
239
+
240
+ if self._closed:
241
+ raise RuntimeError("无法通过已关闭的 Client 发送消息")
242
+ if getattr(self, "_transport_mode", None) != "websocket":
243
+ return
244
+ connection = getattr(self, "_connection", None)
245
+ if connection is None and not self._gateway_starting:
246
+ return
247
+ if connection is not None and connection.is_ready:
248
+ return
249
+
250
+ timeout = self._gateway_send_timeout
251
+ deadline = None if timeout is None else asyncio.get_running_loop().time() + timeout
252
+ _log.info("[botpy] Gateway 正在连接或重连,消息发送等待恢复,timeout=%s", timeout)
253
+ try:
254
+ if connection is None:
255
+ if deadline is None:
256
+ await self._gateway_connection_created.wait()
257
+ else:
258
+ await asyncio.wait_for(
259
+ self._gateway_connection_created.wait(),
260
+ timeout=max(0.0, deadline - asyncio.get_running_loop().time()),
261
+ )
262
+ if self._closed:
263
+ raise RuntimeError("无法通过已关闭的 Client 发送消息")
264
+ connection = self._connection
265
+ if connection is None:
266
+ raise TransportError("Gateway 启动未完成,消息尚未发送", method="POST", attempts=0)
267
+ if connection.is_ready:
268
+ return
269
+ remaining = (
270
+ None
271
+ if deadline is None
272
+ else max(
273
+ 0.0,
274
+ deadline - asyncio.get_running_loop().time(),
275
+ )
276
+ )
277
+ await connection.wait_until_ready(timeout=remaining)
278
+ except asyncio.TimeoutError as exc:
279
+ timeout_text = "无限" if timeout is None else f"{timeout:g} 秒"
280
+ raise TransportError(
281
+ f"Gateway 在{timeout_text}内未恢复,消息尚未发送",
282
+ method="POST",
283
+ cause=exc,
284
+ attempts=0,
285
+ ) from exc
286
+ if self._closed:
287
+ raise RuntimeError("无法通过已关闭的 Client 发送消息")
288
+
216
289
  def use(self, *middlewares: Middleware) -> "Client":
217
290
  """追加统一消息中间件,并返回当前 Client 以便链式配置。"""
218
291
 
@@ -585,9 +658,7 @@ class Client:
585
658
  raise ValueError("media file must not be empty")
586
659
  limit = MEDIA_FILE_SIZE_LIMITS[int(file_type)]
587
660
  if size > limit:
588
- raise ValueError(
589
- f"media is too large for {file_type.name.lower()}; limit is {limit // (1024 * 1024)} MiB"
590
- )
661
+ raise ValueError(f"media is too large for {file_type.name.lower()}; limit is {limit // (1024 * 1024)} MiB")
591
662
 
592
663
  @staticmethod
593
664
  def _validate_base64_upload(file_data: str) -> None:
@@ -719,6 +790,13 @@ class Client:
719
790
  return
720
791
 
721
792
  self._closed = True
793
+ self._gateway_starting = False
794
+ gateway_connection_created = getattr(self, "_gateway_connection_created", None)
795
+ if gateway_connection_created is not None:
796
+ gateway_connection_created.set()
797
+ connection = getattr(self, "_connection", None)
798
+ if connection is not None:
799
+ connection.mark_closed()
722
800
 
723
801
  event_transport = getattr(self, "_event_transport", None)
724
802
  if event_transport is not None:
@@ -820,6 +898,8 @@ class Client:
820
898
  ret_coro: :class:`bool`
821
899
  是否需要返回协程对象
822
900
  """
901
+ if getattr(self, "_closed", False):
902
+ raise RuntimeError("无法启动已关闭的 Client")
823
903
  # login后再进行后面的操作
824
904
  token = Token(
825
905
  appid,
@@ -835,8 +915,19 @@ class Client:
835
915
  await self._async_setup_hook()
836
916
 
837
917
  use_gateway = self._transport_mode == "websocket"
838
- await self._bot_login(token, use_gateway=use_gateway)
839
918
  if use_gateway:
919
+ self._gateway_starting = True
920
+ self._gateway_connection_created.clear()
921
+ try:
922
+ await self._bot_login(token, use_gateway=use_gateway)
923
+ except BaseException:
924
+ if use_gateway:
925
+ self._gateway_starting = False
926
+ self._gateway_connection_created.set()
927
+ raise
928
+ if use_gateway:
929
+ if self._closed:
930
+ raise RuntimeError("无法启动已关闭的 Client")
840
931
  return await self._bot_init(token)
841
932
 
842
933
  if self._transport_mode == "webhook":
@@ -887,6 +978,8 @@ class Client:
887
978
 
888
979
  # 通过api获取websocket链接
889
980
  self._ws_ap = await self.api.get_ws_url()
981
+ if getattr(self, "_closed", False):
982
+ raise RuntimeError("无法启动已关闭的 Client")
890
983
 
891
984
  # 实例一个session_pool
892
985
  self._connection = ConnectionSession(
@@ -896,6 +989,12 @@ class Client:
896
989
  loop=self.loop,
897
990
  api=self.api,
898
991
  )
992
+ self._gateway_starting = False
993
+ gateway_connection_created = getattr(self, "_gateway_connection_created", None)
994
+ if gateway_connection_created is None:
995
+ gateway_connection_created = asyncio.Event()
996
+ self._gateway_connection_created = gateway_connection_created
997
+ gateway_connection_created.set()
899
998
 
900
999
  self._connection.state.robot = self._robot
901
1000
 
@@ -952,21 +1051,19 @@ class Client:
952
1051
  loop = self._connection.loop
953
1052
  loop.set_exception_handler(_loop_exception_handler)
954
1053
 
955
- while not self._closed:
956
- _log.debug("[botpy] 会话循环检查...")
957
- try:
958
- # 返回协程对象,交由开发者自行调控
959
- coroutine = self._connection.multi_run(session_interval)
960
- if self.ret_coro:
961
- return coroutine
962
- elif coroutine:
963
- await coroutine
964
- else:
965
- await self.close()
966
- _log.info("[botpy] 服务意外停止!")
967
- except KeyboardInterrupt:
968
- _log.info("[botpy] 服务强行停止!")
969
- # cancel all tasks lingering
1054
+ # 返回协程对象,交由开发者自行调控
1055
+ coroutine = self._connection.multi_run(session_interval)
1056
+ if self.ret_coro:
1057
+ return coroutine
1058
+ try:
1059
+ await coroutine
1060
+ except KeyboardInterrupt:
1061
+ _log.info("[botpy] 服务强行停止!")
1062
+ return None
1063
+ if not self._closed:
1064
+ await self.close()
1065
+ _log.info("[botpy] 服务意外停止!")
1066
+ return None
970
1067
 
971
1068
  async def _create_gateway_session(self, token: Token, shard_id: int, shard_count: int):
972
1069
  session_id = ""
@@ -993,6 +1090,7 @@ class Client:
993
1090
  "intent": self.intents,
994
1091
  "token": token,
995
1092
  "url": self._ws_ap["url"],
1093
+ "ssl": self._ssl,
996
1094
  "shards": {"shard_id": shard_id, "shard_count": shard_count},
997
1095
  "session_store": self._session_store,
998
1096
  }
@@ -1006,6 +1104,8 @@ class Client:
1006
1104
 
1007
1105
  param session: session对象
1008
1106
  """
1107
+ if self._closed:
1108
+ return
1009
1109
  _log.info("[botpy] 会话启动中...")
1010
1110
 
1011
1111
  client = BotWebSocket(session, self._connection)
@@ -1056,7 +1156,6 @@ class Client:
1056
1156
  else:
1057
1157
  _log.debug("[botpy] 事件: %s 未注册", event)
1058
1158
 
1059
-
1060
1159
  def _schedule_event(
1061
1160
  self,
1062
1161
  coro: Callable[..., Coroutine[Any, Any, Any]],