qbotsdk 0.6.0__py3-none-any.whl

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 (59) hide show
  1. qbotsdk/__init__.py +76 -0
  2. qbotsdk/adapter/__init__.py +9 -0
  3. qbotsdk/adapter/base.py +35 -0
  4. qbotsdk/adapter/webhook.py +78 -0
  5. qbotsdk/adapter/websocket.py +65 -0
  6. qbotsdk/api/__init__.py +46 -0
  7. qbotsdk/api/_parse.py +29 -0
  8. qbotsdk/api/channel.py +136 -0
  9. qbotsdk/api/group.py +184 -0
  10. qbotsdk/api/guild.py +73 -0
  11. qbotsdk/api/interaction.py +34 -0
  12. qbotsdk/api/message.py +55 -0
  13. qbotsdk/api/reaction.py +125 -0
  14. qbotsdk/api/user.py +57 -0
  15. qbotsdk/api/user_message.py +115 -0
  16. qbotsdk/bot.py +136 -0
  17. qbotsdk/constant.py +10 -0
  18. qbotsdk/exceptions.py +195 -0
  19. qbotsdk/gateway/__init__.py +61 -0
  20. qbotsdk/gateway/connection.py +316 -0
  21. qbotsdk/gateway/dispatcher.py +142 -0
  22. qbotsdk/gateway/handler.py +81 -0
  23. qbotsdk/gateway/payload.py +198 -0
  24. qbotsdk/gateway/session.py +59 -0
  25. qbotsdk/gateway/webhook.py +66 -0
  26. qbotsdk/gateway/webhook_server.py +171 -0
  27. qbotsdk/model/__init__.py +145 -0
  28. qbotsdk/model/base.py +9 -0
  29. qbotsdk/model/channel.py +74 -0
  30. qbotsdk/model/draft.py +234 -0
  31. qbotsdk/model/event.py +410 -0
  32. qbotsdk/model/group.py +119 -0
  33. qbotsdk/model/guild.py +29 -0
  34. qbotsdk/model/keyboard.py +112 -0
  35. qbotsdk/model/media.py +43 -0
  36. qbotsdk/model/member.py +27 -0
  37. qbotsdk/model/message.py +79 -0
  38. qbotsdk/model/reaction.py +65 -0
  39. qbotsdk/model/role.py +31 -0
  40. qbotsdk/model/scene.py +98 -0
  41. qbotsdk/model/token.py +9 -0
  42. qbotsdk/model/user.py +63 -0
  43. qbotsdk/module/__init__.py +1 -0
  44. qbotsdk/module/http_client.py +372 -0
  45. qbotsdk/module/pagination.py +74 -0
  46. qbotsdk/module/rate_limit.py +101 -0
  47. qbotsdk/module/token_manager.py +151 -0
  48. qbotsdk/options.py +43 -0
  49. qbotsdk/py.typed +1 -0
  50. qbotsdk/storage/__init__.py +28 -0
  51. qbotsdk/storage/base.py +42 -0
  52. qbotsdk/storage/file.py +142 -0
  53. qbotsdk/storage/memory.py +25 -0
  54. qbotsdk/storage/redis.py +219 -0
  55. qbotsdk-0.6.0.dist-info/METADATA +250 -0
  56. qbotsdk-0.6.0.dist-info/RECORD +59 -0
  57. qbotsdk-0.6.0.dist-info/WHEEL +4 -0
  58. qbotsdk-0.6.0.dist-info/entry_points.txt +4 -0
  59. qbotsdk-0.6.0.dist-info/licenses/LICENSE +201 -0
