diff --git a/CHANGELOG.md b/CHANGELOG.md index 24d699c..6668689 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ 本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 格式, 版本号遵循[语义化版本](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 首个公开版本:QQ 官方机器人 Python SDK(Python 3.13 / asyncio / aiohttp)。 diff --git a/README.md b/README.md index 3f70b88..42edf38 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ flowchart TB - 断线自动重连(指数退避 + 心跳看门狗),重连时自动 RESUME 续传 - Webhook 接入自带 Ed25519 验签、op=13 验证应答与 60s TTL 事件去重 - 事件订阅由项目根目录的 `intents.toml` 控制,将对应事件改为 `true` 即可 +- Markdown 与内嵌按钮一等支持:`post_group_message(..., markdown=..., keyboard=...)` 自动置 msg_type=2,`respond_interaction()` 回应按钮回调 - 组件生命周期由 dishka DI 容器统一管理,全 SDK 共享一个 HTTP 连接池 ## 配置 diff --git a/docs/API.md b/docs/API.md index 578b1a8..42488f1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -100,6 +100,7 @@ async def handler(...): ... | `request(method, path, **kwargs)` | 通用请求,kwargs 透传 aiohttp(`json=`/`params=` 等) | | `get` / `post` / `delete` / `put` / `patch`(path, **kwargs) | `request` 快捷方式 | | `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`。 @@ -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` 上传即发送 | | `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 到期时间;传早于当前的时间即解禁 | @@ -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` | | `withdraw_c2c_message(openid, message_id)` | DELETE /v2/users/{openid}/messages/{message_id} | 2 分钟内可撤回 | diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 02c5547..f1ad70a 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -161,6 +161,28 @@ await api.post_group_message(group_openid, content="# 标题\n正文", 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`); diff --git a/intents.toml b/intents.toml index c46f84a..f225d9a 100644 --- a/intents.toml +++ b/intents.toml @@ -49,7 +49,7 @@ GROUP_MSG_RECEIVE = true # 群管理员主动开启通知 # INTERACTION (1 << 26) [INTERACTION] -INTERACTION_CREATE = false # 互动事件创建时 +INTERACTION_CREATE = true # 互动事件创建时(音乐插件的卡片按钮回调依赖) # MESSAGE_AUDIT (1 << 27) [MESSAGE_AUDIT] diff --git a/src/qqbotsdk/api.py b/src/qqbotsdk/api.py index 269e7bc..d067a34 100644 --- a/src/qqbotsdk/api.py +++ b/src/qqbotsdk/api.py @@ -82,6 +82,14 @@ class BotApi: """机器人自身信息(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) ---------- async def post_c2c_message( @@ -92,13 +100,17 @@ class BotApi: msg_seq: int = 1, media: dict | None = None, event_id: str | None = None, + markdown: str | dict | None = None, + keyboard: dict | None = None, **extra: Any, ) -> dict: """发单聊消息(POST /v2/users/{openid}/messages)。 media 为富媒体上传接口返回的 {"file_info": ...} 结构, 此时 content 作媒体下方文本。msg_id(回复消息)与 event_id - (响应事件)二选一,均为被动回复。 + (响应事件)二选一,均为被动回复。markdown 为字符串或完整 + dict(msg_type=2,与 content 互斥);keyboard 为内嵌键盘 + 结构({"content": {"rows": [...]}} 或 {"id": ...})。 """ return await self._post_message( f"/v2/users/{openid}/messages", @@ -107,6 +119,8 @@ class BotApi: msg_seq=msg_seq, media=media, event_id=event_id, + markdown=markdown, + keyboard=keyboard, **extra, ) @@ -133,12 +147,16 @@ class BotApi: msg_seq: int = 1, media: dict | None = None, event_id: str | None = None, + markdown: str | dict | None = None, + keyboard: dict | None = None, **extra: Any, ) -> dict: """发群消息(POST /v2/groups/{group_openid}/messages)。 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( f"/v2/groups/{group_openid}/messages", @@ -147,6 +165,8 @@ class BotApi: msg_seq=msg_seq, media=media, event_id=event_id, + markdown=markdown, + keyboard=keyboard, **extra, ) @@ -167,19 +187,32 @@ class BotApi: msg_seq: int, media: dict | None, event_id: str | None, + markdown: str | dict | None = None, + keyboard: dict | None = None, **extra: Any, ) -> dict: - body: dict[str, Any] = {"msg_type": MSG_TYPE_MEDIA if media else MSG_TYPE_TEXT} - if content: - body["content"] = content + # 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: + body["content"] = content if msg_id: body["msg_id"] = msg_id body["msg_seq"] = msg_seq elif event_id: body["event_id"] = event_id body["msg_seq"] = msg_seq - if media is not None: - body["media"] = media + if keyboard is not None: + body["keyboard"] = keyboard body.update(extra) return await self.post(path, json=body) diff --git a/src/qqbotsdk/payloads.py b/src/qqbotsdk/payloads.py index 651dab5..7c8e334 100644 --- a/src/qqbotsdk/payloads.py +++ b/src/qqbotsdk/payloads.py @@ -203,17 +203,26 @@ class MessageDelete: @dataclass(slots=True) 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 + type: int | None = None + scene: str | None = None # c2c / group / guild + chat_type: int | None = None # 0 频道 / 1 群聊 / 2 单聊 guild_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 version: int | None = None application_id: str | None = None - group_member: dict | None = None - c2c_member: dict | None = None + timestamp: str | None = None @dataclass(slots=True) diff --git a/tests/test_api.py b/tests/test_api.py index 194f871..dd812ec 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -120,3 +120,46 @@ async def test_me(): api, cap = make_api() await api.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}