feat: add keyboard button support for proactive and passive message sending

This commit is contained in:
jxxghp
2026-06-08 14:22:48 +08:00
parent 84ff7476c0
commit c7dc6e0d97
4 changed files with 64 additions and 6 deletions
+20 -4
View File
@@ -100,6 +100,7 @@ def send_proactive_c2c_message(
openid: str, openid: str,
content: str, content: str,
use_markdown: bool = False, use_markdown: bool = False,
keyboard: Optional[dict] = None,
) -> dict: ) -> dict:
""" """
主动发送 C2C 单聊消息(不需要 msg_id) 主动发送 C2C 单聊消息(不需要 msg_id)
@@ -108,11 +109,14 @@ def send_proactive_c2c_message(
:param openid: 用户 openid :param openid: 用户 openid
:param content: 消息内容 :param content: 消息内容
:param use_markdown: 是否使用 Markdown 格式(需机器人开通 Markdown 能力) :param use_markdown: 是否使用 Markdown 格式(需机器人开通 Markdown 能力)
:param keyboard: 键盘按钮配置
""" """
if not content or not content.strip(): if not content or not content.strip():
raise ValueError("主动消息内容不能为空") raise ValueError("主动消息内容不能为空")
content = content.strip() content = content.strip()
body = {"markdown": {"content": content}, "msg_type": 2} if use_markdown else {"content": content, "msg_type": 0} 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( return _api_request(
access_token, "POST", f"/v2/users/{openid}/messages", body access_token, "POST", f"/v2/users/{openid}/messages", body
) )
@@ -123,6 +127,7 @@ def send_proactive_group_message(
group_openid: str, group_openid: str,
content: str, content: str,
use_markdown: bool = False, use_markdown: bool = False,
keyboard: Optional[dict] = None,
) -> dict: ) -> dict:
""" """
主动发送群聊消息(不需要 msg_id) 主动发送群聊消息(不需要 msg_id)
@@ -131,11 +136,14 @@ def send_proactive_group_message(
:param group_openid: 群聊 openid :param group_openid: 群聊 openid
:param content: 消息内容 :param content: 消息内容
:param use_markdown: 是否使用 Markdown 格式(需机器人开通 Markdown 能力) :param use_markdown: 是否使用 Markdown 格式(需机器人开通 Markdown 能力)
:param keyboard: 键盘按钮配置
""" """
if not content or not content.strip(): if not content or not content.strip():
raise ValueError("主动消息内容不能为空") raise ValueError("主动消息内容不能为空")
content = content.strip() content = content.strip()
body = {"markdown": {"content": content}, "msg_type": 2} if use_markdown else {"content": content, "msg_type": 0} 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( return _api_request(
access_token, "POST", f"/v2/groups/{group_openid}/messages", body access_token, "POST", f"/v2/groups/{group_openid}/messages", body
) )
@@ -146,11 +154,14 @@ def send_c2c_message(
openid: str, openid: str,
content: str, content: str,
msg_id: Optional[str] = None, msg_id: Optional[str] = None,
keyboard: Optional[dict] = None,
) -> dict: ) -> dict:
"""被动回复 C2C 单聊消息(1 小时内最多 4 次)""" """被动回复 C2C 单聊消息(1 小时内最多 4 次)"""
body = {"content": content, "msg_type": 0, "msg_seq": 1} body = {"content": content, "msg_type": 0, "msg_seq": 1}
if msg_id: if msg_id:
body["msg_id"] = msg_id body["msg_id"] = msg_id
if keyboard:
body["keyboard"] = {"content": keyboard}
return _api_request( return _api_request(
access_token, "POST", f"/v2/users/{openid}/messages", body access_token, "POST", f"/v2/users/{openid}/messages", body
) )
@@ -161,11 +172,14 @@ def send_group_message(
group_openid: str, group_openid: str,
content: str, content: str,
msg_id: Optional[str] = None, msg_id: Optional[str] = None,
keyboard: Optional[dict] = None,
) -> dict: ) -> dict:
"""被动回复群聊消息(1 小时内最多 4 次)""" """被动回复群聊消息(1 小时内最多 4 次)"""
body = {"content": content, "msg_type": 0, "msg_seq": 1} body = {"content": content, "msg_type": 0, "msg_seq": 1}
if msg_id: if msg_id:
body["msg_id"] = msg_id body["msg_id"] = msg_id
if keyboard:
body["keyboard"] = {"content": keyboard}
return _api_request( return _api_request(
access_token, "POST", f"/v2/groups/{group_openid}/messages", body access_token, "POST", f"/v2/groups/{group_openid}/messages", body
) )
@@ -188,6 +202,7 @@ def send_message(
content: str, content: str,
msg_type: Literal["c2c", "group"] = "c2c", msg_type: Literal["c2c", "group"] = "c2c",
msg_id: Optional[str] = None, msg_id: Optional[str] = None,
keyboard: Optional[dict] = None,
) -> dict: ) -> dict:
""" """
统一发送接口 统一发送接口
@@ -196,11 +211,12 @@ def send_message(
:param content: 消息内容 :param content: 消息内容
:param msg_type: c2c 单聊 / group 群聊 :param msg_type: c2c 单聊 / group 群聊
:param msg_id: 可选,被动回复时传入原消息 id :param msg_id: 可选,被动回复时传入原消息 id
:param keyboard: 可选,键盘按钮配置
""" """
if msg_id: if msg_id:
if msg_type == "c2c": if msg_type == "c2c":
return send_c2c_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) return send_group_message(access_token, target, content, msg_id, keyboard)
if msg_type == "c2c": if msg_type == "c2c":
return send_proactive_c2c_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) return send_proactive_group_message(access_token, target, content, keyboard=keyboard)
+7
View File
@@ -4,6 +4,7 @@ QQ Bot Gateway WebSocket 客户端
""" """
import json import json
import re
import threading import threading
import time import time
from typing import Callable, List, Optional from typing import Callable, List, Optional
@@ -108,6 +109,9 @@ def run_gateway(
author = d.get("author", {}) author = d.get("author", {})
user_openid = author.get("user_openid", "") user_openid = author.get("user_openid", "")
content = d.get("content", "").strip() 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", "") msg_id = d.get("id", "")
if content: if content:
on_message_fn({ on_message_fn({
@@ -122,6 +126,9 @@ def run_gateway(
member_openid = author.get("member_openid", "") member_openid = author.get("member_openid", "")
group_openid = d.get("group_openid", "") group_openid = d.get("group_openid", "")
content = d.get("content", "").strip() 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", "") msg_id = d.get("id", "")
if content: if content:
on_message_fn({ on_message_fn({
+32 -2
View File
@@ -347,13 +347,43 @@ class QQBot:
logger.warn("QQ Bot: 消息内容为空") logger.warn("QQ Bot: 消息内容为空")
return False 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 success_count = 0
try: try:
token = get_access_token(self._app_id, self._app_secret) token = get_access_token(self._app_id, self._app_secret)
for tgt, tgt_is_group in targets_to_send: for tgt, tgt_is_group in targets_to_send:
send_fn = send_proactive_group_message if tgt_is_group else send_proactive_c2c_message send_fn = send_proactive_group_message if tgt_is_group else send_proactive_c2c_message
try: try:
send_fn(token, tgt, content, use_markdown=use_markdown) send_fn(token, tgt, content, use_markdown=use_markdown, keyboard=keyboard)
success_count += 1 success_count += 1
logger.debug(f"QQ Bot: 消息已发送到 {'' if tgt_is_group else '用户'} {tgt}") logger.debug(f"QQ Bot: 消息已发送到 {'' if tgt_is_group else '用户'} {tgt}")
except Exception as e: except Exception as e:
@@ -371,7 +401,7 @@ class QQBot:
plain_parts.append(link) plain_parts.append(link)
plain_content = "\n".join(plain_parts).strip() plain_content = "\n".join(plain_parts).strip()
if plain_content: 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 success_count += 1
logger.debug(f"QQ Bot: Markdown 不可用,已回退纯文本发送至 {tgt}") logger.debug(f"QQ Bot: Markdown 不可用,已回退纯文本发送至 {tgt}")
else: else:
+5
View File
@@ -450,7 +450,12 @@ class ChannelCapabilityManager:
ChannelCapability.RICH_TEXT, ChannelCapability.RICH_TEXT,
ChannelCapability.IMAGES, ChannelCapability.IMAGES,
ChannelCapability.LINKS, 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, fallback_enabled=True,
), ),
} }