qqbot-md-sdk 0.1.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.
qgbot/__init__.py ADDED
@@ -0,0 +1,59 @@
1
+ """qgbot —— QQ 官方机器人 Markdown 消息构造 SDK。
2
+
3
+ 只做一件事:把 QQ 官方机器人那套 markdown 语法、键盘 JSON 和素材上传流程包装成可组合的
4
+ Python API,让插件不用手拼字符串字面量。
5
+
6
+ 设计边界:
7
+ - **不碰网络**。素材上传只算签名、组请求、解响应,实际 HTTP 由调用方注入的 transport 完成。
8
+ - **不依赖框架**。纯标准库,任何框架(NoneBot / 自研 / 裸 requests)都能直接 import。
9
+ - **不做语法校验**。平台限制(标题层级、按钮数量等)通过常量暴露,由调用方自己控制,
10
+ SDK 不擅自截断或改写用户的内容。
11
+
12
+ from qgbot import md
13
+
14
+ text = md.render(
15
+ md.title("今日运势", level=1),
16
+ md.quote("宜:出门走走"),
17
+ md.image("https://example.com/a.png", width=200, height=200),
18
+ md.mention("user_openid"),
19
+ )
20
+ """
21
+
22
+ from . import md
23
+ from .keyboard import (
24
+ MAX_ROWS,
25
+ STYLE_BLUE,
26
+ STYLE_GREY,
27
+ Button,
28
+ Keyboard,
29
+ callback,
30
+ command,
31
+ link,
32
+ make_keyboard,
33
+ )
34
+ from .media import MediaError, MediaImage, Transport, delete_image, upload_image
35
+ from .message import Message, markdown_payload, markdown_with_keyboard
36
+
37
+ __version__ = "0.1.0"
38
+
39
+ __all__ = [
40
+ "MAX_ROWS",
41
+ "STYLE_BLUE",
42
+ "STYLE_GREY",
43
+ "Button",
44
+ "Keyboard",
45
+ "MediaError",
46
+ "MediaImage",
47
+ "Message",
48
+ "Transport",
49
+ "callback",
50
+ "command",
51
+ "delete_image",
52
+ "link",
53
+ "make_keyboard",
54
+ "markdown_payload",
55
+ "markdown_with_keyboard",
56
+ "md",
57
+ "upload_image",
58
+ "__version__",
59
+ ]
qgbot/keyboard.py ADDED
@@ -0,0 +1,245 @@
1
+ """QQ 官方机器人的按钮 / 键盘构造。
2
+
3
+ 平台结构(发给 `keyboard` 字段):
4
+
5
+ {
6
+ "content": {
7
+ "rows": [
8
+ {"buttons": [ {...}, {...} ]},
9
+ {"buttons": [ {...} ]}
10
+ ]
11
+ }
12
+ }
13
+
14
+ 单个按钮:
15
+
16
+ {
17
+ "id": "1",
18
+ "render_data": {"label": "显示文字", "visited_label": "点过之后", "style": 1},
19
+ "action": {"type": 2, "data": "指令", "permission": {"type": 2}}
20
+ }
21
+
22
+ `action.type` 的取值:
23
+ - 0 链接跳转,`data` 是 URL
24
+ - 1 回调,点后平台推送 `INTERACTION_CREATE` 事件,`data` 是回调值
25
+ - 2 指令,点后填入输入框(配 `enter: true` 则直接发送)
26
+ - 4 订阅(`subscribe_data`)
27
+
28
+ `permission.type` 的取值:
29
+ - 0 指定用户,配 `specify_user_ids`
30
+ - 1 仅管理员
31
+ - 2 所有人
32
+ - 3 指定身份组,配 `specify_role_ids`
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from dataclasses import dataclass, field
38
+ from typing import Any
39
+
40
+ # 按钮样式:0 灰色线框,1 蓝色线框
41
+ STYLE_GREY = 0
42
+ STYLE_BLUE = 1
43
+
44
+ # 平台限制:键盘最多 5 行
45
+ MAX_ROWS = 5
46
+ # 每行最多 5 个按钮(平台未明确写死,但超过这个数会挤在一起,实际上按 3 个最舒服)
47
+ MAX_BUTTONS_PER_ROW = 5
48
+
49
+ # action.type
50
+ ACTION_URL = 0
51
+ ACTION_CALLBACK = 1
52
+ ACTION_COMMAND = 2
53
+ ACTION_SUBSCRIBE = 4
54
+
55
+ # permission.type
56
+ PERM_USER = 0
57
+ PERM_ADMIN = 1
58
+ PERM_EVERYONE = 2
59
+ PERM_ROLE = 3
60
+
61
+
62
+ @dataclass
63
+ class Button:
64
+ """一个按钮。
65
+
66
+ 用工厂函数 `link()` / `callback()` / `command()` 构造,比直接拼 dict 安全。
67
+ """
68
+
69
+ label: str
70
+ action_type: int
71
+ data: str = ""
72
+ visited_label: str = ""
73
+ style: int = STYLE_BLUE
74
+ enter: bool = False
75
+ reply: bool = False
76
+ permission: dict[str, Any] = field(default_factory=lambda: {"type": PERM_EVERYONE})
77
+ click_limit: int | None = None
78
+ unsupport_tips: str = ""
79
+ subscribe_data: dict[str, Any] | None = None
80
+ button_id: str = ""
81
+
82
+ def to_dict(self, index: int = 0) -> dict[str, Any]:
83
+ """转成平台要求的按钮结构。
84
+
85
+ `index` 用于在未显式指定 id 时生成一个稳定标识——平台要求按钮 id 在同一条消息
86
+ 内唯一,重名会导致后一个覆盖前一个的点击状态。
87
+ """
88
+ render_data: dict[str, Any] = {
89
+ "label": self.label,
90
+ "visited_label": self.visited_label or self.label,
91
+ "style": self.style,
92
+ }
93
+ action: dict[str, Any] = {
94
+ "type": self.action_type,
95
+ "data": self.data,
96
+ "permission": self.permission,
97
+ }
98
+ if self.enter:
99
+ action["enter"] = True
100
+ if self.reply:
101
+ action["reply"] = True
102
+ if self.click_limit is not None:
103
+ action["click_limit"] = int(self.click_limit)
104
+ if self.unsupport_tips:
105
+ action["unsupport_tips"] = self.unsupport_tips
106
+ if self.subscribe_data is not None:
107
+ action["subscribe_data"] = self.subscribe_data
108
+
109
+ return {
110
+ "id": self.button_id or str(index),
111
+ "render_data": render_data,
112
+ "action": action,
113
+ }
114
+
115
+
116
+ @dataclass
117
+ class Keyboard:
118
+ """键盘:若干行,每行若干按钮。"""
119
+
120
+ rows: list[list[Button]] = field(default_factory=list)
121
+ font_size: str = "" # "small" / "medium" / "large"
122
+
123
+ def add_row(self, *buttons: Button) -> Keyboard:
124
+ """追加一行。链式调用。"""
125
+ if buttons:
126
+ self.rows.append(list(buttons))
127
+ return self
128
+
129
+ def to_dict(self) -> dict[str, Any]:
130
+ """转成平台要求的 keyboard 结构。
131
+
132
+ 超过 `MAX_ROWS` 的行会被丢弃——平台只认前 5 行,多出来的不会渲染。
133
+ 这里截断而不是抛错:调用方可能批量生成按钮,硬失败会让整条消息发不出去。
134
+ """
135
+ counter = 0
136
+ rows = []
137
+ for row in self.rows[:MAX_ROWS]:
138
+ buttons = []
139
+ for btn in row[:MAX_BUTTONS_PER_ROW]:
140
+ buttons.append(btn.to_dict(counter))
141
+ counter += 1
142
+ if buttons:
143
+ rows.append({"buttons": buttons})
144
+
145
+ content: dict[str, Any] = {"rows": rows}
146
+ if self.font_size:
147
+ content["style"] = {"font_size": self.font_size}
148
+ return {"content": content}
149
+
150
+ def __bool__(self) -> bool:
151
+ """空键盘转成 False,方便调用方判断要不要带 keyboard 字段。"""
152
+ return any(row for row in self.rows)
153
+
154
+
155
+ def link(label: str, url: str, *, visited_label: str = "", style: int = STYLE_BLUE) -> Button:
156
+ """跳转按钮:点击打开链接或客户端能识别的 scheme。"""
157
+ return Button(
158
+ label=label, action_type=ACTION_URL, data=url,
159
+ visited_label=visited_label, style=style,
160
+ )
161
+
162
+
163
+ def callback(
164
+ label: str, value: str, *, visited_label: str = "", style: int = STYLE_BLUE,
165
+ ) -> Button:
166
+ """回调按钮:点击后平台推送 `INTERACTION_CREATE` 事件,`value` 原样带回来。"""
167
+ return Button(
168
+ label=label, action_type=ACTION_CALLBACK, data=value,
169
+ visited_label=visited_label, style=style,
170
+ )
171
+
172
+
173
+ def command(
174
+ label: str,
175
+ command_text: str,
176
+ *,
177
+ visited_label: str = "",
178
+ style: int = STYLE_BLUE,
179
+ enter: bool = True,
180
+ reply: bool = False,
181
+ ) -> Button:
182
+ """指令按钮:点击后把指令填入输入框;`enter=True` 时直接发送。
183
+
184
+ `enter` 默认 True——用户的预期是"点一下就执行",还要再按一次发送键会很烦。
185
+ `reply=True` 会让指令以"回复当前消息"的形式发出。
186
+ """
187
+ return Button(
188
+ label=label, action_type=ACTION_COMMAND, data=command_text,
189
+ visited_label=visited_label, style=style, enter=enter, reply=reply,
190
+ )
191
+
192
+
193
+ def for_users(button: Button, user_ids: list[str]) -> Button:
194
+ """把按钮限定为只有指定用户可点。"""
195
+ button.permission = {"type": PERM_USER, "specify_user_ids": [str(u) for u in user_ids]}
196
+ return button
197
+
198
+
199
+ def for_admins(button: Button) -> Button:
200
+ """把按钮限定为仅管理员可点。"""
201
+ button.permission = {"type": PERM_ADMIN}
202
+ return button
203
+
204
+
205
+ def for_roles(button: Button, role_ids: list[str]) -> Button:
206
+ """把按钮限定为只有指定身份组可点(仅频道场景有效)。"""
207
+ button.permission = {"type": PERM_ROLE, "specify_role_ids": [str(r) for r in role_ids]}
208
+ return button
209
+
210
+
211
+ def rows_of(buttons: list[Button], per_row: int = 3) -> list[list[Button]]:
212
+ """把一串按钮按每行 `per_row` 个切分成行。"""
213
+ per_row = max(1, min(MAX_BUTTONS_PER_ROW, int(per_row)))
214
+ return [buttons[i:i + per_row] for i in range(0, len(buttons), per_row)]
215
+
216
+
217
+ def make_keyboard(*buttons: Button, per_row: int = 3, font_size: str = "") -> Keyboard:
218
+ """一次性构造键盘:按 `per_row` 自动分行。"""
219
+ return Keyboard(rows=rows_of(list(buttons), per_row), font_size=font_size)
220
+
221
+
222
+ __all__ = [
223
+ "ACTION_CALLBACK",
224
+ "ACTION_COMMAND",
225
+ "ACTION_SUBSCRIBE",
226
+ "ACTION_URL",
227
+ "MAX_BUTTONS_PER_ROW",
228
+ "MAX_ROWS",
229
+ "PERM_ADMIN",
230
+ "PERM_EVERYONE",
231
+ "PERM_ROLE",
232
+ "PERM_USER",
233
+ "STYLE_BLUE",
234
+ "STYLE_GREY",
235
+ "Button",
236
+ "Keyboard",
237
+ "callback",
238
+ "command",
239
+ "for_admins",
240
+ "for_roles",
241
+ "for_users",
242
+ "make_keyboard",
243
+ "link",
244
+ "rows_of",
245
+ ]
qgbot/md.py ADDED
@@ -0,0 +1,246 @@
1
+ """QQ 官方机器人 Markdown 语法构造器。
2
+
3
+ 平台支持的语法(来自官方文档):
4
+ - 标题 `#` ~ `##`
5
+ - 粗体 `**`、下划线加粗 `__`、斜体 `_` / `*`、
6
+ 粗斜体 `***`、删除线 `~~`
7
+ - 链接 `[文字](url)`、自动链接 `<url>`
8
+ - 图片 `![描述 #宽px #高px](url)`,尺寸写在 alt 文本里
9
+ - 有序 / 无序列表,二级缩进 4 空格
10
+ - 引用块 `>`
11
+ - 分隔线 `***`
12
+ - 多个空行要用零宽空格 `​` 单独成行
13
+
14
+ **与标准 Markdown 的差异**:图片尺寸必须写在 alt 里,不写就按原图尺寸铺满,长图会把
15
+ 消息撑得极大。`image()` 因此默认要求显式宽高,避免误用。
16
+
17
+ **正文里的小指令按钮**(`<qqbot-cmd-input>`)也在这个模块:它本质是嵌在 markdown 字符串里
18
+ 的标签,不是独立元素。
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Iterable
24
+ from urllib.parse import quote as urlquote
25
+
26
+ # 零宽空格:平台用它表示"再来一个空行"。直接写两个 `\n` 只会被合并成一个。
27
+ ZERO_WIDTH_SPACE = "​"
28
+
29
+ # 标题最高只到二级,更深层级平台不渲染
30
+ MAX_TITLE_LEVEL = 2
31
+
32
+
33
+ def text(content: str) -> str:
34
+ """普通文本。原样返回,只做类型归一。"""
35
+ return str(content)
36
+
37
+
38
+ def title(content: str, level: int = 1) -> str:
39
+ """标题。`level` 取 1 或 2,超出范围会被夹到边界值。
40
+
41
+ 平台只支持两级标题,传 3 不会被渲染成三级,而是直接当正文处理——
42
+ 与其让用户困惑,不如夹到合法范围并保持视觉层次。
43
+ """
44
+ level = max(1, min(MAX_TITLE_LEVEL, int(level)))
45
+ return f"{'#' * level} {content}"
46
+
47
+
48
+ def bold(content: str) -> str:
49
+ return f"**{content}**"
50
+
51
+
52
+ def italic(content: str) -> str:
53
+ return f"_{content}_"
54
+
55
+
56
+ def bold_italic(content: str) -> str:
57
+ return f"***{content}***"
58
+
59
+
60
+ def underline(content: str) -> str:
61
+ """下划线加粗(平台语法是 `__`,不是常见的 `<u>`)。"""
62
+ return f"__{content}__"
63
+
64
+
65
+ def strike(content: str) -> str:
66
+ return f"~~{content}~~"
67
+
68
+
69
+ def link(label: str, url: str) -> str:
70
+ """行内链接。"""
71
+ return f"[{label}]({url})"
72
+
73
+
74
+ def autolink(url: str) -> str:
75
+ """自动链接:平台会自己把 URL 变成可点击的链接。"""
76
+ return f"<{url}>"
77
+
78
+
79
+ def image(url: str, width: int, height: int, alt: str = "text") -> str:
80
+ """图片。尺寸必须显式给出。
81
+
82
+ QQ 按 alt 里的 `#宽px #高px` 决定显示尺寸;不给就按原图铺满,一张 1080×1920 的图
83
+ 会把消息撑满整屏。宽高传 0 或负数时抛错,逼调用方做决定而不是默默出问题。
84
+
85
+ 图片 URL 必须公网可访问。本地图片要先传到平台素材库,再用 `qqbot-media://` 引用,
86
+ 这里不做上传。
87
+ """
88
+ width, height = int(width), int(height)
89
+ if width <= 0 or height <= 0:
90
+ raise ValueError(f"图片尺寸必须为正数,收到 {width}x{height}")
91
+ return f"![{alt} #{width}px #{height}px]({url})"
92
+
93
+
94
+ def media(media_id: str, width: int = 0, height: int = 0, alt: str = "text") -> str:
95
+ """引用平台上已上传的素材(`qqbot-media://` scheme)。
96
+
97
+ `media_id` 是素材上传接口返回的 ID。不传尺寸时按原图显示,需要控制大小就补宽高。
98
+ """
99
+ if width and height:
100
+ return f"![{alt} #{int(width)}px #{int(height)}px](qqbot-media://{media_id})"
101
+ return f"![{alt}](qqbot-media://{media_id})"
102
+
103
+
104
+ def ordered(items: Iterable[str], level: int = 1) -> str:
105
+ """有序列表。`level=2` 时整块缩进 4 空格。"""
106
+ indent = " " * (int(level) - 1)
107
+ return "\n".join(f"{indent}{i}. {item}" for i, item in enumerate(items, start=1))
108
+
109
+
110
+ def unordered(items: Iterable[str], level: int = 1) -> str:
111
+ """无序列表。`level=2` 时整块缩进 4 空格。"""
112
+ indent = " " * (int(level) - 1)
113
+ return "\n".join(f"{indent}- {item}" for item in items)
114
+
115
+
116
+ def quote(content: str, lines: int = 1) -> str:
117
+ """引用块。多行内容每行都会加上 `>` 前缀。
118
+
119
+ `lines` 参数保留给「空一行再引用」的排版需求:平台要求引用块与上一段正文之间
120
+ 有空行,这里不自动加(见 `blank()`),由调用方决定间距。
121
+ """
122
+ body = "\n".join(f"> {line}" for line in str(content).splitlines())
123
+ return body
124
+
125
+
126
+ def divider() -> str:
127
+ """水平分隔线。平台语法是 `***`,不是 `---`。"""
128
+ return "***"
129
+
130
+
131
+ def blank(count: int = 1) -> str:
132
+ """空行。
133
+
134
+ 平台会把连续 `\n` 合并,真正想要多个空行要在中间插入零宽空格行。
135
+ 这里返回的内容需要调用方用 `render()` 拼接,`render` 会自动处理分隔。
136
+ """
137
+ return "\n".join([ZERO_WIDTH_SPACE] * max(1, int(count)))
138
+
139
+
140
+ def cmd_input(command: str, show: str | None = None, reference: bool = False) -> str:
141
+ """正文里的「小指令按钮」,点击等同于在输入框填入并发送该指令。
142
+
143
+ `show` 是按钮上显示的文字,不传就显示指令本身。
144
+ `reference=False` 表示点击时不引用当前消息;绝大多数场景都不需要引用。
145
+
146
+ 参数里的引号、`&` 等会被 URL 编码——不编码的话平台解析标签属性时会截断。
147
+ """
148
+ encoded_command = urlquote(command, safe="")
149
+ encoded_show = urlquote(show or command, safe="")
150
+ ref = "true" if reference else "false"
151
+ return f'<qqbot-cmd-input text="{encoded_command}" show="{encoded_show}" reference="{ref}" />'
152
+
153
+
154
+ def cmd_enter(command: str, show: str | None = None) -> str:
155
+ """正文里的「回车发送」按钮:点击后直接发送,不经过输入框。
156
+
157
+ 与 `cmd_input` 的区别是少了"填入输入框"这一步,适合确认类操作。
158
+
159
+ **群聊与文字子频道不可用**——平台限制,只能在单聊里用。
160
+ """
161
+ encoded_command = urlquote(command, safe="")
162
+ encoded_show = urlquote(show or command, safe="")
163
+ return f'<qqbot-cmd-enter text="{encoded_command}" show="{encoded_show}" />'
164
+
165
+
166
+ def mention(user_id: str) -> str:
167
+ """@ 某个用户。
168
+
169
+ `user_id` 的含义随场景变化:
170
+ - 群聊:成员的 `member_openid`
171
+ - 文字子频道:用户的 `user_openid`
172
+
173
+ **群聊里 @ 用户只在 markdown 消息中有效**,纯文本消息不支持。
174
+
175
+ 旧的 `<@id>` 写法平台已标记废弃,这里生成的是新的 `<qqbot-at-user>` 标签。
176
+ """
177
+ uid = str(user_id or "").strip()
178
+ if not uid:
179
+ raise ValueError("mention 需要非空的用户 id")
180
+ return f'<qqbot-at-user id="{uid}" />'
181
+
182
+
183
+ def mention_everyone() -> str:
184
+ """@ 全体成员。
185
+
186
+ 两个硬限制:
187
+ - 机器人必须是群管理员
188
+ - **每个群每个自然日只能 @ 一次**
189
+
190
+ 第二条容易踩坑——超出后平台直接拒收整条消息,而不是只忽略这个标签。
191
+ """
192
+ return "<qqbot-at-everyone />"
193
+
194
+
195
+ def channel(channel_id: str) -> str:
196
+ """跳转到子频道(仅频道场景,且必须是当前频道下的子频道)。"""
197
+ return f"<#{channel_id}>"
198
+
199
+
200
+ def emoji(emoji_id: str) -> str:
201
+ """引用系统表情(仅频道场景,且只支持 type=1 的表情)。"""
202
+ return f"<emoji:{emoji_id}>"
203
+
204
+
205
+ def join(*parts: str) -> str:
206
+ """把多段 markdown 用换行拼起来,跳过空段。"""
207
+ return "\n".join(str(p) for p in parts if p)
208
+
209
+
210
+ def render(*parts: str) -> str:
211
+ """把多段内容拼成完整 markdown 正文。
212
+
213
+ 每个参数之间用单个换行分隔,空字符串和 None 会被跳过。
214
+ 需要空行请显式插入 `blank()`。
215
+ """
216
+ return join(*parts)
217
+
218
+
219
+ __all__ = [
220
+ "MAX_TITLE_LEVEL",
221
+ "ZERO_WIDTH_SPACE",
222
+ "autolink",
223
+ "blank",
224
+ "bold",
225
+ "bold_italic",
226
+ "channel",
227
+ "cmd_enter",
228
+ "cmd_input",
229
+ "emoji",
230
+ "divider",
231
+ "image",
232
+ "italic",
233
+ "join",
234
+ "link",
235
+ "media",
236
+ "mention",
237
+ "mention_everyone",
238
+ "ordered",
239
+ "quote",
240
+ "render",
241
+ "strike",
242
+ "text",
243
+ "title",
244
+ "underline",
245
+ "unordered",
246
+ ]
qgbot/media.py ADDED
@@ -0,0 +1,268 @@
1
+ """素材上传:把本地图片变成平台可引用的 `qqbot-media://` 链接。
2
+
3
+ **为什么需要它**:markdown 消息里的图片必须是公网 URL,让开放平台自己去拉。但插件生成的
4
+ 图片(渲染的卡片、表情包)只存在于本地磁盘,没有公网地址。旧办法是找个第三方图床传上去,
5
+ 现在开放平台自己提供了图库,传上去就能用 `qqbot-media://` 引用,不依赖外部服务。
6
+
7
+ 上传流程是**三步预签名**,不是一次 multipart:
8
+
9
+ 1. `POST /v2/media-images/prepare` — 声明文件名、大小、MIME、MD5、SHA1,
10
+ 拿回 `upload_id` 和 COS 预签名地址
11
+ 2. `PUT <presigned_url>` — 把原始字节传上去,`Content-Type` 必须和声明的一致,
12
+ 否则 COS 返回 403
13
+ 3. `POST /v2/media-images/complete` — 带上 `upload_id` 和 ETag 里的 MD5 收尾,
14
+ 拿回最终的 `qqbot-media://image/v1/<token>`
15
+
16
+ **设计取舍:本模块不发 HTTP。** SDK 只负责算签名、组装请求、解析响应,实际的网络调用由
17
+ 调用方注入一个 `transport`。这样:
18
+
19
+ - SDK 不绑定 httpx / aiohttp / requests 中的任何一个
20
+ - 测试不需要起 mock server
21
+ - 调用方能复用自己框架的连接池、代理、重试策略
22
+
23
+ result = await upload_image(
24
+ data=png_bytes,
25
+ file_name="card.png",
26
+ transport=my_transport, # 自己实现三个方法
27
+ )
28
+ text = md.image(result.url, 400, 300)
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import hashlib
34
+ from dataclasses import dataclass
35
+ from typing import Any, Protocol
36
+
37
+ # 支持的图片格式与对应的 MIME。扩展名必须和 MIME 一致,平台会校验。
38
+ SUPPORTED_TYPES: dict[str, str] = {
39
+ ".png": "image/png",
40
+ ".jpg": "image/jpeg",
41
+ ".jpeg": "image/jpeg",
42
+ ".gif": "image/gif",
43
+ ".webp": "image/webp",
44
+ }
45
+
46
+ # 单张图片上限 10 MB
47
+ MAX_IMAGE_BYTES = 10 * 1024 * 1024
48
+ # 每个机器人最多存 100 张,满了要先删
49
+ MAX_IMAGES_PER_BOT = 100
50
+ # 上传任务的存活时间:4 小时,超时未完成要重新 prepare
51
+ UPLOAD_TASK_TTL_SECONDS = 4 * 3600
52
+ # 图片的滑动有效期:90 天未被引用就过期,每次引用会重置
53
+ IMAGE_TTL_SECONDS = 90 * 24 * 3600
54
+
55
+ # prepare 接口路径
56
+ PATH_PREPARE = "/v2/media-images/prepare"
57
+ PATH_COMPLETE = "/v2/media-images/complete"
58
+ PATH_LIST = "/v2/media-images"
59
+ PATH_DELETE = "/v2/media-images/{image_id}/delete"
60
+
61
+ # 错误码 → 可读原因
62
+ ERROR_CODES: dict[int, str] = {
63
+ 40035001: "不支持的图片格式",
64
+ 40035002: "超过大小限制",
65
+ 40035003: "上传任务不存在、已过期、不属于该机器人,或 MD5 不匹配",
66
+ 40035007: "图片数量已达上限(100 张),请先删除",
67
+ 40035008: "图片不存在",
68
+ 40035009: "图片已过期或已删除",
69
+ 40035010: "链接无效,或属于其他机器人",
70
+ }
71
+
72
+
73
+ class Transport(Protocol):
74
+ """调用方需实现的网络层。
75
+
76
+ 三个方法都要能 await。返回值约定:
77
+
78
+ - `post_json(path, body)` → `(status_code, response_json)`
79
+ - `put_bytes(url, data, content_type)` → `(status_code, headers)`,
80
+ headers 需要能取到 `ETag` 或 `etag`(大小写不敏感由实现方处理)
81
+ - `delete(path)` → `(status_code, response_json)`
82
+ """
83
+
84
+ async def post_json(self, path: str, body: dict[str, Any]) -> tuple[int, dict[str, Any]]: ...
85
+
86
+ async def put_bytes(
87
+ self, url: str, data: bytes, content_type: str,
88
+ ) -> tuple[int, dict[str, str]]: ...
89
+
90
+ async def delete(self, path: str) -> tuple[int, dict[str, Any]]: ...
91
+
92
+
93
+ class MediaError(Exception):
94
+ """上传失败。`code` 是平台错误码,`message` 是可直接展示的中文原因。"""
95
+
96
+ def __init__(self, code: int, message: str) -> None:
97
+ self.code = code
98
+ self.message = message
99
+ super().__init__(f"[{code}] {message}")
100
+
101
+
102
+ @dataclass
103
+ class MediaImage:
104
+ """上传成功后拿到的图片信息。"""
105
+
106
+ image_id: str
107
+ url: str
108
+ ttl: int = IMAGE_TTL_SECONDS
109
+ preview_url: str = ""
110
+
111
+ def to_markdown(self, width: int, height: int, alt: str = "text") -> str:
112
+ """直接转成 markdown 图片语法,省得调用方再拼一次。"""
113
+ return f"![{alt} #{int(width)}px #{int(height)}px]({self.url})"
114
+
115
+ def to_markdown_auto(self, alt: str = "text") -> str:
116
+ """不带尺寸的引用:平台按原图大小显示。"""
117
+ return f"![{alt}]({self.url})"
118
+
119
+
120
+ def _digest(data: bytes) -> tuple[str, str]:
121
+ """算 MD5 和 SHA1。MD5 用于完整性校验,SHA1 用于幂等。"""
122
+ return hashlib.md5(data).hexdigest(), hashlib.sha1(data).hexdigest()
123
+
124
+
125
+ def resolve_content_type(file_name: str) -> str:
126
+ """从扩展名推 MIME,不支持的格式直接抛错。
127
+
128
+ 平台的校验是硬性的:扩展名和 Content-Type 对不上会拒收,所以这里不猜。
129
+ """
130
+ lowered = file_name.lower()
131
+ for ext, mime in SUPPORTED_TYPES.items():
132
+ if lowered.endswith(ext):
133
+ return mime
134
+ raise ValueError(
135
+ f"不支持的图片格式:{file_name},仅支持 {', '.join(SUPPORTED_TYPES)}"
136
+ )
137
+
138
+
139
+ def check_size(data: bytes) -> None:
140
+ """超出 10 MB 平台会拒收,提前拦下来省一次请求。"""
141
+ if len(data) > MAX_IMAGE_BYTES:
142
+ raise ValueError(
143
+ f"图片 {len(data) / 1024 / 1024:.1f} MB 超过上限 "
144
+ f"{MAX_IMAGE_BYTES / 1024 / 1024:.0f} MB"
145
+ )
146
+ if not data:
147
+ raise ValueError("图片内容为空")
148
+
149
+
150
+ def build_prepare_body(data: bytes, file_name: str) -> dict[str, Any]:
151
+ """组装 prepare 请求体。"""
152
+ check_size(data)
153
+ md5, sha1 = _digest(data)
154
+ return {
155
+ "file_name": file_name,
156
+ "file_size": len(data),
157
+ "content_type": resolve_content_type(file_name),
158
+ "md5": md5,
159
+ "sha1": sha1,
160
+ }
161
+
162
+
163
+ def _raise_for(status: int, body: dict[str, Any]) -> None:
164
+ """把平台的错误响应转成 MediaError。"""
165
+ if 200 <= status < 300:
166
+ return
167
+ code = int(body.get("code") or status)
168
+ message = ERROR_CODES.get(code) or str(body.get("message") or f"HTTP {status}")
169
+ raise MediaError(code, message)
170
+
171
+
172
+ async def upload_image(
173
+ *,
174
+ data: bytes,
175
+ file_name: str,
176
+ transport: Transport,
177
+ ) -> MediaImage:
178
+ """完整的上传流程:prepare → PUT → complete。
179
+
180
+ `file_name` 必须带扩展名(png/jpg/jpeg/gif/webp),平台据此推断 MIME。
181
+
182
+ 失败时抛 `MediaError`(平台拒绝)或 `ValueError`(本地校验不过)。
183
+ """
184
+ body = build_prepare_body(data, file_name)
185
+
186
+ status, prepared = await transport.post_json(PATH_PREPARE, body)
187
+ _raise_for(status, prepared)
188
+
189
+ upload_id = str(prepared.get("upload_id") or "")
190
+ presigned_url = str(prepared.get("presigned_url") or "")
191
+ if not upload_id or not presigned_url:
192
+ raise MediaError(-1, "prepare 响应缺少 upload_id 或 presigned_url")
193
+
194
+ # PUT 的 Content-Type 必须与 prepare 时声明的一致,否则 COS 直接 403
195
+ put_status, headers = await transport.put_bytes(
196
+ presigned_url, data, body["content_type"],
197
+ )
198
+ if not 200 <= put_status < 300:
199
+ raise MediaError(put_status, f"上传到对象存储失败(HTTP {put_status})")
200
+
201
+ # ETag 里是带引号的 MD5,去掉引号才能和 prepare 时声明的一致
202
+ etag = ""
203
+ for key, value in headers.items():
204
+ if key.lower() == "etag":
205
+ etag = str(value).strip('"')
206
+ break
207
+ if not etag:
208
+ raise MediaError(-1, "对象存储未返回 ETag,无法校验完整性")
209
+
210
+ complete_status, completed = await transport.post_json(
211
+ PATH_COMPLETE, {"upload_id": upload_id, "md5": etag},
212
+ )
213
+ _raise_for(complete_status, completed)
214
+
215
+ url = str(completed.get("url") or "")
216
+ if not url:
217
+ raise MediaError(-1, "complete 响应缺少 url")
218
+
219
+ return MediaImage(
220
+ image_id=str(completed.get("image_id") or ""),
221
+ url=url,
222
+ ttl=int(completed.get("ttl") or IMAGE_TTL_SECONDS),
223
+ preview_url=str(completed.get("preview_url") or ""),
224
+ )
225
+
226
+
227
+ async def delete_image(*, image_id: str, transport: Transport) -> bool:
228
+ """删除一张图,释放配额。已发送的消息不受影响。"""
229
+ path = PATH_DELETE.format(image_id=image_id)
230
+ status, body = await transport.delete(path)
231
+ _raise_for(status, body)
232
+ return True
233
+
234
+
235
+ def parse_list_response(body: dict[str, Any]) -> tuple[list[MediaImage], str]:
236
+ """解析列表响应,返回 `(图片列表, 下一页游标)`。"""
237
+ images = [
238
+ MediaImage(
239
+ image_id=str(item.get("image_id") or ""),
240
+ url=str(item.get("url") or ""),
241
+ preview_url=str(item.get("preview_url") or ""),
242
+ )
243
+ for item in body.get("images") or []
244
+ ]
245
+ return images, str(body.get("next_cursor") or "")
246
+
247
+
248
+ __all__ = [
249
+ "ERROR_CODES",
250
+ "IMAGE_TTL_SECONDS",
251
+ "MAX_IMAGES_PER_BOT",
252
+ "MAX_IMAGE_BYTES",
253
+ "PATH_COMPLETE",
254
+ "PATH_DELETE",
255
+ "PATH_LIST",
256
+ "PATH_PREPARE",
257
+ "SUPPORTED_TYPES",
258
+ "UPLOAD_TASK_TTL_SECONDS",
259
+ "MediaError",
260
+ "MediaImage",
261
+ "Transport",
262
+ "build_prepare_body",
263
+ "check_size",
264
+ "delete_image",
265
+ "parse_list_response",
266
+ "resolve_content_type",
267
+ "upload_image",
268
+ ]
qgbot/message.py ADDED
@@ -0,0 +1,76 @@
1
+ """把 markdown 正文与键盘组装成可直接发送的 payload。
2
+
3
+ QQ 官方机器人发消息的 body 长这样(群聊 / 单聊):
4
+
5
+ {
6
+ "msg_type": 2,
7
+ "markdown": {"content": "# 标题\\n正文"},
8
+ "keyboard": {"content": {"rows": [...]}},
9
+ "msg_id": "..." # 被动回复必填,主动消息用 msg_seq
10
+ }
11
+
12
+ `msg_type=2` 是 markdown。`msg_id` 是被动回复必须带上的那条消息的 id——不带就无法回复,
13
+ 只能走主动消息(有频次限制)。本模块只管组结构,消息 id 与频次由调用方处理。
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from typing import Any
20
+
21
+ from .keyboard import Keyboard
22
+
23
+ # 消息类型:2 = markdown
24
+ MSG_TYPE_MARKDOWN = 2
25
+
26
+
27
+ @dataclass
28
+ class Message:
29
+ """一条待发送的 markdown 消息。
30
+
31
+ msg = Message("今日运势", keyboard=keyboard(cmd, link_btn))
32
+ payload = msg.to_payload(msg_id="xxx")
33
+
34
+ `content` 用 `md.render()` 拼好再传进来,本类不解析 markdown。
35
+ """
36
+
37
+ content: str
38
+ keyboard: Keyboard | None = None
39
+ # 平台限制:一条消息最多能带的按钮数,超过部分平台会整条拒收。
40
+ # 这里不主动截断(Keyboard 已限制 5 行),只是把限制暴露出来供调用方自查。
41
+
42
+ def to_payload(self, *, msg_id: str = "", msg_seq: int | None = None) -> dict[str, Any]:
43
+ """转成发送用的 body。
44
+
45
+ `msg_id` 是被动回复要回复的那条消息 id;`msg_seq` 用于同一条 msg_id 下连发多条
46
+ (此时必须递增,否则平台按重复消息丢弃)。
47
+ """
48
+ payload: dict[str, Any] = {
49
+ "msg_type": MSG_TYPE_MARKDOWN,
50
+ "markdown": {"content": self.content},
51
+ }
52
+ if self.keyboard:
53
+ payload["keyboard"] = self.keyboard.to_dict()
54
+ if msg_id:
55
+ payload["msg_id"] = str(msg_id)
56
+ if msg_seq is not None:
57
+ payload["msg_seq"] = int(msg_seq)
58
+ return payload
59
+
60
+
61
+ def markdown_payload(content: str) -> dict[str, Any]:
62
+ """只要正文、不要键盘时的快捷构造。"""
63
+ return {"msg_type": MSG_TYPE_MARKDOWN, "markdown": {"content": content}}
64
+
65
+
66
+ def markdown_with_keyboard(content: str, keyboard: Keyboard) -> dict[str, Any]:
67
+ """正文 + 键盘。`keyboard` 为空(所有行都空)时不带该字段。"""
68
+ return Message(content, keyboard=keyboard if keyboard else None).to_payload()
69
+
70
+
71
+ __all__ = [
72
+ "MSG_TYPE_MARKDOWN",
73
+ "Message",
74
+ "markdown_payload",
75
+ "markdown_with_keyboard",
76
+ ]
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.1
2
+ Name: qqbot-md-sdk
3
+ Version: 0.1.0
4
+ Summary: QQ 官方机器人 Markdown 消息构造 SDK
5
+ Keywords: qq,qqbot,markdown,keyboard,sdk
6
+ Author: MimoKit
7
+ License: MIT
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Project-URL: Homepage, https://github.com/MimoKit/qgbot
14
+ Project-URL: Repository, https://github.com/MimoKit/qgbot
15
+ Project-URL: Issues, https://github.com/MimoKit/qgbot/issues
16
+ Project-URL: Documentation, https://github.com/MimoKit/qgbot/tree/main/docs
17
+ Requires-Python: >=3.10
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=8.0; extra == "dev"
20
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
21
+ Requires-Dist: ruff>=0.6; extra == "dev"
22
+ Description-Content-Type: text/markdown
23
+
24
+ <div align="center">
25
+
26
+ # qgbot
27
+
28
+ _把 QQ 官方机器人那套 Markdown 语法和键盘 JSON,变成可组合的 Python 调用_
29
+
30
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
31
+ [![Python](https://img.shields.io/badge/Python-%E2%89%A53.10-3776ab)](https://www.python.org/)
32
+ [![Docs](https://img.shields.io/badge/文档-中文-8f5db7)](https://docs.example.com/)
33
+
34
+ 写插件时不用再手拼 `<qqbot-cmd-input>` 和按钮 JSON —— 一句 `md.image(url, 400, 300)` 就够。
35
+
36
+ </div>
37
+
38
+ ## 为什么需要它
39
+
40
+ QQ 官方机器人的 markdown 跟标准 markdown 不是一回事。图片尺寸得写在 alt 文本里(`![x #200px #300px](url)`)、分隔线是 `***` 不是 `---`、下划线是 `__` 不是 `<u>`、连续换行会被合并所以要多空一行得插零宽空格、@ 用户的标签从 `<@id>` 换成了 `<qqbot-at-user id="" />`。这些细节散在文档各处,每个插件都要重复踩一遍。
41
+
42
+ 按钮是另一层麻烦:平台要一段嵌套 JSON,`action.type` 区分链接 / 回调 / 指令 / 订阅,`permission.type` 区分所有人 / 管理员 / 指定用户 / 指定身份组,手写容易把 `type` 和 `style` 搞混。
43
+
44
+ 图片还有第三层:markdown 里的图必须是公网 URL,平台自己去拉;插件渲染出来的卡片只存在本地磁盘。官方图库的上传是**三步预签名流程**——声明元信息拿预签名地址、PUT 原始字节、回传 ETag 里的 MD5 收尾——不是一次 multipart 提交。
45
+
46
+ qgbot 把这三块分别封成 `md`、`keyboard`、`media`,再加一个 `message` 负责组装发送用的 payload。
47
+
48
+ ## 特性
49
+
50
+ - **零依赖** —— 纯标准库实现,不引入任何运行时包,`pip install` 不拉任何东西
51
+ - **不绑框架** —— 没有 NoneBot / 自研框架的 import,任何 Python 环境都能直接用
52
+ - **不碰网络** —— 素材上传只算签名、组请求、解响应,HTTP 由调用方注入,复用你自己的连接池与重试策略
53
+ - **语法对齐平台** —— 分隔线、下划线、图片尺寸、零宽空格、@ 标签这些与标准 md 不同的地方全部按官方语法生成
54
+ - **限制可见** —— 标题只支持两级、键盘最多 5 行、图片 10 MB、图库 100 张,都以常量形式暴露
55
+ - **结构可测** —— 89 个用例覆盖每一处语法与上传流程,不需要起 mock server
56
+
57
+ ## 模块一览
58
+
59
+ | 模块 | 覆盖内容 |
60
+ | --- | --- |
61
+ | `qgbot.md` | 标题、六种文字样式、行内链接与自动链接、带尺寸图片、两种列表(含二级缩进)、引用块、分隔线、多空行、正文内联指令按钮、@ 用户与 @ 全体、频道子频道跳转与系统表情 |
62
+ | `qgbot.keyboard` | 链接 / 回调 / 指令三类按钮,权限限定,按行切分,字号设置 |
63
+ | `qgbot.media` | 图库上传三步流程,含签名计算、错误码映射、列表解析与删除 |
64
+ | `qgbot.message` | markdown 正文与键盘合成发送 body,带 `msg_id` / `msg_seq` 被动回复字段 |
65
+
66
+ ```python
67
+ from qgbot import md, command, make_keyboard, Message
68
+
69
+ content = md.render(
70
+ md.title("今日运势"),
71
+ md.image("https://example.com/card.png", 400, 300),
72
+ md.mention("user_openid"),
73
+ md.quote("宜:出门走走"),
74
+ )
75
+
76
+ payload = Message(
77
+ content,
78
+ keyboard=make_keyboard(command("再抽一次", "今日运势")),
79
+ ).to_payload(msg_id="收到的那条消息id")
80
+ ```
81
+
82
+ ## 边界
83
+
84
+ **不做网络请求。** 素材上传只算 MD5 / SHA1、组装请求体、解析响应,实际 HTTP 由调用方注入的 `Transport` 完成。这样 SDK 不绑定 httpx / aiohttp / requests 中的任何一个,测试也不必起 mock server。
85
+
86
+ **不管鉴权。** AccessToken 的获取与刷新属于调用方,SDK 产出的 payload 里不含凭据。
87
+
88
+ **不做语法校验。** 平台限制以常量与注释暴露,但 SDK 不主动改写用户内容。两处例外:标题层级会被夹到合法范围(传 3 得到 2),键盘超过 5 行会被截断(平台本身就不渲染,抛错反而让整条消息发不出去)。
89
+
90
+ **不解析已有 markdown。** 只有构造,没有反向解析。
91
+
92
+ ## 环境要求
93
+
94
+ - Python `>= 3.10`
95
+
96
+ ## 开源协议
97
+
98
+ [MIT](LICENSE)
@@ -0,0 +1,9 @@
1
+ qgbot/__init__.py,sha256=eKrSgBWbyRE1fdlupsFXtTelGoRSScgQTn5feIVPxD4,1673
2
+ qgbot/keyboard.py,sha256=JlsequN2C9Qz-_6MkLdyC6IKQoh3-QqUXM8tHKzE48g,7613
3
+ qgbot/md.py,sha256=lnz_4yvtMkJASZ_FZuiGBLb-6--pz_W4d3W_KTY0-a8,8262
4
+ qgbot/media.py,sha256=-SBiRY67vUiX1iE0_Opkyzmv8JRo7Fc4SKZ3V-2vgjU,9254
5
+ qgbot/message.py,sha256=f8TYd-9mnqCzU05PhYiszlJlWhwhE8xJ6e-2fV9VRUc,2580
6
+ qqbot_md_sdk-0.1.0.dist-info/METADATA,sha256=LAw5jhGy0EcbadNoqc4NMrAiz5BCcyMvHMosfZDQt9Q,5098
7
+ qqbot_md_sdk-0.1.0.dist-info/WHEEL,sha256=IuIk3wbaEnFH1cx75iM2R1CmliRI3dFLXTbRakgvgFQ,90
8
+ qqbot_md_sdk-0.1.0.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
9
+ qqbot_md_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: pdm-backend (2.5.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+
3
+ [gui_scripts]
4
+