qbotsdk/__init__.py ADDED
@@ -0,0 +1,76 @@
1
+ # Copyright (c) 2026-present Half_nothing.
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ from .adapter import Adapter, AdapterContext, WebhookAdapter, WebSocketAdapter
8
+ from .api import ApiClient
9
+ from .bot import Bot
10
+ from .exceptions import APIError, ErrorCode, QQBotError, RateLimitExceededError, TransportError
11
+ from .gateway import WebhookServer, build_webhook_handler
12
+ from .model import (
13
+ Channel,
14
+ ChannelMessageCreate,
15
+ ChannelMessageDraft,
16
+ Group,
17
+ GroupBotState,
18
+ GroupMember,
19
+ GroupMessageDraft,
20
+ GroupMessageReceipt,
21
+ GroupMuteState,
22
+ GroupUser,
23
+ Guild,
24
+ GuildUser,
25
+ InteractionCreate,
26
+ Member,
27
+ Message,
28
+ Role,
29
+ User,
30
+ )
31
+ from .options import ClientOptions
32
+ from .storage import FileSessionStorage, MemorySessionStorage, SessionData, SessionStorage
33
+
34
+ try:
35
+ __version__ = version("qbotsdk")
36
+ except PackageNotFoundError: # pragma: no cover
37
+ __version__ = "0.0.0"
38
+
39
+ __all__ = [
40
+ "APIError",
41
+ "Adapter",
42
+ "AdapterContext",
43
+ "ApiClient",
44
+ "Bot",
45
+ "Channel",
46
+ "ChannelMessageCreate",
47
+ "ChannelMessageDraft",
48
+ "ClientOptions",
49
+ "ErrorCode",
50
+ "FileSessionStorage",
51
+ "Group",
52
+ "GroupBotState",
53
+ "GroupMember",
54
+ "GroupMessageDraft",
55
+ "GroupMessageReceipt",
56
+ "GroupMuteState",
57
+ "GroupUser",
58
+ "Guild",
59
+ "GuildUser",
60
+ "InteractionCreate",
61
+ "Member",
62
+ "MemorySessionStorage",
63
+ "Message",
64
+ "QQBotError",
65
+ "RateLimitExceededError",
66
+ "Role",
67
+ "SessionData",
68
+ "SessionStorage",
69
+ "TransportError",
70
+ "User",
71
+ "WebSocketAdapter",
72
+ "WebhookAdapter",
73
+ "WebhookServer",
74
+ "__version__",
75
+ "build_webhook_handler",
76
+ ]
@@ -0,0 +1,9 @@
1
+ # Copyright (c) 2026-present Half_nothing.
2
+
3
+ """适配器:核心不内置任何接入方式, 使用者显式选择或自己实现。"""
4
+
5
+ from .base import Adapter, AdapterContext
6
+ from .webhook import WebhookAdapter
7
+ from .websocket import WebSocketAdapter
8
+
9
+ __all__ = ["Adapter", "AdapterContext", "WebSocketAdapter", "WebhookAdapter"]
@@ -0,0 +1,35 @@
1
+ # Copyright (c) 2026-present Half_nothing.
2
+
3
+ """适配器抽象:事件从哪里来由使用者决定, 核心只认这个接口。"""
4
+
5
+ from __future__ import annotations
6
+
7
+ from abc import ABC, abstractmethod
8
+ from dataclasses import dataclass
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from qbotsdk.api import ApiClient
13
+ from qbotsdk.gateway.dispatcher import Dispatcher
14
+ from qbotsdk.module.http_client import HTTPClient
15
+
16
+
17
+ @dataclass(slots=True)
18
+ class AdapterContext:
19
+ """适配器启动时能拿到的东西, 由 Bot.start() 组装。"""
20
+
21
+ http: HTTPClient
22
+ api: ApiClient
23
+ dispatcher: Dispatcher
24
+
25
+
26
+ class Adapter(ABC):
27
+ """接入方式的公共接口。"""
28
+
29
+ @abstractmethod
30
+ async def start(self, context: AdapterContext) -> None:
31
+ """开始收事件并立即返回, 后台任务由适配器自己持有。"""
32
+
33
+ @abstractmethod
34
+ async def stop(self) -> None:
35
+ """停止收事件并释放资源, 允许重复调用。"""
@@ -0,0 +1,78 @@
1
+ # Copyright (c) 2026-present Half_nothing.
2
+
3
+ """Webhook 适配器:由平台回调推事件, 自己不再连网关。"""
4
+
5
+ from __future__ import annotations
6
+
7
+ from ssl import SSLContext
8
+
9
+ from qbotsdk.gateway.webhook_server import WebhookServer
10
+
11
+ from .base import Adapter, AdapterContext
12
+
13
+
14
+ class WebhookAdapter(Adapter):
15
+ """把 HTTP 回调收进同一个分发器, 与Websocket二选一。"""
16
+
17
+ def __init__(
18
+ self,
19
+ app_secret: str,
20
+ *,
21
+ host: str = "0.0.0.0",
22
+ port: int = 8443,
23
+ path: str = "/qbotsdk",
24
+ certfile: str | None = None,
25
+ keyfile: str | None = None,
26
+ ssl_context: SSLContext | None = None,
27
+ ) -> None:
28
+ """记录监听参数, 真正监听发生在 start()。
29
+
30
+ Args:
31
+ app_secret: 机器人密钥, 用于验签与 op 13 应答。
32
+ host: 监听地址。
33
+ port: 监听端口, 0 表示让操作系统挑一个空闲端口。
34
+ path: 回调路径, 管理端填的地址要与之对应。
35
+ certfile: 证书链路径, 必须与 keyfile 成对出现。
36
+ keyfile: 私钥路径。
37
+ ssl_context: 现成的 SSLContext, 与 certfile/keyfile 互斥。
38
+ """
39
+
40
+ self._app_secret = app_secret
41
+ self._host = host
42
+ self._port = port
43
+ self._path = path
44
+ self._certfile = certfile
45
+ self._keyfile = keyfile
46
+ self._ssl_context = ssl_context
47
+ self._server: WebhookServer | None = None
48
+
49
+ @property
50
+ def port(self) -> int:
51
+ """实际监听的端口, 传 0 时要等 start() 之后才有值。"""
52
+
53
+ if self._server is not None:
54
+ return self._server.port
55
+ return self._port
56
+
57
+ async def start(self, context: AdapterContext) -> None:
58
+ if self._server is not None:
59
+ raise RuntimeError("webhook adapter is already started")
60
+
61
+ server = WebhookServer(
62
+ context.dispatcher,
63
+ self._app_secret,
64
+ host=self._host,
65
+ port=self._port,
66
+ path=self._path,
67
+ certfile=self._certfile,
68
+ keyfile=self._keyfile,
69
+ ssl_context=self._ssl_context,
70
+ )
71
+ await server.start()
72
+ self._server = server
73
+ self._port = server.port
74
+
75
+ async def stop(self) -> None:
76
+ if self._server is not None:
77
+ await self._server.close()
78
+ self._server = None
@@ -0,0 +1,65 @@
1
+ # Copyright (c) 2026-present Half_nothing.
2
+
3
+ """WebSocket 适配器:连上网关、维持心跳、断线续连。"""
4
+
5
+ from __future__ import annotations
6
+
7
+ from asyncio import Task, create_task, gather
8
+
9
+ from qbotsdk.gateway.connection import GatewayConnection
10
+ from qbotsdk.gateway.payload import Intents
11
+ from qbotsdk.storage import SessionStorage
12
+
13
+ from .base import Adapter, AdapterContext
14
+
15
+
16
+ class WebSocketAdapter(Adapter):
17
+ """用Websocket收事件。"""
18
+
19
+ def __init__(
20
+ self,
21
+ *,
22
+ intents: int | None = None,
23
+ shard: tuple[int, int] = (0, 1),
24
+ session_storage: SessionStorage | None = None,
25
+ ) -> None:
26
+ """记录连接参数, 真正连接发生在 start()。
27
+
28
+ Args:
29
+ intents: 订阅的事件位, 不传用 Intents.DEFAULT。
30
+ shard: (分片序号, 分片总数), 单连接保持默认即可。
31
+ session_storage: 会话存储, 不传则用内存实现。
32
+ """
33
+
34
+ self._intents = intents if intents is not None else Intents.DEFAULT
35
+ self._shard = shard
36
+ self._storage = session_storage
37
+ self._connection: GatewayConnection | None = None
38
+ self._tasks: set[Task[None]] = set()
39
+
40
+ async def start(self, context: AdapterContext) -> None:
41
+ if self._connection is not None:
42
+ raise RuntimeError("websocket adapter is already started")
43
+
44
+ connection = GatewayConnection(
45
+ context.http,
46
+ options=context.http.options,
47
+ intents=self._intents,
48
+ shard=self._shard,
49
+ session_storage=self._storage,
50
+ )
51
+ self._connection = connection
52
+ self._tasks = {
53
+ create_task(connection.run()),
54
+ create_task(context.dispatcher.run(connection.events)),
55
+ }
56
+
57
+ async def stop(self) -> None:
58
+ if self._connection is not None:
59
+ await self._connection.close()
60
+ for task in list(self._tasks):
61
+ task.cancel()
62
+ if self._tasks:
63
+ await gather(*self._tasks, return_exceptions=True)
64
+ self._tasks.clear()
65
+ self._connection = None
@@ -0,0 +1,46 @@
1
+ # Copyright (c) 2026-present Half_nothing.
2
+
3
+ """资源层,每个资源只负责拼路径、调 HTTPClient、把返回值转模型。"""
4
+
5
+ from __future__ import annotations
6
+
7
+ from qbotsdk.module.http_client import HTTPClient
8
+ from qbotsdk.module.rate_limit import RateLimiter
9
+
10
+ from .channel import ChannelResource
11
+ from .group import GroupResource
12
+ from .guild import GuildResource
13
+ from .interaction import InteractionResource
14
+ from .message import MessageResource
15
+ from .reaction import ReactionResource
16
+ from .user import UserResource
17
+ from .user_message import UserMessageResource
18
+
19
+ __all__ = [
20
+ "ApiClient",
21
+ "ChannelResource",
22
+ "GroupResource",
23
+ "GuildResource",
24
+ "InteractionResource",
25
+ "MessageResource",
26
+ "ReactionResource",
27
+ "UserMessageResource",
28
+ "UserResource",
29
+ ]
30
+
31
+
32
+ class ApiClient:
33
+ """八个资源域的聚合入口,由 `Bot` 持有并暴露为 `bot.api`。"""
34
+
35
+ def __init__(self, http: HTTPClient) -> None:
36
+ options = http.options
37
+ # 关掉限流就整层不建桶, 资源层拿到的 limiter 为 None
38
+ limiter = RateLimiter(options.rate_limits) if options.respect_rate_limits else None
39
+ self.user = UserResource(http)
40
+ self.guild = GuildResource(http)
41
+ self.group = GroupResource(http, limiter=limiter)
42
+ self.channel = ChannelResource(http)
43
+ self.message = MessageResource(http)
44
+ self.user_message = UserMessageResource(http, limiter=limiter)
45
+ self.interaction = InteractionResource(http)
46
+ self.reaction = ReactionResource(http)
qbotsdk/api/_parse.py ADDED
@@ -0,0 +1,29 @@
1
+ # Copyright (c) 2026-present Half_nothing.
2
+
3
+ """响应形状校验。
4
+ 资源层只认对象与数组,形状不符说明接口契约变了,直接抛TransportError并带上 path。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from qbotsdk.exceptions import TransportError
10
+
11
+
12
+ def expect_object(payload: object, path: str) -> dict[str, object]:
13
+ if not isinstance(payload, dict):
14
+ raise TransportError(
15
+ f"expected a JSON object from {path}, got {type(payload).__name__}",
16
+ method="GET",
17
+ path=path,
18
+ )
19
+ return payload
20
+
21
+
22
+ def expect_list(payload: object, path: str) -> list[object]:
23
+ if not isinstance(payload, list):
24
+ raise TransportError(
25
+ f"expected a JSON array from {path}, got {type(payload).__name__}",
26
+ method="GET",
27
+ path=path,
28
+ )
29
+ return payload
qbotsdk/api/channel.py ADDED
@@ -0,0 +1,136 @@
1
+ # Copyright (c) 2026-present Half_nothing.
2
+
3
+ """子频道域接口。"""
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+
9
+ from aiohttp import FormData
10
+
11
+ from qbotsdk.model.channel import Channel
12
+ from qbotsdk.model.draft import ChannelMessageDraft, MessageMarkdown, MessageReference
13
+ from qbotsdk.model.message import Message
14
+ from qbotsdk.module.http_client import HTTPClient
15
+
16
+ from ._parse import expect_object
17
+
18
+ # 官方支持 multipart 直传时就这几种图片类型, 其余按二进制流发
19
+ _CONTENT_TYPES: dict[str, str] = {
20
+ ".png": "image/png",
21
+ ".jpg": "image/jpeg",
22
+ ".jpeg": "image/jpeg",
23
+ }
24
+
25
+
26
+ def _content_type(file_name: str) -> str:
27
+ return _CONTENT_TYPES.get(Path(file_name).suffix.lower(), "application/octet-stream")
28
+
29
+
30
+ class ChannelResource:
31
+ def __init__(self, http: HTTPClient) -> None:
32
+ self._http = http
33
+
34
+ async def get(self, channel_id: str) -> Channel:
35
+ """子频道详情。"""
36
+
37
+ path = f"/channels/{channel_id}"
38
+ return Channel.model_validate(expect_object(await self._http.get(path), path))
39
+
40
+ async def send_message(
41
+ self,
42
+ channel_id: str,
43
+ *,
44
+ content: str | None = None,
45
+ markdown: MessageMarkdown | None = None,
46
+ image: str | None = None,
47
+ file_image: bytes | None = None,
48
+ file_name: str = "image.png",
49
+ reply_to: str | None = None,
50
+ event_id: str | None = None,
51
+ ) -> Message:
52
+ """发子频道消息。
53
+
54
+ 每个子频道主动与被动合计每秒最多5条
55
+ 主动消息每个子频道每天 20 条、每天最多 2 个子频道
56
+ 超限返回 `40034100` 等错误码
57
+
58
+ Args:
59
+ channel_id: 频道标识
60
+ content: 纯文本内容
61
+ markdown: markdown格式的内容
62
+ image: 传URL由平台转存, 与file_image参数互斥
63
+ file_image: 传字节走multipart, 与image参数互斥
64
+ file_name: 文件名
65
+ reply_to: 被动回复的msg_id, 有效期5分钟
66
+ event_id: 消息事件ID
67
+ """
68
+
69
+ if image is not None and file_image is not None:
70
+ raise ValueError("image and file_image are mutually exclusive")
71
+
72
+ path = f"/channels/{channel_id}/messages"
73
+ reference = MessageReference(message_id=reply_to) if reply_to is not None else None
74
+
75
+ if file_image is not None:
76
+ # Markdown 消息不带 content, 同传时以 markdown 为准
77
+ text = None if markdown is not None else content
78
+ file_bytes: bytes = file_image
79
+
80
+ def build_form() -> FormData:
81
+ form = FormData()
82
+ if text is not None:
83
+ form.add_field("content", text)
84
+ if image is not None:
85
+ form.add_field("image", image)
86
+ if event_id is not None:
87
+ form.add_field("event_id", event_id)
88
+ if markdown is not None:
89
+ form.add_field("markdown", _as_form_json(markdown))
90
+ if reference is not None:
91
+ form.add_field("message_reference", _as_form_json(reference))
92
+ form.add_field(
93
+ "file_image",
94
+ file_bytes,
95
+ filename=file_name,
96
+ content_type=_content_type(file_name),
97
+ )
98
+ return form
99
+
100
+ data = await self._http.post_form(path, form=build_form)
101
+ else:
102
+ draft = ChannelMessageDraft(
103
+ content=None if markdown is not None else content,
104
+ markdown=markdown,
105
+ image=image,
106
+ event_id=event_id,
107
+ message_reference=reference,
108
+ )
109
+ data = await self._http.post(
110
+ path,
111
+ json=draft.model_dump(by_alias=True, exclude_none=True, mode="json"),
112
+ )
113
+
114
+ return Message.model_validate(expect_object(data, path))
115
+
116
+ async def recall_message(
117
+ self,
118
+ channel_id: str,
119
+ message_id: str,
120
+ *,
121
+ hide_tip: bool = False,
122
+ ) -> None:
123
+ """撤回消息, 发送后 2 分钟内有效。hidetip 是 query, 不是包体。"""
124
+
125
+ path = f"/channels/{channel_id}/messages/{message_id}"
126
+ await self._http.request(
127
+ "DELETE",
128
+ path,
129
+ params={"hidetip": "true" if hide_tip else "false"},
130
+ )
131
+
132
+
133
+ def _as_form_json(model: MessageMarkdown | MessageReference) -> str:
134
+ """multipart 里的嵌套对象要序列化成字符串字段。"""
135
+
136
+ return model.model_dump_json(by_alias=True, exclude_none=True, ensure_ascii=False)
qbotsdk/api/group.py ADDED
@@ -0,0 +1,184 @@
1
+ # Copyright (c) 2026-present Half_nothing.
2
+
3
+ """群组(QQ 群)域接口,读写。"""
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import AsyncIterator
8
+
9
+ from aiohttp import FormData
10
+
11
+ from qbotsdk.model.draft import GroupMessageDraft, MessageMarkdown
12
+ from qbotsdk.model.group import Group, GroupBotState, GroupMember, GroupMembersPage, GroupMuteState
13
+ from qbotsdk.model.keyboard import Keyboard
14
+ from qbotsdk.model.media import FileType, MediaInfo, UploadedFile
15
+ from qbotsdk.model.message import GroupMessageReceipt
16
+ from qbotsdk.module.http_client import HTTPClient
17
+ from qbotsdk.module.pagination import paginate
18
+ from qbotsdk.module.rate_limit import RateLimiter
19
+
20
+ from ._parse import expect_object
21
+
22
+ # 二进制直传上限,官方文档对图片/语音写 20MB、视频 30MB、文件 200MB
23
+ # 但 multipart 直传字段本身限制 5MB,超过要走 url 或分片上传。
24
+ _MAX_UPLOAD_BYTES = 5 * 1024 * 1024
25
+
26
+
27
+ class GroupResource:
28
+ def __init__(self, http: HTTPClient, *, limiter: RateLimiter | None = None) -> None:
29
+ self._http = http
30
+ self._limiter = limiter
31
+
32
+ async def get(self, group_openid: str) -> Group:
33
+ """群基本信息,频率限制 30 QPM。"""
34
+
35
+ path = f"/v2/groups/{group_openid}/info"
36
+ return Group.model_validate(expect_object(await self._http.get(path), path))
37
+
38
+ async def bot_state(self, group_openid: str) -> GroupBotState:
39
+ """机器人在群内的状态:是否收主动推送、接收消息范围、自身角色。"""
40
+
41
+ path = f"/v2/groups/{group_openid}/bot_state"
42
+ return GroupBotState.model_validate(expect_object(await self._http.get(path), path))
43
+
44
+ async def members(self, group_openid: str, *, cursor: str = "") -> GroupMembersPage:
45
+ """
46
+ 群成员分页,每次最多 30 条。
47
+
48
+ `cursor` 首次不传,后续传上一页的 `next_cursor`;
49
+ 返回整页而不是列表,因为末页标志 `next_cursor` 与成员在同一层。
50
+ """
51
+
52
+ path = f"/v2/groups/{group_openid}/members"
53
+ params = {"cursor": cursor} if cursor else None
54
+ payload = await self._http.get(path, params=params)
55
+ return GroupMembersPage.model_validate(expect_object(payload, path))
56
+
57
+ async def member(self, group_openid: str, member_openid: str) -> GroupMember:
58
+ """单个群成员。"""
59
+
60
+ path = f"/v2/groups/{group_openid}/members/{member_openid}"
61
+ return GroupMember.model_validate(expect_object(await self._http.get(path), path))
62
+
63
+ async def mute_setting(self, group_openid: str) -> GroupMuteState:
64
+ """群禁言状态,机器人为群管理员时才有权限(错误码 11253)。"""
65
+
66
+ path = f"/v2/groups/{group_openid}/restrict_chat_setting"
67
+ return GroupMuteState.model_validate(expect_object(await self._http.get(path), path))
68
+
69
+ async def iter_members(self, group_openid: str) -> AsyncIterator[GroupMember]:
70
+ """按 `next_cursor` 翻页遍历群成员,末页或空页结束。"""
71
+
72
+ state = {"next": ""}
73
+
74
+ async def fetch_page(cursor: str | None) -> list[GroupMember]:
75
+ page = await self.members(group_openid, cursor=cursor or "")
76
+ state["next"] = page.next_cursor
77
+ return page.members
78
+
79
+ def cursor_of(page: list[GroupMember]) -> str | None:
80
+ return state["next"] or None
81
+
82
+ async for member in paginate(fetch_page, cursor_of=cursor_of):
83
+ yield member
84
+
85
+ async def send_message(
86
+ self,
87
+ group_openid: str,
88
+ *,
89
+ content: str | None = None,
90
+ markdown: MessageMarkdown | None = None,
91
+ media: MediaInfo | None = None,
92
+ keyboard: Keyboard | None = None,
93
+ reply_to: str | None = None,
94
+ event_id: str | None = None,
95
+ msg_seq: int | None = None,
96
+ ) -> GroupMessageReceipt:
97
+ """发群消息。
98
+
99
+ 被动回复有效期为 5 分钟, 每条消息最多回 5 次。
100
+ 同一个 msg_id + msg_seq 重复发送会被拒
101
+ 要回第二次请显式传 msg_seq=2
102
+
103
+ keyboard 只在 Markdown 消息上渲染: 只传 content + keyboard 时,
104
+ content 会被自动搬进 markdown 并把 msg_type 置 2。
105
+ """
106
+
107
+ path = f"/v2/groups/{group_openid}/messages"
108
+ if self._limiter is not None:
109
+ self._limiter.check("group", group_openid)
110
+ if reply_to is not None and msg_seq is None:
111
+ msg_seq = 1
112
+ draft = GroupMessageDraft(
113
+ content=content,
114
+ markdown=markdown,
115
+ media=media,
116
+ keyboard=keyboard,
117
+ msg_id=reply_to,
118
+ event_id=event_id,
119
+ msg_seq=msg_seq,
120
+ )
121
+ payload = await self._http.post(
122
+ path,
123
+ json=draft.model_dump(by_alias=True, exclude_none=True, mode="json"),
124
+ )
125
+ return GroupMessageReceipt.model_validate(expect_object(payload, path))
126
+
127
+ async def recall_message(self, group_openid: str, message_id: str) -> None:
128
+ """撤回群消息, 发送后 2 分钟内有效, 接口限制 10 QPS。"""
129
+
130
+ path = f"/v2/groups/{group_openid}/messages/{message_id}"
131
+ await self._http.request("DELETE", path)
132
+
133
+ async def upload_file(
134
+ self,
135
+ group_openid: str,
136
+ file_type: FileType,
137
+ /,
138
+ *,
139
+ url: str | None = None,
140
+ file_data: bytes | None = None,
141
+ file_name: str | None = None,
142
+ srv_send_msg: bool = False,
143
+ ) -> UploadedFile:
144
+ """上传群富媒体(图片 / 视频 / 语音 / 文件)。
145
+
146
+ url 与 file_data 二选一,前者交给服务端下载,后者按 multipart 直传,上限 5MB。
147
+ 返回的 file_info 直接填进 send_message 的 media。
148
+ srv_send_msg=True 时服务端上传后立即发送,会占用主动消息频次。
149
+ """
150
+
151
+ if (url is None) == (file_data is None):
152
+ raise ValueError("upload_file needs exactly one of url or file_data")
153
+
154
+ path = f"/v2/groups/{group_openid}/files"
155
+
156
+ if url is not None:
157
+ payload = await self._http.post(
158
+ path,
159
+ json={
160
+ "file_type": int(file_type),
161
+ "url": url,
162
+ "srv_send_msg": srv_send_msg,
163
+ },
164
+ )
165
+ return UploadedFile.model_validate(expect_object(payload, path))
166
+
167
+ assert file_data is not None # 由上面的二选一校验保证
168
+ if len(file_data) > _MAX_UPLOAD_BYTES:
169
+ raise ValueError("file_data must not exceed 5 MB, use url instead")
170
+ if file_name is None:
171
+ raise ValueError("file_data needs file_name so the server can infer the type")
172
+
173
+ name = file_name
174
+
175
+ def build_form() -> FormData:
176
+ form = FormData()
177
+ form.add_field("file_type", str(int(file_type)))
178
+ form.add_field("srv_send_msg", "true" if srv_send_msg else "false")
179
+ form.add_field("file_data", file_data, filename=name)
180
+ form.add_field("file_name", name)
181
+ return form
182
+
183
+ payload = await self._http.post_form(path, form=build_form)
184
+ return UploadedFile.model_validate(expect_object(payload, path))