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.
Files changed (93) hide show
  1. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/PKG-INFO +2 -2
  2. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/README.md +1 -1
  3. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/api.py +118 -0
  4. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/client.py +64 -0
  5. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/middleware/control.py +1 -1
  6. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/middleware/conversation.py +4 -2
  7. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/middleware/typing.py +1 -1
  8. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/__init__.py +12 -0
  9. qq_botpy_sdk-2.2.0/botpy/protocol/media_image.py +170 -0
  10. qq_botpy_sdk-2.2.0/botpy/types/media_image.py +63 -0
  11. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/message.py +6 -3
  12. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/session.py +9 -6
  13. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/pyproject.toml +1 -1
  14. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/pyproject.toml.orig +1 -1
  15. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/LICENSE +0 -0
  16. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/__init__.py +0 -0
  17. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/audio.py +0 -0
  18. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/channel.py +0 -0
  19. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/configuration.py +0 -0
  20. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/connection.py +0 -0
  21. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/errors.py +0 -0
  22. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/ext/__init__.py +0 -0
  23. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/ext/channel_jump/__init__.py +0 -0
  24. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/ext/cog_apscheduler/__init__.py +0 -0
  25. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/ext/cog_yaml/__init__.py +0 -0
  26. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/ext/command_util.py +0 -0
  27. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/ext/convert_color/__init__.py +0 -0
  28. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/flags.py +0 -0
  29. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/forum.py +0 -0
  30. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/gateway.py +0 -0
  31. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/guild.py +0 -0
  32. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/http.py +0 -0
  33. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/interaction.py +0 -0
  34. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/logging.py +0 -0
  35. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/manage.py +0 -0
  36. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/message.py +0 -0
  37. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/middleware/__init__.py +0 -0
  38. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/middleware/base.py +0 -0
  39. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/middleware/builtin.py +0 -0
  40. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/middleware/commands.py +0 -0
  41. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/middleware/policy.py +0 -0
  42. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/audio.py +0 -0
  43. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/auth.py +0 -0
  44. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/constants.py +0 -0
  45. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/errors.py +0 -0
  46. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/events.py +0 -0
  47. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/formatting.py +0 -0
  48. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/http.py +0 -0
  49. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/image.py +0 -0
  50. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/media.py +0 -0
  51. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/media_utils.py +0 -0
  52. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/message.py +0 -0
  53. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/models.py +0 -0
  54. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/proxy.py +0 -0
  55. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/ratelimit.py +0 -0
  56. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/reconnect.py +0 -0
  57. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/reply.py +0 -0
  58. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/send_policy.py +0 -0
  59. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/session.py +0 -0
  60. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/streaming.py +0 -0
  61. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/target.py +0 -0
  62. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/text.py +0 -0
  63. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/transport/__init__.py +0 -0
  64. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/transport/base.py +0 -0
  65. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/transport/webhook.py +0 -0
  66. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/transport/webhook_verify.py +0 -0
  67. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/protocol/upload_cache.py +0 -0
  68. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/reaction.py +0 -0
  69. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/robot.py +0 -0
  70. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/storage/__init__.py +0 -0
  71. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/storage/adapters.py +0 -0
  72. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/storage/kv.py +0 -0
  73. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/__init__.py +0 -0
  74. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/announce.py +0 -0
  75. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/audio.py +0 -0
  76. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/channel.py +0 -0
  77. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/emoji.py +0 -0
  78. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/forum.py +0 -0
  79. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/gateway.py +0 -0
  80. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/group.py +0 -0
  81. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/guild.py +0 -0
  82. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/inline.py +0 -0
  83. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/interaction.py +0 -0
  84. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/menu.py +0 -0
  85. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/panel.py +0 -0
  86. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/permission.py +0 -0
  87. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/pins_message.py +0 -0
  88. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/reaction.py +0 -0
  89. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/rich_text.py +0 -0
  90. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/robot.py +0 -0
  91. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/schedule.py +0 -0
  92. {qq_botpy_sdk-2.1.2 → qq_botpy_sdk-2.2.0}/botpy/types/user.py +0 -0
  93. {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.1.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) | 新高层接口、统一发送、媒体、REST 和 Interaction |
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) | 新高层接口、统一发送、媒体、REST 和 Interaction |
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
- sections.append(f"<history>\n{'\n'.join(lines)}\n</history>")
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
- sections.append(f"<message>\n{'\n'.join(message_parts)}\n</message>")
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
 
@@ -65,7 +65,7 @@ async def _keepalive_loop(
65
65
  try:
66
66
  await asyncio.wait_for(stop_event.wait(), timeout=interval_seconds)
67
67
  return
68
- except TimeoutError:
68
+ except asyncio.TimeoutError:
69
69
  await _safe_send(context, duration_seconds)
70
70
 
71
71
 
@@ -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"![{alt}]({url})"
64
+ return f"![{alt} #{size.width}px #{size.height}px]({url})"
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, NotRequired, TypedDict
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 Media(TypedDict):
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, NotRequired, Optional, TypedDict
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 Session(TypedDict):
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]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qq-botpy-sdk"
3
- version = "2.1.2"
3
+ version = "2.2.0"
4
4
  description = "Community-maintained async Python SDK for the QQ Bot Open Platform"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10,<4.0"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qq-botpy-sdk"
3
- version = "2.1.2"
3
+ version = "2.2.0"
4
4
  description = "Community-maintained async Python SDK for the QQ Bot Open Platform"
5
5
  authors = [{ name = "Teahouse Studios" }]
6
6
  readme = "README.md"
File without changes
File without changes
File without changes