mirror of
https://github.com/snowy-yang/qqbotsdk.git
synced 2026-10-21 13:46:18 +00:00
feat: Markdown 与内嵌按钮一等支持
- post_group_message/post_c2c_message 新增 markdown/keyboard 参数,自动置 msg_type=2(与 content 互斥)
This commit is contained in:
1 parent
7a1b8ac691
commit
953d44f62e
8 files changed
+131
-12
No files matched your search
@@ -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)。
|
||||||
|
|||||||
@@ -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
@@ -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 分钟内可撤回 |
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
Reference in new issue
Block a user