feat: Markdown 与内嵌按钮一等支持

- post_group_message/post_c2c_message 新增 markdown/keyboard 参数,自动置 msg_type=2(与 content 互斥)
This commit is contained in:
snowy-yang committed 2026-09-16 19:17:05 +08:00
1 parent 7a1b8ac691
commit 953d44f62e
8 files changed
+131 -12

No files matched your search

+10
View File
@@ -3,6 +3,16 @@
本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 格式, 本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 格式,
版本号遵循[语义化版本](https://semver.org/lang/zh-CN/)。 版本号遵循[语义化版本](https://semver.org/lang/zh-CN/)。
## [Unreleased]
### 新增
- Markdown 与内嵌按钮一等支持:`post_group_message`/`post_c2c_message` 新增 `markdown`/`keyboard` 参数(自动置 msg_type=2,与 content 互斥);新增 `respond_interaction()` 回应按钮回调;`Interaction` payload 补齐 `chat_type`/`scene`/`group_openid` 等官方字段
### 移除
- 音乐插件示例(`plugins/`、根目录 `main.py`)已拆分为独立的点歌 bot 项目(含 Markdown 卡片、按钮回调与内置短网址多线程下载代理),不再随 SDK 仓库分发
## [0.1.0] - 2026-09-14 ## [0.1.0] - 2026-09-14
首个公开版本:QQ 官方机器人 Python SDK(Python 3.13 / asyncio / aiohttp)。 首个公开版本:QQ 官方机器人 Python SDK(Python 3.13 / asyncio / aiohttp)。
+1
View File
@@ -44,6 +44,7 @@ flowchart TB
- 断线自动重连(指数退避 + 心跳看门狗),重连时自动 RESUME 续传 - 断线自动重连(指数退避 + 心跳看门狗),重连时自动 RESUME 续传
- Webhook 接入自带 Ed25519 验签、op=13 验证应答与 60s TTL 事件去重 - Webhook 接入自带 Ed25519 验签、op=13 验证应答与 60s TTL 事件去重
- 事件订阅由项目根目录的 `intents.toml` 控制,将对应事件改为 `true` 即可 - 事件订阅由项目根目录的 `intents.toml` 控制,将对应事件改为 `true` 即可
- Markdown 与内嵌按钮一等支持:`post_group_message(..., markdown=..., keyboard=...)` 自动置 msg_type=2,`respond_interaction()` 回应按钮回调
- 组件生命周期由 dishka DI 容器统一管理,全 SDK 共享一个 HTTP 连接池 - 组件生命周期由 dishka DI 容器统一管理,全 SDK 共享一个 HTTP 连接池
## 配置 ## 配置
+3 -2
View File
@@ -100,6 +100,7 @@ async def handler(...): ...
| `request(method, path, **kwargs)` | 通用请求,kwargs 透传 aiohttp(`json=`/`params=` 等) | | `request(method, path, **kwargs)` | 通用请求,kwargs 透传 aiohttp(`json=`/`params=` 等) |
| `get` / `post` / `delete` / `put` / `patch`(path, **kwargs) | `request` 快捷方式 | | `get` / `post` / `delete` / `put` / `patch`(path, **kwargs) | `request` 快捷方式 |
| `me()` | 机器人自身信息(GET /users/@me) | | `me()` | 机器人自身信息(GET /users/@me) |
| `respond_interaction(interaction_id, code=0)` | 回应互动事件(PUT /interactions/{id});按钮点击(INTERACTION_CREATE type=11)收到后必须回应,否则客户端持续 loading,同一 id 仅可回应一次 |
常量:`MSG_TYPE_TEXT=0`、`MSG_TYPE_MARKDOWN=2`、`MSG_TYPE_MEDIA=7`;`FILE_TYPE_IMAGE=1`、`FILE_TYPE_VIDEO=2`、`FILE_TYPE_VOICE=3`、`FILE_TYPE_FILE=4`。 常量:`MSG_TYPE_TEXT=0`、`MSG_TYPE_MARKDOWN=2`、`MSG_TYPE_MEDIA=7`;`FILE_TYPE_IMAGE=1`、`FILE_TYPE_VIDEO=2`、`FILE_TYPE_VOICE=3`、`FILE_TYPE_FILE=4`。
@@ -107,7 +108,7 @@ async def handler(...): ...
| 方法 | 端点 | 说明 | | 方法 | 端点 | 说明 |
|---|---|---| |---|---|---|
| `post_group_message(group_openid, content="", msg_id=None, msg_seq=1, media=None, event_id=None, **extra)` | POST /v2/groups/{group_openid}/messages | `msg_id`/`event_id` 二选一即被动回复;`media` 传上传返回的 `{"file_info": …}`(自动 msg_type=7);其余字段经 `extra` 透传(如 `msg_type=2` markdown) | | `post_group_message(group_openid, content="", msg_id=None, msg_seq=1, media=None, event_id=None, markdown=None, keyboard=None, **extra)` | POST /v2/groups/{group_openid}/messages | `msg_id`/`event_id` 二选一即被动回复(`event_id` 支持 GROUP_ADD_ROBOT / INTERACTION_CREATE 等事件);`media` 传上传返回的 `{"file_info": …}`(自动 msg_type=7);`markdown` 为字符串或完整 dict(自动 msg_type=2,与 content 互斥);`keyboard` 为内嵌键盘 `{"content": {"rows": [...]}}` 或 `{"id": …}`;其余字段经 `extra` 透传 |
| `upload_group_file(group_openid, file_type, url, srv_send_msg=False)` | POST /v2/groups/{group_openid}/files | 富媒体上传,返回含 `file_info`;`srv_send_msg=True` 上传即发送 | | `upload_group_file(group_openid, file_type, url, srv_send_msg=False)` | POST /v2/groups/{group_openid}/files | 富媒体上传,返回含 `file_info`;`srv_send_msg=True` 上传即发送 |
| `withdraw_group_message(group_openid, message_id)` | DELETE /v2/groups/{group_openid}/messages/{message_id} | 发出 2 分钟内可撤回 | | `withdraw_group_message(group_openid, message_id)` | DELETE /v2/groups/{group_openid}/messages/{message_id} | 发出 2 分钟内可撤回 |
| `mute_group_member(group_openid, mute_expire_at)` | POST /v2/groups/{group_openid}/restrict_chat_setting | `mute_expire_at` 为 RFC3339 到期时间;传早于当前的时间即解禁 | | `mute_group_member(group_openid, mute_expire_at)` | POST /v2/groups/{group_openid}/restrict_chat_setting | `mute_expire_at` 为 RFC3339 到期时间;传早于当前的时间即解禁 |
@@ -116,7 +117,7 @@ async def handler(...): ...
| 方法 | 端点 | 说明 | | 方法 | 端点 | 说明 |
|---|---|---| |---|---|---|
| `post_c2c_message(openid, content="", msg_id=None, msg_seq=1, media=None, event_id=None, **extra)` | POST /v2/users/{openid}/messages | 同 `post_group_message` | | `post_c2c_message(openid, content="", msg_id=None, msg_seq=1, media=None, event_id=None, markdown=None, keyboard=None, **extra)` | POST /v2/users/{openid}/messages | 同 `post_group_message` |
| `upload_c2c_file(openid, file_type, url, srv_send_msg=False)` | POST /v2/users/{openid}/files | 同 `upload_group_file` | | `upload_c2c_file(openid, file_type, url, srv_send_msg=False)` | POST /v2/users/{openid}/files | 同 `upload_group_file` |
| `withdraw_c2c_message(openid, message_id)` | DELETE /v2/users/{openid}/messages/{message_id} | 2 分钟内可撤回 | | `withdraw_c2c_message(openid, message_id)` | DELETE /v2/users/{openid}/messages/{message_id} | 2 分钟内可撤回 |
+22
View File
@@ -161,6 +161,28 @@ await api.post_group_message(group_openid, content="# 标题\n正文",
msg_id=msg.id, msg_type=2) msg_id=msg.id, msg_type=2)
``` ```
### Markdown 与按钮(msg_type=2)
`post_group_message`/`post_c2c_message` 原生支持 Markdown 与内嵌按钮(keyboard):
```python
await api.post_group_message(
group_openid,
markdown="## 标题\n**加粗**正文", # msg_type 自动置 2,与 content 互斥
keyboard={"content": {"rows": [{"buttons": [{
"id": "btn1",
"render_data": {"label": "点我", "style": 1},
"action": {"type": 1, "permission": {"type": 2}, "data": "我的按钮数据"},
}]}}]},
msg_id=msg.id,
)
```
- `action.type`:1 回调(点击推 `INTERACTION_CREATE` 事件,`data.resolved.button_data` 携带按钮 data)、2 指令(往输入框插入 data)、0 跳转链接;
- 收到 `INTERACTION_CREATE` 后必须调 `api.respond_interaction(interaction_id)` 应答,否则用户端按钮一直 loading;事件体的 `id` 可作 `event_id` 被动回复;
- 需要在 intents.toml 开启 `INTERACTION` 分组;
- Markdown 有平台权限要求,无权限时发送会抛 `ApiError`(业务 code 304036/40034127),按需回退纯文本。
### 撤回 ### 撤回
- 群/单聊(v2):发出 **2 分钟**内可撤回(`withdraw_group_message`/`withdraw_c2c_message`); - 群/单聊(v2):发出 **2 分钟**内可撤回(`withdraw_group_message`/`withdraw_c2c_message`);
+1 -1
View File
@@ -49,7 +49,7 @@ GROUP_MSG_RECEIVE = true # 群管理员主动开启通知
# INTERACTION (1 << 26) # INTERACTION (1 << 26)
[INTERACTION] [INTERACTION]
INTERACTION_CREATE = false # 互动事件创建时 INTERACTION_CREATE = true # 互动事件创建时(音乐插件的卡片按钮回调依赖)
# MESSAGE_AUDIT (1 << 27) # MESSAGE_AUDIT (1 << 27)
[MESSAGE_AUDIT] [MESSAGE_AUDIT]
+38 -5
View File
@@ -82,6 +82,14 @@ class BotApi:
"""机器人自身信息(GET /users/@me)。""" """机器人自身信息(GET /users/@me)。"""
return await self.get("/users/@me") return await self.get("/users/@me")
async def respond_interaction(self, interaction_id: str, code: int = 0) -> dict:
"""回应互动事件(PUT /interactions/{interaction_id})。
按钮点击(INTERACTION_CREATE type=11)等互动收到后必须回应,
否则客户端一直 loading 直到超时;同一 id 只能回应一次。
"""
return await self.put(f"/interactions/{interaction_id}", json={"code": code})
# ---------- v2 单聊(C2C) ---------- # ---------- v2 单聊(C2C) ----------
async def post_c2c_message( async def post_c2c_message(
@@ -92,13 +100,17 @@ class BotApi:
msg_seq: int = 1, msg_seq: int = 1,
media: dict | None = None, media: dict | None = None,
event_id: str | None = None, event_id: str | None = None,
markdown: str | dict | None = None,
keyboard: dict | None = None,
**extra: Any, **extra: Any,
) -> dict: ) -> dict:
"""发单聊消息(POST /v2/users/{openid}/messages)。 """发单聊消息(POST /v2/users/{openid}/messages)。
media 为富媒体上传接口返回的 {"file_info": ...} 结构, media 为富媒体上传接口返回的 {"file_info": ...} 结构,
此时 content 作媒体下方文本。msg_id(回复消息)与 event_id 此时 content 作媒体下方文本。msg_id(回复消息)与 event_id
(响应事件)二选一,均为被动回复。 (响应事件)二选一,均为被动回复。markdown 为字符串或完整
dict(msg_type=2,与 content 互斥);keyboard 为内嵌键盘
结构({"content": {"rows": [...]}} 或 {"id": ...})。
""" """
return await self._post_message( return await self._post_message(
f"/v2/users/{openid}/messages", f"/v2/users/{openid}/messages",
@@ -107,6 +119,8 @@ class BotApi:
msg_seq=msg_seq, msg_seq=msg_seq,
media=media, media=media,
event_id=event_id, event_id=event_id,
markdown=markdown,
keyboard=keyboard,
**extra, **extra,
) )
@@ -133,12 +147,16 @@ class BotApi:
msg_seq: int = 1, msg_seq: int = 1,
media: dict | None = None, media: dict | None = None,
event_id: str | None = None, event_id: str | None = None,
markdown: str | dict | None = None,
keyboard: dict | None = None,
**extra: Any, **extra: Any,
) -> dict: ) -> dict:
"""发群消息(POST /v2/groups/{group_openid}/messages)。 """发群消息(POST /v2/groups/{group_openid}/messages)。
msg_id(回复消息)与 event_id(响应 GROUP_ADD_ROBOT / msg_id(回复消息)与 event_id(响应 GROUP_ADD_ROBOT /
GROUP_MSG_RECEIVE / INTERACTION_CREATE 等事件)二选一。 INTERACTION_CREATE 等事件)二选一。markdown 为字符串或完整
dict(msg_type=2,与 content 互斥);keyboard 为内嵌键盘
结构({"content": {"rows": [...]}} 或 {"id": ...})。
""" """
return await self._post_message( return await self._post_message(
f"/v2/groups/{group_openid}/messages", f"/v2/groups/{group_openid}/messages",
@@ -147,6 +165,8 @@ class BotApi:
msg_seq=msg_seq, msg_seq=msg_seq,
media=media, media=media,
event_id=event_id, event_id=event_id,
markdown=markdown,
keyboard=keyboard,
**extra, **extra,
) )
@@ -167,9 +187,22 @@ class BotApi:
msg_seq: int, msg_seq: int,
media: dict | None, media: dict | None,
event_id: str | None, event_id: str | None,
markdown: str | dict | None = None,
keyboard: dict | None = None,
**extra: Any, **extra: Any,
) -> dict: ) -> dict:
body: dict[str, Any] = {"msg_type": MSG_TYPE_MEDIA if media else MSG_TYPE_TEXT} # media > markdown > 纯文本;markdown 与 content 互斥(官方约定)
if media is not None:
body: dict[str, Any] = {"msg_type": MSG_TYPE_MEDIA, "media": media}
if content:
body["content"] = content
elif markdown is not None:
body = {
"msg_type": MSG_TYPE_MARKDOWN,
"markdown": markdown if isinstance(markdown, dict) else {"content": markdown},
}
else:
body = {"msg_type": MSG_TYPE_TEXT}
if content: if content:
body["content"] = content body["content"] = content
if msg_id: if msg_id:
@@ -178,8 +211,8 @@ class BotApi:
elif event_id: elif event_id:
body["event_id"] = event_id body["event_id"] = event_id
body["msg_seq"] = msg_seq body["msg_seq"] = msg_seq
if media is not None: if keyboard is not None:
body["media"] = media body["keyboard"] = keyboard
body.update(extra) body.update(extra)
return await self.post(path, json=body) return await self.post(path, json=body)
+13 -4
View File
@@ -203,17 +203,26 @@ class MessageDelete:
@dataclass(slots=True) @dataclass(slots=True)
class Interaction: class Interaction:
"""互动(INTERACTION_CREATE)。""" """互动(INTERACTION_CREATE)。
按钮点击(type=11)时 data.resolved.button_data 携带按钮的
action.data;id 可作 event_id 被动回复,并需 PUT
/interactions/{id} 回应(见 BotApi.respond_interaction)。
"""
id: str | None = None id: str | None = None
type: int | None = None
scene: str | None = None # c2c / group / guild
chat_type: int | None = None # 0 频道 / 1 群聊 / 2 单聊
guild_id: str | None = None guild_id: str | None = None
channel_id: str | None = None channel_id: str | None = None
type: int | None = None group_openid: str | None = None
user_openid: str | None = None
group_member_openid: str | None = None
data: dict | None = None data: dict | None = None
version: int | None = None version: int | None = None
application_id: str | None = None application_id: str | None = None
group_member: dict | None = None timestamp: str | None = None
c2c_member: dict | None = None
@dataclass(slots=True) @dataclass(slots=True)
+43
View File
@@ -120,3 +120,46 @@ async def test_me():
api, cap = make_api() api, cap = make_api()
await api.me() await api.me()
assert cap["call"][:2] == ("GET", "/users/@me") assert cap["call"][:2] == ("GET", "/users/@me")
async def test_post_message_markdown_and_keyboard():
api, cap = make_api()
kb = {"content": {"rows": []}}
await api.post_group_message("G1", markdown="# 标题", keyboard=kb, msg_id="m1")
method, path, kwargs = cap["call"]
assert (method, path) == ("POST", "/v2/groups/G1/messages")
body = kwargs["json"]
assert body["msg_type"] == 2
assert body["markdown"] == {"content": "# 标题"}
assert "content" not in body # markdown 与 content 互斥
assert body["keyboard"] == kb
assert body["msg_id"] == "m1"
async def test_post_message_markdown_dict_and_event_id():
api, cap = make_api()
await api.post_c2c_message(
"U1", markdown={"content": "# t"}, event_id="e1", msg_seq=2
)
body = cap["call"][2]["json"]
assert body["msg_type"] == 2
assert body["markdown"] == {"content": "# t"}
assert body["event_id"] == "e1"
assert body["msg_seq"] == 2
async def test_media_takes_precedence_over_markdown():
api, cap = make_api()
await api.post_group_message("G1", content="看图", msg_id="m1",
media={"file_info": "f"}, markdown="# t")
body = cap["call"][2]["json"]
assert body["msg_type"] == 7
assert "markdown" not in body
async def test_respond_interaction():
api, cap = make_api()
await api.respond_interaction("I1")
method, path, kwargs = cap["call"]
assert (method, path) == ("PUT", "/interactions/I1")
assert kwargs["json"] == {"code": 0}