qq-botpy-sdk 2.1.2__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.
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/PKG-INFO +2 -2
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/README.md +1 -1
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/api.py +118 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/client.py +64 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/middleware/control.py +1 -1
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/middleware/conversation.py +4 -2
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/middleware/typing.py +1 -1
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/__init__.py +12 -0
- qq_botpy_sdk-2.2.0/botpy/protocol/media_image.py +170 -0
- qq_botpy_sdk-2.2.0/botpy/types/media_image.py +63 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/message.py +6 -3
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/session.py +9 -6
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/pyproject.toml +1 -1
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/pyproject.toml.orig +1 -1
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/LICENSE +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/__init__.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/audio.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/channel.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/configuration.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/connection.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/errors.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/ext/__init__.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/ext/channel_jump/__init__.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/ext/cog_apscheduler/__init__.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/ext/cog_yaml/__init__.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/ext/command_util.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/ext/convert_color/__init__.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/flags.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/forum.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/gateway.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/guild.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/http.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/interaction.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/logging.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/manage.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/message.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/middleware/__init__.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/middleware/base.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/middleware/builtin.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/middleware/commands.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/middleware/policy.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/audio.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/auth.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/constants.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/errors.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/events.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/formatting.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/http.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/image.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/media.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/media_utils.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/message.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/models.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/proxy.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/ratelimit.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/reconnect.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/reply.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/send_policy.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/session.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/streaming.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/target.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/text.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/transport/__init__.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/transport/base.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/transport/webhook.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/transport/webhook_verify.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/upload_cache.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/reaction.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/robot.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/storage/__init__.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/storage/adapters.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/storage/kv.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/__init__.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/announce.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/audio.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/channel.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/emoji.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/forum.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/gateway.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/group.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/guild.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/inline.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/interaction.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/menu.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/panel.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/permission.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/pins_message.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/reaction.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/rich_text.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/robot.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/schedule.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/user.py +0 -0
- {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/user.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: qq-botpy-sdk
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Community-maintained async Python SDK for the QQ Bot Open Platform
|
|
5
5
|
Author: Teahouse Studios
|
|
6
6
|
License: MIT License
|
|
@@ -494,7 +494,7 @@ client = MyClient(
|
|
|
494
494
|
|
|
495
495
|
| 文档 | 内容 |
|
|
496
496
|
| --- | --- |
|
|
497
|
-
| [API 参考](./docs/API.md) |
|
|
497
|
+
| [API 参考](./docs/API.md) | 新高层接口、统一发送、媒体、素材管理、REST 和 Interaction |
|
|
498
498
|
| [自定义菜单与指令面板](./docs/MENU_PANEL.md) | 声明式 Menu/Panel、启动同步和多副本部署风险 |
|
|
499
499
|
| [群管理 API 与事件](./docs/GROUP_MANAGEMENT.md) | 群信息、群成员管理、入群审批、成员禁言、自动审批策略和群成员事件 |
|
|
500
500
|
| [请求整流与接口限速](./docs/RATE_LIMITS.md) | 通道划分、文档核对后的接口配额表和整流配置 |
|
|
@@ -448,7 +448,7 @@ client = MyClient(
|
|
|
448
448
|
|
|
449
449
|
| 文档 | 内容 |
|
|
450
450
|
| --- | --- |
|
|
451
|
-
| [API 参考](./docs/API.md) |
|
|
451
|
+
| [API 参考](./docs/API.md) | 新高层接口、统一发送、媒体、素材管理、REST 和 Interaction |
|
|
452
452
|
| [自定义菜单与指令面板](./docs/MENU_PANEL.md) | 声明式 Menu/Panel、启动同步和多副本部署风险 |
|
|
453
453
|
| [群管理 API 与事件](./docs/GROUP_MANAGEMENT.md) | 群信息、群成员管理、入群审批、成员禁言、自动审批策略和群成员事件 |
|
|
454
454
|
| [请求整流与接口限速](./docs/RATE_LIMITS.md) | 通道划分、文档核对后的接口配额表和整流配置 |
|
|
@@ -25,8 +25,11 @@ from .types import (
|
|
|
25
25
|
group,
|
|
26
26
|
menu,
|
|
27
27
|
panel,
|
|
28
|
+
media_image,
|
|
28
29
|
)
|
|
29
30
|
|
|
31
|
+
from .types.media_image import MEDIA_IMAGE_CONTENT_TYPES, MEDIA_IMAGE_MAX_SIZE
|
|
32
|
+
|
|
30
33
|
|
|
31
34
|
def _bounded_list(value: Sequence[Any], name: str, maximum: int) -> List[Any]:
|
|
32
35
|
if isinstance(value, (str, bytes, bytearray)) or not isinstance(value, Sequence):
|
|
@@ -182,6 +185,21 @@ def _validate_panel(value: Mapping[str, Any]) -> Dict[str, Any]:
|
|
|
182
185
|
return result
|
|
183
186
|
|
|
184
187
|
|
|
188
|
+
_HEX_DIGITS = frozenset("0123456789abcdefABCDEF")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _validate_digest(value: Any, name: str, length: int) -> str:
|
|
192
|
+
if not isinstance(value, str) or len(value) != length or any(character not in _HEX_DIGITS for character in value):
|
|
193
|
+
raise ValueError(f"{name} must be a {length}-character hexadecimal string")
|
|
194
|
+
return value.lower()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _validate_media_image_content_type(content_type: Any) -> str:
|
|
198
|
+
if content_type not in MEDIA_IMAGE_CONTENT_TYPES:
|
|
199
|
+
raise ValueError("content_type must be 'image/png', 'image/jpeg', 'image/gif' or 'image/webp'")
|
|
200
|
+
return content_type
|
|
201
|
+
|
|
202
|
+
|
|
185
203
|
def _validate_panel_scope(scope: str) -> str:
|
|
186
204
|
if scope not in {"c2c", "group", "channel", "dm"}:
|
|
187
205
|
raise ValueError("scope must be 'c2c', 'group', 'channel', or 'dm'")
|
|
@@ -2575,3 +2593,103 @@ class BotAPI:
|
|
|
2575
2593
|
if srv_send_msg:
|
|
2576
2594
|
return await self._request_message(route, json=payload, timeout=120)
|
|
2577
2595
|
return await self._http.request(route, json=payload, timeout=120)
|
|
2596
|
+
|
|
2597
|
+
async def post_media_image_prepare(
|
|
2598
|
+
self,
|
|
2599
|
+
*,
|
|
2600
|
+
file_name: str,
|
|
2601
|
+
file_size: int,
|
|
2602
|
+
content_type: str,
|
|
2603
|
+
md5: str,
|
|
2604
|
+
sha1: str,
|
|
2605
|
+
) -> media_image.MediaImagePrepareResponse:
|
|
2606
|
+
"""申请一张素材图片的上传,返回预签名直传地址。
|
|
2607
|
+
|
|
2608
|
+
``file_name`` 的扩展名必须与 ``content_type`` 一致;``md5`` 与 ``sha1``
|
|
2609
|
+
为文件内容的十六进制摘要,平台用它们登记上传任务。
|
|
2610
|
+
"""
|
|
2611
|
+
|
|
2612
|
+
_required_text(file_name, "file_name")
|
|
2613
|
+
if "." not in file_name:
|
|
2614
|
+
raise ValueError("file_name must include an extension")
|
|
2615
|
+
if isinstance(file_size, bool) or not isinstance(file_size, int) or file_size <= 0:
|
|
2616
|
+
raise ValueError("file_size must be a positive integer")
|
|
2617
|
+
if file_size > MEDIA_IMAGE_MAX_SIZE:
|
|
2618
|
+
raise ValueError(f"file_size must not exceed {MEDIA_IMAGE_MAX_SIZE} bytes")
|
|
2619
|
+
_validate_media_image_content_type(content_type)
|
|
2620
|
+
_validate_digest(md5, "md5", 32)
|
|
2621
|
+
_validate_digest(sha1, "sha1", 40)
|
|
2622
|
+
|
|
2623
|
+
payload = {
|
|
2624
|
+
"file_name": file_name,
|
|
2625
|
+
"file_size": file_size,
|
|
2626
|
+
"content_type": content_type,
|
|
2627
|
+
"md5": md5.lower(),
|
|
2628
|
+
"sha1": sha1.lower(),
|
|
2629
|
+
}
|
|
2630
|
+
route = Route("POST", "/v2/media-images/prepare")
|
|
2631
|
+
return await self._http.request(route, json=payload, timeout=120)
|
|
2632
|
+
|
|
2633
|
+
async def put_media_image(
|
|
2634
|
+
self,
|
|
2635
|
+
presigned_url: str,
|
|
2636
|
+
data: bytes,
|
|
2637
|
+
*,
|
|
2638
|
+
content_type: str,
|
|
2639
|
+
timeout: float = 300,
|
|
2640
|
+
):
|
|
2641
|
+
"""把图片二进制 PUT 到 ``prepare`` 返回的预签名地址。
|
|
2642
|
+
|
|
2643
|
+
``Content-Type`` 必须与 prepare 声明值完全一致,否则 COS 会拒绝(403)。
|
|
2644
|
+
"""
|
|
2645
|
+
|
|
2646
|
+
_validate_https_url(presigned_url, "presigned_url")
|
|
2647
|
+
if not isinstance(data, bytes):
|
|
2648
|
+
raise TypeError("data must be bytes")
|
|
2649
|
+
if not data:
|
|
2650
|
+
raise ValueError("data must not be empty")
|
|
2651
|
+
if len(data) > MEDIA_IMAGE_MAX_SIZE:
|
|
2652
|
+
raise ValueError(f"data must not exceed {MEDIA_IMAGE_MAX_SIZE} bytes")
|
|
2653
|
+
_validate_media_image_content_type(content_type)
|
|
2654
|
+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout <= 0:
|
|
2655
|
+
raise ValueError("timeout must be a positive number")
|
|
2656
|
+
|
|
2657
|
+
return await self._http.request_url(
|
|
2658
|
+
"PUT",
|
|
2659
|
+
presigned_url,
|
|
2660
|
+
data=data,
|
|
2661
|
+
headers={"Content-Type": content_type, "Content-Length": str(len(data))},
|
|
2662
|
+
auth=False,
|
|
2663
|
+
retries=2,
|
|
2664
|
+
timeout=timeout,
|
|
2665
|
+
)
|
|
2666
|
+
|
|
2667
|
+
async def post_media_image_complete(
|
|
2668
|
+
self,
|
|
2669
|
+
*,
|
|
2670
|
+
upload_id: str,
|
|
2671
|
+
md5: str,
|
|
2672
|
+
) -> media_image.MediaImageCompleteResponse:
|
|
2673
|
+
"""完成素材图片上传,返回 ``qqbot-media://`` 引用与 ``image_id``。"""
|
|
2674
|
+
|
|
2675
|
+
_required_text(upload_id, "upload_id")
|
|
2676
|
+
_validate_digest(md5, "md5", 32)
|
|
2677
|
+
route = Route("POST", "/v2/media-images/complete")
|
|
2678
|
+
return await self._http.request(route, json={"upload_id": upload_id, "md5": md5.lower()}, timeout=120)
|
|
2679
|
+
|
|
2680
|
+
async def get_media_images(self, *, cursor: Optional[str] = None) -> media_image.MediaImageListResponse:
|
|
2681
|
+
"""分页查询当前机器人的有效素材图片,按上传时间倒序。"""
|
|
2682
|
+
|
|
2683
|
+
if cursor is not None and not isinstance(cursor, str):
|
|
2684
|
+
raise TypeError("cursor must be a string or None")
|
|
2685
|
+
route = Route("GET", "/v2/media-images")
|
|
2686
|
+
if cursor:
|
|
2687
|
+
return await self._http.request(route, params={"cursor": cursor})
|
|
2688
|
+
return await self._http.request(route)
|
|
2689
|
+
|
|
2690
|
+
async def delete_media_image(self, image_id: str) -> Any:
|
|
2691
|
+
"""删除一张素材图片并释放数量配额;``image_id`` 不存在时平台返回 40035008。"""
|
|
2692
|
+
|
|
2693
|
+
_required_text(image_id, "image_id")
|
|
2694
|
+
route = Route("DELETE", "/v2/media-images/{image_id}/delete", image_id=image_id)
|
|
2695
|
+
return await self._http.request(route)
|
|
@@ -23,6 +23,7 @@ from .protocol.events import normalize_inbound_message
|
|
|
23
23
|
from .protocol.constants import DEFAULT_API_BASE_URL
|
|
24
24
|
from .protocol.errors import TransportError
|
|
25
25
|
from .protocol.media import ChunkedMediaUploader, ProgressCallback
|
|
26
|
+
from .protocol.media_image import MediaImageUploader, MediaImageUploadResult
|
|
26
27
|
from .protocol.message import (
|
|
27
28
|
LARGE_MEDIA_THRESHOLD,
|
|
28
29
|
MAX_MEDIA_UPLOAD_SIZE,
|
|
@@ -607,6 +608,69 @@ class Client:
|
|
|
607
608
|
raise RuntimeError("media upload response does not contain raw_url")
|
|
608
609
|
return MediaUrlResult(upload=response, raw_url=raw_url, ttl=_coerce_ttl(response.get("ttl")))
|
|
609
610
|
|
|
611
|
+
async def upload_media_image(
|
|
612
|
+
self,
|
|
613
|
+
*,
|
|
614
|
+
data: Optional[bytes] = None,
|
|
615
|
+
local_path: Optional[Union[str, Path]] = None,
|
|
616
|
+
file_name: Optional[str] = None,
|
|
617
|
+
content_type: Optional[str] = None,
|
|
618
|
+
alt: Optional[str] = None,
|
|
619
|
+
timeout: float = 300,
|
|
620
|
+
) -> MediaImageUploadResult:
|
|
621
|
+
"""上传素材图片并返回 ``qqbot-media://`` 引用。
|
|
622
|
+
|
|
623
|
+
素材图片与富媒体消息的 ``file_info`` 不是同一套协议:这里只负责拿到引用,
|
|
624
|
+
通过 ``result.markdown`` 或 :meth:`send_markdown` 把图片嵌进 Markdown 消息。
|
|
625
|
+
``data`` 与 ``local_path`` 必须二选一。
|
|
626
|
+
"""
|
|
627
|
+
|
|
628
|
+
uploader = getattr(self, "_media_image_uploader", None)
|
|
629
|
+
if uploader is None:
|
|
630
|
+
uploader = MediaImageUploader(self.api, logger=_log)
|
|
631
|
+
self._media_image_uploader = uploader
|
|
632
|
+
return await uploader.upload(
|
|
633
|
+
data=data,
|
|
634
|
+
local_path=local_path,
|
|
635
|
+
file_name=file_name,
|
|
636
|
+
content_type=content_type,
|
|
637
|
+
alt=alt,
|
|
638
|
+
timeout=timeout,
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
async def list_media_images(self, *, cursor: Optional[str] = None) -> Mapping[str, Any]:
|
|
642
|
+
"""分页查询当前机器人的有效素材图片。"""
|
|
643
|
+
|
|
644
|
+
return await self.api.get_media_images(cursor=cursor)
|
|
645
|
+
|
|
646
|
+
async def delete_media_image(self, image_id: str) -> Any:
|
|
647
|
+
"""删除一张素材图片并释放配额。"""
|
|
648
|
+
|
|
649
|
+
return await self.api.delete_media_image(image_id)
|
|
650
|
+
|
|
651
|
+
async def send_markdown_image(
|
|
652
|
+
self,
|
|
653
|
+
target: ReplyTarget,
|
|
654
|
+
*,
|
|
655
|
+
data: Optional[bytes] = None,
|
|
656
|
+
local_path: Optional[Union[str, Path]] = None,
|
|
657
|
+
file_name: Optional[str] = None,
|
|
658
|
+
content_type: Optional[str] = None,
|
|
659
|
+
alt: Optional[str] = None,
|
|
660
|
+
keyboard: Optional[Mapping[str, Any]] = None,
|
|
661
|
+
):
|
|
662
|
+
"""上传素材图片并把它作为 Markdown 图片消息发出去。"""
|
|
663
|
+
|
|
664
|
+
uploaded = await Client.upload_media_image(
|
|
665
|
+
self,
|
|
666
|
+
data=data,
|
|
667
|
+
local_path=local_path,
|
|
668
|
+
file_name=file_name,
|
|
669
|
+
content_type=content_type,
|
|
670
|
+
alt=alt,
|
|
671
|
+
)
|
|
672
|
+
return await Client.send_markdown(self, target, uploaded.markdown, keyboard=keyboard)
|
|
673
|
+
|
|
610
674
|
async def _upload_media(
|
|
611
675
|
self,
|
|
612
676
|
target: ReplyTarget,
|
|
@@ -135,7 +135,7 @@ def concurrency_guard(
|
|
|
135
135
|
if max_processing_seconds > 0:
|
|
136
136
|
try:
|
|
137
137
|
await asyncio.wait_for(next_call(), timeout=max_processing_seconds)
|
|
138
|
-
except TimeoutError:
|
|
138
|
+
except asyncio.TimeoutError:
|
|
139
139
|
context.abort("concurrency:processing-timeout")
|
|
140
140
|
else:
|
|
141
141
|
await next_call()
|
|
@@ -350,14 +350,16 @@ def _build_envelope(
|
|
|
350
350
|
sender = entry.sender_name or entry.sender_id
|
|
351
351
|
lines.append(f"{sender}: {entry.content[:200]}")
|
|
352
352
|
if lines:
|
|
353
|
-
|
|
353
|
+
history_text = "\n".join(lines)
|
|
354
|
+
sections.append(f"<history>\n{history_text}\n</history>")
|
|
354
355
|
|
|
355
356
|
message_parts = []
|
|
356
357
|
if context.message.content.strip():
|
|
357
358
|
message_parts.append(context.message.content.strip())
|
|
358
359
|
message_parts.extend(_attachment_label(attachment) for attachment in context.message.attachments)
|
|
359
360
|
if message_parts:
|
|
360
|
-
|
|
361
|
+
message_text = "\n".join(message_parts)
|
|
362
|
+
sections.append(f"<message>\n{message_text}\n</message>")
|
|
361
363
|
return "\n\n".join(sections)
|
|
362
364
|
|
|
363
365
|
|
|
@@ -27,6 +27,13 @@ from .image import (
|
|
|
27
27
|
parse_image_size,
|
|
28
28
|
)
|
|
29
29
|
from .media import ChunkedMediaUploader, ChunkedUploadProgress, ProgressCallback, UploadHashes
|
|
30
|
+
from .media_image import (
|
|
31
|
+
MediaImageUploadApi,
|
|
32
|
+
MediaImageUploader,
|
|
33
|
+
MediaImageUploadResult,
|
|
34
|
+
format_media_image_markdown,
|
|
35
|
+
guess_media_image_content_type,
|
|
36
|
+
)
|
|
30
37
|
from .message import (
|
|
31
38
|
CHUNKED_MEDIA_MAX_SIZE,
|
|
32
39
|
LARGE_MEDIA_THRESHOLD,
|
|
@@ -137,6 +144,9 @@ __all__ = (
|
|
|
137
144
|
"MAX_MEDIA_UPLOAD_SIZE",
|
|
138
145
|
"MEDIA_FILE_SIZE_LIMITS",
|
|
139
146
|
"MediaFileType",
|
|
147
|
+
"MediaImageUploadApi",
|
|
148
|
+
"MediaImageUploader",
|
|
149
|
+
"MediaImageUploadResult",
|
|
140
150
|
"MediaSendResult",
|
|
141
151
|
"MediaUrlResult",
|
|
142
152
|
"MessageQuota",
|
|
@@ -189,8 +199,10 @@ __all__ = (
|
|
|
189
199
|
"format_duration",
|
|
190
200
|
"format_error_message",
|
|
191
201
|
"format_file_size",
|
|
202
|
+
"format_media_image_markdown",
|
|
192
203
|
"format_qqbot_markdown_image",
|
|
193
204
|
"get_image_size_from_data_url",
|
|
205
|
+
"guess_media_image_content_type",
|
|
194
206
|
"guess_mime_type",
|
|
195
207
|
"has_qqbot_image_size",
|
|
196
208
|
"is_audio_file",
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""素材管理(media image)上传流程。
|
|
3
|
+
|
|
4
|
+
平台要求先把 Markdown 消息要用的图片上传换取 ``qqbot-media://`` 引用:
|
|
5
|
+
|
|
6
|
+
1. ``POST /v2/media-images/prepare`` 申请上传,得到预签名 URL;
|
|
7
|
+
2. 用 HTTP ``PUT`` 把图片二进制直传到预签名 URL;
|
|
8
|
+
3. ``POST /v2/media-images/complete`` 完成上传,得到 ``image_id`` 与引用链接。
|
|
9
|
+
|
|
10
|
+
本模块把这三步串起来,并负责校验格式、大小与计算摘要。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import hashlib
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Mapping, Optional, Protocol, Tuple, Union
|
|
18
|
+
|
|
19
|
+
from .. import logging
|
|
20
|
+
from ..types.media_image import MEDIA_IMAGE_EXTENSIONS, MEDIA_IMAGE_MAX_SIZE
|
|
21
|
+
from .image import ImageSize, parse_image_size
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MediaImageUploadApi(Protocol):
|
|
25
|
+
"""``MediaImageUploader`` 需要的最小 API 集合,便于测试替换。"""
|
|
26
|
+
|
|
27
|
+
async def post_media_image_prepare(self, **payload: Any) -> Mapping[str, Any]: ...
|
|
28
|
+
|
|
29
|
+
async def put_media_image(self, presigned_url: str, data: bytes, **kwargs: Any) -> Any: ...
|
|
30
|
+
|
|
31
|
+
async def post_media_image_complete(self, **payload: Any) -> Mapping[str, Any]: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class MediaImageUploadResult:
|
|
36
|
+
"""一次素材图片上传的结果。"""
|
|
37
|
+
|
|
38
|
+
image_id: str
|
|
39
|
+
url: str
|
|
40
|
+
ttl: int
|
|
41
|
+
preview_url: Optional[str]
|
|
42
|
+
upload_id: str
|
|
43
|
+
file_name: str
|
|
44
|
+
content_type: str
|
|
45
|
+
size: int
|
|
46
|
+
markdown: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def guess_media_image_content_type(file_name: str) -> Optional[str]:
|
|
50
|
+
"""按扩展名返回平台的 MIME 类型;不支持的扩展名返回 ``None``。"""
|
|
51
|
+
|
|
52
|
+
if not isinstance(file_name, str) or not file_name:
|
|
53
|
+
return None
|
|
54
|
+
return MEDIA_IMAGE_EXTENSIONS.get(Path(file_name).suffix.lower())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def format_media_image_markdown(url: str, *, alt: str = "", size: Optional[ImageSize] = None) -> str:
|
|
58
|
+
"""生成 Markdown 图片语法;已知宽高时附带 ``#宽px #高px``。"""
|
|
59
|
+
|
|
60
|
+
if not isinstance(url, str) or not url:
|
|
61
|
+
raise ValueError("url must be a non-empty string")
|
|
62
|
+
if size is None:
|
|
63
|
+
return f""
|
|
64
|
+
return f""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _hash_media_image(data: bytes) -> Tuple[str, str]:
|
|
68
|
+
return hashlib.md5(data).hexdigest(), hashlib.sha1(data).hexdigest()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MediaImageUploader:
|
|
72
|
+
"""执行素材图片的 prepare → PUT → complete 三步上传。"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, api: MediaImageUploadApi, *, logger: Any = None) -> None:
|
|
75
|
+
self.api = api
|
|
76
|
+
self.logger = logger or logging.get_logger()
|
|
77
|
+
|
|
78
|
+
async def upload(
|
|
79
|
+
self,
|
|
80
|
+
*,
|
|
81
|
+
data: Optional[bytes] = None,
|
|
82
|
+
local_path: Optional[Union[str, Path]] = None,
|
|
83
|
+
file_name: Optional[str] = None,
|
|
84
|
+
content_type: Optional[str] = None,
|
|
85
|
+
alt: Optional[str] = None,
|
|
86
|
+
timeout: float = 300,
|
|
87
|
+
) -> MediaImageUploadResult:
|
|
88
|
+
"""上传一张图片并返回引用链接。
|
|
89
|
+
|
|
90
|
+
``data`` 与 ``local_path`` 必须二选一;``content_type`` 省略时按扩展名推断。
|
|
91
|
+
``alt`` 是 Markdown 图片的替代文本,省略时用文件名(不含扩展名)。
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
if (data is None) == (local_path is None):
|
|
95
|
+
raise ValueError("exactly one of data or local_path is required")
|
|
96
|
+
if file_name is not None and not isinstance(file_name, str):
|
|
97
|
+
raise TypeError("file_name must be a string")
|
|
98
|
+
if content_type is not None and not isinstance(content_type, str):
|
|
99
|
+
raise TypeError("content_type must be a string")
|
|
100
|
+
if alt is not None and not isinstance(alt, str):
|
|
101
|
+
raise TypeError("alt must be a string")
|
|
102
|
+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout <= 0:
|
|
103
|
+
raise ValueError("timeout must be a positive number")
|
|
104
|
+
|
|
105
|
+
if data is not None:
|
|
106
|
+
if not isinstance(data, bytes):
|
|
107
|
+
raise TypeError("data must be bytes")
|
|
108
|
+
payload = data
|
|
109
|
+
display_name = file_name or "image.png"
|
|
110
|
+
else:
|
|
111
|
+
path = Path(local_path)
|
|
112
|
+
if path.is_symlink() or not path.is_file():
|
|
113
|
+
raise ValueError("local_path must point to a regular file and may not be a symlink")
|
|
114
|
+
payload = await asyncio.to_thread(path.read_bytes)
|
|
115
|
+
display_name = file_name or path.name
|
|
116
|
+
|
|
117
|
+
if not payload:
|
|
118
|
+
raise ValueError("image data must not be empty")
|
|
119
|
+
file_size = len(payload)
|
|
120
|
+
if file_size > MEDIA_IMAGE_MAX_SIZE:
|
|
121
|
+
raise ValueError(f"image size {file_size} exceeds the {MEDIA_IMAGE_MAX_SIZE} byte limit")
|
|
122
|
+
|
|
123
|
+
expected_type = guess_media_image_content_type(display_name)
|
|
124
|
+
if expected_type is None:
|
|
125
|
+
raise ValueError("file_name must end with .png, .jpg, .jpeg, .gif or .webp")
|
|
126
|
+
if content_type is not None and content_type != expected_type:
|
|
127
|
+
raise ValueError("content_type does not match the file_name extension")
|
|
128
|
+
content_type = content_type or expected_type
|
|
129
|
+
|
|
130
|
+
md5, sha1 = await asyncio.to_thread(_hash_media_image, payload)
|
|
131
|
+
prepared = await self.api.post_media_image_prepare(
|
|
132
|
+
file_name=display_name,
|
|
133
|
+
file_size=file_size,
|
|
134
|
+
content_type=content_type,
|
|
135
|
+
md5=md5,
|
|
136
|
+
sha1=sha1,
|
|
137
|
+
)
|
|
138
|
+
upload_id = prepared.get("upload_id")
|
|
139
|
+
presigned_url = prepared.get("presigned_url")
|
|
140
|
+
if not upload_id or not presigned_url:
|
|
141
|
+
raise RuntimeError("prepare 响应缺少 upload_id 或 presigned_url")
|
|
142
|
+
|
|
143
|
+
await self.api.put_media_image(
|
|
144
|
+
presigned_url,
|
|
145
|
+
payload,
|
|
146
|
+
content_type=content_type,
|
|
147
|
+
timeout=timeout,
|
|
148
|
+
)
|
|
149
|
+
completed = await self.api.post_media_image_complete(upload_id=upload_id, md5=md5)
|
|
150
|
+
image_url = completed.get("url")
|
|
151
|
+
if not image_url:
|
|
152
|
+
raise RuntimeError("complete 响应缺少 url")
|
|
153
|
+
|
|
154
|
+
size = parse_image_size(payload)
|
|
155
|
+
markdown = format_media_image_markdown(
|
|
156
|
+
image_url,
|
|
157
|
+
alt=alt if alt is not None else Path(display_name).stem,
|
|
158
|
+
size=size,
|
|
159
|
+
)
|
|
160
|
+
return MediaImageUploadResult(
|
|
161
|
+
image_id=str(completed.get("image_id") or ""),
|
|
162
|
+
url=image_url,
|
|
163
|
+
ttl=int(completed.get("ttl") or 0),
|
|
164
|
+
preview_url=completed.get("preview_url"),
|
|
165
|
+
upload_id=str(upload_id),
|
|
166
|
+
file_name=display_name,
|
|
167
|
+
content_type=content_type,
|
|
168
|
+
size=file_size,
|
|
169
|
+
markdown=markdown,
|
|
170
|
+
)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
from typing import List, Literal, TypedDict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
MediaImageContentType = Literal["image/png", "image/jpeg", "image/gif", "image/webp"]
|
|
6
|
+
|
|
7
|
+
MEDIA_IMAGE_CONTENT_TYPES = frozenset({"image/png", "image/jpeg", "image/gif", "image/webp"})
|
|
8
|
+
"""平台支持的素材图片 MIME 类型。"""
|
|
9
|
+
|
|
10
|
+
MEDIA_IMAGE_MAX_SIZE = 10 * 1024 * 1024
|
|
11
|
+
"""单张素材图片大小上限(10 MB)。"""
|
|
12
|
+
|
|
13
|
+
MEDIA_IMAGE_MAX_COUNT = 100
|
|
14
|
+
"""单个机器人可保存的有效素材图片数量上限。"""
|
|
15
|
+
|
|
16
|
+
MEDIA_IMAGE_EXTENSIONS = {
|
|
17
|
+
".png": "image/png",
|
|
18
|
+
".jpg": "image/jpeg",
|
|
19
|
+
".jpeg": "image/jpeg",
|
|
20
|
+
".gif": "image/gif",
|
|
21
|
+
".webp": "image/webp",
|
|
22
|
+
}
|
|
23
|
+
"""扩展名到 MIME 类型的映射;平台要求二者一致。"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _MediaImagePrepareOptionalFields(TypedDict, total=False):
|
|
27
|
+
expires_in: int # 预签名 URL 有效期(秒)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MediaImagePrepareResponse(_MediaImagePrepareOptionalFields):
|
|
31
|
+
upload_id: str # 上传任务 ID,4 小时内有效
|
|
32
|
+
presigned_url: str # 预签名上传 URL,用 HTTP PUT 直传图片二进制
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _MediaImageCompleteOptionalFields(TypedDict, total=False):
|
|
36
|
+
ttl: int # 滑动有效期窗口(秒),默认 7776000
|
|
37
|
+
preview_url: str # 临时预览链接,短时效,请勿持久化
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MediaImageCompleteResponse(_MediaImageCompleteOptionalFields):
|
|
41
|
+
image_id: str # 图片 ID,用于查询与删除
|
|
42
|
+
url: str # Markdown 引用链接:qqbot-media://image/v1/<token>
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _MediaImageRecordOptionalFields(TypedDict, total=False):
|
|
46
|
+
file_name: str # 上传时的文件名
|
|
47
|
+
preview_url: str # 临时预览链接,短时效
|
|
48
|
+
created_at: int # 上传时间(Unix 秒)
|
|
49
|
+
expires_at: int # 过期时间(Unix 秒),每次被引用后自动顺延
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class MediaImageRecord(_MediaImageRecordOptionalFields):
|
|
53
|
+
image_id: str
|
|
54
|
+
url: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _MediaImageListOptionalFields(TypedDict, total=False):
|
|
58
|
+
next_cursor: str # 下一页游标,为空表示末页
|
|
59
|
+
total: int # 当前有效图片总数
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class MediaImageListResponse(_MediaImageListOptionalFields):
|
|
63
|
+
images: List[MediaImageRecord]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
2
|
from enum import Enum
|
|
3
|
-
from typing import List,
|
|
3
|
+
from typing import List, TypedDict
|
|
4
4
|
|
|
5
5
|
from .gateway import MessagePayload
|
|
6
6
|
from .inline import Keyboard
|
|
@@ -66,11 +66,14 @@ class KeyboardPayload(TypedDict, total=False):
|
|
|
66
66
|
content: Keyboard
|
|
67
67
|
|
|
68
68
|
|
|
69
|
-
class
|
|
69
|
+
class _MediaOptionalFields(TypedDict, total=False):
|
|
70
|
+
raw_url: str # 临时直链,可用于 Markdown 图片等内容;仅在平台返回时存在,随 ttl 过期
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Media(_MediaOptionalFields):
|
|
70
74
|
file_uuid: str # 文件ID
|
|
71
75
|
file_info: str # 文件信息,用于发消息接口的media字段使用
|
|
72
76
|
ttl: int # 有效期,标识剩余多少秒到期,到期后 file_info 失效,当等于 0 时,表示可长期使用
|
|
73
|
-
raw_url: NotRequired[str] # 临时直链,可用于 Markdown 图片等内容;仅在平台返回时存在,随 ttl 过期
|
|
74
77
|
|
|
75
78
|
|
|
76
79
|
class Message(MessagePayload):
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Any,
|
|
1
|
+
from typing import Any, Optional, TypedDict
|
|
2
2
|
|
|
3
3
|
from ..robot import Token
|
|
4
4
|
|
|
@@ -8,14 +8,17 @@ class ShardConfig(TypedDict):
|
|
|
8
8
|
shard_count: int
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
class
|
|
11
|
+
class _SessionOptionalFields(TypedDict, total=False):
|
|
12
|
+
ssl: Any
|
|
13
|
+
proxy: Any
|
|
14
|
+
reconnect_policy: Any
|
|
15
|
+
session_store: Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Session(_SessionOptionalFields):
|
|
12
19
|
session_id: str
|
|
13
20
|
last_seq: Optional[int]
|
|
14
21
|
intent: int
|
|
15
22
|
token: Token
|
|
16
23
|
url: str
|
|
17
24
|
shards: ShardConfig
|
|
18
|
-
ssl: NotRequired[Any]
|
|
19
|
-
proxy: NotRequired[Any]
|
|
20
|
-
reconnect_policy: NotRequired[Any]
|
|
21
|
-
session_store: NotRequired[Any]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|