From c7dc6e0d97ae3a7cc779f48edb8b77fac3dc883b Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 8 Jun 2026 14:22:48 +0800 Subject: [PATCH] feat: add keyboard button support for proactive and passive message sending --- app/modules/qqbot/api.py | 24 ++++++++++++++++++++---- app/modules/qqbot/gateway.py | 7 +++++++ app/modules/qqbot/qqbot.py | 34 ++++++++++++++++++++++++++++++++-- app/schemas/message.py | 5 +++++ 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/app/modules/qqbot/api.py b/app/modules/qqbot/api.py index 2c7f8f0b..bbe08f68 100644 --- a/app/modules/qqbot/api.py +++ b/app/modules/qqbot/api.py @@ -100,6 +100,7 @@ def send_proactive_c2c_message( openid: str, content: str, use_markdown: bool = False, + keyboard: Optional[dict] = None, ) -> dict: """ 主动发送 C2C 单聊消息(不需要 msg_id) @@ -108,11 +109,14 @@ def send_proactive_c2c_message( :param openid: 用户 openid :param content: 消息内容 :param use_markdown: 是否使用 Markdown 格式(需机器人开通 Markdown 能力) + :param keyboard: 键盘按钮配置 """ if not content or not content.strip(): raise ValueError("主动消息内容不能为空") content = content.strip() body = {"markdown": {"content": content}, "msg_type": 2} if use_markdown else {"content": content, "msg_type": 0} + if keyboard: + body["keyboard"] = {"content": keyboard} return _api_request( access_token, "POST", f"/v2/users/{openid}/messages", body ) @@ -123,6 +127,7 @@ def send_proactive_group_message( group_openid: str, content: str, use_markdown: bool = False, + keyboard: Optional[dict] = None, ) -> dict: """ 主动发送群聊消息(不需要 msg_id) @@ -131,11 +136,14 @@ def send_proactive_group_message( :param group_openid: 群聊 openid :param content: 消息内容 :param use_markdown: 是否使用 Markdown 格式(需机器人开通 Markdown 能力) + :param keyboard: 键盘按钮配置 """ if not content or not content.strip(): raise ValueError("主动消息内容不能为空") content = content.strip() body = {"markdown": {"content": content}, "msg_type": 2} if use_markdown else {"content": content, "msg_type": 0} + if keyboard: + body["keyboard"] = {"content": keyboard} return _api_request( access_token, "POST", f"/v2/groups/{group_openid}/messages", body ) @@ -146,11 +154,14 @@ def send_c2c_message( openid: str, content: str, msg_id: Optional[str] = None, + keyboard: Optional[dict] = None, ) -> dict: """被动回复 C2C 单聊消息(1 小时内最多 4 次)""" body = {"content": content, "msg_type": 0, "msg_seq": 1} if msg_id: body["msg_id"] = msg_id + if keyboard: + body["keyboard"] = {"content": keyboard} return _api_request( access_token, "POST", f"/v2/users/{openid}/messages", body ) @@ -161,11 +172,14 @@ def send_group_message( group_openid: str, content: str, msg_id: Optional[str] = None, + keyboard: Optional[dict] = None, ) -> dict: """被动回复群聊消息(1 小时内最多 4 次)""" body = {"content": content, "msg_type": 0, "msg_seq": 1} if msg_id: body["msg_id"] = msg_id + if keyboard: + body["keyboard"] = {"content": keyboard} return _api_request( access_token, "POST", f"/v2/groups/{group_openid}/messages", body ) @@ -188,6 +202,7 @@ def send_message( content: str, msg_type: Literal["c2c", "group"] = "c2c", msg_id: Optional[str] = None, + keyboard: Optional[dict] = None, ) -> dict: """ 统一发送接口 @@ -196,11 +211,12 @@ def send_message( :param content: 消息内容 :param msg_type: c2c 单聊 / group 群聊 :param msg_id: 可选,被动回复时传入原消息 id + :param keyboard: 可选,键盘按钮配置 """ if msg_id: if msg_type == "c2c": - return send_c2c_message(access_token, target, content, msg_id) - return send_group_message(access_token, target, content, msg_id) + return send_c2c_message(access_token, target, content, msg_id, keyboard) + return send_group_message(access_token, target, content, msg_id, keyboard) if msg_type == "c2c": - return send_proactive_c2c_message(access_token, target, content) - return send_proactive_group_message(access_token, target, content) + return send_proactive_c2c_message(access_token, target, content, keyboard=keyboard) + return send_proactive_group_message(access_token, target, content, keyboard=keyboard) diff --git a/app/modules/qqbot/gateway.py b/app/modules/qqbot/gateway.py index 31cbd5d5..6716f2e8 100644 --- a/app/modules/qqbot/gateway.py +++ b/app/modules/qqbot/gateway.py @@ -4,6 +4,7 @@ QQ Bot Gateway WebSocket 客户端 """ import json +import re import threading import time from typing import Callable, List, Optional @@ -108,6 +109,9 @@ def run_gateway( author = d.get("author", {}) user_openid = author.get("user_openid", "") content = d.get("content", "").strip() + match = re.search(r'(agent_interaction:choice:[\w\-]+:\d+|agent_choice:[\w\-]+:\d+)', content) + if match: + content = f"CALLBACK:{match.group(1)}" msg_id = d.get("id", "") if content: on_message_fn({ @@ -122,6 +126,9 @@ def run_gateway( member_openid = author.get("member_openid", "") group_openid = d.get("group_openid", "") content = d.get("content", "").strip() + match = re.search(r'(agent_interaction:choice:[\w\-]+:\d+|agent_choice:[\w\-]+:\d+)', content) + if match: + content = f"CALLBACK:{match.group(1)}" msg_id = d.get("id", "") if content: on_message_fn({ diff --git a/app/modules/qqbot/qqbot.py b/app/modules/qqbot/qqbot.py index 14b61481..7a45b38f 100644 --- a/app/modules/qqbot/qqbot.py +++ b/app/modules/qqbot/qqbot.py @@ -347,13 +347,43 @@ class QQBot: logger.warn("QQ Bot: 消息内容为空") return False + # 处理按钮 + buttons = kwargs.get("buttons") + keyboard = None + if buttons: + rows = [] + btn_id = 1 + for row in buttons: + btns = [] + for btn in row: + action_type = 0 if btn.get("url") else 2 + btns.append({ + "id": str(btn_id), + "render_data": { + "label": btn.get("text", "按钮")[:30], + "visited_label": btn.get("text", "按钮")[:30], + "style": 1 + }, + "action": { + "type": action_type, + "data": btn.get("url") if action_type == 0 else btn.get("callback_data", ""), + "permission": {"type": 2} + } + }) + btn_id += 1 + if btns: + rows.append({"buttons": btns}) + if rows: + keyboard = {"rows": rows} + use_markdown = True + success_count = 0 try: token = get_access_token(self._app_id, self._app_secret) for tgt, tgt_is_group in targets_to_send: send_fn = send_proactive_group_message if tgt_is_group else send_proactive_c2c_message try: - send_fn(token, tgt, content, use_markdown=use_markdown) + send_fn(token, tgt, content, use_markdown=use_markdown, keyboard=keyboard) success_count += 1 logger.debug(f"QQ Bot: 消息已发送到 {'群' if tgt_is_group else '用户'} {tgt}") except Exception as e: @@ -371,7 +401,7 @@ class QQBot: plain_parts.append(link) plain_content = "\n".join(plain_parts).strip() if plain_content: - send_fn(token, tgt, plain_content, use_markdown=False) + send_fn(token, tgt, plain_content, use_markdown=False, keyboard=None) success_count += 1 logger.debug(f"QQ Bot: Markdown 不可用,已回退纯文本发送至 {tgt}") else: diff --git a/app/schemas/message.py b/app/schemas/message.py index edae1760..8a6c3698 100644 --- a/app/schemas/message.py +++ b/app/schemas/message.py @@ -450,7 +450,12 @@ class ChannelCapabilityManager: ChannelCapability.RICH_TEXT, ChannelCapability.IMAGES, ChannelCapability.LINKS, + ChannelCapability.INLINE_BUTTONS, + ChannelCapability.CALLBACK_QUERIES, }, + max_buttons_per_row=5, + max_button_rows=5, + max_button_text_length=30, fallback_enabled=True, ), }