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.
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/PKG-INFO +33 -18
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/README.md +23 -7
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/api.py +46 -11
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/client.py +121 -22
- qq_botpy_sdk-2.0.4/botpy/connection.py +452 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/flags.py +15 -3
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/gateway.py +221 -43
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/http.py +38 -89
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/auth.py +109 -62
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/errors.py +12 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/http.py +146 -68
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/reconnect.py +0 -2
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/transport/__init__.py +2 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/transport/webhook.py +70 -39
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/robot.py +10 -3
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/group.py +7 -3
- qq_botpy_sdk-2.0.4/pyproject.toml +46 -0
- qq_botpy_sdk-2.0.2/pyproject.toml → qq_botpy_sdk-2.0.4/pyproject.toml.orig +13 -9
- qq_botpy_sdk-2.0.2/botpy/connection.py +0 -340
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/LICENSE +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/__init__.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/audio.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/channel.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/configuration.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/errors.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/ext/__init__.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/ext/channel_jump/__init__.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/ext/cog_apscheduler/__init__.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/ext/cog_yaml/__init__.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/ext/command_util.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/ext/convert_color/__init__.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/forum.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/guild.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/interaction.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/logging.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/manage.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/message.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/middleware/__init__.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/middleware/base.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/middleware/builtin.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/middleware/commands.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/middleware/control.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/middleware/conversation.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/middleware/policy.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/middleware/typing.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/__init__.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/audio.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/events.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/formatting.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/image.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/media.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/media_utils.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/message.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/models.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/reply.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/session.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/streaming.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/target.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/text.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/transport/base.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/transport/webhook_verify.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/protocol/upload_cache.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/reaction.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/storage/__init__.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/storage/adapters.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/storage/kv.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/__init__.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/announce.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/audio.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/channel.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/emoji.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/forum.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/gateway.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/guild.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/inline.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/interaction.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/menu.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/message.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/panel.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/permission.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/pins_message.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/reaction.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/rich_text.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/robot.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/schedule.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/session.py +0 -0
- {qq_botpy_sdk-2.0.2 → qq_botpy_sdk-2.0.4}/botpy/types/user.py +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
2
|
Name: qq-botpy-sdk
|
|
3
|
-
Version: 2.0.
|
|
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:
|
|
39
|
-
Requires-Dist:
|
|
40
|
-
Requires-Dist:
|
|
41
|
-
Requires-Dist: cryptography
|
|
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
|
-
|
|
166
|
+
uv 项目可以使用:
|
|
165
167
|
|
|
166
168
|
```bash
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
414
|
+
uv sync
|
|
400
415
|
```
|
|
401
416
|
|
|
402
417
|
运行纯本地测试:
|
|
403
418
|
|
|
404
419
|
```bash
|
|
405
|
-
|
|
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
|
-
|
|
415
|
-
|
|
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
|
-
|
|
120
|
+
uv 项目可以使用:
|
|
119
121
|
|
|
120
122
|
```bash
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
368
|
+
uv sync
|
|
354
369
|
```
|
|
355
370
|
|
|
356
371
|
运行纯本地测试:
|
|
357
372
|
|
|
358
373
|
```bash
|
|
359
|
-
|
|
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
|
-
|
|
369
|
-
|
|
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__(
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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: 传给
|
|
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
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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]],
|