qqbot-md-sdk 0.1.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.
- qqbot_md_sdk-0.1.0/PKG-INFO +98 -0
- qqbot_md_sdk-0.1.0/README.md +75 -0
- qqbot_md_sdk-0.1.0/pyproject.toml +66 -0
- qqbot_md_sdk-0.1.0/qgbot/__init__.py +59 -0
- qqbot_md_sdk-0.1.0/qgbot/keyboard.py +245 -0
- qqbot_md_sdk-0.1.0/qgbot/md.py +246 -0
- qqbot_md_sdk-0.1.0/qgbot/media.py +268 -0
- qqbot_md_sdk-0.1.0/qgbot/message.py +76 -0
- qqbot_md_sdk-0.1.0/tests/test_keyboard.py +149 -0
- qqbot_md_sdk-0.1.0/tests/test_md.py +163 -0
- qqbot_md_sdk-0.1.0/tests/test_media.py +199 -0
- qqbot_md_sdk-0.1.0/tests/test_mention.py +38 -0
|
@@ -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)
|
|
31
|
+
[](https://www.python.org/)
|
|
32
|
+
[](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 文本里(``)、分隔线是 `***` 不是 `---`、下划线是 `__` 不是 `<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,75 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# qgbot
|
|
4
|
+
|
|
5
|
+
_把 QQ 官方机器人那套 Markdown 语法和键盘 JSON,变成可组合的 Python 调用_
|
|
6
|
+
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](https://www.python.org/)
|
|
9
|
+
[](https://docs.example.com/)
|
|
10
|
+
|
|
11
|
+
写插件时不用再手拼 `<qqbot-cmd-input>` 和按钮 JSON —— 一句 `md.image(url, 400, 300)` 就够。
|
|
12
|
+
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
## 为什么需要它
|
|
16
|
+
|
|
17
|
+
QQ 官方机器人的 markdown 跟标准 markdown 不是一回事。图片尺寸得写在 alt 文本里(``)、分隔线是 `***` 不是 `---`、下划线是 `__` 不是 `<u>`、连续换行会被合并所以要多空一行得插零宽空格、@ 用户的标签从 `<@id>` 换成了 `<qqbot-at-user id="" />`。这些细节散在文档各处,每个插件都要重复踩一遍。
|
|
18
|
+
|
|
19
|
+
按钮是另一层麻烦:平台要一段嵌套 JSON,`action.type` 区分链接 / 回调 / 指令 / 订阅,`permission.type` 区分所有人 / 管理员 / 指定用户 / 指定身份组,手写容易把 `type` 和 `style` 搞混。
|
|
20
|
+
|
|
21
|
+
图片还有第三层:markdown 里的图必须是公网 URL,平台自己去拉;插件渲染出来的卡片只存在本地磁盘。官方图库的上传是**三步预签名流程**——声明元信息拿预签名地址、PUT 原始字节、回传 ETag 里的 MD5 收尾——不是一次 multipart 提交。
|
|
22
|
+
|
|
23
|
+
qgbot 把这三块分别封成 `md`、`keyboard`、`media`,再加一个 `message` 负责组装发送用的 payload。
|
|
24
|
+
|
|
25
|
+
## 特性
|
|
26
|
+
|
|
27
|
+
- **零依赖** —— 纯标准库实现,不引入任何运行时包,`pip install` 不拉任何东西
|
|
28
|
+
- **不绑框架** —— 没有 NoneBot / 自研框架的 import,任何 Python 环境都能直接用
|
|
29
|
+
- **不碰网络** —— 素材上传只算签名、组请求、解响应,HTTP 由调用方注入,复用你自己的连接池与重试策略
|
|
30
|
+
- **语法对齐平台** —— 分隔线、下划线、图片尺寸、零宽空格、@ 标签这些与标准 md 不同的地方全部按官方语法生成
|
|
31
|
+
- **限制可见** —— 标题只支持两级、键盘最多 5 行、图片 10 MB、图库 100 张,都以常量形式暴露
|
|
32
|
+
- **结构可测** —— 89 个用例覆盖每一处语法与上传流程,不需要起 mock server
|
|
33
|
+
|
|
34
|
+
## 模块一览
|
|
35
|
+
|
|
36
|
+
| 模块 | 覆盖内容 |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| `qgbot.md` | 标题、六种文字样式、行内链接与自动链接、带尺寸图片、两种列表(含二级缩进)、引用块、分隔线、多空行、正文内联指令按钮、@ 用户与 @ 全体、频道子频道跳转与系统表情 |
|
|
39
|
+
| `qgbot.keyboard` | 链接 / 回调 / 指令三类按钮,权限限定,按行切分,字号设置 |
|
|
40
|
+
| `qgbot.media` | 图库上传三步流程,含签名计算、错误码映射、列表解析与删除 |
|
|
41
|
+
| `qgbot.message` | markdown 正文与键盘合成发送 body,带 `msg_id` / `msg_seq` 被动回复字段 |
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from qgbot import md, command, make_keyboard, Message
|
|
45
|
+
|
|
46
|
+
content = md.render(
|
|
47
|
+
md.title("今日运势"),
|
|
48
|
+
md.image("https://example.com/card.png", 400, 300),
|
|
49
|
+
md.mention("user_openid"),
|
|
50
|
+
md.quote("宜:出门走走"),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
payload = Message(
|
|
54
|
+
content,
|
|
55
|
+
keyboard=make_keyboard(command("再抽一次", "今日运势")),
|
|
56
|
+
).to_payload(msg_id="收到的那条消息id")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## 边界
|
|
60
|
+
|
|
61
|
+
**不做网络请求。** 素材上传只算 MD5 / SHA1、组装请求体、解析响应,实际 HTTP 由调用方注入的 `Transport` 完成。这样 SDK 不绑定 httpx / aiohttp / requests 中的任何一个,测试也不必起 mock server。
|
|
62
|
+
|
|
63
|
+
**不管鉴权。** AccessToken 的获取与刷新属于调用方,SDK 产出的 payload 里不含凭据。
|
|
64
|
+
|
|
65
|
+
**不做语法校验。** 平台限制以常量与注释暴露,但 SDK 不主动改写用户内容。两处例外:标题层级会被夹到合法范围(传 3 得到 2),键盘超过 5 行会被截断(平台本身就不渲染,抛错反而让整条消息发不出去)。
|
|
66
|
+
|
|
67
|
+
**不解析已有 markdown。** 只有构造,没有反向解析。
|
|
68
|
+
|
|
69
|
+
## 环境要求
|
|
70
|
+
|
|
71
|
+
- Python `>= 3.10`
|
|
72
|
+
|
|
73
|
+
## 开源协议
|
|
74
|
+
|
|
75
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "qqbot-md-sdk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "QQ 官方机器人 Markdown 消息构造 SDK"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "MimoKit" },
|
|
9
|
+
]
|
|
10
|
+
keywords = [
|
|
11
|
+
"qq",
|
|
12
|
+
"qqbot",
|
|
13
|
+
"markdown",
|
|
14
|
+
"keyboard",
|
|
15
|
+
"sdk",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
]
|
|
24
|
+
dependencies = []
|
|
25
|
+
|
|
26
|
+
[project.license]
|
|
27
|
+
text = "MIT"
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = [
|
|
31
|
+
"pytest>=8.0",
|
|
32
|
+
"pytest-asyncio>=0.24",
|
|
33
|
+
"ruff>=0.6",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/MimoKit/qgbot"
|
|
38
|
+
Repository = "https://github.com/MimoKit/qgbot"
|
|
39
|
+
Issues = "https://github.com/MimoKit/qgbot/issues"
|
|
40
|
+
Documentation = "https://github.com/MimoKit/qgbot/tree/main/docs"
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = [
|
|
44
|
+
"pdm-backend",
|
|
45
|
+
]
|
|
46
|
+
build-backend = "pdm.backend"
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
line-length = 120
|
|
50
|
+
target-version = "py310"
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint]
|
|
53
|
+
select = [
|
|
54
|
+
"E",
|
|
55
|
+
"F",
|
|
56
|
+
"I",
|
|
57
|
+
"W",
|
|
58
|
+
"UP",
|
|
59
|
+
"B",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
testpaths = [
|
|
64
|
+
"tests",
|
|
65
|
+
]
|
|
66
|
+
asyncio_mode = "auto"
|
|
@@ -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
|
+
]
|
|
@@ -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
|
+
]
|