diff --git a/astrbot/api/all.py b/astrbot/api/all.py index 7fd5504ca3..ae032ca75d 100644 --- a/astrbot/api/all.py +++ b/astrbot/api/all.py @@ -15,6 +15,7 @@ # star register from astrbot.core.star.register import ( + register_button_interaction as button_interaction, register_command as command, register_command_group as command_group, register_event_message_type as event_message_type, diff --git a/astrbot/api/event/filter/__init__.py b/astrbot/api/event/filter/__init__.py index 650bce0425..209956a396 100644 --- a/astrbot/api/event/filter/__init__.py +++ b/astrbot/api/event/filter/__init__.py @@ -1,3 +1,4 @@ +from astrbot.core.star.filter.button_interaction import ButtonInteractionFilter from astrbot.core.star.filter.custom_filter import CustomFilter from astrbot.core.star.filter.event_message_type import ( EventMessageType, @@ -9,6 +10,9 @@ PlatformAdapterTypeFilter, ) from astrbot.core.star.register import register_after_message_sent as after_message_sent +from astrbot.core.star.register import ( + register_button_interaction as button_interaction, +) from astrbot.core.star.register import register_command as command from astrbot.core.star.register import register_command_group as command_group from astrbot.core.star.register import register_custom_filter as custom_filter @@ -41,6 +45,7 @@ __all__ = [ "CustomFilter", + "ButtonInteractionFilter", "EventMessageType", "EventMessageTypeFilter", "PermissionType", @@ -48,6 +53,7 @@ "PlatformAdapterType", "PlatformAdapterTypeFilter", "after_message_sent", + "button_interaction", "command", "command_group", "custom_filter", diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 942bcda65f..5c29f3a463 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -50,6 +50,7 @@ "slack", "lark", "line", + "mattermost", ] # 默认配置 @@ -540,6 +541,8 @@ "mattermost_url": "https://chat.example.com", "mattermost_bot_token": "", "mattermost_reconnect_delay": 5.0, + "unified_webhook_mode": True, + "webhook_uuid": "", }, # "WebChat": { # "id": "webchat", diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index b2136df99e..ad8c63e45a 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -15,6 +15,7 @@ import time import traceback from asyncio import Queue +from pathlib import Path from astrbot.api import logger, sp from astrbot.core import LogBroker, LogManager @@ -27,6 +28,9 @@ from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager from astrbot.core.persona_mgr import PersonaManager from astrbot.core.pipeline.scheduler import PipelineContext, PipelineScheduler +from astrbot.core.platform.button_interaction import ( + configure_button_callback_registry, +) from astrbot.core.platform.manager import PlatformManager from astrbot.core.platform_message_history_mgr import PlatformMessageHistoryManager from astrbot.core.process_restart import restart_process @@ -172,6 +176,9 @@ async def initialize(self) -> None: LogManager.configure_trace_logger(self.astrbot_config) await self.db.initialize() + button_callback_db_path = getattr(self.db, "db_path", None) + if isinstance(button_callback_db_path, (str, Path)): + configure_button_callback_registry(button_callback_db_path) if sp.db_helper is self.db: await sp.initialize() diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index 0916c14525..557310ce30 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -29,13 +29,21 @@ import uuid from enum import Enum from pathlib import Path, PurePosixPath +from typing import Any, Literal, TypeAlias from deprecated import deprecated if sys.version_info >= (3, 14): - from pydantic import BaseModel + from pydantic import BaseModel, Field, StrictBool, StrictFloat, StrictInt, StrictStr else: - from pydantic.v1 import BaseModel + from pydantic.v1 import ( + BaseModel, + Field, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, + ) from astrbot.core import astrbot_config, file_token_service, logger from astrbot.core.utils.astrbot_path import get_astrbot_temp_path @@ -50,6 +58,9 @@ class ComponentType(str, Enum): Record = "Record" # audio Video = "Video" # video File = "File" # file attachment + ActionRow = "ActionRow" # a row of interactive controls + Button = "Button" # an interactive button + ButtonInteraction = "ButtonInteraction" # an inbound button click # IM-specific Segment Types Face = "Face" # Emoji segment for Tencent QQ platform @@ -124,6 +135,104 @@ async def to_dict(self) -> dict: return {"type": "text", "data": {"text": self.text}} +JSONValue: TypeAlias = ( + StrictStr | StrictInt | StrictFloat | StrictBool | None | list[Any] | dict[str, Any] +) + + +class ButtonStyle(str, Enum): + """Portable visual intent for a button.""" + + DEFAULT = "default" + PRIMARY = "primary" + SUCCESS = "success" + DANGER = "danger" + + +class CallbackAction(BaseModel): + """Run bot-side logic when a button is clicked.""" + + type: Literal["callback"] = "callback" + data: JSONValue | None = None + + def __init__(self, **values) -> None: + """Validate callback context when the component is constructed. + + Args: + **values: Pydantic field values for the callback action. + + Raises: + ValueError: If data is not valid JSON. + """ + super().__init__(**values) + try: + json.dumps(self.data, allow_nan=False) + except (TypeError, ValueError) as exc: + raise ValueError("Button callback data must be JSON-compatible.") from exc + + +class UrlAction(BaseModel): + """Open a URL when a button is clicked.""" + + type: Literal["url"] = "url" + url: str = Field(min_length=1) + + +class Button(BaseMessageComponent): + """A portable interactive button.""" + + type: ComponentType = ComponentType.Button + id: str = Field(min_length=1) + label: str = Field(min_length=1) + action: CallbackAction | UrlAction + style: ButtonStyle = ButtonStyle.DEFAULT + + def toDict(self) -> dict: + """Serialize the button using the public message component format.""" + action = {"type": self.action.type} + if isinstance(self.action, CallbackAction): + if self.action.data is not None: + action["data"] = self.action.data + else: + action["url"] = self.action.url + return { + "type": "button", + "data": { + "id": self.id, + "label": self.label, + "action": action, + "style": self.style.value, + }, + } + + +class ActionRow(BaseMessageComponent): + """A group of buttons that should be displayed on one row when possible.""" + + type: ComponentType = ComponentType.ActionRow + buttons: list[Button] + fallback_text: str | None = None + + def toDict(self) -> dict: + """Serialize the row using the public message component format.""" + data: dict = { + "buttons": [button.toDict()["data"] for button in self.buttons], + } + if self.fallback_text is not None: + data["fallback_text"] = self.fallback_text + return {"type": "actionrow", "data": data} + + +class ButtonInteraction(BaseMessageComponent): + """Normalized inbound event produced by a callback button click.""" + + type: ComponentType = ComponentType.ButtonInteraction + action_id: str + data: JSONValue | None = None + interaction_id: str + source_message_id: str | None = None + + class Face(BaseMessageComponent): type: ComponentType = ComponentType.Face id: int @@ -924,6 +1033,9 @@ async def to_dict(self): "record": Record, "video": Video, "file": File, + "actionrow": ActionRow, + "button": Button, + "buttoninteraction": ButtonInteraction, # IM-specific Message Segments "face": Face, "at": At, diff --git a/astrbot/core/pipeline/waking_check/stage.py b/astrbot/core/pipeline/waking_check/stage.py index 342a20a67d..a0bf6ec25a 100644 --- a/astrbot/core/pipeline/waking_check/stage.py +++ b/astrbot/core/pipeline/waking_check/stage.py @@ -5,6 +5,7 @@ from astrbot.core.message.message_event_result import MessageChain, MessageEventResult from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.platform.message_type import MessageType +from astrbot.core.star.filter.button_interaction import ButtonInteractionFilter from astrbot.core.star.filter.command_group import CommandGroupFilter from astrbot.core.star.filter.permission import PermissionTypeFilter from astrbot.core.star.session_plugin_manager import SessionPluginManager @@ -142,9 +143,13 @@ async def process( event.is_at_or_wake_command = True break # 检查是否是私聊 - if event.is_private_chat() and ( - not self.friend_message_needs_wake_prefix - or event.get_platform_name() == "webchat" + if ( + not event.is_button_interaction() + and event.is_private_chat() + and ( + not self.friend_message_needs_wake_prefix + or event.get_platform_name() == "webchat" + ) ): is_wake = True event.is_wake = True @@ -168,6 +173,11 @@ async def process( EventType.AdapterMessageEvent, plugins_name=event.plugins_name, ): + if event.is_button_interaction() and not any( + isinstance(handler_filter, ButtonInteractionFilter) + for handler_filter in handler.event_filters + ): + continue if ( self.disable_builtin_commands and handler.handler_module_path diff --git a/astrbot/core/platform/astr_message_event.py b/astrbot/core/platform/astr_message_event.py index 0fbddf35f0..72c6fca432 100644 --- a/astrbot/core/platform/astr_message_event.py +++ b/astrbot/core/platform/astr_message_event.py @@ -17,6 +17,7 @@ At, AtAll, BaseMessageComponent, + ButtonInteraction, Face, Forward, Image, @@ -181,6 +182,24 @@ def get_messages(self) -> list[BaseMessageComponent]: """获取消息链。""" return getattr(self.message_obj, "message", []) + def is_button_interaction(self) -> bool: + """Return whether this event represents a portable button click.""" + return any( + isinstance(component, ButtonInteraction) + for component in self.get_messages() + ) + + def get_button_interaction(self) -> ButtonInteraction | None: + """Return the normalized button click carried by this event, if any.""" + return next( + ( + component + for component in self.get_messages() + if isinstance(component, ButtonInteraction) + ), + None, + ) + def get_message_type(self) -> MessageType: """获取消息类型。""" message_type = getattr(self.message_obj, "type", None) diff --git a/astrbot/core/platform/button_interaction.py b/astrbot/core/platform/button_interaction.py new file mode 100644 index 0000000000..92f9aa9c58 --- /dev/null +++ b/astrbot/core/platform/button_interaction.py @@ -0,0 +1,166 @@ +"""Helpers for transporting portable button callback payloads.""" + +import hashlib +import json +import sqlite3 +import threading +from copy import deepcopy +from pathlib import Path + +from astrbot.core.message.components import JSONValue + +BUTTON_CALLBACK_PREFIX = "astrbot:" + + +class _ButtonCallbackRegistry: + """Store callback data behind compact, platform-safe tokens.""" + + def __init__(self) -> None: + self._cache: dict[str, tuple[str, JSONValue | None]] = {} + self._db_path: Path | None = None + self._lock = threading.RLock() + + def configure(self, db_path: str | Path) -> None: + """Enable persistent callback lookup using AstrBot's SQLite database. + + Args: + db_path: Path to the initialized AstrBot SQLite database. + """ + resolved_path = Path(db_path) + with self._lock, sqlite3.connect(resolved_path, timeout=30) as connection: + connection.execute( + "CREATE TABLE IF NOT EXISTS button_callbacks (" + "token TEXT PRIMARY KEY, payload TEXT NOT NULL)" + ) + connection.commit() + self._db_path = resolved_path + + def register(self, action_id: str, data: JSONValue | None) -> str: + """Register one callback payload and return its compact token. + + Args: + action_id: Stable identifier used to route the click. + data: Optional JSON-compatible callback context. + + Returns: + A deterministic URL-safe token. + + Raises: + ValueError: If the payload is not JSON-compatible. + """ + try: + payload = json.dumps( + {"i": action_id, "d": data}, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ) + except (TypeError, ValueError) as exc: + raise ValueError("Button callback data must be JSON-compatible.") from exc + + token = hashlib.blake2s(payload.encode("utf-8"), digest_size=16).hexdigest() + with self._lock: + self._cache[token] = (action_id, deepcopy(data)) + if self._db_path is not None: + with sqlite3.connect(self._db_path, timeout=30) as connection: + connection.execute( + "INSERT INTO button_callbacks(token, payload) VALUES (?, ?) " + "ON CONFLICT(token) DO UPDATE SET payload = excluded.payload", + (token, payload), + ) + connection.commit() + return token + + def resolve(self, token: str) -> tuple[str, JSONValue | None]: + """Resolve a callback token from memory or persistent storage. + + Args: + token: Compact callback token returned by register(). + + Returns: + The registered action identifier and callback data. + + Raises: + ValueError: If the token is unknown or its stored payload is invalid. + """ + with self._lock: + cached = self._cache.get(token) + if cached is not None: + return cached[0], deepcopy(cached[1]) + + payload = None + if self._db_path is not None: + with sqlite3.connect(self._db_path, timeout=30) as connection: + row = connection.execute( + "SELECT payload FROM button_callbacks WHERE token = ?", + (token,), + ).fetchone() + if row is not None: + payload = row[0] + + if payload is None: + raise ValueError("Unknown AstrBot button callback token.") + try: + decoded = json.loads(payload) + except (TypeError, json.JSONDecodeError) as exc: + raise ValueError("Invalid stored button callback payload.") from exc + if not isinstance(decoded, dict) or not isinstance(decoded.get("i"), str): + raise ValueError("Invalid stored button callback payload.") + resolved = (decoded["i"], decoded.get("d")) + self._cache[token] = resolved + return resolved[0], deepcopy(resolved[1]) + + +_button_callback_registry = _ButtonCallbackRegistry() + + +def configure_button_callback_registry(db_path: str | Path) -> None: + """Enable persistent callback tokens after the core database is initialized. + + Args: + db_path: Path to AstrBot's SQLite database. + """ + _button_callback_registry.configure(db_path) + + +def encode_button_callback( + action_id: str, + data: JSONValue | None = None, +) -> str: + """Encode a callback action as a compact opaque token. + + Args: + action_id: Stable identifier used by plugin code to route the click. + data: Optional JSON-compatible context returned with the click. + + Returns: + A compact token that does not expose callback data to the IM platform. + + Raises: + ValueError: If action_id is empty or data cannot be serialized as JSON. + """ + if not action_id: + raise ValueError("Button action_id cannot be empty.") + token = _button_callback_registry.register(action_id, data) + return f"{BUTTON_CALLBACK_PREFIX}{token}" + + +def decode_button_callback(payload: str) -> tuple[str, JSONValue | None]: + """Resolve an AstrBot button callback token. + + Args: + payload: The exact callback value returned by the IM platform. + + Returns: + The action identifier and its optional JSON data. + + Raises: + ValueError: If the payload is not a known AstrBot button callback. + """ + if not payload.startswith(BUTTON_CALLBACK_PREFIX): + raise ValueError("Not an AstrBot button callback payload.") + token = payload.removeprefix(BUTTON_CALLBACK_PREFIX) + if len(token) != 32 or any(char not in "0123456789abcdef" for char in token): + raise ValueError("Invalid AstrBot button callback token.") + return _button_callback_registry.resolve(token) diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py index 91a7444f38..efb4405386 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py @@ -6,14 +6,18 @@ from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.message_components import ( + ActionRow, At, BaseMessageComponent, + Button, + CallbackAction, File, Image, Node, Nodes, Plain, Record, + UrlAction, Video, ) from astrbot.api.platform import Group, MessageMember @@ -81,6 +85,30 @@ async def _parse_onebot_json(message_chain: MessageChain): continue d = await AiocqhttpMessageEvent._from_segment_to_dict(segment) ret.append(d) + elif isinstance(segment, ActionRow | Button): + # OneBot v11 has no portable button segment. Keep URL actions usable + # as plain links and make callback degradation explicit. + lines = [] + if isinstance(segment, ActionRow): + buttons = segment.buttons + if segment.fallback_text and segment.fallback_text.strip(): + lines.append(segment.fallback_text.strip()) + else: + buttons = [segment] + + for button in buttons: + if isinstance(button.action, UrlAction): + lines.append(f"{button.label}: {button.action.url}") + elif isinstance(button.action, CallbackAction): + lines.append(f"[Button unavailable] {button.label}") + + if lines: + ret.append( + { + "type": "text", + "data": {"text": "\n".join(lines)}, + } + ) else: d = await AiocqhttpMessageEvent._from_segment_to_dict(segment) ret.append(d) diff --git a/astrbot/core/platform/sources/dingtalk/app_registration.py b/astrbot/core/platform/sources/dingtalk/app_registration.py index c177d30ae8..10b64ebabd 100644 --- a/astrbot/core/platform/sources/dingtalk/app_registration.py +++ b/astrbot/core/platform/sources/dingtalk/app_registration.py @@ -121,7 +121,7 @@ async def poll_dingtalk_app_registration_once(device_code: str) -> dict[str, Any def dingtalk_registration_poll_result(raw: dict[str, Any]) -> dict[str, Any]: status_raw = _string_field(raw, "status").upper() - if status_raw == "WAITING": + if status_raw in {"WAITING", "CREATING"}: return {"status": "pending"} if status_raw == "SUCCESS": client_id = _string_field(raw, "client_id") diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py index bae98a8076..e440ad3f5f 100644 --- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py @@ -13,7 +13,19 @@ from astrbot import logger from astrbot.api.event import MessageChain -from astrbot.api.message_components import At, File, Image, Plain, Record, Video +from astrbot.api.message_components import ( + ActionRow, + At, + ButtonInteraction, + ButtonStyle, + CallbackAction, + File, + Image, + Plain, + Record, + UrlAction, + Video, +) from astrbot.api.platform import ( AstrBotMessage, MessageMember, @@ -23,6 +35,10 @@ ) from astrbot.core import sp from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.button_interaction import ( + decode_button_callback, + encode_button_callback, +) from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file from astrbot.core.utils.media_utils import ( @@ -39,6 +55,8 @@ DINGTALK_RECONNECT_INITIAL_DELAY = 10 DINGTALK_RECONNECT_MAX_DELAY = 300 DINGTALK_RECONNECT_STABLE_SECONDS = 300 +DINGTALK_BUTTON_CARD_TEMPLATE_ID = "382e4302-551d-4880-bf29-a30acfab2e71.schema" +DINGTALK_CARD_CALLBACK_TOPIC = "/v1.0/card/instances/callback" def _dingtalk_reconnect_delay(retry_count: int) -> int: @@ -87,7 +105,15 @@ async def process(self, message: dingtalk_stream.CallbackMessage): return AckMessage.STATUS_OK, "OK" + class AstrCardCallbackClient(dingtalk_stream.CallbackHandler): + async def process(self, message: dingtalk_stream.CallbackMessage): + abm = outer_self.convert_card_callback(message) + if abm is not None: + await outer_self.handle_msg(abm) + return AckMessage.STATUS_OK, "OK" + self.client = AstrCallbackClient() + self.card_callback_client = AstrCardCallbackClient() credential = dingtalk_stream.Credential(self.client_id, self.client_secret) client = dingtalk_stream.DingTalkStreamClient(credential, logger=logger) @@ -96,6 +122,10 @@ async def process(self, message: dingtalk_stream.CallbackMessage): dingtalk_stream.ChatbotMessage.TOPIC, self.client, ) + client.register_callback_handler( + DINGTALK_CARD_CALLBACK_TOPIC, + self.card_callback_client, + ) self.client_ = client # 用于 websockets 的 client self._shutdown_event = threading.Event() self._terminated_event = threading.Event() @@ -318,6 +348,102 @@ async def convert_msg( await self._remember_sender_binding(message, abm) return abm # 别忘了返回转换后的消息对象 + def convert_card_callback( + self, + callback: dingtalk_stream.CallbackMessage, + ) -> AstrBotMessage | None: + """Convert a DingTalk interactive-card callback into an AstrBot event. + + Args: + callback: Callback frame received from DingTalk Stream mode. + + Returns: + A normalized AstrBot button event, or ``None`` for foreign or malformed + card actions. + """ + raw_data = callback.data + if not isinstance(raw_data, dict): + return None + + content = raw_data.get("content", {}) + if isinstance(content, str): + try: + content = json.loads(content) + except json.JSONDecodeError: + logger.debug("忽略无效的钉钉卡片回调 content") + return None + if not isinstance(content, dict): + return None + + card_private_data = content.get("cardPrivateData", {}) + if not isinstance(card_private_data, dict): + return None + action_ids = card_private_data.get("actionIds", []) + if not isinstance(action_ids, list) or not action_ids: + return None + params = card_private_data.get("params", {}) + if not isinstance(params, dict): + params = {} + + decoded_callback = None + for native_action_id in action_ids: + if not isinstance(native_action_id, str): + continue + for callback_payload in ( + params.get("id"), + params.get(native_action_id), + native_action_id, + ): + if not isinstance(callback_payload, str): + continue + try: + decoded_callback = decode_button_callback(callback_payload) + break + except ValueError: + continue + if decoded_callback is not None: + break + if decoded_callback is None: + logger.debug( + "忽略非 AstrBot 生成的钉钉卡片回调: actionIds=%s, paramKeys=%s", + action_ids, + list(params), + ) + return None + action_id, data = decoded_callback + + interaction_id = cast( + str, + getattr(callback.headers, "message_id", None) or uuid.uuid4().hex, + ) + user_id = cast(str, raw_data.get("userId") or "unknown") + space_type = cast(str, raw_data.get("spaceType") or "") + space_id = cast(str, raw_data.get("spaceId") or "") + is_group = space_type == "IM_GROUP" + + abm = AstrBotMessage() + abm.type = MessageType.GROUP_MESSAGE if is_group else MessageType.FRIEND_MESSAGE + abm.self_id = self.client_id + abm.sender = MessageMember(user_id=user_id, nickname=user_id) + abm.session_id = space_id if is_group and space_id else user_id + if is_group: + abm.group_id = abm.session_id + abm.message_id = interaction_id + abm.message_str = action_id + abm.message = [ + ButtonInteraction( + action_id=action_id, + data=data, + interaction_id=interaction_id, + source_message_id=cast(str | None, raw_data.get("outTrackId")), + ) + ] + abm.raw_message = callback + callback_time = getattr(callback.headers, "time", None) + if isinstance(callback_time, int): + abm.timestamp = callback_time // 1000 + return abm + async def _remember_sender_binding( self, message: dingtalk_stream.ChatbotMessage, @@ -501,6 +627,114 @@ async def _send_private_message( f"钉钉私聊消息发送失败: {resp.status}, {await resp.text()}", ) + async def _send_button_card( + self, + target_type: Literal["group", "user"], + target_id: str, + robot_code: str, + content: str, + rows: list[ActionRow], + ) -> bool: + """Send portable buttons through DingTalk's built-in dynamic card. + + Args: + target_type: Whether the destination is a group or a user. + target_id: Open conversation ID or staff ID. + robot_code: DingTalk robot code used for group delivery. + content: Markdown content displayed above the buttons. + rows: Portable action rows to flatten into DingTalk's button list. + + Returns: + Whether DingTalk accepted the interactive card. + """ + colors = { + ButtonStyle.DEFAULT: "gray", + ButtonStyle.PRIMARY: "blue", + ButtonStyle.SUCCESS: "green", + ButtonStyle.DANGER: "red", + } + buttons: list[dict] = [] + for row in rows: + for button in row.buttons: + item = { + "text": button.label, + "color": colors[button.style], + "id": button.id, + } + if isinstance(button.action, CallbackAction): + try: + item["id"] = encode_button_callback( + button.id, + button.action.data, + ) + except ValueError as exc: + logger.warning(f"钉钉按钮卡片编码失败: {exc}") + return False + item["request"] = True + elif isinstance(button.action, UrlAction): + item["url"] = button.action.url + item["iosUrl"] = button.action.url + buttons.append(item) + if not buttons: + return False + + access_token = await self.get_access_token() + if not access_token: + logger.warning("钉钉按钮卡片发送失败: access_token 为空") + return False + + card_instance_id = uuid.uuid4().hex + payload = { + "cardTemplateId": DINGTALK_BUTTON_CARD_TEMPLATE_ID, + "outTrackId": card_instance_id, + "cardData": { + "cardParamMap": { + "flowStatus": "3", + "staticMsgContent": content or " ", + "sys_full_json_obj": json.dumps( + { + "order": ["staticMsgContent", "msgButtons"], + "msgButtons": buttons, + }, + ensure_ascii=False, + separators=(",", ":"), + ), + } + }, + "callbackType": "STREAM", + "imGroupOpenSpaceModel": {"supportForward": True}, + "imRobotOpenSpaceModel": {"supportForward": True}, + } + if target_type == "group": + payload["openSpaceId"] = f"dtv1.card//IM_GROUP.{target_id}" + payload["imGroupOpenDeliverModel"] = {"robotCode": robot_code} + else: + payload["openSpaceId"] = f"dtv1.card//IM_ROBOT.{target_id}" + payload["imRobotOpenDeliverModel"] = {"spaceType": "IM_ROBOT"} + + headers = { + "Content-Type": "application/json", + "x-acs-dingtalk-access-token": access_token, + } + try: + async with ( + aiohttp.ClientSession() as session, + session.post( + "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver", + headers=headers, + json=payload, + ) as resp, + ): + if 200 <= resp.status < 300: + return True + logger.warning( + f"钉钉按钮卡片发送失败: {resp.status}, {await resp.text()}" + ) + return False + except (aiohttp.ClientError, TimeoutError) as exc: + logger.warning(f"钉钉按钮卡片发送失败: {exc}") + return False + def _safe_remove_file(self, file_path: str | None) -> None: if not file_path: return @@ -583,8 +817,50 @@ async def send_message(msg_key: str, msg_param: dict) -> None: msg_param=msg_param, ) + action_rows = [ + segment for segment in message_chain.chain if isinstance(segment, ActionRow) + ] + if action_rows: + card_content = "\n".join( + segment.text.strip() + for segment in message_chain.chain + if isinstance(segment, Plain) and segment.text.strip() + ) + card_content = f"{at_str} {card_content}".strip() + if not card_content: + card_content = "\n".join( + row.fallback_text for row in action_rows if row.fallback_text + ) + card_sent = await self._send_button_card( + target_type=target_type, + target_id=target_id, + robot_code=robot_code, + content=card_content, + rows=action_rows, + ) + if not card_sent: + fallback_parts = [card_content] if card_content else [] + for row in action_rows: + for button in row.buttons: + if isinstance(button.action, UrlAction): + fallback_parts.append( + f"[{button.label}]({button.action.url})" + ) + else: + fallback_parts.append(f"[{button.label}]") + fallback_text = "\n".join(fallback_parts) + if fallback_text: + await send_message( + msg_key="sampleMarkdown", + msg_param={"title": "AstrBot", "text": fallback_text}, + ) + for segment in message_chain.chain: + if isinstance(segment, ActionRow): + continue if isinstance(segment, Plain): + if action_rows: + continue text = segment.text.strip() if not text and not at_str: continue diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_event.py b/astrbot/core/platform/sources/dingtalk/dingtalk_event.py index 3331c51476..b54cc3ae62 100644 --- a/astrbot/core/platform/sources/dingtalk/dingtalk_event.py +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_event.py @@ -2,6 +2,7 @@ from astrbot import logger from astrbot.api.event import AstrMessageEvent, MessageChain +from astrbot.core.platform.astr_message_event import MessageSesion class DingtalkMessageEvent(AstrMessageEvent): @@ -22,10 +23,21 @@ async def send(self, message: MessageChain) -> None: if not self.adapter: logger.error("钉钉消息发送失败: 缺少 adapter") return - await self.adapter.send_message_chain_with_incoming( - incoming_message=self.message_obj.raw_message, - message_chain=message, - ) + raw_message = self.message_obj.raw_message + if hasattr(raw_message, "conversation_type"): + await self.adapter.send_message_chain_with_incoming( + incoming_message=raw_message, + message_chain=message, + ) + else: + await self.adapter.send_by_session( + MessageSesion( + platform_name=self.platform_meta.id, + message_type=self.get_message_type(), + session_id=self.session_id, + ), + message, + ) await super().send(message) async def send_streaming(self, generator, use_fallback: bool = False): diff --git a/astrbot/core/platform/sources/discord/client.py b/astrbot/core/platform/sources/discord/client.py index fb21ac3d89..531b1dc027 100644 --- a/astrbot/core/platform/sources/discord/client.py +++ b/astrbot/core/platform/sources/discord/client.py @@ -109,6 +109,32 @@ async def on_message(self, message: discord.Message) -> None: message_data = self._create_message_data(message) await self.on_message_received(message_data) + async def on_interaction(self, interaction: discord.Interaction) -> None: + """Dispatch application commands and acknowledge callback buttons. + + Args: + interaction: Discord interaction received by the bot. + """ + if interaction.type != discord.InteractionType.component: + await super().on_interaction(interaction) + return + + interaction_data = interaction.data or {} + if ( + interaction_data.get("component_type") != discord.ComponentType.button.value + or not self.on_message_received + ): + await super().on_interaction(interaction) + return + + try: + if not interaction.response.is_done(): + await interaction.response.defer() + except Exception as e: + logger.warning(f"[Discord] Failed to acknowledge interaction: {e}") + + await self.on_message_received(self._create_interaction_data(interaction)) + def _extract_interaction_content(self, interaction: discord.Interaction) -> str: """从交互中提取内容""" interaction_type = interaction.type diff --git a/astrbot/core/platform/sources/discord/components.py b/astrbot/core/platform/sources/discord/components.py index 433509f5e1..15a347d29d 100644 --- a/astrbot/core/platform/sources/discord/components.py +++ b/astrbot/core/platform/sources/discord/components.py @@ -1,6 +1,69 @@ import discord -from astrbot.api.message_components import BaseMessageComponent +from astrbot.api.message_components import ( + ActionRow, + BaseMessageComponent, + ButtonStyle, + CallbackAction, + UrlAction, +) +from astrbot.core.platform.button_interaction import encode_button_callback + + +def action_rows_to_discord_view( + action_rows: list[ActionRow], +) -> discord.ui.View: + """Convert common action rows to a Discord view. + + Args: + action_rows: Common action rows to render. + + Returns: + A Discord view containing the supported buttons. + + Raises: + ValueError: If the rows exceed Discord's component limits. + """ + if len(action_rows) > 5: + raise ValueError("Discord messages support at most five action rows.") + + view = discord.ui.View(timeout=None) + style_mapping = { + ButtonStyle.DEFAULT: discord.ButtonStyle.secondary, + ButtonStyle.PRIMARY: discord.ButtonStyle.primary, + ButtonStyle.SUCCESS: discord.ButtonStyle.success, + ButtonStyle.DANGER: discord.ButtonStyle.danger, + } + + for row_index, action_row in enumerate(action_rows): + if len(action_row.buttons) > 5: + raise ValueError("Discord action rows support at most five buttons.") + for button in action_row.buttons: + label = button.label[:80] + if isinstance(button.action, UrlAction): + item = discord.ui.Button( + label=label, + style=discord.ButtonStyle.link, + url=button.action.url, + row=row_index, + ) + elif isinstance(button.action, CallbackAction): + custom_id = encode_button_callback(button.id, button.action.data) + if len(custom_id) > 100: + raise ValueError( + f"Discord button callback payload exceeds 100 characters: {button.id}" + ) + item = discord.ui.Button( + label=label, + style=style_mapping[button.style], + custom_id=custom_id, + row=row_index, + ) + else: + continue + view.add_item(item) + + return view # Discord专用组件 diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py index 7c6e009254..92802a877f 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_adapter.py +++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py @@ -9,7 +9,7 @@ from astrbot import logger from astrbot.api.event import MessageChain -from astrbot.api.message_components import File, Image, Plain, Record +from astrbot.api.message_components import ButtonInteraction, File, Image, Plain, Record from astrbot.api.platform import ( AstrBotMessage, MessageMember, @@ -19,6 +19,7 @@ register_platform_adapter, ) from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.button_interaction import decode_button_callback from astrbot.core.star.filter.command import CommandFilter from astrbot.core.star.filter.command_group import CommandGroupFilter from astrbot.core.star.star import star_map @@ -125,7 +126,9 @@ async def on_received(message_data) -> None: if self.bot_self_id is None: self.bot_self_id = message_data.get("bot_id") abm = await self.convert_message(data=message_data) - await self.handle_msg(abm) + interaction = message_data.get("interaction") + followup_webhook = interaction.followup if interaction is not None else None + await self.handle_msg(abm, followup_webhook) # 初始化 Discord 客户端 token = str(self.config.get("discord_token")) @@ -261,7 +264,49 @@ def _convert_message_to_abm(self, data: dict) -> AstrBotMessage: async def convert_message(self, data: dict) -> AstrBotMessage: """将平台消息转换成 AstrBotMessage""" - # 由于 on_interaction 已被禁用,我们只处理普通消息 + if data.get("type") == "interaction": + interaction = cast(discord.Interaction, data["interaction"]) + interaction_data = cast(dict, interaction.data or {}) + callback_payload = str(interaction_data.get("custom_id", "")) + try: + action_id, callback_data = decode_button_callback(callback_payload) + except (TypeError, ValueError): + # Legacy DiscordButton custom IDs were sent without an AstrBot envelope. + action_id, callback_data = callback_payload, None + + abm = AstrBotMessage() + abm.type = ( + MessageType.GROUP_MESSAGE + if interaction.guild_id is not None + else MessageType.FRIEND_MESSAGE + ) + abm.group_id = ( + str(interaction.channel_id) + if interaction.channel_id is not None + else None + ) + abm.message_str = action_id + abm.sender = MessageMember( + user_id=str(interaction.user.id), + nickname=interaction.user.display_name, + ) + source_message = getattr(interaction, "message", None) + abm.message = [ + ButtonInteraction( + action_id=action_id, + data=callback_data, + interaction_id=str(interaction.id), + source_message_id=( + str(source_message.id) if source_message is not None else None + ), + ) + ] + abm.raw_message = interaction + abm.self_id = cast(str, self.bot_self_id) + abm.session_id = str(interaction.channel_id or interaction.user.id) + abm.message_id = str(interaction.id) + return abm + abm = self._convert_message_to_abm(data) for component in abm.message: if isinstance(component, Record): @@ -308,11 +353,14 @@ async def handle_msg(self, message: AstrBotMessage, followup_webhook=None) -> No ) return - # 检查是否为斜杠指令 - is_slash_command = message_event.interaction_followup_webhook is not None + if message_event.is_button_interaction(): + message_event.is_wake = True + message_event.is_at_or_wake_command = True + self.commit_event(message_event) + return - # 1. 优先处理斜杠指令 - if is_slash_command: + # Slash commands carry a follow-up webhook after their initial defer. + if message_event.is_slash_command(): message_event.is_wake = True message_event.is_at_or_wake_command = True self.commit_event(message_event) diff --git a/astrbot/core/platform/sources/discord/discord_platform_event.py b/astrbot/core/platform/sources/discord/discord_platform_event.py index ff085dafd6..3c5ae1677f 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_event.py +++ b/astrbot/core/platform/sources/discord/discord_platform_event.py @@ -10,6 +10,7 @@ from astrbot import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.message_components import ( + ActionRow, BaseMessageComponent, File, Image, @@ -25,7 +26,7 @@ ) from .client import DiscordBotClient -from .components import DiscordEmbed, DiscordView +from .components import DiscordEmbed, DiscordView, action_rows_to_discord_view # 自定义Discord视图组件(兼容旧版本) @@ -143,6 +144,7 @@ async def _parse_to_discord( content_parts = [] files = [] view = None + action_rows = [] embeds = [] reference_message_id = None for i in message.chain: # 遍历消息链 @@ -262,9 +264,18 @@ async def _parse_to_discord( # 如果消息链中包含Discord视图组件(兼容旧版本) if isinstance(i.view, discord.ui.View): view = i.view + elif isinstance(i, ActionRow): + action_rows.append(i) else: logger.debug(f"[Discord] 忽略了不支持的消息组件: {i.type}") + if action_rows: + if view is not None: + logger.warning( + "[Discord] Common ActionRow components override a legacy Discord view." + ) + view = action_rows_to_discord_view(action_rows) + content = "".join(content_parts) if len(content) > 2000: logger.warning("[Discord] 消息内容超过2000字符,将被截断。") @@ -293,17 +304,11 @@ def is_slash_command(self) -> bool: == discord.InteractionType.application_command ) - def is_button_interaction(self) -> bool: - """判断是否为按钮交互""" - return ( - hasattr(self.message_obj, "raw_message") - and hasattr(self.message_obj.raw_message, "type") - and cast(discord.Interaction, self.message_obj.raw_message).type - == discord.InteractionType.component - ) - def get_interaction_custom_id(self) -> str: - """获取交互组件的custom_id""" + """Return the portable action ID, with legacy raw-data fallback.""" + button_interaction = self.get_button_interaction() + if button_interaction is not None: + return button_interaction.action_id if self.is_button_interaction(): try: return cast( diff --git a/astrbot/core/platform/sources/kook/kook_adapter.py b/astrbot/core/platform/sources/kook/kook_adapter.py index e23f781912..0f91c74eca 100644 --- a/astrbot/core/platform/sources/kook/kook_adapter.py +++ b/astrbot/core/platform/sources/kook/kook_adapter.py @@ -13,8 +13,15 @@ PlatformMetadata, register_platform_adapter, ) -from astrbot.core.message.components import BaseMessageComponent, File, Record, Video +from astrbot.core.message.components import ( + BaseMessageComponent, + ButtonInteraction, + File, + Record, + Video, +) from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.button_interaction import decode_button_callback from astrbot.core.utils.media_utils import MediaResolver from .kook_client import KookClient @@ -95,6 +102,13 @@ async def _on_received(self, event: KookMessageEventData): logger.error(f"[KOOK] 消息处理异常: {e}") elif event_type == KookMessageType.SYSTEM: match event.extra.type: + case "message_btn_click": + try: + abm = await self.convert_message(event) + if abm is not None: + await self.handle_msg(abm) + except Exception as e: + logger.error(f"[KOOK] Failed to process button click: {e}") case KookRoleExtraType(): # 此时 target_id 就是频道id(guild_id) guild_id = event.target_id @@ -439,7 +453,89 @@ def _handle_image_group( valid_urls.append(el.src) return valid_urls - async def convert_message(self, data: KookMessageEventData) -> AstrBotMessage: + def _convert_button_interaction( + self, + data: KookMessageEventData, + ) -> AstrBotMessage | None: + """Convert a KOOK button system event into a portable interaction. + + Args: + data: Parsed KOOK system event with a ``message_btn_click`` body. + + Returns: + A normalized AstrBot message, or ``None`` for malformed or foreign + callback payloads. + """ + body = data.extra.body + if not isinstance(body, dict): + logger.debug("[KOOK] Ignored a button click without an event body.") + return None + + callback_payload = body.get("value") + if not isinstance(callback_payload, str): + logger.debug("[KOOK] Ignored a button click without a callback value.") + return None + try: + action_id, callback_data = decode_button_callback(callback_payload) + except ValueError: + logger.debug("[KOOK] Ignored a button click not created by AstrBot.") + return None + + user_id = str(body.get("user_id") or "").strip() + if not user_id: + logger.debug("[KOOK] Ignored a button click without a user id.") + return None + + target_id = str(body.get("target_id") or data.target_id or "unknown") + user_info = body.get("user_info") + if not isinstance(user_info, dict): + user_info = {} + nickname = str( + user_info.get("nickname") or user_info.get("username") or user_id + ) + + abm = AstrBotMessage() + abm.raw_message = data.to_dict() + abm.self_id = str(self.client.bot_id) + abm.sender = MessageMember(user_id=user_id, nickname=nickname) + abm.message_id = data.msg_id or "unknown" + abm.timestamp = ( + data.msg_timestamp // 1000 + if data.msg_timestamp > 10_000_000_000 + else data.msg_timestamp + ) + if data.channel_type == KookChannelType.GROUP: + abm.type = MessageType.GROUP_MESSAGE + abm.group_id = target_id + abm.session_id = target_id + elif data.channel_type == KookChannelType.PERSON: + abm.type = MessageType.FRIEND_MESSAGE + abm.session_id = user_id + else: + abm.type = MessageType.OTHER_MESSAGE + abm.group_id = target_id + abm.session_id = target_id + + abm.message_str = action_id + abm.message = [ + ButtonInteraction( + action_id=action_id, + data=callback_data, + interaction_id=abm.message_id, + source_message_id=str(body.get("msg_id") or "") or None, + ) + ] + return abm + + async def convert_message( + self, + data: KookMessageEventData, + ) -> AstrBotMessage | None: + if data.type == KookMessageType.SYSTEM: + if data.extra.type == "message_btn_click": + return self._convert_button_interaction(data) + return None + abm = AstrBotMessage() abm.raw_message = data.to_dict() abm.self_id = self.client.bot_id diff --git a/astrbot/core/platform/sources/kook/kook_event.py b/astrbot/core/platform/sources/kook/kook_event.py index 884d066d8d..6a51a830e7 100644 --- a/astrbot/core/platform/sources/kook/kook_event.py +++ b/astrbot/core/platform/sources/kook/kook_event.py @@ -8,21 +8,29 @@ from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.platform import AstrBotMessage, PlatformMetadata from astrbot.core.message.components import ( + ActionRow, At, AtAll, BaseMessageComponent, + Button, + ButtonStyle, + CallbackAction, File, Image, Json, Plain, Record, Reply, + UrlAction, Video, ) from astrbot.core.platform import MessageType +from astrbot.core.platform.button_interaction import encode_button_callback from .kook_client import KookClient from .kook_types import ( + ActionGroupModule, + ButtonElement, FileModule, KookCardMessage, KookCardMessageContainer, @@ -132,6 +140,67 @@ async def handle_audio(index: int, f_item: Record): return handle_plain(index, "(met)all(met)") case Reply(): return handle_plain(index, "", reply_id=message_component.id) + case ActionRow() | Button(): + buttons = ( + message_component.buttons + if isinstance(message_component, ActionRow) + else [message_component] + ) + if not buttons: + fallback_text = ( + message_component.fallback_text + if isinstance(message_component, ActionRow) + else "" + ) + return handle_plain(index, fallback_text) + + theme_map = { + ButtonStyle.DEFAULT: "secondary", + ButtonStyle.PRIMARY: "primary", + ButtonStyle.SUCCESS: "success", + ButtonStyle.DANGER: "danger", + } + elements = [] + for button in buttons: + if isinstance(button.action, CallbackAction): + click = "return-val" + value = encode_button_callback( + button.id, + button.action.data, + ) + elif isinstance(button.action, UrlAction): + click = "link" + value = button.action.url + else: + raise ValueError( + f"Unsupported KOOK button action: {button.action.type}" + ) + elements.append( + ButtonElement( + text=button.label, + theme=theme_map[button.style], + value=value, + click=click, + ) + ) + + modules = [ + ActionGroupModule(elements=elements[offset : offset + 4]) + for offset in range(0, len(elements), 4) + ] + cards = [ + KookCardMessage(modules=modules[offset : offset + 50]) + for offset in range(0, len(modules), 50) + ] + if len(cards) > 5: + raise ValueError( + "KOOK supports at most 1,000 buttons in one card message." + ) + return handle_plain( + index, + text=KookCardMessageContainer(cards).to_json(), + type=KookMessageType.CARD, + ) case Json(): json_data = message_component.data # kook卡片json外层得是一个列表 diff --git a/astrbot/core/platform/sources/lark/lark_adapter.py b/astrbot/core/platform/sources/lark/lark_adapter.py index c03ed48cb9..23ca1c7d38 100644 --- a/astrbot/core/platform/sources/lark/lark_adapter.py +++ b/astrbot/core/platform/sources/lark/lark_adapter.py @@ -13,6 +13,10 @@ GetMessageResourceRequest, ) from lark_oapi.api.im.v1.processor import P2ImMessageReceiveV1Processor +from lark_oapi.event.callback.model.p2_card_action_trigger import ( + P2CardActionTrigger, + P2CardActionTriggerResponse, +) import astrbot.api.message_components as Comp from astrbot import logger @@ -25,6 +29,7 @@ PlatformMetadata, ) from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.button_interaction import decode_button_callback from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.media_utils import MediaResolver from astrbot.core.utils.webhook_utils import log_webhook_info @@ -63,13 +68,22 @@ async def on_msg_event_recv(event: lark.im.v1.P2ImMessageReceiveV1) -> None: def do_v2_msg_event(event: lark.im.v1.P2ImMessageReceiveV1) -> None: asyncio.create_task(on_msg_event_recv(event)) + def do_card_action_trigger( + event: P2CardActionTrigger, + ) -> P2CardActionTriggerResponse: + # Return immediately so Lark receives the callback ACK within three seconds. + asyncio.create_task(self.convert_card_action(event)) + return P2CardActionTriggerResponse() + self.event_handler = ( lark.EventDispatcherHandler.builder("", "") .register_p2_im_message_receive_v1(do_v2_msg_event) + .register_p2_card_action_trigger(do_card_action_trigger) .build() ) self.do_v2_msg_event = do_v2_msg_event + self.do_card_action_trigger = do_card_action_trigger self.client = lark.ws.Client( app_id=self.appid, @@ -487,6 +501,7 @@ async def send_by_session( self.lark_api, receive_id=receive_id, receive_id_type=id_type, + message_type=session.message_type.value, ) await super().send_by_session(session, message_chain) @@ -593,6 +608,103 @@ async def convert_msg(self, event: lark.im.v1.P2ImMessageReceiveV1) -> None: await self.handle_msg(abm) + async def convert_card_action( + self, + callback: P2CardActionTrigger | dict[str, Any], + ) -> None: + """Convert a Lark card callback into a portable button interaction. + + Args: + callback: SDK callback model or decoded webhook payload. + """ + callback_data = ( + callback + if isinstance(callback, dict) + else json.loads(lark.JSON.marshal(callback)) + ) + header = callback_data.get("header", {}) + event = callback_data.get("event", {}) + if not isinstance(header, dict) or not isinstance(event, dict): + logger.debug("[Lark] Ignored an incomplete card callback.") + return + action = event.get("action", {}) + operator = event.get("operator", {}) + context = event.get("context", {}) + if not all(isinstance(item, dict) for item in (action, operator, context)): + logger.debug("[Lark] Ignored an incomplete card callback.") + return + + event_id = str(header.get("event_id") or "") + create_time = str(header.get("create_time") or "") + value = action.get("value") + sender_id = str(operator.get("open_id") or "") + source_message_id = str(context.get("open_message_id") or "") + chat_id = str(context.get("open_chat_id") or "") + + if event_id and self._is_duplicate_event(event_id): + logger.debug(f"[Lark] Ignored duplicate card callback: {event_id}") + return + + if not isinstance(value, dict): + logger.debug("[Lark] Ignored a card callback without AstrBot metadata.") + return + payload = value.get("astrbot_callback") + if not isinstance(payload, str): + logger.debug("[Lark] Ignored a non-AstrBot card callback.") + return + + try: + action_id, data = decode_button_callback(payload) + except ValueError: + logger.debug("[Lark] Ignored an invalid AstrBot card callback payload.") + return + + message_type_value = value.get("astrbot_message_type") + try: + message_type = MessageType(message_type_value) + except (TypeError, ValueError): + message_type = ( + MessageType.GROUP_MESSAGE if chat_id else MessageType.FRIEND_MESSAGE + ) + + if not sender_id: + logger.debug("[Lark] Ignored a card callback without an operator open_id.") + return + + abm = AstrBotMessage() + if create_time: + try: + raw_timestamp = int(create_time) + while raw_timestamp > 10_000_000_000: + raw_timestamp //= 1000 + abm.timestamp = raw_timestamp + except ValueError: + pass + abm.type = message_type + abm.self_id = self.bot_open_id or self.bot_name + abm.message_id = source_message_id or event_id + abm.message_str = "" + abm.raw_message = callback + abm.sender = MessageMember( + user_id=sender_id, + nickname=sender_id[:8], + ) + if message_type == MessageType.GROUP_MESSAGE and chat_id: + abm.group_id = chat_id + abm.session_id = chat_id + else: + abm.session_id = sender_id + abm.message = [ + Comp.ButtonInteraction( + action_id=action_id, + data=data, + interaction_id=event_id, + source_message_id=source_message_id or None, + ) + ] + + await self.handle_msg(abm) + def create_event(self, message: AstrBotMessage) -> LarkMessageEvent: """Creates a Lark message event. @@ -622,14 +734,16 @@ async def handle_webhook_event(self, event_data: dict) -> None: try: header = event_data.get("header", {}) event_id = header.get("event_id", "") - if event_id and self._is_duplicate_event(event_id): - logger.debug(f"[Lark Webhook] 跳过重复事件: {event_id}") - return event_type = header.get("event_type", "") if event_type == "im.message.receive_v1": + if event_id and self._is_duplicate_event(event_id): + logger.debug(f"[Lark Webhook] 跳过重复事件: {event_id}") + return processor = P2ImMessageReceiveV1Processor(self.do_v2_msg_event) data = (processor.type())(event_data) processor.do(data) + elif event_type == "card.action.trigger": + await self.convert_card_action(event_data) else: logger.debug(f"[Lark Webhook] 未处理的事件类型: {event_type}") except Exception as e: diff --git a/astrbot/core/platform/sources/lark/lark_event.py b/astrbot/core/platform/sources/lark/lark_event.py index ccc90455f2..9dae3885b3 100644 --- a/astrbot/core/platform/sources/lark/lark_event.py +++ b/astrbot/core/platform/sources/lark/lark_event.py @@ -26,8 +26,20 @@ from astrbot import logger from astrbot.api.event import AstrMessageEvent, MessageChain -from astrbot.api.message_components import At, File, Json, Plain, Record, Video +from astrbot.api.message_components import ( + ActionRow, + At, + ButtonStyle, + CallbackAction, + File, + Json, + Plain, + Record, + UrlAction, + Video, +) from astrbot.api.message_components import Image as AstrBotImage +from astrbot.core.platform.button_interaction import encode_button_callback from astrbot.core.utils.media_utils import ( MediaResolver, convert_audio_to_opus, @@ -341,6 +353,91 @@ def _build_reasoning_card(message_chain: MessageChain) -> dict | None: else None ) + @staticmethod + def _build_button_card( + components: list[Plain | ActionRow], + message_type: str | None = None, + ) -> dict: + """Build a Card JSON 2.0 payload for portable buttons. + + Args: + components: Plain text and button rows to render in order. + message_type: AstrBot message type used to restore click context. + + Returns: + A Lark Card JSON 2.0 object. + """ + style_map = { + ButtonStyle.DEFAULT: "default", + ButtonStyle.PRIMARY: "primary", + ButtonStyle.SUCCESS: "primary", + ButtonStyle.DANGER: "danger", + } + elements: list[dict] = [] + for component in components: + if isinstance(component, Plain): + if component.text: + elements.append({"tag": "markdown", "content": component.text}) + continue + + columns: list[dict] = [] + for button in component.buttons: + lark_button = { + "tag": "button", + "type": style_map[button.style], + "text": { + "tag": "plain_text", + "content": button.label, + }, + } + if isinstance(button.action, CallbackAction): + callback = encode_button_callback( + button.id, + button.action.data, + ) + callback_value = {"astrbot_callback": callback} + if message_type: + callback_value["astrbot_message_type"] = message_type + lark_button["behaviors"] = [ + { + "type": "callback", + "value": callback_value, + } + ] + elif isinstance(button.action, UrlAction): + lark_button["behaviors"] = [ + { + "type": "open_url", + "default_url": button.action.url, + } + ] + + columns.append( + { + "tag": "column", + "width": "auto", + "vertical_align": "top", + "elements": [lark_button], + } + ) + + if columns: + elements.append( + { + "tag": "column_set", + "flex_mode": "flow", + "horizontal_spacing": "8px", + "columns": columns, + } + ) + + return { + "schema": "2.0", + "body": { + "elements": elements, + }, + } + @staticmethod async def _send_interactive_card( card_json: dict, @@ -418,6 +515,7 @@ async def send_message_chain( reply_message_id: str | None = None, receive_id: str | None = None, receive_id_type: str | None = None, + message_type: str | None = None, ) -> None: """通用的消息链发送方法 @@ -427,6 +525,7 @@ async def send_message_chain( reply_message_id: 回复的消息ID(用于回复消息) receive_id: 接收者ID(用于主动发送) receive_id_type: 接收者ID类型,如 'open_id', 'chat_id'(用于主动发送) + message_type: AstrBot message type embedded in callback context """ if lark_client.im is None: logger.error("[Lark] API Client im 模块未初始化") @@ -470,6 +569,39 @@ async def send_message_chain( ): return + button_components = [ + comp for comp in other_components if isinstance(comp, (Plain, ActionRow)) + ] + action_rows = [ + comp for comp in button_components if isinstance(comp, ActionRow) + ] + if action_rows: + other_components = [ + comp + for comp in other_components + if not isinstance(comp, (Plain, ActionRow)) + ] + card_json = LarkMessageEvent._build_button_card( + button_components, + message_type, + ) + if not await LarkMessageEvent._send_interactive_card( + card_json, + lark_client=lark_client, + reply_message_id=reply_message_id, + receive_id=receive_id, + receive_id_type=receive_id_type, + ): + for component in button_components: + if isinstance(component, Plain): + other_components.append(component) + continue + fallback = component.fallback_text or " / ".join( + button.label for button in component.buttons + ) + if fallback: + other_components.append(Plain(fallback)) + # 先发送非文件内容(如果有) if other_components: buffered_components: list = [] @@ -554,6 +686,7 @@ async def send(self, message: MessageChain) -> None: message, self.bot, reply_message_id=self.message_obj.message_id, + message_type=self.message_obj.type.value, ) await super().send(message) diff --git a/astrbot/core/platform/sources/line/line_adapter.py b/astrbot/core/platform/sources/line/line_adapter.py index 06daee6529..7a8c77ee8f 100644 --- a/astrbot/core/platform/sources/line/line_adapter.py +++ b/astrbot/core/platform/sources/line/line_adapter.py @@ -7,7 +7,15 @@ from astrbot.api import logger from astrbot.api.event import MessageChain -from astrbot.api.message_components import At, File, Image, Plain, Record, Video +from astrbot.api.message_components import ( + At, + ButtonInteraction, + File, + Image, + Plain, + Record, + Video, +) from astrbot.api.platform import ( AstrBotMessage, Group, @@ -17,6 +25,7 @@ PlatformMetadata, ) from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.button_interaction import decode_button_callback from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.media_utils import MediaResolver from astrbot.core.utils.webhook_utils import log_webhook_info @@ -168,7 +177,8 @@ async def handle_webhook_event(self, payload: dict[str, Any]) -> None: await self.handle_msg(abm) async def convert_message(self, event: dict[str, Any]) -> AstrBotMessage | None: - if str(event.get("type", "")) != "message": + event_type = str(event.get("type", "")) + if event_type not in {"message", "postback"}: return None if str(event.get("mode", "active")) == "standby": return None @@ -177,9 +187,12 @@ async def convert_message(self, event: dict[str, Any]) -> AstrBotMessage | None: if not isinstance(source, dict): return None - message = event.get("message", {}) - if not isinstance(message, dict): - return None + message: dict[str, Any] = {} + if event_type == "message": + raw_message = event.get("message", {}) + if not isinstance(raw_message, dict): + return None + message = raw_message source_type = str(source.get("type", "")) user_id = str(source.get("userId", "")).strip() @@ -224,7 +237,28 @@ async def convert_message(self, event: dict[str, Any]) -> AstrBotMessage | None: abm.sender = MessageMember(user_id=sender_id, nickname=sender_id[:8]) - components = await self._parse_line_message_components(message) + if event_type == "postback": + postback = event.get("postback", {}) + if not isinstance(postback, dict): + return None + payload = postback.get("data") + if not isinstance(payload, str): + return None + try: + action_id, data = decode_button_callback(payload) + except ValueError: + logger.debug("[LINE] ignored unrecognized postback payload") + return None + components = [ + ButtonInteraction( + action_id=action_id, + data=data, + interaction_id=str(event.get("webhookEventId") or uuid.uuid4().hex), + source_message_id=None, + ) + ] + else: + components = await self._parse_line_message_components(message) if not components: return None abm.message = components @@ -444,6 +478,8 @@ def _build_message_str(components: list) -> str: parts.append("[audio]") elif isinstance(comp, File): parts.append(str(comp.name or "[file]")) + elif isinstance(comp, ButtonInteraction): + parts.append(comp.action_id) else: parts.append(f"[{comp.type}]") return " ".join(i for i in parts if i).strip() diff --git a/astrbot/core/platform/sources/line/line_event.py b/astrbot/core/platform/sources/line/line_event.py index 8b82ad1820..bbc4547e83 100644 --- a/astrbot/core/platform/sources/line/line_event.py +++ b/astrbot/core/platform/sources/line/line_event.py @@ -8,14 +8,18 @@ from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.message_components import ( + ActionRow, At, BaseMessageComponent, + CallbackAction, File, Image, Plain, Record, + UrlAction, Video, ) +from astrbot.core.platform.button_interaction import encode_button_callback from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.media_utils import get_media_duration @@ -23,6 +27,10 @@ class LineMessageEvent(AstrMessageEvent): + POSTBACK_DATA_MAX_BYTES = 300 + BUTTON_LABEL_MAX_CHARS = 20 + BUTTONS_PER_TEMPLATE = 4 + def __init__( self, message_str, @@ -99,6 +107,57 @@ async def _component_to_message_object( "originalContentUrl": file_url, } + if isinstance(segment, ActionRow): + actions: list[dict] = [] + for button in segment.buttons: + label = button.label.strip()[: LineMessageEvent.BUTTON_LABEL_MAX_CHARS] + if not label: + continue + + if isinstance(button.action, CallbackAction): + callback_data = encode_button_callback( + button.id, + button.action.data, + ) + if ( + len(callback_data.encode("utf-8")) + > LineMessageEvent.POSTBACK_DATA_MAX_BYTES + ): + raise ValueError( + "LINE postback data must not exceed 300 bytes." + ) + actions.append( + { + "type": "postback", + "label": label, + "data": callback_data, + "displayText": label, + } + ) + elif isinstance(button.action, UrlAction): + actions.append( + { + "type": "uri", + "label": label, + "uri": button.action.url, + } + ) + + if not actions: + return None + if len(actions) > LineMessageEvent.BUTTONS_PER_TEMPLATE: + raise ValueError("LINE buttons templates support at most 4 actions.") + fallback_text = (segment.fallback_text or "").strip() or "Choose an option" + return { + "type": "template", + "altText": fallback_text[:400], + "template": { + "type": "buttons", + "text": fallback_text[:160], + "actions": actions, + }, + } + return None @staticmethod diff --git a/astrbot/core/platform/sources/mattermost/client.py b/astrbot/core/platform/sources/mattermost/client.py index c35b893873..37b55dd81e 100644 --- a/astrbot/core/platform/sources/mattermost/client.py +++ b/astrbot/core/platform/sources/mattermost/client.py @@ -8,15 +8,33 @@ from astrbot.api import logger from astrbot.api.event import MessageChain -from astrbot.api.message_components import At, File, Image, Plain, Record, Reply, Video +from astrbot.api.message_components import ( + ActionRow, + At, + CallbackAction, + File, + Image, + Plain, + Record, + Reply, + UrlAction, + Video, +) +from astrbot.core.platform.button_interaction import encode_button_callback from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.media_utils import MediaResolver, detect_image_mime_type_async class MattermostClient: - def __init__(self, base_url: str, token: str) -> None: + def __init__( + self, + base_url: str, + token: str, + action_callback_url: str = "", + ) -> None: self.base_url = base_url.rstrip("/") self.token = token + self.action_callback_url = action_callback_url self._session: aiohttp.ClientSession | None = None async def ensure_session(self) -> aiohttp.ClientSession: @@ -126,6 +144,7 @@ async def create_post( *, file_ids: list[str] | None = None, root_id: str | None = None, + props: dict[str, Any] | None = None, ) -> dict[str, Any]: payload: dict[str, Any] = { "channel_id": channel_id, @@ -135,6 +154,8 @@ async def create_post( payload["file_ids"] = file_ids if root_id: payload["root_id"] = root_id + if props: + payload["props"] = props return await self.post_json("posts", payload) async def ws_connect(self) -> aiohttp.ClientWebSocketResponse: @@ -152,9 +173,10 @@ async def send_message_chain( ) -> dict[str, Any]: text_parts: list[str] = [] file_ids: list[str] = [] + attachments: list[dict[str, Any]] = [] root_id: str | None = None - for segment in message_chain.chain: + for row_index, segment in enumerate(message_chain.chain): if isinstance(segment, Plain): text_parts.append(segment.text) elif isinstance(segment, At): @@ -200,6 +222,10 @@ async def send_message_chain( mimetypes.guess_type(filename)[0] or "application/octet-stream", ) ) + elif isinstance(segment, ActionRow): + attachments.append( + self._build_action_row_attachment(segment, row_index) + ) else: logger.debug( "Mattermost send_message_chain skipped unsupported segment: %s", @@ -211,7 +237,73 @@ async def send_message_chain( "".join(text_parts).strip(), file_ids=file_ids or None, root_id=root_id, + props={"attachments": attachments} if attachments else None, + ) + + def _build_action_row_attachment( + self, + row: ActionRow, + row_index: int, + ) -> dict[str, Any]: + """Map one portable action row to a Mattermost attachment. + + Args: + row: Portable button row to render. + row_index: Component index used to produce unique native action IDs. + + Returns: + Mattermost message attachment containing actions and link fallbacks. + """ + actions: list[dict[str, Any]] = [] + link_parts: list[str] = [] + unavailable_callbacks: list[str] = [] + for button_index, button in enumerate(row.buttons): + if isinstance(button.action, UrlAction): + link_parts.append(f"[{button.label}]({button.action.url})") + continue + if not isinstance(button.action, CallbackAction): + continue + if not self.action_callback_url: + unavailable_callbacks.append(button.label) + continue + actions.append( + { + "id": f"astrbot{row_index}b{button_index}", + "type": "button", + "name": button.label, + "style": button.style.value, + "integration": { + "url": self.action_callback_url, + "context": { + "astrbot_callback": encode_button_callback( + button.id, + button.action.data, + ), + }, + }, + } + ) + + fallback = row.fallback_text or " / ".join( + button.label for button in row.buttons ) + attachment: dict[str, Any] = {"fallback": fallback} + attachment_text: list[str] = [] + if row.fallback_text: + attachment_text.append(row.fallback_text) + if link_parts: + attachment_text.append(" · ".join(link_parts)) + if unavailable_callbacks: + attachment_text.append(" / ".join(unavailable_callbacks)) + logger.warning( + "Mattermost callback buttons require callback_api_base and " + "webhook_uuid; rendered labels as fallback text." + ) + if attachment_text: + attachment["text"] = "\n".join(attachment_text) + if actions: + attachment["actions"] = actions + return attachment async def parse_post_attachments( self, diff --git a/astrbot/core/platform/sources/mattermost/mattermost_adapter.py b/astrbot/core/platform/sources/mattermost/mattermost_adapter.py index 622edadeeb..d7b8a73cb7 100644 --- a/astrbot/core/platform/sources/mattermost/mattermost_adapter.py +++ b/astrbot/core/platform/sources/mattermost/mattermost_adapter.py @@ -2,14 +2,16 @@ import json import re import time +import uuid from collections import deque from typing import Any, cast +from urllib.parse import urlparse import aiohttp from astrbot.api import logger from astrbot.api.event import MessageChain -from astrbot.api.message_components import At, Plain +from astrbot.api.message_components import At, ButtonInteraction, Plain from astrbot.api.platform import ( AstrBotMessage, MessageMember, @@ -17,7 +19,10 @@ Platform, PlatformMetadata, ) +from astrbot.core import astrbot_config from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.button_interaction import decode_button_callback +from astrbot.core.utils.webhook_utils import log_webhook_info from ...register import register_platform_adapter from .client import MattermostClient @@ -49,7 +54,26 @@ def __init__( if not self.bot_token: raise ValueError("Mattermost bot token 是必需的") - self.client = MattermostClient(self.base_url, self.bot_token) + callback_base = str(astrbot_config.get("callback_api_base", "")).strip() + webhook_uuid = str(self.config.get("webhook_uuid", "")).strip() + parsed_callback_base = urlparse(callback_base) + if ( + self.config.get("unified_webhook_mode", False) + and parsed_callback_base.scheme in {"http", "https"} + and parsed_callback_base.netloc + and webhook_uuid + ): + action_callback_url = ( + f"{callback_base.rstrip('/')}/api/platform/webhook/{webhook_uuid}" + ) + else: + action_callback_url = "" + + self.client = MattermostClient( + self.base_url, + self.bot_token, + action_callback_url=action_callback_url, + ) self.metadata = PlatformMetadata( name="mattermost", description="Mattermost 平台适配器", @@ -88,6 +112,14 @@ async def run(self) -> None: self.bot_username, self.bot_self_id, ) + webhook_uuid = str(self.config.get("webhook_uuid", "")).strip() + if self.client.action_callback_url: + log_webhook_info(f"{self.meta().id}(Mattermost)", webhook_uuid) + else: + logger.warning( + "Mattermost callback buttons are disabled because callback_api_base " + "or webhook_uuid is not configured." + ) while self._running: try: @@ -238,6 +270,113 @@ async def convert_message( ) return abm + async def convert_button_interaction( + self, + payload: dict[str, Any], + ) -> AstrBotMessage | None: + """Convert a Mattermost post action into a portable button interaction. + + Args: + payload: Mattermost PostActionIntegrationRequest payload. + + Returns: + Converted message, or ``None`` for non-AstrBot button actions. + """ + context = payload.get("context") + if not isinstance(context, dict): + return None + encoded_callback = context.get("astrbot_callback") + if not isinstance(encoded_callback, str): + return None + try: + action_id, callback_data = decode_button_callback(encoded_callback) + except ValueError: + return None + + channel_id = str(payload.get("channel_id", "")).strip() + user_id = str(payload.get("user_id", "")).strip() + if not channel_id or not user_id: + return None + + is_direct = False + try: + channel = await self.client.get_channel(channel_id) + is_direct = str(channel.get("type", "")) == "D" + except Exception as exc: + logger.debug( + "Mattermost could not resolve interaction channel %s: %s", + channel_id, + exc, + ) + + interaction_id = str(payload.get("trigger_id", "")).strip() or uuid.uuid4().hex + source_message_id = str(payload.get("post_id", "")).strip() or None + + abm = AstrBotMessage() + abm.self_id = self.bot_self_id + abm.sender = MessageMember( + user_id=user_id, + nickname=str(payload.get("user_name", "")).strip() or user_id, + ) + abm.type = ( + MessageType.FRIEND_MESSAGE if is_direct else MessageType.GROUP_MESSAGE + ) + abm.group_id = None if is_direct else channel_id + abm.session_id = channel_id + abm.message_id = interaction_id + abm.timestamp = int(time.time()) + abm.message_str = "" + abm.message = [ + ButtonInteraction( + action_id=action_id, + data=callback_data, + interaction_id=interaction_id, + source_message_id=source_message_id, + ) + ] + abm.raw_message = payload + return abm + + async def _dispatch_button_interaction(self, payload: dict[str, Any]) -> None: + """Resolve and dispatch an acknowledged Mattermost button callback. + + Args: + payload: Mattermost PostActionIntegrationRequest payload. + """ + message = await self.convert_button_interaction(payload) + if message is not None: + await self.handle_msg(message) + + async def webhook_callback(self, request: Any) -> Any: + """Acknowledge a Mattermost post action and dispatch it asynchronously. + + Args: + request: Dashboard webhook request wrapper. + + Returns: + An empty JSON object accepted by Mattermost. + """ + try: + payload = await request.get_json(silent=False) + except Exception as exc: + logger.warning("Mattermost received invalid action callback JSON: %s", exc) + return {"error": {"message": "Invalid callback payload."}}, 400 + if not isinstance(payload, dict): + return {"error": {"message": "Invalid callback payload."}}, 400 + + task = asyncio.create_task(self._dispatch_button_interaction(payload)) + task.add_done_callback( + lambda done: ( + logger.error( + "Mattermost button interaction failed: %s", + done.exception(), + ) + if not done.cancelled() and done.exception() + else None + ) + ) + return {} + def _parse_text_components(self, message_text: str) -> list[Any]: if not message_text: return [] diff --git a/astrbot/core/platform/sources/misskey/misskey_utils.py b/astrbot/core/platform/sources/misskey/misskey_utils.py index c8ec3a1474..34f7c86a52 100644 --- a/astrbot/core/platform/sources/misskey/misskey_utils.py +++ b/astrbot/core/platform/sources/misskey/misskey_utils.py @@ -86,6 +86,33 @@ def process_component(component): nonlocal has_at if isinstance(component, Comp.Plain): return component.text + if isinstance(component, Comp.ActionRow): + # Misskey notes can render MFM links but do not expose a bot callback + # channel for controls embedded in a note. + has_callback = any( + isinstance(button.action, Comp.CallbackAction) + for button in component.buttons + ) + + buttons = [] + if component.fallback_text and has_callback: + buttons.append(component.fallback_text) + for button in component.buttons: + if isinstance(button.action, Comp.UrlAction): + label = button.label.replace("\\", "\\\\").replace("]", "\\]") + url = button.action.url.replace("\\", "%5C").replace(")", "%29") + buttons.append(f"[{label}]({url})") + elif not component.fallback_text: + buttons.append(button.label) + return " | ".join(buttons) + if isinstance(component, Comp.Button): + if isinstance(component.action, Comp.UrlAction): + label = component.label.replace("\\", "\\\\").replace("]", "\\]") + url = component.action.url.replace("\\", "%5C").replace(")", "%29") + return f"[{label}]({url})" + return component.label + if isinstance(component, Comp.ButtonInteraction): + return "" if isinstance(component, Comp.File): # 为文件组件返回占位符,但适配器仍会处理原组件 return "[文件]" diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 49f7a9e8fe..eb282778c2 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -15,6 +15,7 @@ import botpy.types.message from botpy import Client from botpy.http import Route +from botpy.interaction import Interaction from botpy.types import message from botpy.types.message import MarkdownPayload, Media from tenacity import ( @@ -27,8 +28,19 @@ from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain -from astrbot.api.message_components import File, Image, Plain, Record, Video +from astrbot.api.message_components import ( + ActionRow, + ButtonStyle, + CallbackAction, + File, + Image, + Plain, + Record, + UrlAction, + Video, +) from astrbot.api.platform import AstrBotMessage, PlatformMetadata +from astrbot.core.platform.button_interaction import encode_button_callback from astrbot.core.platform.sources.qqofficial.qqofficial_chunked_upload import ( QQOFFICIAL_CHUNKED_UPLOAD_THRESHOLD, QQOfficialChunkedUploader, @@ -95,6 +107,10 @@ class QQOfficialMessageEvent(AstrMessageEvent): VOICE_FILE_TYPE = 3 FILE_FILE_TYPE = 4 STREAM_MARKDOWN_NEWLINE_ERROR = "流式消息md分片需要\\n结束" + KEYBOARD_MAX_ROWS = 5 + KEYBOARD_MAX_BUTTONS_PER_ROW = 5 + KEYBOARD_LABEL_MAX_CHARS = 10 + KEYBOARD_ACTION_DATA_MAX_BYTES = 1024 def __init__( self, @@ -279,7 +295,8 @@ async def _post_send_one( botpy.message.Message | botpy.message.GroupMessage | botpy.message.DirectMessage - | botpy.message.C2CMessage, + | botpy.message.C2CMessage + | Interaction, ): logger.warning(f"[QQOfficial] 不支持的消息源类型: {type(source)}") return None @@ -293,6 +310,21 @@ async def _post_send_one( file_source, file_name, ) = await QQOfficialMessageEvent._parse_to_qqofficial(message_to_send) + keyboard = QQOfficialMessageEvent._parse_keyboard(message_to_send) + if keyboard and not plain_text: + plain_text = next( + ( + row.fallback_text + for row in message_to_send.chain + if isinstance(row, ActionRow) and row.fallback_text + ), + "", + ) or " / ".join( + button.label + for row in message_to_send.chain + if isinstance(row, ActionRow) + for button in row.buttons + ) # C2C 流式仅用于文本分片,富媒体时降级为普通发送,避免平台侧流式校验报错。 if stream and ( @@ -308,6 +340,7 @@ async def _post_send_one( and not record_file_path and not video_file_source and not file_source + and not keyboard ): return None @@ -328,16 +361,24 @@ async def _post_send_one( payload: dict = { "content": plain_text, "msg_type": 0, - "msg_id": self.message_obj.message_id, } else: payload = { "markdown": MarkdownPayload(content=plain_text) if plain_text else None, "msg_type": 2, - "msg_id": self.message_obj.message_id, } + if isinstance(source, Interaction): + if source.event_id: + payload["event_id"] = source.event_id + else: + payload["msg_id"] = self.message_obj.message_id + if keyboard: + payload["keyboard"] = keyboard - if not isinstance(source, botpy.message.Message | botpy.message.DirectMessage): + if not isinstance( + source, + botpy.message.Message | botpy.message.DirectMessage, + ) and not (isinstance(source, Interaction) and source.scene == "guild"): payload["msg_seq"] = random.randint(1, 10000) ret = None @@ -500,6 +541,52 @@ async def _post_send_one( stream=stream, ) + case Interaction(): + if any( + ( + image_base64, + image_path, + record_file_path, + video_file_source, + file_source, + ) + ): + logger.warning( + "[QQOfficial] Button interaction replies currently ignore media." + ) + if source.scene == "group" and source.group_openid: + ret = await self._send_with_markdown_fallback( + send_func=lambda retry_payload: self.bot.api.post_group_message( + group_openid=source.group_openid, + **retry_payload, + ), + payload=payload, + plain_text=plain_text, + ) + elif source.scene == "c2c" and source.user_openid: + ret = await self._send_with_markdown_fallback( + send_func=lambda retry_payload: self.post_c2c_message( + openid=source.user_openid, + **retry_payload, + ), + payload=payload, + plain_text=plain_text, + ) + elif source.scene == "guild" and source.channel_id: + payload.pop("msg_type", None) + ret = await self._send_with_markdown_fallback( + send_func=lambda retry_payload: self.bot.api.post_message( + channel_id=source.channel_id, + **retry_payload, + ), + payload=payload, + plain_text=plain_text, + ) + else: + logger.warning( + "[QQOfficial] Cannot reply to button interaction without a scene target." + ) + case _: pass @@ -518,9 +605,10 @@ async def _send_with_markdown_fallback( return await send_func(payload) except _QQOFFICIAL_SEND_API_ERRORS as err: logger.info("[QQOfficial] 回复消息失败: %s, 尝试使用主动发送接口。", err) - if payload.get("msg_id"): + if payload.get("msg_id") or payload.get("event_id"): fallback_payload = payload.copy() fallback_payload.pop("msg_id", None) + fallback_payload.pop("event_id", None) try: ret = await send_func(fallback_payload) logger.info("[QQOfficial] 使用主动发送接口发送成功。") @@ -759,6 +847,8 @@ async def post_c2c_message( payload.pop("self", None) if payload.get("msg_id") is None: payload.pop("msg_id", None) + if payload.get("event_id") is None: + payload.pop("event_id", None) # QQ API does not accept stream.id=None; remove it when not yet assigned if "stream" in payload and payload["stream"] is not None: stream_data = dict(payload["stream"]) @@ -792,6 +882,93 @@ async def _do_request(): return message.Message(**result) + @staticmethod + def _parse_keyboard(message_chain: MessageChain) -> dict | None: + """Convert portable action rows into a QQ inline keyboard. + + Args: + message_chain: Portable message chain containing action rows. + + Returns: + QQ keyboard payload, or None when no action rows are present. + + Raises: + ValueError: The keyboard exceeds QQ limits or contains invalid data. + """ + action_rows = [ + component + for component in message_chain.chain + if isinstance(component, ActionRow) and component.buttons + ] + if not action_rows: + return None + if len(action_rows) > QQOfficialMessageEvent.KEYBOARD_MAX_ROWS: + raise ValueError("QQ inline keyboards support at most 5 rows.") + + rows = [] + button_ids: set[str] = set() + style_map = { + ButtonStyle.DEFAULT: 0, + ButtonStyle.PRIMARY: 3, + ButtonStyle.SUCCESS: 1, + ButtonStyle.DANGER: 0, + } + for row in action_rows: + if len(row.buttons) > QQOfficialMessageEvent.KEYBOARD_MAX_BUTTONS_PER_ROW: + raise ValueError("QQ inline keyboard rows support at most 5 buttons.") + buttons = [] + for button in row.buttons: + if not button.id or button.id in button_ids: + raise ValueError( + "QQ inline keyboard button IDs must be non-empty and unique." + ) + if not button.label: + raise ValueError( + "QQ inline keyboard button labels cannot be empty." + ) + button_ids.add(button.id) + + if isinstance(button.action, CallbackAction): + action_type = 1 + action_data = encode_button_callback( + button.id, + button.action.data, + ) + elif isinstance(button.action, UrlAction): + action_type = 0 + action_data = button.action.url + else: + raise ValueError("Unsupported QQ inline keyboard button action.") + if ( + len(action_data.encode("utf-8")) + > QQOfficialMessageEvent.KEYBOARD_ACTION_DATA_MAX_BYTES + ): + raise ValueError( + "QQ inline keyboard button action data exceeds 1024 bytes." + ) + + buttons.append( + { + "id": button.id, + "render_data": { + "label": button.label[ + : QQOfficialMessageEvent.KEYBOARD_LABEL_MAX_CHARS + ], + "visited_label": button.label[ + : QQOfficialMessageEvent.KEYBOARD_LABEL_MAX_CHARS + ], + "style": style_map[button.style], + }, + "action": { + "type": action_type, + "permission": {"type": 2}, + "data": action_data, + }, + } + ) + rows.append({"buttons": buttons}) + return {"content": {"rows": rows}} + @staticmethod async def _parse_to_qqofficial(message: MessageChain): plain_text = "" @@ -848,6 +1025,8 @@ async def _parse_to_qqofficial(message: MessageChain): file_source = file_path elif i.url: file_source = i.url + elif isinstance(i, ActionRow): + continue else: logger.debug(f"qq_official 忽略 {i.type}") return ( diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index 66d2fc3472..7d6dcf67ea 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -14,10 +14,21 @@ from botpy import Client from botpy.connection import ConnectionState from botpy.gateway import BotWebSocket +from botpy.interaction import Interaction from astrbot import logger from astrbot.api.event import MessageChain -from astrbot.api.message_components import At, File, Image, Plain, Record, Reply, Video +from astrbot.api.message_components import ( + ActionRow, + At, + ButtonInteraction, + File, + Image, + Plain, + Record, + Reply, + Video, +) from astrbot.api.platform import ( AstrBotMessage, MessageMember, @@ -27,6 +38,7 @@ ) from astrbot.core.message.components import BaseMessageComponent from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.button_interaction import decode_button_callback from astrbot.core.utils.media_utils import MediaResolver from ...register import register_platform_adapter @@ -160,6 +172,27 @@ def parse_message(self, payload: dict[str, Any]) -> None: ) +async def _handle_interaction_create(client: Any, interaction: Interaction) -> None: + """Acknowledge and dispatch a QQ inline-keyboard click. + + Args: + client: QQ websocket or webhook bot client. + interaction: QQ interaction payload parsed by qq-botpy. + """ + if interaction.type != 11: + return + try: + abm = QQOfficialPlatformAdapter._parse_interaction_from_qqofficial(interaction) + except ValueError as exc: + logger.warning("[QQOfficial] Ignore invalid button interaction: %s", exc) + await client.api.on_interaction_result(interaction.id, 1) + return + + await client.api.on_interaction_result(interaction.id, 0) + client.platform.remember_session_scene(abm.session_id, interaction.scene) + client.platform.commit_event(client.platform.create_event(abm)) + + class ManagedBotWebSocket(BotWebSocket): def __init__(self, session, connection: Any, client: botClient): super().__init__(session, connection) @@ -250,6 +283,14 @@ async def on_c2c_message_create(self, message: botpy.message.C2CMessage) -> None self.platform.remember_session_scene(abm.session_id, "friend") self._commit(abm) + async def on_interaction_create(self, interaction: Interaction) -> None: + """Handle a QQ inline-keyboard click. + + Args: + interaction: QQ interaction payload parsed by qq-botpy. + """ + await _handle_interaction_create(self, interaction) + def _commit(self, abm: AstrBotMessage) -> None: self.platform.remember_session_message_id(abm.session_id, abm.message_id) self.platform.commit_event(self.platform.create_event(abm)) @@ -299,11 +340,13 @@ def __init__( public_messages=True, public_guild_messages=True, direct_message=guild_dm, + interaction=True, ) else: self.intents = botpy.Intents( public_guild_messages=True, direct_message=guild_dm, + interaction=True, ) self.client = botClient( intents=self.intents, @@ -350,6 +393,21 @@ async def _send_by_session_common( file_source, file_name, ) = await QQOfficialMessageEvent._parse_to_qqofficial(message_chain) + keyboard = QQOfficialMessageEvent._parse_keyboard(message_chain) + if keyboard and not plain_text: + plain_text = next( + ( + row.fallback_text + for row in message_chain.chain + if isinstance(row, ActionRow) and row.fallback_text + ), + "", + ) or " / ".join( + button.label + for row in message_chain.chain + if isinstance(row, ActionRow) + for button in row.buttons + ) if ( not plain_text and not image_path @@ -357,6 +415,7 @@ async def _send_by_session_common( and not record_file_path and not video_file_source and not file_source + and not keyboard ): return @@ -380,6 +439,8 @@ async def _send_by_session_common( return payload: dict[str, Any] = {"content": plain_text} + if keyboard: + payload["keyboard"] = keyboard if msg_id and not allow_group_proactive_send: payload["msg_id"] = msg_id ret: Any = None @@ -549,6 +610,67 @@ def create_event(self, message: AstrBotMessage) -> QQOfficialMessageEvent: self.client, ) + @staticmethod + def _parse_interaction_from_qqofficial( + interaction: Interaction, + ) -> AstrBotMessage: + """Convert a QQ button interaction into an AstrBot message. + + Args: + interaction: QQ inline-keyboard interaction. + + Returns: + Normalized AstrBot message carrying a ButtonInteraction component. + + Raises: + ValueError: The interaction lacks a routable button or session target. + """ + resolved = interaction.data.resolved + button_id = str(resolved.button_id or "") + button_data = str(resolved.button_data or "") + try: + action_id, data = decode_button_callback(button_data) + except ValueError: + action_id = button_id or button_data + data = button_data or None + if not action_id: + raise ValueError("QQ button interaction has no button identifier.") + + abm = AstrBotMessage() + abm.timestamp = int(time.time()) + abm.raw_message = interaction + abm.message_id = str(interaction.id) + abm.self_id = str(interaction.application_id or "qq_official") + if interaction.scene == "group": + abm.type = MessageType.GROUP_MESSAGE + abm.session_id = str(interaction.group_openid or "") + abm.group_id = abm.session_id + sender_id = str(interaction.group_member_openid or "") + elif interaction.scene == "guild": + abm.type = MessageType.GROUP_MESSAGE + abm.session_id = str(interaction.channel_id or "") + abm.group_id = abm.session_id + sender_id = str(resolved.user_id or "") + else: + abm.type = MessageType.FRIEND_MESSAGE + abm.session_id = str(interaction.user_openid or "") + sender_id = abm.session_id + if not abm.session_id or not sender_id: + raise ValueError("QQ button interaction has no session or sender target.") + + component = ButtonInteraction( + action_id=action_id, + data=data, + interaction_id=str(interaction.id), + source_message_id=( + str(resolved.message_id) if resolved.message_id else None + ), + ) + abm.sender = MessageMember(sender_id) + abm.message = [component] + abm.message_str = "" + return abm + @staticmethod def _normalize_attachment_url(url: str | None) -> str: if not url: diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py index c877d7540a..ff0cd6f6d8 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py @@ -5,6 +5,7 @@ import botpy import botpy.message from botpy import Client +from botpy.interaction import Interaction from astrbot import logger from astrbot.api.event import MessageChain @@ -16,6 +17,7 @@ from ..qqofficial.qqofficial_platform_adapter import ( QQOfficialPlatformAdapter, _ensure_group_message_create_parser, + _handle_interaction_create, ) from .qo_webhook_event import QQOfficialWebhookMessageEvent from .qo_webhook_server import QQOfficialWebhook @@ -89,6 +91,14 @@ async def on_c2c_message_create(self, message: botpy.message.C2CMessage) -> None self.platform.remember_session_scene(abm.session_id, "friend") self._commit(abm) + async def on_interaction_create(self, interaction: Interaction) -> None: + """Handle a QQ inline-keyboard click delivered by webhook. + + Args: + interaction: QQ interaction payload parsed by qq-botpy. + """ + await _handle_interaction_create(self, interaction) + def _commit(self, abm: AstrBotMessage) -> None: self.platform.remember_session_message_id(abm.session_id, abm.message_id) self.platform.commit_event(self.platform.create_event(abm)) @@ -112,6 +122,7 @@ def __init__( public_messages=True, public_guild_messages=True, direct_message=True, + interaction=True, ) self.client = botClient( intents=intents, # 已经无用 diff --git a/astrbot/core/platform/sources/satori/satori_adapter.py b/astrbot/core/platform/sources/satori/satori_adapter.py index 8748996fb4..80593216e8 100644 --- a/astrbot/core/platform/sources/satori/satori_adapter.py +++ b/astrbot/core/platform/sources/satori/satori_adapter.py @@ -12,6 +12,7 @@ from astrbot.api.event import MessageChain from astrbot.api.message_components import ( At, + ButtonInteraction, File, Image, Plain, @@ -27,6 +28,7 @@ register_platform_adapter, ) from astrbot.core.platform.astr_message_event import MessageSession +from astrbot.core.platform.button_interaction import decode_button_callback from astrbot.core.utils.media_utils import MediaResolver if TYPE_CHECKING: @@ -309,9 +311,95 @@ async def handle_event(self, event_data: dict) -> None: if abm: await self.handle_msg(abm) + elif event_type == "interaction/button": + abm = self.convert_satori_button_interaction(event_data) + if abm: + await self.handle_msg(abm) + except Exception as e: logger.error(f"处理事件失败: {e}") + def convert_satori_button_interaction( + self, + event_data: dict, + ) -> AstrBotMessage | None: + """Convert a Satori button event into a portable interaction. + + Args: + event_data: Satori ``interaction/button`` event payload. + + Returns: + A normalized AstrBot message, or ``None`` for malformed or foreign + button events. + """ + button = event_data.get("button") + if not isinstance(button, dict): + logger.debug("[Satori] Ignored a button interaction without a button.") + return None + callback_payload = button.get("id") + if not isinstance(callback_payload, str): + logger.debug("[Satori] Ignored a button interaction without an id.") + return None + try: + action_id, callback_data = decode_button_callback(callback_payload) + except ValueError: + logger.debug("[Satori] Ignored a button not created by AstrBot.") + return None + + operator = event_data.get("operator") or event_data.get("user") or {} + channel = event_data.get("channel") or {} + guild = event_data.get("guild") + login = event_data.get("login") or {} + if not isinstance(operator, dict) or not isinstance(channel, dict): + logger.debug("[Satori] Ignored an incomplete button interaction.") + return None + + operator_id = str(operator.get("id") or "").strip() + channel_id = str(channel.get("id") or "").strip() + if not operator_id or not channel_id: + logger.debug("[Satori] Ignored a button interaction without a session.") + return None + + timestamp = int(event_data.get("timestamp") or time.time()) + sequence = event_data.get("sn") + interaction_id = ( + str(sequence) + if sequence is not None + else f"{timestamp}:{operator_id}:{callback_payload}" + ) + source_message = event_data.get("message") + source_message_id = ( + str(source_message.get("id")) + if isinstance(source_message, dict) and source_message.get("id") + else None + ) + + abm = AstrBotMessage() + abm.raw_message = event_data + abm.self_id = str((login.get("user") or {}).get("id") or "") + abm.sender = MessageMember( + user_id=operator_id, + nickname=str(operator.get("nick") or operator.get("name") or operator_id), + ) + abm.session_id = channel_id + abm.message_id = interaction_id + abm.timestamp = timestamp + if isinstance(guild, dict) and guild.get("id"): + abm.type = MessageType.GROUP_MESSAGE + abm.group_id = str(guild["id"]) + else: + abm.type = MessageType.FRIEND_MESSAGE + abm.message_str = action_id + abm.message = [ + ButtonInteraction( + action_id=action_id, + data=callback_data, + interaction_id=interaction_id, + source_message_id=source_message_id, + ) + ] + return abm + async def convert_satori_message( self, message: dict, diff --git a/astrbot/core/platform/sources/satori/satori_event.py b/astrbot/core/platform/sources/satori/satori_event.py index 7e2e92eb40..01d469f830 100644 --- a/astrbot/core/platform/sources/satori/satori_event.py +++ b/astrbot/core/platform/sources/satori/satori_event.py @@ -1,9 +1,14 @@ +import html from typing import TYPE_CHECKING from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.message_components import ( + ActionRow, At, + Button, + ButtonStyle, + CallbackAction, File, Forward, Image, @@ -12,9 +17,11 @@ Plain, Record, Reply, + UrlAction, Video, ) from astrbot.api.platform import AstrBotMessage, PlatformMetadata +from astrbot.core.platform.button_interaction import encode_button_callback from astrbot.core.utils.media_utils import resolve_media_ref_to_base64_data if TYPE_CHECKING: @@ -285,6 +292,47 @@ async def _convert_component_to_satori(self, component) -> str: elif isinstance(component, Forward): return f'' + elif isinstance(component, (ActionRow, Button)): + buttons = ( + component.buttons + if isinstance(component, ActionRow) + else [component] + ) + if not buttons: + fallback_text = ( + component.fallback_text + if isinstance(component, ActionRow) + else "" + ) + return html.escape(fallback_text or "") + + theme_map = { + ButtonStyle.DEFAULT: "secondary", + ButtonStyle.PRIMARY: "primary", + ButtonStyle.SUCCESS: "success", + ButtonStyle.DANGER: "danger", + } + button_elements = [] + for button in buttons: + label = html.escape(button.label) + theme = theme_map[button.style] + if isinstance(button.action, CallbackAction): + callback_id = html.escape( + encode_button_callback(button.id, button.action.data), + quote=True, + ) + button_elements.append( + f'' + ) + elif isinstance(button.action, UrlAction): + href = html.escape(button.action.url, quote=True) + button_elements.append( + f'' + ) + return "".join(button_elements) + # 对于其他未处理的组件类型,返回空字符串 return "" @@ -378,6 +426,47 @@ async def _convert_component_to_satori_static(cls, component) -> str: elif isinstance(component, Forward): return f'' + elif isinstance(component, (ActionRow, Button)): + buttons = ( + component.buttons + if isinstance(component, ActionRow) + else [component] + ) + if not buttons: + fallback_text = ( + component.fallback_text + if isinstance(component, ActionRow) + else "" + ) + return html.escape(fallback_text or "") + + theme_map = { + ButtonStyle.DEFAULT: "secondary", + ButtonStyle.PRIMARY: "primary", + ButtonStyle.SUCCESS: "success", + ButtonStyle.DANGER: "danger", + } + button_elements = [] + for button in buttons: + label = html.escape(button.label) + theme = theme_map[button.style] + if isinstance(button.action, CallbackAction): + callback_id = html.escape( + encode_button_callback(button.id, button.action.data), + quote=True, + ) + button_elements.append( + f'' + ) + elif isinstance(button.action, UrlAction): + href = html.escape(button.action.url, quote=True) + button_elements.append( + f'' + ) + return "".join(button_elements) + # 对于其他未处理的组件类型,返回空字符串 return "" diff --git a/astrbot/core/platform/sources/slack/client.py b/astrbot/core/platform/sources/slack/client.py index 97f5f26ae1..d87a00db5e 100644 --- a/astrbot/core/platform/sources/slack/client.py +++ b/astrbot/core/platform/sources/slack/client.py @@ -4,6 +4,7 @@ import json from collections.abc import Callable from typing import cast +from urllib.parse import parse_qs from fastapi.responses import Response from slack_sdk.socket_mode.aiohttp import SocketModeClient @@ -65,7 +66,16 @@ async def handle_callback(self, req): try: # 获取请求体和头部 body = cast(bytes, await req.get_data()) - event_data = json.loads(body.decode("utf-8")) + body_text = body.decode("utf-8") + content_type = req.headers.get("Content-Type", "") + if "application/x-www-form-urlencoded" in content_type: + form_data = parse_qs(body_text) + payload = form_data.get("payload", [None])[0] + if not payload: + return Response("Missing payload", status_code=400) + event_data = json.loads(payload) + else: + event_data = json.loads(body_text) # Verify Slack request signature timestamp = req.headers.get("X-Slack-Request-Timestamp") @@ -73,7 +83,7 @@ async def handle_callback(self, req): if not timestamp or not signature: return Response("Missing headers", status_code=400) # Calculate the HMAC signature - sig_basestring = f"v0:{timestamp}:{body.decode('utf-8')}" + sig_basestring = f"v0:{timestamp}:{body_text}" my_signature = ( "v0=" + hmac.new( @@ -91,6 +101,20 @@ async def handle_callback(self, req): # 处理 URL 验证事件 if event_data.get("type") == "url_verification": return {"challenge": event_data.get("challenge")} + if self.event_handler and event_data.get("type") == "block_actions": + # Slack requires interactive callbacks to be acknowledged within + # three seconds, so processing continues after the HTTP response. + task = asyncio.create_task(self.event_handler(event_data)) + task.add_done_callback( + lambda done: ( + logger.error( + f"Slack interactive event failed: {done.exception()}" + ) + if not done.cancelled() and done.exception() + else None + ) + ) + return Response("", status_code=200) # 处理事件 if self.event_handler and event_data.get("type") == "event_callback": await self.event_handler(event_data) diff --git a/astrbot/core/platform/sources/slack/slack_adapter.py b/astrbot/core/platform/sources/slack/slack_adapter.py index 191df9e136..650f8552d7 100644 --- a/astrbot/core/platform/sources/slack/slack_adapter.py +++ b/astrbot/core/platform/sources/slack/slack_adapter.py @@ -20,6 +20,7 @@ PlatformMetadata, ) from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.button_interaction import decode_button_callback from astrbot.core.utils.webhook_utils import log_webhook_info from ...register import register_platform_adapter @@ -206,6 +207,74 @@ async def convert_message(self, event: dict) -> AstrBotMessage: abm.raw_message = event return abm + async def convert_button_interaction( + self, + payload: dict, + ) -> AstrBotMessage | None: + """Convert a Slack block action into a generic button interaction. + + Args: + payload: Slack interactive callback payload. + + Returns: + Converted message, or ``None`` for non-AstrBot button actions. + """ + actions = payload.get("actions") or [] + if not actions: + return None + + action = actions[0] + encoded_value = action.get("value") + if not isinstance(encoded_value, str): + # URL buttons also produce a Slack interaction payload, but they are + # navigation actions rather than AstrBot callbacks. + return None + try: + action_id, data = decode_button_callback(encoded_value) + except ValueError: + return None + + user = payload.get("user") or {} + user_id = user.get("id", "") + user_name = user.get("name") or user.get("username") or user_id + channel = payload.get("channel") or {} + channel_id = channel.get("id", "") + + is_im = False + if channel_id: + try: + channel_info = await self.web_client.conversations_info( + channel=channel_id + ) + is_im = bool(cast(dict, channel_info["channel"]).get("is_im")) + except Exception: + pass + + container = payload.get("container") or {} + source_message_id = container.get("message_ts") or ( + payload.get("message") or {} + ).get("ts") + interaction_id = payload.get("trigger_id") or uuid.uuid4().hex + + abm = AstrBotMessage() + abm.self_id = cast(str, self.bot_self_id) + abm.sender = MessageMember(user_id=user_id, nickname=user_name) + abm.type = MessageType.FRIEND_MESSAGE if is_im else MessageType.GROUP_MESSAGE + abm.group_id = None if is_im else channel_id + abm.session_id = user_id if is_im else channel_id + abm.message_id = interaction_id + abm.message_str = "" + abm.message = [ + ButtonInteraction( + action_id=action_id, + data=data, + interaction_id=interaction_id, + source_message_id=source_message_id, + ) + ] + abm.raw_message = payload + return abm + def _parse_blocks(self, blocks: list) -> list: """解析 Slack blocks 格式的消息内容""" message_components = [] @@ -287,6 +356,12 @@ def _parse_blocks(self, blocks: list) -> list: async def _handle_socket_event(self, req: SocketModeRequest) -> None: """处理 Socket Mode 事件""" + if req.type == "interactive" and req.payload.get("type") == "block_actions": + abm = await self.convert_button_interaction(req.payload) + if abm: + await self.handle_msg(abm) + return + if req.type == "events_api": # 事件 API event = req.payload.get("event", {}) @@ -376,6 +451,12 @@ async def run(self) -> None: async def _handle_webhook_event(self, event_data: dict) -> None: """处理 Webhook 事件""" + if event_data.get("type") == "block_actions": + abm = await self.convert_button_interaction(event_data) + if abm: + await self.handle_msg(abm) + return + event = event_data.get("event", {}) # 忽略机器人自己的消息和消息编辑 diff --git a/astrbot/core/platform/sources/slack/slack_event.py b/astrbot/core/platform/sources/slack/slack_event.py index 5fb26d22f0..4c007c861a 100644 --- a/astrbot/core/platform/sources/slack/slack_event.py +++ b/astrbot/core/platform/sources/slack/slack_event.py @@ -9,12 +9,16 @@ from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.message_components import ( + ActionRow, BaseMessageComponent, + CallbackAction, File, Image, Plain, + UrlAction, ) from astrbot.api.platform import Group, MessageMember +from astrbot.core.platform.button_interaction import encode_button_callback class SlackMessageEvent(AstrMessageEvent): @@ -87,6 +91,40 @@ async def _from_segment_to_slack_block( "text": f"文件: <{file_url}|{segment.name or '文件'}>", }, } + if isinstance(segment, ActionRow): + elements = [] + for button in segment.buttons: + if len(button.label) > 75: + raise ValueError("Slack button labels cannot exceed 75 characters") + if len(button.id) > 255: + raise ValueError("Slack button IDs cannot exceed 255 characters") + + element = { + "type": "button", + "text": {"type": "plain_text", "text": button.label}, + "action_id": button.id, + } + if isinstance(button.action, CallbackAction): + value = encode_button_callback(button.id, button.action.data) + if len(value.encode("utf-8")) > 2000: + raise ValueError( + "Slack callback payloads cannot exceed 2000 bytes" + ) + element["value"] = value + elif isinstance(button.action, UrlAction): + element["url"] = button.action.url + + if button.style.value in {"primary", "success"}: + element["style"] = "primary" + elif button.style.value == "danger": + element["style"] = "danger" + elements.append(element) + + if len(elements) > 25: + raise ValueError( + "Slack action rows cannot contain more than 25 buttons" + ) + return {"type": "actions", "elements": elements} @staticmethod async def _parse_slack_blocks( @@ -96,10 +134,12 @@ async def _parse_slack_blocks( """解析成 Slack 块格式""" blocks = [] text_content = "" + fallback_text = "" for segment in message_chain.chain: if isinstance(segment, Plain): text_content += segment.text + fallback_text += segment.text else: # 如果有文本内容,先添加文本块 if text_content.strip(): @@ -118,6 +158,10 @@ async def _parse_slack_blocks( ) if block: blocks.append(block) + if isinstance(segment, ActionRow): + fallback_text += segment.fallback_text or " / ".join( + button.label for button in segment.buttons + ) # 如果最后还有文本内容 if text_content.strip(): @@ -125,7 +169,7 @@ async def _parse_slack_blocks( {"type": "section", "text": {"type": "mrkdwn", "text": text_content}}, ) - return blocks, "" if blocks else text_content + return blocks, fallback_text async def send(self, message: MessageChain) -> None: blocks, text = await SlackMessageEvent._parse_slack_blocks( diff --git a/astrbot/core/platform/sources/telegram/tg_adapter.py b/astrbot/core/platform/sources/telegram/tg_adapter.py index b8c3a7a079..a0fb0666fe 100644 --- a/astrbot/core/platform/sources/telegram/tg_adapter.py +++ b/astrbot/core/platform/sources/telegram/tg_adapter.py @@ -11,7 +11,13 @@ from telegram import BotCommand, Update from telegram.constants import ChatType from telegram.error import Forbidden, InvalidToken, NetworkError -from telegram.ext import ApplicationBuilder, ContextTypes, ExtBot, filters +from telegram.ext import ( + ApplicationBuilder, + CallbackQueryHandler, + ContextTypes, + ExtBot, + filters, +) from telegram.ext import MessageHandler as TelegramMessageHandler import astrbot.api.message_components as Comp @@ -26,6 +32,7 @@ register_platform_adapter, ) from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.button_interaction import decode_button_callback from astrbot.core.star.filter.command import CommandFilter from astrbot.core.star.filter.command_group import CommandGroupFilter from astrbot.core.star.star import star_map @@ -141,6 +148,7 @@ def _build_application(self) -> None: filters=filters.ALL, callback=self.message_handler, ) + self.application.add_handler(CallbackQueryHandler(self.callback_query_handler)) self.application.add_handler(message_handler) self.client = self.application.bot logger.debug(f"Telegram base url: {self.client.base_url}") @@ -436,6 +444,78 @@ async def message_handler( if abm: await self.handle_msg(abm) + async def callback_query_handler( + self, + update: Update, + context: ContextTypes.DEFAULT_TYPE, + ) -> None: + """Convert a Telegram callback query into a button interaction event. + + Args: + update: Telegram update containing the callback query. + context: Telegram callback context. + """ + query = update.callback_query + if query is None: + return + + try: + await query.answer() + except Exception as exc: + logger.warning("Failed to answer Telegram callback query: %s", exc) + + if not isinstance(query.data, str): + logger.warning("Received a Telegram callback query without string data.") + return + + try: + action_id, data = decode_button_callback(query.data) + except (TypeError, ValueError): + logger.debug("Ignoring a Telegram callback query not created by AstrBot.") + return + + source_message = query.message + chat = source_message.chat if source_message else update.effective_chat + if chat is None: + logger.warning("Received a Telegram callback query without a chat.") + return + + message = AstrBotMessage() + message.session_id = str(chat.id) + if chat.type == ChatType.PRIVATE: + message.type = MessageType.FRIEND_MESSAGE + else: + message.type = MessageType.GROUP_MESSAGE + message.group_id = str(chat.id) + if ( + source_message is not None + and getattr(source_message, "is_topic_message", False) + and getattr(source_message, "message_thread_id", None) + ): + message.group_id += f"#{source_message.message_thread_id}" + message.session_id = message.group_id + + source_message_id = ( + str(source_message.message_id) if source_message is not None else None + ) + message.message_id = str(query.id) + message.sender = MessageMember( + str(query.from_user.id), + query.from_user.username or "Unknown", + ) + message.self_id = str(context.bot.username) + message.raw_message = update + message.message_str = action_id + message.message = [ + Comp.ButtonInteraction( + action_id=action_id, + data=data, + interaction_id=str(query.id), + source_message_id=source_message_id, + ) + ] + await self.handle_msg(message) + async def convert_message( self, update: Update, diff --git a/astrbot/core/platform/sources/telegram/tg_event.py b/astrbot/core/platform/sources/telegram/tg_event.py index 8445a8ea1e..1379eb5f7f 100644 --- a/astrbot/core/platform/sources/telegram/tg_event.py +++ b/astrbot/core/platform/sources/telegram/tg_event.py @@ -5,7 +5,12 @@ from typing import Any, cast import telegramify_markdown -from telegram import ReactionTypeCustomEmoji, ReactionTypeEmoji +from telegram import ( + InlineKeyboardButton, + InlineKeyboardMarkup, + ReactionTypeCustomEmoji, + ReactionTypeEmoji, +) from telegram.constants import ChatAction from telegram.error import BadRequest from telegram.ext import ExtBot @@ -13,15 +18,19 @@ from astrbot import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.message_components import ( + ActionRow, At, + CallbackAction, File, Image, Plain, Record, Reply, + UrlAction, Video, ) from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata +from astrbot.core.platform.button_interaction import encode_button_callback from astrbot.core.utils.metrics import Metric @@ -113,7 +122,11 @@ async def _send_text_chunks( payload: dict[str, Any], ) -> None: """按 Telegram 限制切分文本后逐段发送。""" - for chunk in cls._split_message(text): + chunks = cls._split_message(text) + for index, chunk in enumerate(chunks): + chunk_payload = dict(payload) + if index < len(chunks) - 1: + chunk_payload.pop("reply_markup", None) try: markdown_text = telegramify_markdown.markdownify( chunk, @@ -121,13 +134,60 @@ async def _send_text_chunks( await client.send_message( text=markdown_text, parse_mode="MarkdownV2", - **cast(Any, payload), + **cast(Any, chunk_payload), ) except (ValueError, BadRequest) as e: logger.warning( f"Failed to convert message to Markdown,using normal text: {e!s}" ) - await client.send_message(text=chunk, **cast(Any, payload)) + await client.send_message(text=chunk, **cast(Any, chunk_payload)) + + @classmethod + def _build_inline_keyboard( + cls, + rows: list[ActionRow], + ) -> InlineKeyboardMarkup | None: + """Render common button rows as a Telegram inline keyboard. + + Args: + rows: Common action rows to render. + + Returns: + Telegram markup, or ``None`` when no valid buttons remain. + """ + keyboard: list[list[InlineKeyboardButton]] = [] + for row in rows: + telegram_row: list[InlineKeyboardButton] = [] + for button in row.buttons: + if isinstance(button.action, UrlAction): + telegram_row.append( + InlineKeyboardButton(button.label, url=button.action.url) + ) + continue + + if not isinstance(button.action, CallbackAction): + continue + callback_data = encode_button_callback( + button.id, + button.action.data, + ) + if len(callback_data.encode("utf-8")) > 64: + logger.warning( + "Skipping Telegram button %r because its callback token " + "exceeds 64 bytes.", + button.id, + ) + continue + telegram_row.append( + InlineKeyboardButton( + button.label, + callback_data=callback_data, + ) + ) + if telegram_row: + keyboard.append(telegram_row) + + return InlineKeyboardMarkup(keyboard) if keyboard else None @classmethod async def _send_chat_action( @@ -274,6 +334,15 @@ async def send_with_client( ) -> None: image_path = None + action_rows = [i for i in message.chain if isinstance(i, ActionRow)] + reply_markup = cls._build_inline_keyboard(action_rows) + renderable_indexes = [ + index + for index, component in enumerate(message.chain) + if isinstance(component, (Plain, Image, File, Record, Video)) + ] + last_renderable_index = renderable_indexes[-1] if renderable_indexes else None + has_reply = False reply_message_id = None at_user_id = None @@ -294,7 +363,7 @@ async def send_with_client( action = cls._get_chat_action_for_chain(message.chain) await cls._send_chat_action(client, user_name, action, message_thread_id) - for i in message.chain: + for index, i in enumerate(message.chain): payload = { "chat_id": user_name, } @@ -302,6 +371,8 @@ async def send_with_client( payload["reply_to_message_id"] = str(reply_message_id) if message_thread_id: payload["message_thread_id"] = message_thread_id + if reply_markup is not None and index == last_renderable_index: + payload["reply_markup"] = reply_markup if isinstance(i, Plain): if at_user_id and not at_flag: @@ -340,6 +411,20 @@ async def send_with_client( **cast(Any, payload), ) + if action_rows and last_renderable_index is None: + fallback_text = next( + (row.fallback_text for row in action_rows if row.fallback_text), + "Choose an option:", + ) + payload = {"chat_id": user_name} + if reply_markup is not None: + payload["reply_markup"] = reply_markup + if has_reply: + payload["reply_to_message_id"] = str(reply_message_id) + if message_thread_id: + payload["message_thread_id"] = message_thread_id + await cls._send_text_chunks(client, fallback_text, payload) + async def send(self, message: MessageChain) -> None: if self.get_message_type() == MessageType.GROUP_MESSAGE: await self.send_with_client(self.client, message, self.message_obj.group_id) diff --git a/astrbot/core/platform/sources/webchat/message_parts_helper.py b/astrbot/core/platform/sources/webchat/message_parts_helper.py index a8dc040972..bd5d29001a 100644 --- a/astrbot/core/platform/sources/webchat/message_parts_helper.py +++ b/astrbot/core/platform/sources/webchat/message_parts_helper.py @@ -1,12 +1,16 @@ import json import mimetypes import shutil +import uuid from collections.abc import Awaitable, Callable, Sequence from pathlib import Path, PurePosixPath from typing import Any from astrbot.core.db.po import Attachment from astrbot.core.message.components import ( + ActionRow, + ButtonInteraction, + CallbackAction, File, Image, Json, @@ -16,6 +20,10 @@ Video, ) from astrbot.core.message.message_event_result import MessageChain +from astrbot.core.platform.button_interaction import ( + decode_button_callback, + encode_button_callback, +) from astrbot.core.utils.datetime_utils import generate_timestamp_id from astrbot.core.utils.media_utils import MediaResolver @@ -52,8 +60,11 @@ def strip_message_parts_path_fields(message_parts: list[dict]) -> list[dict]: def webchat_message_parts_have_content(message_parts: list[dict]) -> bool: return any( - part.get("type") in ("plain", "image", "record", "file", "video") - and (part.get("text") or part.get("attachment_id") or part.get("filename")) + ( + part.get("type") in ("plain", "image", "record", "file", "video") + and (part.get("text") or part.get("attachment_id") or part.get("filename")) + ) + or (part.get("type") == "button_interaction" and part.get("callback_data")) for part in message_parts ) @@ -145,6 +156,35 @@ async def parse_webchat_message_parts( ) continue + if part_type == "button_interaction": + callback_payload = part.get("callback_data") + if not isinstance(callback_payload, str): + if strict: + raise ValueError("button_interaction part missing callback_data") + continue + try: + action_id, data = decode_button_callback(callback_payload) + except ValueError: + if strict: + raise + continue + source_message_id = part.get("source_message_id") + components.append( + ButtonInteraction( + action_id=action_id, + data=data, + interaction_id=str(part.get("interaction_id") or uuid.uuid4().hex), + source_message_id=( + str(source_message_id) + if source_message_id is not None + else None + ), + ) + ) + text_parts.append(action_id) + has_content = True + continue + if part_type not in MEDIA_PART_TYPES: if strict: raise ValueError(f"unsupported message part type: {part_type}") @@ -228,6 +268,28 @@ async def build_webchat_message_parts( ) continue + if part_type == "button_interaction": + callback_payload = part.get("callback_data") + if not isinstance(callback_payload, str): + if strict: + raise ValueError("button_interaction part missing callback_data") + continue + try: + decode_button_callback(callback_payload) + except ValueError: + if strict: + raise + continue + message_parts.append( + { + "type": "button_interaction", + "callback_data": callback_payload, + "source_message_id": part.get("source_message_id"), + "interaction_id": uuid.uuid4().hex, + } + ) + continue + if part_type not in MEDIA_PART_TYPES: if strict: raise ValueError(f"unsupported message part type: {part_type}") @@ -414,6 +476,21 @@ async def message_chain_to_storage_message_parts( ) continue + if isinstance(comp, ActionRow): + row_data = comp.toDict()["data"] + for button, serialized_button in zip( + comp.buttons, + row_data["buttons"], + strict=True, + ): + if isinstance(button.action, CallbackAction): + serialized_button["action"]["callback_data"] = ( + encode_button_callback(button.id, button.action.data) + ) + serialized_button["action"].pop("data", None) + parts.append({"type": "actionrow", **row_data}) + continue + if isinstance(comp, Image): file_path = await comp.convert_to_file_path() attachment_part = await _copy_file_to_attachment_part( diff --git a/astrbot/core/platform/sources/webchat/webchat_event.py b/astrbot/core/platform/sources/webchat/webchat_event.py index 11c2d35128..f55fbebfd3 100644 --- a/astrbot/core/platform/sources/webchat/webchat_event.py +++ b/astrbot/core/platform/sources/webchat/webchat_event.py @@ -7,7 +7,16 @@ from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain -from astrbot.api.message_components import File, Image, Json, Plain, Record +from astrbot.api.message_components import ( + ActionRow, + CallbackAction, + File, + Image, + Json, + Plain, + Record, +) +from astrbot.core.platform.button_interaction import encode_button_callback from astrbot.core.utils.astrbot_path import get_astrbot_data_path from astrbot.core.utils.datetime_utils import generate_timestamp_id from astrbot.core.utils.media_utils import ( @@ -75,6 +84,29 @@ async def _send( ) if not accepted: return None + elif isinstance(comp, ActionRow): + row_data = comp.toDict()["data"] + for button, serialized_button in zip( + comp.buttons, + row_data["buttons"], + strict=True, + ): + if isinstance(button.action, CallbackAction): + serialized_button["action"]["callback_data"] = ( + encode_button_callback(button.id, button.action.data) + ) + serialized_button["action"].pop("data", None) + accepted = await webchat_queue_mgr.put_back_queue( + request_id, + { + "type": "actionrow", + "data": row_data, + "streaming": streaming, + "message_id": message_id, + }, + ) + if not accepted: + return None elif isinstance(comp, Image): # save image to local image_base64 = await comp.convert_to_base64() diff --git a/astrbot/core/platform/sources/wecom/wecom_adapter.py b/astrbot/core/platform/sources/wecom/wecom_adapter.py index cefb7234bf..8981cb7309 100644 --- a/astrbot/core/platform/sources/wecom/wecom_adapter.py +++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py @@ -17,7 +17,7 @@ from wechatpy.messages import BaseMessage from astrbot.api.event import MessageChain -from astrbot.api.message_components import File, Image, Plain, Record +from astrbot.api.message_components import ButtonInteraction, File, Image, Plain, Record from astrbot.api.platform import ( AstrBotMessage, MessageMember, @@ -28,6 +28,7 @@ ) from astrbot.core import logger from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.button_interaction import decode_button_callback from astrbot.core.platform.webhook_server import FastAPIWebhookServer from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.media_utils import ( @@ -370,6 +371,42 @@ async def convert_message(self, msg: BaseMessage) -> AstrBotMessage | None: abm.timestamp = int(cast(int | str, msg.time)) abm.session_id = abm.sender.user_id abm.raw_message = msg + elif msg.type == "unknown" and msg._data.get("Event") == "template_card_event": + callback_payload = msg._data.get("EventKey") + if not isinstance(callback_payload, str): + logger.debug("Ignored a WeCom template card callback without EventKey.") + return None + try: + action_id, data = decode_button_callback(callback_payload) + except ValueError: + logger.debug("Ignored a template card callback not created by AstrBot.") + return None + + sender_id = str(msg._data.get("FromUserName", "")) + if not sender_id: + logger.debug("Ignored a WeCom template card callback without a sender.") + return None + response_code = str(msg._data.get("ResponseCode") or "") + task_id = str(msg._data.get("TaskId") or "") + interaction_id = response_code or task_id or uuid.uuid4().hex + abm.message_str = action_id + abm.self_id = str(msg._data.get("AgentID", "")) + abm.message = [ + ButtonInteraction( + action_id=action_id, + data=data, + interaction_id=interaction_id, + source_message_id=task_id or None, + ) + ] + abm.type = MessageType.FRIEND_MESSAGE + abm.sender = MessageMember(sender_id, sender_id) + abm.message_id = interaction_id + create_time = msg._data.get("CreateTime") + if create_time: + abm.timestamp = int(cast(int | str, create_time)) + abm.session_id = sender_id + abm.raw_message = msg elif isinstance(msg, ImageMessage): abm.message_str = "[图片]" abm.self_id = str(msg.agent) @@ -423,6 +460,7 @@ async def convert_message(self, msg: BaseMessage) -> AstrBotMessage | None: self.agent_id = abm.self_id logger.info(f"abm: {abm}") await self.handle_msg(abm) + return abm async def convert_wechat_kf_message(self, msg: dict) -> AstrBotMessage | None: msgtype = msg.get("msgtype") @@ -437,7 +475,29 @@ async def convert_wechat_kf_message(self, msg: dict) -> AstrBotMessage | None: abm.message_id = msg.get("msgid", uuid.uuid4().hex[:8]) abm.message_str = "" if msgtype == "text": - text = msg.get("text", {}).get("content", "").strip() + text_data = msg.get("text", {}) + text = text_data.get("content", "").strip() + menu_id = text_data.get("menu_id") + if isinstance(menu_id, str) and menu_id: + try: + action_id, data = decode_button_callback(menu_id) + except ValueError: + logger.debug( + "Treating an unrecognized WeCom customer-service menu " + "callback as a regular text message." + ) + else: + abm.message = [ + ButtonInteraction( + action_id=action_id, + data=data, + interaction_id=abm.message_id, + source_message_id=None, + ) + ] + abm.message_str = action_id + await self.handle_msg(abm) + return abm if self._is_duplicate_wechat_kf_text_message(abm.session_id, text): logger.debug( "忽略 15 秒内重复微信客服文本消息 session_id=%s text=%s", @@ -514,6 +574,7 @@ async def convert_wechat_kf_message(self, msg: dict) -> AstrBotMessage | None: logger.warning(f"未实现的微信客服消息事件: {msg}") return await self.handle_msg(abm) + return abm def create_event(self, message: AstrBotMessage) -> WecomPlatformEvent: """Creates a WeCom message event. diff --git a/astrbot/core/platform/sources/wecom/wecom_event.py b/astrbot/core/platform/sources/wecom/wecom_event.py index 265b41014f..0570fecc49 100644 --- a/astrbot/core/platform/sources/wecom/wecom_event.py +++ b/astrbot/core/platform/sources/wecom/wecom_event.py @@ -1,13 +1,25 @@ import asyncio import os +import uuid from wechatpy.enterprise import WeChatClient from wechatpy.exceptions import WeChatClientException from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain -from astrbot.api.message_components import File, Image, Plain, Record, Video +from astrbot.api.message_components import ( + ActionRow, + ButtonStyle, + CallbackAction, + File, + Image, + Plain, + Record, + UrlAction, + Video, +) from astrbot.api.platform import AstrBotMessage, PlatformMetadata +from astrbot.core.platform.button_interaction import encode_button_callback from astrbot.core.utils.media_utils import convert_audio_to_amr from .wecom_kf_message import WeChatKFMessage @@ -194,6 +206,47 @@ async def send(self, message: MessageChain) -> None: self.get_self_id(), response["media_id"], ) + elif isinstance(comp, ActionRow): + if not comp.buttons: + continue + menu_list = [] + for button in comp.buttons: + if isinstance(button.action, CallbackAction): + callback_id = encode_button_callback( + button.id, + button.action.data, + ) + if len(callback_id.encode("utf-8")) > 64: + raise ValueError( + "WeCom customer-service callback IDs cannot exceed " + "64 bytes." + ) + menu_list.append( + { + "type": "click", + "click": { + "id": callback_id, + "content": button.label, + }, + } + ) + elif isinstance(button.action, UrlAction): + menu_list.append( + { + "type": "view", + "view": { + "url": button.action.url, + "content": button.label, + }, + } + ) + kf_message_api.send_msgmenu( + user_id, + self.get_self_id(), + comp.fallback_text or "", + menu_list, + "", + ) else: logger.warning(f"还没实现这个消息类型的发送逻辑: {comp.type}。") else: @@ -293,6 +346,50 @@ async def send(self, message: MessageChain) -> None: message_obj.session_id, response["media_id"], ) + elif isinstance(comp, ActionRow): + if not comp.buttons: + continue + if len(comp.buttons) > 6: + raise ValueError( + "WeCom template cards support at most 6 buttons." + ) + style_map = { + ButtonStyle.DEFAULT: 1, + ButtonStyle.PRIMARY: 2, + ButtonStyle.DANGER: 3, + ButtonStyle.SUCCESS: 4, + } + button_list = [] + for button in comp.buttons: + item = { + "text": button.label, + "style": style_map[button.style], + } + if isinstance(button.action, CallbackAction): + item["type"] = 0 + item["key"] = encode_button_callback( + button.id, + button.action.data, + ) + elif isinstance(button.action, UrlAction): + item["type"] = 1 + item["url"] = button.action.url + button_list.append(item) + self.client.message.send( + message_obj.self_id, + message_obj.session_id, + msg={ + "msgtype": "template_card", + "template_card": { + "card_type": "button_interaction", + "main_title": { + "title": comp.fallback_text or "请选择操作" + }, + "button_list": button_list, + "task_id": f"astrbot_{uuid.uuid4().hex}", + }, + }, + ) else: logger.warning(f"还没实现这个消息类型的发送逻辑: {comp.type}。") diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py index 1c0ee96c0d..5a3209e58d 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py @@ -6,6 +6,7 @@ import asyncio import base64 import hashlib +import json import time import uuid from collections.abc import Awaitable, Callable @@ -13,7 +14,7 @@ from astrbot.api import logger from astrbot.api.event import MessageChain -from astrbot.api.message_components import At, Image, Plain +from astrbot.api.message_components import At, ButtonInteraction, Image, Plain from astrbot.api.platform import ( AstrBotMessage, MessageMember, @@ -22,6 +23,7 @@ PlatformMetadata, ) from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.button_interaction import decode_button_callback from astrbot.core.utils.webhook_utils import log_webhook_info from ...register import register_platform_adapter @@ -266,6 +268,7 @@ async def _process_message( cached_plain_content = self._stream_plain_cache.get(stream_id, "") latest_plain_content = cached_plain_content image_base64 = [] + template_card = None finish = False while not queue.empty(): msg = await queue.get() @@ -280,6 +283,8 @@ async def _process_message( latest_plain_content = cached_plain_content elif msg["type"] == "image": image_base64.append(msg["image_data"]) + elif msg["type"] == "template_card": + template_card = msg["template_card"] elif msg["type"] == "break": continue elif msg["type"] in {"end", "complete"}: @@ -294,7 +299,12 @@ async def _process_message( ) if not finish: self._stream_plain_cache[stream_id] = cached_plain_content - if finish and not latest_plain_content and not image_base64: + if ( + finish + and not latest_plain_content + and not image_base64 + and not template_card + ): end_message = WecomAIBotStreamMessageBuilder.make_text_stream( stream_id, "", @@ -305,7 +315,7 @@ async def _process_message( callback_params["nonce"], callback_params["timestamp"], ) - if latest_plain_content or image_base64: + if latest_plain_content or image_base64 or template_card: msg_items = [] if finish and image_base64: for img_b64 in image_base64: @@ -320,12 +330,23 @@ async def _process_message( ) image_base64 = [] - plain_message = WecomAIBotStreamMessageBuilder.make_mixed_stream( - stream_id, - latest_plain_content, - msg_items, - finish, - ) + if template_card: + plain_message = ( + WecomAIBotStreamMessageBuilder.make_stream_with_template_card( + stream_id, + latest_plain_content, + msg_items, + template_card, + finish, + ) + ) + else: + plain_message = WecomAIBotStreamMessageBuilder.make_mixed_stream( + stream_id, + latest_plain_content, + msg_items, + finish, + ) encrypted_message = await self.api_client.encrypt_message( plain_message, callback_params["nonce"], @@ -340,8 +361,9 @@ async def _process_message( return encrypted_message return None elif msgtype == "event": - event = message_data.get("event") - if event == "enter_chat" and self.friend_message_welcome_text: + event = message_data.get("event") or {} + event_type = event.get("eventtype") if isinstance(event, dict) else event + if event_type == "enter_chat" and self.friend_message_welcome_text: # 用户进入会话,发送欢迎消息 try: resp = WecomAIBotStreamMessageBuilder.make_text( @@ -355,6 +377,54 @@ async def _process_message( except Exception as e: logger.error("处理欢迎消息时发生异常: %s", e) return None + if event_type == "template_card_event" and isinstance(event, dict): + interaction = event.get("template_card_event") or event + event_key = interaction.get("event_key") + if not isinstance(event_key, str): + logger.warning( + "Ignored WeCom template-card callback without event_key." + ) + return None + try: + decode_button_callback(event_key) + except ValueError as e: + logger.warning("Ignored unknown WeCom button callback: %s", e) + return None + + stream_id = f"{session_id}_interaction_{generate_random_string(10)}" + interaction_params = { + **callback_params, + "button_interaction": "true", + "task_id": str(interaction.get("task_id") or ""), + } + self.queue_mgr.get_or_create_back_queue(stream_id) + self.queue_mgr.set_pending_response(stream_id, interaction_params) + await self._enqueue_message( + message_data, + interaction_params, + stream_id, + session_id, + ) + + queue = self.queue_mgr.get_or_create_back_queue(stream_id) + try: + response = await asyncio.wait_for(queue.get(), timeout=4.5) + except TimeoutError: + logger.warning( + "WeCom template-card callback did not produce an update within 4.5 seconds: %s", + message_data.get("msgid"), + ) + self.queue_mgr.remove_queues(stream_id) + return None + + self.queue_mgr.remove_queues(stream_id) + if response.get("type") != "template_card_update": + return None + return await self.api_client.encrypt_message( + json.dumps(response["body"], ensure_ascii=False), + callback_params["nonce"], + callback_params["timestamp"], + ) async def _process_long_connection_payload( self, @@ -405,6 +475,38 @@ async def _process_long_connection_payload( and req_id ): await self._send_long_connection_respond_welcome(req_id) + elif event_type == "template_card_event": + interaction = event.get("template_card_event") or event + event_key = interaction.get("event_key") + if not isinstance(event_key, str): + logger.warning( + "[WecomAI][LongConn] Ignored template-card callback without event_key." + ) + return + try: + decode_button_callback(event_key) + except ValueError as e: + logger.warning( + "[WecomAI][LongConn] Ignored unknown button callback: %s", e + ) + return + + session_id = self._extract_session_id(body) + stream_id = f"{session_id}_interaction_{generate_random_string(10)}" + callback_params = { + "req_id": req_id or "", + "connection_mode": "long_connection", + "button_interaction": "true", + "task_id": str(interaction.get("task_id") or ""), + } + self.queue_mgr.get_or_create_back_queue(stream_id) + self.queue_mgr.set_pending_response(stream_id, callback_params) + await self._enqueue_message( + body, + callback_params, + stream_id, + session_id, + ) elif event_type == "disconnected_event": logger.warning( "[WecomAI][LongConn] 收到 disconnected_event,旧连接将被关闭" @@ -439,6 +541,29 @@ async def _send_long_connection_respond_msg( body=body, ) + async def _send_long_connection_update_msg( + self, + req_id: str, + body: dict[str, Any], + ) -> bool: + """Replace a card in response to a WeCom interaction event. + + Args: + req_id: Request ID from the template-card callback frame. + body: WeCom update-template-card response body. + + Returns: + Whether WeCom accepted the update command. + """ + client = self.long_connection_client + if not client: + return False + return await client.send_command( + cmd="aibot_respond_update_msg", + req_id=req_id, + body=body, + ) + def _extract_session_id(self, message_data: dict[str, Any]) -> str: """从消息数据中提取会话ID 群聊使用 chatid,单聊使用 userid @@ -480,6 +605,7 @@ async def convert_message(self, payload: dict) -> AstrBotMessage: msgtype = message_data.get("msgtype") content = "" image_base64 = [] + button_interaction = None _img_url_to_process: list[tuple[str, str | None]] = [] msg_items = [] @@ -508,6 +634,25 @@ async def convert_message(self, payload: dict) -> AstrBotMessage: (image_url, image_payload.get("aeskey")) ) content = " ".join(text_parts) if text_parts else "" + elif msgtype == WecomAIBotConstants.MSG_TYPE_EVENT: + event = message_data.get("event") or {} + event_type = event.get("eventtype") if isinstance(event, dict) else event + if event_type == "template_card_event" and isinstance(event, dict): + native_interaction = event.get("template_card_event") or event + event_key = native_interaction.get("event_key") + if not isinstance(event_key, str): + raise ValueError("WeCom template card event is missing event_key.") + action_id, data = decode_button_callback(event_key) + interaction_id = str(message_data.get("msgid") or uuid.uuid4()) + button_interaction = ButtonInteraction( + action_id=action_id, + data=data, + interaction_id=interaction_id, + source_message_id=str(native_interaction.get("task_id") or "") + or None, + ) + else: + content = f"[{event_type or 'event'}事件]" else: content = f"[{msgtype}消息]" @@ -527,9 +672,9 @@ async def convert_message(self, payload: dict) -> AstrBotMessage: # 构建 AstrBotMessage abm = AstrBotMessage() abm.self_id = self.bot_name - abm.message_str = content or "[未知消息]" - abm.message_id = str(uuid.uuid4()) - abm.timestamp = int(time.time()) + abm.message_str = "" if button_interaction else content or "[未知消息]" + abm.message_id = str(message_data.get("msgid") or uuid.uuid4()) + abm.timestamp = int(message_data.get("create_time") or time.time()) abm.raw_message = payload # 发送者信息 @@ -549,6 +694,11 @@ async def convert_message(self, payload: dict) -> AstrBotMessage: # 消息内容 abm.message = [] + if button_interaction: + abm.message.append(button_interaction) + logger.debug(f"WecomAIAdapter: {abm.message}") + return abm + # 处理 At if self.bot_name and f"@{self.bot_name}" in abm.message_str: abm.message_str = abm.message_str.replace(f"@{self.bot_name}", "").strip() @@ -661,9 +811,11 @@ def create_event(self, message: AstrBotMessage) -> WecomAIBotMessageEvent: webhook_client=self.webhook_client, only_use_webhook_url_to_send=self.only_use_webhook_url_to_send, long_connection_sender=self._send_long_connection_respond_msg, + long_connection_update_sender=self._send_long_connection_update_msg, ) - message_event.is_at_or_wake_command = True - message_event.is_wake = True + if not message_event.is_button_interaction(): + message_event.is_at_or_wake_command = True + message_event.is_wake = True return message_event async def handle_msg(self, message: AstrBotMessage) -> None: diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_api.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_api.py index 97831fbb22..7c9c8079dc 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_api.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_api.py @@ -307,6 +307,40 @@ def make_mixed_stream( plain["stream"]["content"] = content return json.dumps(plain, ensure_ascii=False) + @staticmethod + def make_stream_with_template_card( + stream_id: str, + content: str, + msg_items: list, + template_card: dict[str, Any], + finish: bool = False, + ) -> str: + """Build a stream response containing one template card. + + Args: + stream_id: Stream identifier returned by AstrBot. + content: Accumulated Markdown content. + msg_items: Optional image message items. + template_card: WeCom template-card payload. + finish: Whether this is the final stream response. + + Returns: + JSON-encoded stream-with-card response. + """ + stream: dict[str, Any] = {"id": stream_id, "finish": finish} + if content: + stream["content"] = content + if msg_items: + stream["msg_item"] = msg_items + return json.dumps( + { + "msgtype": "stream_with_template_card", + "stream": stream, + "template_card": template_card, + }, + ensure_ascii=False, + ) + @staticmethod def make_text(content: str) -> str: """构建文本消息 diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_buttons.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_buttons.py new file mode 100644 index 0000000000..7cbefdcba8 --- /dev/null +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_buttons.py @@ -0,0 +1,76 @@ +"""Portable button rendering for WeCom AI Bot template cards.""" + +import uuid + +from astrbot.api.message_components import ( + ActionRow, + ButtonStyle, + CallbackAction, + UrlAction, +) +from astrbot.core.platform.button_interaction import encode_button_callback + +_BUTTON_STYLE_MAP = { + ButtonStyle.DEFAULT: 4, + ButtonStyle.PRIMARY: 1, + ButtonStyle.SUCCESS: 3, + ButtonStyle.DANGER: 2, +} + + +def build_wecom_button_card( + rows: list[ActionRow], + task_id: str | None = None, +) -> dict | None: + """Build one WeCom button-interaction template card. + + WeCom displays at most six buttons in one interaction card. Portable rows are + flattened because the native card format does not preserve row boundaries. + + Args: + rows: Portable action rows to render. + task_id: Existing WeCom task ID when replacing a clicked card. + + Returns: + A WeCom template-card object, or ``None`` when no buttons are present. + + Raises: + ValueError: More than six buttons were supplied for one card. + """ + buttons = [button for row in rows for button in row.buttons] + if not buttons: + return None + if len(buttons) > 6: + raise ValueError("WeCom AI Bot supports at most 6 buttons per card.") + + native_buttons = [] + for button in buttons: + native_button = { + "text": (button.label or button.id)[:10], + "style": _BUTTON_STYLE_MAP[button.style], + } + if isinstance(button.action, CallbackAction): + native_button.update( + { + "type": 0, + "key": encode_button_callback(button.id, button.action.data), + } + ) + elif isinstance(button.action, UrlAction): + native_button.update({"type": 1, "url": button.action.url}) + native_buttons.append(native_button) + + title = next( + ( + row.fallback_text.strip() + for row in rows + if row.fallback_text and row.fallback_text.strip() + ), + "Choose an action", + ) + return { + "card_type": "button_interaction", + "main_title": {"title": title[:26]}, + "button_list": native_buttons, + "task_id": task_id or f"astrbot_{uuid.uuid4().hex}", + } diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py index 74d120f5f3..4b35270590 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py @@ -5,9 +5,10 @@ from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain -from astrbot.api.message_components import At, Image, Plain +from astrbot.api.message_components import ActionRow, At, Image, Plain from .wecomai_api import WecomAIBotAPIClient +from .wecomai_buttons import build_wecom_button_card from .wecomai_queue_mgr import WecomAIQueueMgr from .wecomai_webhook import WecomAIBotWebhookClient @@ -28,6 +29,9 @@ def __init__( webhook_client: WecomAIBotWebhookClient | None = None, only_use_webhook_url_to_send: bool = False, long_connection_sender: (Callable[[str, dict], Awaitable[bool]] | None) = None, + long_connection_update_sender: ( + Callable[[str, dict], Awaitable[bool]] | None + ) = None, ) -> None: """初始化消息事件 @@ -37,6 +41,11 @@ def __init__( platform_meta: 平台元数据 session_id: 会话 ID api_client: API 客户端 + queue_mgr: Queue manager used by HTTP streaming replies. + webhook_client: Optional proactive-message webhook client. + only_use_webhook_url_to_send: Whether all output uses the webhook client. + long_connection_sender: Sender for normal long-connection replies. + long_connection_update_sender: Sender for template-card replacements. """ super().__init__(message_str, message_obj, platform_meta, session_id) @@ -45,6 +54,7 @@ def __init__( self.webhook_client = webhook_client self.only_use_webhook_url_to_send = only_use_webhook_url_to_send self.long_connection_sender = long_connection_sender + self.long_connection_update_sender = long_connection_update_sender async def _mark_stream_complete(self, stream_id: str) -> None: back_queue = self.queue_mgr.get_or_create_back_queue(stream_id) @@ -78,6 +88,22 @@ async def _send( return "" data = "" + action_rows = [ + component + for component in message_chain.chain + if isinstance(component, ActionRow) + ] + template_card = build_wecom_button_card(action_rows) + if template_card: + await back_queue.put( + { + "type": "template_card", + "template_card": template_card, + "streaming": streaming, + "session_id": stream_id, + }, + ) + for comp in message_chain.chain: if isinstance(comp, At): data = f"@{comp.name} " @@ -116,6 +142,8 @@ async def _send( logger.warning("图片数据为空,跳过") except Exception as e: logger.error("处理图片消息失败: %s", e) + elif isinstance(comp, ActionRow): + continue else: if not suppress_unsupported_log: logger.warning( @@ -161,6 +189,51 @@ async def send(self, message: MessageChain | None) -> None: "connection_mode" ) req_id = pending_response.get("callback_params", {}).get("req_id") + callback_params = pending_response.get("callback_params", {}) + + if callback_params.get("button_interaction") == "true": + action_rows = [ + component + for component in message.chain + if isinstance(component, ActionRow) + ] + task_id = callback_params.get("task_id") + template_card = build_wecom_button_card(action_rows, task_id=task_id) + if template_card is None: + content = self._extract_plain_text_from_chain(message) or "Done" + template_card = { + "card_type": "text_notice", + "main_title": {"title": content[:26]}, + "card_action": {"type": 0}, + "task_id": task_id, + } + if len(content) > 26: + template_card["sub_title_text"] = content[26:138] + + update_body = { + "response_type": "update_template_card", + "template_card": template_card, + } + if ( + connection_mode == "long_connection" + and self.long_connection_update_sender + and isinstance(req_id, str) + and req_id + ): + accepted = await self.long_connection_update_sender(req_id, update_body) + if accepted: + self.queue_mgr.remove_queues(stream_id) + else: + back_queue = self.queue_mgr.get_or_create_back_queue(stream_id) + await back_queue.put( + { + "type": "template_card_update", + "body": update_body, + "session_id": stream_id, + }, + ) + await super().send(MessageChain([])) + return if ( connection_mode == "long_connection" @@ -180,16 +253,34 @@ async def send(self, message: MessageChain | None) -> None: ) content = self._extract_plain_text_from_chain(message) - await self.long_connection_sender( - req_id, - { + action_rows = [ + component + for component in message.chain + if isinstance(component, ActionRow) + ] + template_card = build_wecom_button_card(action_rows) + if template_card: + body = { + "msgtype": "stream_with_template_card", + "stream": { + "id": stream_id, + "finish": True, + "content": content, + }, + "template_card": template_card, + } + else: + body = { "msgtype": "stream", "stream": { "id": stream_id, "finish": True, "content": content, }, - }, + } + await self.long_connection_sender( + req_id, + body, ) await super().send(MessageChain([])) return @@ -229,6 +320,16 @@ async def send_streaming(self, generator, use_fallback=False) -> None: req_id = pending_response.get("callback_params", {}).get("req_id") back_queue = self.queue_mgr.get_or_create_back_queue(stream_id) + if ( + pending_response.get("callback_params", {}).get("button_interaction") + == "true" + ): + merged_chain = MessageChain([]) + async for chain in generator: + merged_chain.chain.extend(chain.chain) + await self.send(merged_chain) + return + if ( connection_mode == "long_connection" and self.long_connection_sender @@ -256,6 +357,7 @@ async def send_streaming(self, generator, use_fallback=False) -> None: return increment_plain = "" + action_rows: list[ActionRow] = [] last_stream_update_time = 0.0 async for chain in generator: if self.webhook_client: @@ -265,6 +367,11 @@ async def send_streaming(self, generator, use_fallback=False) -> None: ) chain.squash_plain() + action_rows.extend( + component + for component in chain.chain + if isinstance(component, ActionRow) + ) # 流式输出不 strip,保留换行等格式字符 chunk_text = self._extract_plain_text_from_chain( chain, strip_result=False @@ -286,17 +393,27 @@ async def send_streaming(self, generator, use_fallback=False) -> None: ) last_stream_update_time = now - await self.long_connection_sender( - req_id, - { + template_card = build_wecom_button_card(action_rows) + if template_card: + body = { + "msgtype": "stream_with_template_card", + "stream": { + "id": stream_id, + "finish": True, + "content": increment_plain, + }, + "template_card": template_card, + } + else: + body = { "msgtype": "stream", "stream": { "id": stream_id, "finish": True, "content": increment_plain, }, - }, - ) + } + await self.long_connection_sender(req_id, body) await super().send_streaming(generator, use_fallback) return diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_webhook.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_webhook.py index 6f42f264b9..1dccaf31fe 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_webhook.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_webhook.py @@ -13,9 +13,19 @@ from astrbot.api import logger from astrbot.api.event import MessageChain -from astrbot.api.message_components import At, File, Image, Plain, Record, Video +from astrbot.api.message_components import ( + ActionRow, + At, + File, + Image, + Plain, + Record, + Video, +) from astrbot.core.utils.media_utils import convert_audio_format +from .wecomai_buttons import build_wecom_button_card + class WecomAIBotWebhookError(RuntimeError): """企业微信 webhook 推送异常。""" @@ -158,7 +168,7 @@ async def send_voice(self, file_path: Path) -> None: @staticmethod def is_stream_supported_component(component: Any) -> bool: - return isinstance(component, Plain | Image | At) + return isinstance(component, Plain | Image | At | ActionRow) async def send_message_chain( self, @@ -216,6 +226,16 @@ async def flush_markdown_buffer(parts: list[str]) -> None: logger.warning( "清理临时语音文件失败 %s: %s", target_voice_path, e ) + elif isinstance(component, ActionRow): + await flush_markdown_buffer(markdown_buffer) + template_card = build_wecom_button_card([component]) + if template_card: + await self.send_payload( + { + "msgtype": "template_card", + "template_card": template_card, + } + ) else: logger.warning( "企业微信消息推送暂不支持组件类型 %s,已跳过", diff --git a/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py b/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py index 6c9763c937..81aae5073a 100644 --- a/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py +++ b/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py @@ -17,7 +17,17 @@ from astrbot import logger from astrbot.api.event import MessageChain -from astrbot.api.message_components import File, Image, Plain, Record, Reply, Video +from astrbot.api.message_components import ( + ActionRow, + CallbackAction, + File, + Image, + Plain, + Record, + Reply, + UrlAction, + Video, +) from astrbot.api.platform import ( AstrBotMessage, MessageMember, @@ -1653,6 +1663,31 @@ async def send_by_session( pending_text += segment.text continue + if isinstance(segment, ActionRow): + fallback_parts = [ + f"{button.label}: {button.action.url}" + for button in segment.buttons + if isinstance(button.action, UrlAction) + ] + callback_labels = [ + button.label + for button in segment.buttons + if isinstance(button.action, CallbackAction) + ] + if callback_labels: + fallback_parts.append( + segment.fallback_text + or ( + f"可选操作:{' / '.join(callback_labels)}" + "(当前平台不支持按钮,请发送选项名称)" + ) + ) + if fallback_parts: + if pending_text and not pending_text.endswith("\n"): + pending_text += "\n" + pending_text += "\n".join(fallback_parts) + continue + if isinstance(segment, (Image, Video, File)): has_supported_segment = True sent = await self._send_media_segment( diff --git a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py index ae536593c5..1a934cbe09 100644 --- a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py +++ b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py @@ -7,7 +7,14 @@ from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain -from astrbot.api.message_components import Image, Plain, Record +from astrbot.api.message_components import ( + ActionRow, + CallbackAction, + Image, + Plain, + Record, + UrlAction, +) from astrbot.api.platform import AstrBotMessage, PlatformMetadata from astrbot.core.utils.media_utils import convert_audio_to_amr @@ -86,6 +93,29 @@ async def send(self, message: MessageChain) -> None: "active_send_mode", False ) for comp in message.chain: + if isinstance(comp, ActionRow): + fallback_parts = [ + f"{button.label}: {button.action.url}" + for button in comp.buttons + if isinstance(button.action, UrlAction) + ] + callback_labels = [ + button.label + for button in comp.buttons + if isinstance(button.action, CallbackAction) + ] + if callback_labels: + fallback_parts.append( + comp.fallback_text + or ( + f"可选操作:{' / '.join(callback_labels)}" + "(当前平台不支持按钮,请发送选项名称)" + ) + ) + if not fallback_parts: + continue + comp = Plain("\n".join(fallback_parts)) + if isinstance(comp, Plain): # Split long text messages if needed plain_chunks = await self.split_plain(comp.text) @@ -97,7 +127,7 @@ async def send(self, message: MessageChain) -> None: logger.debug( f"split plain into {len(plain_chunks)} chunks for passive reply. Message not sent." ) - self.message_out["cached_xml"] = plain_chunks + self.message_out.setdefault("cached_xml", []).extend(plain_chunks) elif isinstance(comp, Image): img_path = await comp.convert_to_file_path() diff --git a/astrbot/core/star/filter/button_interaction.py b/astrbot/core/star/filter/button_interaction.py new file mode 100644 index 0000000000..e1926ac5d4 --- /dev/null +++ b/astrbot/core/star/filter/button_interaction.py @@ -0,0 +1,26 @@ +from astrbot.core.config import AstrBotConfig +from astrbot.core.platform.astr_message_event import AstrMessageEvent + +from . import HandlerFilter + + +class ButtonInteractionFilter(HandlerFilter): + """Match portable button interactions, optionally by action ID.""" + + def __init__(self, action_id: str | None = None) -> None: + self.action_id = action_id + + def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool: + """Return whether an event carries the requested button action. + + Args: + event: Incoming AstrBot message event. + cfg: Active AstrBot configuration. + + Returns: + Whether the event contains a matching button interaction. + """ + interaction = event.get_button_interaction() + return interaction is not None and ( + self.action_id is None or interaction.action_id == self.action_id + ) diff --git a/astrbot/core/star/register/__init__.py b/astrbot/core/star/register/__init__.py index 2363c722ac..afbe578419 100644 --- a/astrbot/core/star/register/__init__.py +++ b/astrbot/core/star/register/__init__.py @@ -2,6 +2,7 @@ from .star_handler import ( register_after_message_sent, register_agent, + register_button_interaction, register_command, register_command_group, register_custom_filter, @@ -28,6 +29,7 @@ __all__ = [ "register_after_message_sent", "register_agent", + "register_button_interaction", "register_command", "register_command_group", "register_custom_filter", diff --git a/astrbot/core/star/register/star_handler.py b/astrbot/core/star/register/star_handler.py index 45d91bf15e..ebdf249c46 100644 --- a/astrbot/core/star/register/star_handler.py +++ b/astrbot/core/star/register/star_handler.py @@ -15,6 +15,7 @@ from astrbot.core.provider.func_tool_manager import PY_TO_JSON_TYPE, SUPPORTED_TYPES from astrbot.core.provider.register import llm_tools +from ..filter.button_interaction import ButtonInteractionFilter from ..filter.command import CommandFilter from ..filter.command_group import CommandGroupFilter from ..filter.custom_filter import CustomFilterAnd, CustomFilterOr @@ -274,6 +275,29 @@ def decorator(awaitable): return decorator +def register_button_interaction(action_id: str | None = None, **kwargs): + """Register a handler for portable button interactions. + + Args: + action_id: Optional action ID to match. ``None`` matches every button. + **kwargs: Handler metadata passed to the common registrar. + + Returns: + A decorator for an adapter message event handler. + """ + + def decorator(awaitable): + handler_md = get_handler_or_create( + awaitable, + EventType.AdapterMessageEvent, + **kwargs, + ) + handler_md.event_filters.append(ButtonInteractionFilter(action_id)) + return awaitable + + return decorator + + def register_platform_adapter_type( platform_adapter_type: PlatformAdapterType, **kwargs, diff --git a/astrbot/dashboard/services/chat_service.py b/astrbot/dashboard/services/chat_service.py index 0b72b582d7..adbf5a4020 100644 --- a/astrbot/dashboard/services/chat_service.py +++ b/astrbot/dashboard/services/chat_service.py @@ -149,7 +149,12 @@ def add_plain( else: self.pending_text = result_text - def add_attachment(self, part: dict | None) -> None: + def add_part(self, part: dict | None) -> None: + """Append a structured non-text message part. + + Args: + part: WebChat message part ready for history storage. + """ if not part: return self._flush_pending_text() @@ -994,7 +999,7 @@ async def flush_pending_bot_message(): display_name=display_name, ) for accumulator in (pending_accumulator, display_accumulator): - accumulator.add_attachment(part) + accumulator.add_part(part) if part and part.get("attachment_id") and part.get("type"): attachment_saved_payload = { "type": "attachment_saved", @@ -1003,6 +1008,10 @@ async def flush_pending_bot_message(): "type": part["type"], }, } + elif msg_type == "actionrow" and isinstance(result_text, dict): + part = {"type": "actionrow", **result_text} + for accumulator in (pending_accumulator, display_accumulator): + accumulator.add_part(part) snapshot_accumulator = deepcopy(display_accumulator) run.message_parts = snapshot_accumulator.build_message_parts( @@ -1576,14 +1585,22 @@ async def prepare_thread_chat_payload(self, username: str, data: dict) -> dict: if thread.creator != username: raise ChatServiceError("Permission denied") + message = data.get("message", []) + is_button_interaction = ( + isinstance(message, list) + and len(message) == 1 + and isinstance(message[0], dict) + and message[0].get("type") == "button_interaction" + ) return { "session_id": thread.thread_id, - "message": data.get("message", []), + "message": message, "flags": resolve_webchat_request_flags(data), "selected_provider": data.get("selected_provider"), "selected_model": data.get("selected_model"), "_platform_history_id": "webchat_thread", "_thread_selected_text": thread.selected_text, + "_skip_user_history": is_button_interaction, } async def prepare_thread_chat_payload_from_dashboard_payload( diff --git a/astrbot/dashboard/services/live_chat_service.py b/astrbot/dashboard/services/live_chat_service.py index 16b7eed0ad..75e46fa703 100644 --- a/astrbot/dashboard/services/live_chat_service.py +++ b/astrbot/dashboard/services/live_chat_service.py @@ -713,12 +713,12 @@ async def send_attachment_saved_event(part: dict | None) -> None: elif result_type == "image": filename = str(result_text).replace("[IMAGE]", "") part = await self.create_attachment_from_file(filename, "image") - message_accumulator.add_attachment(part) + message_accumulator.add_part(part) await send_attachment_saved_event(part) elif result_type == "record": filename = str(result_text).replace("[RECORD]", "") part = await self.create_attachment_from_file(filename, "record") - message_accumulator.add_attachment(part) + message_accumulator.add_part(part) await send_attachment_saved_event(part) elif result_type == "file": filename = str(result_text).replace("[FILE]", "", 1) @@ -730,7 +730,7 @@ async def send_attachment_saved_event(part: dict | None) -> None: "file", display_name=display_name, ) - message_accumulator.add_attachment(part) + message_accumulator.add_part(part) await send_attachment_saved_event(part) elif result_type == "video": filename = str(result_text).replace("[VIDEO]", "", 1) @@ -742,8 +742,10 @@ async def send_attachment_saved_event(part: dict | None) -> None: "video", display_name=display_name, ) - message_accumulator.add_attachment(part) + message_accumulator.add_part(part) await send_attachment_saved_event(part) + elif result_type == "actionrow" and isinstance(result_text, dict): + message_accumulator.add_part({"type": "actionrow", **result_text}) should_save = False if result_type == "end": diff --git a/dashboard/src/components/chat/ButtonActionRow.vue b/dashboard/src/components/chat/ButtonActionRow.vue new file mode 100644 index 0000000000..2b4c134656 --- /dev/null +++ b/dashboard/src/components/chat/ButtonActionRow.vue @@ -0,0 +1,176 @@ + + + + + diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index 6e9d6cc461..a82d3e9b31 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -434,6 +434,7 @@ @open-thread="openThreadPanel" @open-reasoning="openReasoningPanel" @open-refs="openRefsSidebar" + @button-click="handleButtonClick" /> @@ -798,6 +799,7 @@ const { loadSessionMessages, createLocalExchange, sendMessageStream, + sendButtonInteraction, editMessage, continueEditedMessage, regenerateMessage, @@ -812,6 +814,18 @@ const { }, }); +function handleButtonClick(payload: { + message: ChatRecord; + callbackData: string; +}) { + if (!currSessionId.value) return; + sendButtonInteraction({ + sessionId: currSessionId.value, + sourceMessageId: payload.message.id, + callbackData: payload.callbackData, + }); +} + const transportMode = ref( (localStorage.getItem("chat.transportMode") as TransportMode) === "websocket" ? "websocket" diff --git a/dashboard/src/components/chat/ChatMessageList.vue b/dashboard/src/components/chat/ChatMessageList.vue index 47aadf7484..d5cf5f7f3c 100644 --- a/dashboard/src/components/chat/ChatMessageList.vue +++ b/dashboard/src/components/chat/ChatMessageList.vue @@ -281,6 +281,17 @@ + +
{{ formatJson(part) }}
@@ -430,6 +441,7 @@ import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue" import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue"; import RefsSidebar from "@/components/chat/message_list_comps/RefsSidebar.vue"; import ActionRef from "@/components/chat/message_list_comps/ActionRef.vue"; +import ButtonActionRow from "@/components/chat/ButtonActionRow.vue"; import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue"; import StyledMenu from "@/components/shared/StyledMenu.vue"; import { @@ -498,6 +510,7 @@ const emit = defineEmits<{ openThread: [thread: ChatThread]; openReasoning: [payload: { message: ChatRecord; blockIndex: number }]; openRefs: [refs: unknown]; + buttonClick: [payload: { message: ChatRecord; callbackData: string }]; }>(); registerChatMarkdownComponents(); diff --git a/dashboard/src/components/chat/StandaloneChat.vue b/dashboard/src/components/chat/StandaloneChat.vue index 4b033c80df..7862da2935 100644 --- a/dashboard/src/components/chat/StandaloneChat.vue +++ b/dashboard/src/components/chat/StandaloneChat.vue @@ -155,6 +155,12 @@ + +
{{
                         formatJson(part)
                       }}
@@ -226,6 +232,7 @@ import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMe import ReasoningBlock from "@/components/chat/message_list_comps/ReasoningBlock.vue"; import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue"; import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue"; +import ButtonActionRow from "@/components/chat/ButtonActionRow.vue"; import { attachmentName, attachmentPresentation, @@ -292,6 +299,7 @@ const { messageContent, createLocalExchange, sendMessageStream, + sendButtonInteraction, stopSession, } = useMessages({ currentSessionId: currSessionId, @@ -302,6 +310,15 @@ const { }, }); +function handleButtonClick(message: ChatRecord, callbackData: string) { + if (!currSessionId.value) return; + sendButtonInteraction({ + sessionId: currSessionId.value, + sourceMessageId: message.id, + callbackData, + }); +} + const transportMode = computed(() => (localStorage.getItem("chat.transportMode") as TransportMode) === "websocket" ? "websocket" diff --git a/dashboard/src/components/chat/ThreadPanel.vue b/dashboard/src/components/chat/ThreadPanel.vue index f6b398408f..803e111a04 100644 --- a/dashboard/src/components/chat/ThreadPanel.vue +++ b/dashboard/src/components/chat/ThreadPanel.vue @@ -28,6 +28,7 @@ :is-dark="isDark" :is-streaming="sending" variant="thread" + @button-click="handleButtonClick" /> @@ -183,6 +184,72 @@ async function send() { } } +async function handleButtonClick(payload: { + message: ChatRecord; + callbackData: string; +}) { + if (!props.thread || sending.value || !payload.callbackData) return; + const messageId = crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`; + const botRecord: ChatRecord = { + id: `local-thread-button-bot-${messageId}`, + created_at: new Date().toISOString(), + content: { + type: "bot", + message: [], + reasoning: "", + isLoading: true, + }, + }; + let visible = false; + const showBotRecord = () => { + if (visible) return; + messages.value.push(botRecord); + visible = true; + scrollToBottom(); + }; + + sending.value = true; + try { + const response = await fetchWithAuth( + chatApi.sendThreadMessageUrl(props.thread.thread_id), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: [ + { + type: "button_interaction", + callback_data: payload.callbackData, + source_message_id: payload.message.id, + }, + ], + flags: buildChatRequestFlags(), + }), + }, + ); + if (!response.ok || !response.body) { + throw new Error(`Thread button request failed: ${response.status}`); + } + await readSseStream(response.body, (streamPayload) => { + const type = streamPayload?.type || streamPayload?.t; + if (type !== "end" && type !== "user_message_saved") { + showBotRecord(); + } + processPayload(botRecord, undefined, streamPayload); + scrollToBottom(); + }); + } catch (error) { + showBotRecord(); + appendPlain( + botRecord, + `\n\n${String((error as Error)?.message || error)}`, + ); + console.error("Failed to send thread button interaction:", error); + } finally { + sending.value = false; + } +} + function normalizeRecord(record: any): ChatRecord { const content = record.content || {}; const normalizedMessage = normalizeMessageParts( @@ -230,7 +297,11 @@ async function readSseStream( } } -function processPayload(botRecord: ChatRecord, userRecord: ChatRecord, payload: any) { +function processPayload( + botRecord: ChatRecord, + userRecord: ChatRecord | undefined, + payload: any, +) { const normalized = payload?.ct === "chat" ? { ...payload, type: payload.type || payload.t } @@ -242,6 +313,7 @@ function processPayload(botRecord: ChatRecord, userRecord: ChatRecord, payload: if (type === "session_id" || type === "session_bound") return; if (type === "user_message_saved") { + if (!userRecord) return; userRecord.id = data?.id || userRecord.id; userRecord.created_at = data?.created_at || userRecord.created_at; userRecord.llm_checkpoint_id = @@ -316,6 +388,15 @@ function processPayload(botRecord: ChatRecord, userRecord: ChatRecord, payload: return; } + if (type === "actionrow" && data && typeof data === "object") { + markMessageStarted(botRecord); + botRecord.content.message.push({ + type: "actionrow", + ...(data as Record), + }); + return; + } + if (["image", "record", "file", "video"].includes(type)) { markMessageStarted(botRecord); const rawFilename = String(data) diff --git a/dashboard/src/composables/useMessages.ts b/dashboard/src/composables/useMessages.ts index 440092e104..5d6fc36ef2 100644 --- a/dashboard/src/composables/useMessages.ts +++ b/dashboard/src/composables/useMessages.ts @@ -131,6 +131,12 @@ interface CreateLocalExchangeOptions { parts: MessagePart[]; } +interface SendButtonInteractionOptions { + sessionId: string; + sourceMessageId?: string | number; + callbackData: string; +} + interface UseMessagesOptions { currentSessionId: Ref; onSessionsChanged?: () => Promise | void; @@ -408,6 +414,43 @@ export function useMessages(options: UseMessagesOptions) { ); } + function sendButtonInteraction({ + sessionId, + sourceMessageId, + callbackData, + }: SendButtonInteractionOptions) { + if (!sessionId || !callbackData) return; + const messageId = crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`; + const botRecord = reactive({ + id: `local-button-bot-${messageId}`, + created_at: new Date().toISOString(), + content: { + type: "bot", + message: [], + reasoning: "", + isLoading: true, + }, + }); + + startSseStream( + sessionId, + messageId, + [ + { + type: "button_interaction", + callback_data: callbackData, + source_message_id: sourceMessageId, + }, + ], + botRecord, + undefined, + true, + "", + "", + true, + ); + } + async function editMessage( sessionId: string, record: ChatRecord, @@ -953,12 +996,18 @@ export function useMessages(options: UseMessagesOptions) { function ensureBotRecordVisible(connection: ActiveConnection) { const { botRecord, userRecord } = connection; if (!botRecord) return; - const records = messagesBySession[connection.sessionId] || []; + const records = + messagesBySession[connection.sessionId] || + (messagesBySession[connection.sessionId] = []); if (records.includes(botRecord)) { connection.botVisible = true; return; } - if (!userRecord) return; + if (!userRecord) { + records.push(botRecord); + connection.botVisible = true; + return; + } const userIndex = records.indexOf(userRecord); let insertionAnchor = connection.deferredBeforeBot; @@ -1128,6 +1177,15 @@ export function useMessages(options: UseMessagesOptions) { return; } + if (msgType === "actionrow" && data && typeof data === "object") { + markMessageStarted(botRecord); + messageContent(botRecord).message.push({ + type: "actionrow", + ...(data as Record), + }); + return; + } + if (["image", "record", "file", "video"].includes(msgType)) { markMessageStarted(botRecord); const rawFilename = String(data) @@ -1174,6 +1232,7 @@ export function useMessages(options: UseMessagesOptions) { loadSessionMessages, createLocalExchange, sendMessageStream, + sendButtonInteraction, editMessage, continueEditedMessage, regenerateMessage, @@ -1367,6 +1426,13 @@ function partToPayload(part: MessagePart) { selected_text: part.selected_text || "", }; } + if (part.type === "button_interaction") { + return { + type: "button_interaction", + callback_data: part.callback_data, + source_message_id: part.source_message_id, + }; + } return { type: part.type, attachment_id: part.attachment_id, diff --git a/tests/fixtures/mocks/telegram.py b/tests/fixtures/mocks/telegram.py index e5e31f178c..9d80f809b0 100644 --- a/tests/fixtures/mocks/telegram.py +++ b/tests/fixtures/mocks/telegram.py @@ -30,6 +30,8 @@ def create_mock_telegram_modules(): mock_telegram = MagicMock() mock_telegram.BotCommand = MagicMock mock_telegram.Update = MagicMock + mock_telegram.InlineKeyboardButton = MagicMock + mock_telegram.InlineKeyboardMarkup = MagicMock mock_telegram.constants = MagicMock() mock_telegram.constants.ChatType = MagicMock() mock_telegram.constants.ChatType.PRIVATE = "private" @@ -54,6 +56,7 @@ def create_mock_telegram_modules(): mock_telegram_ext.filters = MagicMock() mock_telegram_ext.filters.ALL = MagicMock() mock_telegram_ext.MessageHandler = MagicMock + mock_telegram_ext.CallbackQueryHandler = MagicMock # Mock telegramify_markdown mock_telegramify = MagicMock() diff --git a/tests/test_dingtalk_adapter.py b/tests/test_dingtalk_adapter.py index 818ebd1156..3352e1af00 100644 --- a/tests/test_dingtalk_adapter.py +++ b/tests/test_dingtalk_adapter.py @@ -1,13 +1,28 @@ import asyncio +import json import threading import dingtalk_stream import pytest -from astrbot.api.message_components import At, Plain +from astrbot.api.message_components import ( + ActionRow, + At, + Button, + ButtonInteraction, + ButtonStyle, + CallbackAction, + Plain, + UrlAction, +) from astrbot.core.message.message_event_result import MessageChain +from astrbot.core.platform.button_interaction import ( + decode_button_callback, + encode_button_callback, +) from astrbot.core.platform.sources.dingtalk import dingtalk_adapter from astrbot.core.platform.sources.dingtalk.dingtalk_adapter import ( + DINGTALK_BUTTON_CARD_TEMPLATE_ID, DINGTALK_RECONNECT_INITIAL_DELAY, DINGTALK_RECONNECT_MAX_DELAY, DingtalkPlatformAdapter, @@ -219,3 +234,210 @@ async def test_dingtalk_rich_text_preserves_other_leading_mention(): assert result.message[1].qq == "bot" assert isinstance(result.message[2], Plain) assert result.message[2].text == "@AnotherUser" + + +def test_dingtalk_card_callback_converts_to_button_interaction(): + adapter = DingtalkPlatformAdapter.__new__(DingtalkPlatformAdapter) + adapter.client_id = "robot" + callback = dingtalk_stream.CallbackMessage() + callback.headers.message_id = "interaction" + callback.headers.time = 1_700_000_000_000 + callback.data = { + "userId": "user", + "spaceType": "IM_GROUP", + "spaceId": "conversation", + "outTrackId": "card-instance", + "content": json.dumps( + { + "cardPrivateData": { + "actionIds": [encode_button_callback("approve", {"item": 1})] + } + } + ), + } + + result = adapter.convert_card_callback(callback) + + assert result is not None + assert result.session_id == "conversation" + assert result.group_id == "conversation" + assert result.message_str == "approve" + assert result.message_id == "interaction" + assert result.timestamp == 1_700_000_000 + assert len(result.message) == 1 + interaction = result.message[0] + assert isinstance(interaction, ButtonInteraction) + assert interaction.action_id == "approve" + assert interaction.data == {"item": 1} + assert interaction.source_message_id == "card-instance" + + +def test_dingtalk_card_callback_resolves_payload_from_action_params(): + adapter = DingtalkPlatformAdapter.__new__(DingtalkPlatformAdapter) + adapter.client_id = "robot" + callback = dingtalk_stream.CallbackMessage() + callback.headers.message_id = "interaction" + callback.data = { + "userId": "user", + "spaceType": "IM_GROUP", + "spaceId": "conversation", + "outTrackId": "card-instance", + "content": json.dumps( + { + "cardPrivateData": { + "actionIds": ["single_button_node_ocljy2j7wg2"], + "params": { + "id": encode_button_callback( + "approve", + {"item": 1}, + ), + "text": "Approve", + }, + } + } + ), + } + + result = adapter.convert_card_callback(callback) + + assert result is not None + interaction = result.message[0] + assert isinstance(interaction, ButtonInteraction) + assert interaction.action_id == "approve" + assert interaction.data == {"item": 1} + + +def test_dingtalk_card_callback_ignores_foreign_action(): + adapter = DingtalkPlatformAdapter.__new__(DingtalkPlatformAdapter) + callback = dingtalk_stream.CallbackMessage() + callback.data = { + "content": json.dumps({"cardPrivateData": {"actionIds": ["foreign-action"]}}) + } + + assert adapter.convert_card_callback(callback) is None + + +@pytest.mark.asyncio +async def test_dingtalk_button_card_maps_callback_and_url_actions(monkeypatch): + posted = {} + + class FakeResponse: + status = 200 + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, traceback): + return False + + async def text(self): + return "OK" + + class FakeSession: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, traceback): + return False + + def post(self, url, *, headers, json): + posted.update(url=url, headers=headers, json=json) + return FakeResponse() + + adapter = DingtalkPlatformAdapter.__new__(DingtalkPlatformAdapter) + + async def get_access_token(): + return "token" + + adapter.get_access_token = get_access_token + monkeypatch.setattr(dingtalk_adapter.aiohttp, "ClientSession", FakeSession) + rows = [ + ActionRow( + buttons=[ + Button( + id="approve", + label="Approve", + action=CallbackAction(data={"item": 1}), + style=ButtonStyle.SUCCESS, + ), + Button( + id="docs", + label="Docs", + action=UrlAction(url="https://example.com/docs"), + style=ButtonStyle.PRIMARY, + ), + ] + ) + ] + + sent = await adapter._send_button_card( + "group", + "conversation", + "robot", + "Choose", + rows, + ) + + assert sent is True + payload = posted["json"] + assert payload["cardTemplateId"] == DINGTALK_BUTTON_CARD_TEMPLATE_ID + assert payload["callbackType"] == "STREAM" + assert payload["openSpaceId"] == "dtv1.card//IM_GROUP.conversation" + card_data = payload["cardData"]["cardParamMap"] + assert card_data["staticMsgContent"] == "Choose" + buttons = json.loads(card_data["sys_full_json_obj"])["msgButtons"] + assert decode_button_callback(buttons[0]["id"]) == ( + "approve", + {"item": 1}, + ) + assert buttons[0]["request"] is True + assert buttons[0]["color"] == "green" + assert buttons[1]["url"] == "https://example.com/docs" + assert buttons[1]["iosUrl"] == "https://example.com/docs" + assert "request" not in buttons[1] + + +@pytest.mark.asyncio +async def test_dingtalk_buttons_fall_back_to_markdown_when_card_fails(): + sent = [] + adapter = DingtalkPlatformAdapter.__new__(DingtalkPlatformAdapter) + + async def fail_button_card(**kwargs): + return False + + async def capture_message(open_conversation_id, robot_code, msg_key, msg_param): + sent.append((msg_key, msg_param)) + + adapter._send_button_card = fail_button_card + adapter._send_group_message = capture_message + chain = MessageChain( + [ + Plain("Choose"), + ActionRow( + buttons=[ + Button( + id="approve", + label="Approve", + action=CallbackAction(data={"item": 1}), + ), + Button( + id="docs", + label="Docs", + action=UrlAction(url="https://example.com/docs"), + ), + ] + ), + ] + ) + + await adapter._send_message_chain("group", "conversation", "robot", chain) + + assert sent == [ + ( + "sampleMarkdown", + { + "title": "AstrBot", + "text": "Choose\n[Approve]\n[Docs](https://example.com/docs)", + }, + ) + ] diff --git a/tests/test_dingtalk_app_registration.py b/tests/test_dingtalk_app_registration.py index 63ea95905e..920a086cbe 100644 --- a/tests/test_dingtalk_app_registration.py +++ b/tests/test_dingtalk_app_registration.py @@ -15,10 +15,13 @@ def test_dingtalk_registration_defaults(monkeypatch): assert dingtalk_registration_source() == DEFAULT_DINGTALK_REGISTRATION_SOURCE -def test_dingtalk_registration_poll_result_maps_waiting_and_success(): +def test_dingtalk_registration_poll_result_maps_pending_states_and_success(): assert dingtalk_registration_poll_result({"status": "WAITING"}) == { "status": "pending" } + assert dingtalk_registration_poll_result({"status": "CREATING"}) == { + "status": "pending" + } assert dingtalk_registration_poll_result( { diff --git a/tests/test_discord_adapter.py b/tests/test_discord_adapter.py index 0d0e281a8d..c961689257 100644 --- a/tests/test_discord_adapter.py +++ b/tests/test_discord_adapter.py @@ -1,15 +1,34 @@ import base64 from io import BytesIO from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest -from astrbot.api.message_components import Image, Record +from astrbot.api.message_components import ( + ActionRow, + Button, + ButtonInteraction, + ButtonStyle, + CallbackAction, + Image, + Plain, + Record, + UrlAction, +) from astrbot.core.message.message_event_result import MessageChain +from astrbot.core.platform.button_interaction import ( + decode_button_callback, + encode_button_callback, +) +from astrbot.core.platform.sources.discord import ( + client as discord_client, +) from astrbot.core.platform.sources.discord import ( discord_platform_adapter, discord_platform_event, ) +from astrbot.core.platform.sources.discord.client import DiscordBotClient from astrbot.core.platform.sources.discord.discord_platform_adapter import ( DiscordPlatformAdapter, ) @@ -130,3 +149,104 @@ def __init__(self, fp: BytesIO, filename: str) -> None: assert view is None assert embeds == [] assert reference_message_id is None + + +@pytest.mark.asyncio +async def test_discord_renders_common_action_rows(): + event = DiscordPlatformEvent.__new__(DiscordPlatformEvent) + + content, files, view, embeds, reference_message_id = await event._parse_to_discord( + MessageChain( + chain=[ + Plain("Choose an action"), + ActionRow( + buttons=[ + Button( + id="approve", + label="Approve", + action=CallbackAction(data={"order_id": 7}), + style=ButtonStyle.SUCCESS, + ), + Button( + id="docs", + label="Documentation", + action=UrlAction(url="https://example.com/docs"), + ), + ] + ), + ] + ) + ) + + assert content == "Choose an action" + assert files == [] + assert embeds == [] + assert reference_message_id is None + assert view is not None + assert len(view.children) == 2 + action_id, data = decode_button_callback(view.children[0].custom_id) + assert action_id == "approve" + assert data == {"order_id": 7} + assert view.children[0].style == discord_platform_event.discord.ButtonStyle.success + assert view.children[1].url == "https://example.com/docs" + assert view.children[1].style == discord_platform_event.discord.ButtonStyle.link + + +@pytest.mark.asyncio +async def test_discord_converts_component_interaction_to_common_event(): + adapter = DiscordPlatformAdapter.__new__(DiscordPlatformAdapter) + adapter.bot_self_id = "99" + interaction = SimpleNamespace( + id=456, + data={"custom_id": encode_button_callback("approve", {"order_id": 7})}, + guild_id=321, + channel_id=123, + user=SimpleNamespace(id=42, display_name="tester"), + message=SimpleNamespace(id=789), + ) + + message = await adapter.convert_message( + {"type": "interaction", "interaction": interaction} + ) + + assert message.message_str == "approve" + assert message.session_id == "123" + assert message.message_id == "456" + assert len(message.message) == 1 + assert isinstance(message.message[0], ButtonInteraction) + assert message.message[0].action_id == "approve" + assert message.message[0].data == {"order_id": 7} + assert message.message[0].interaction_id == "456" + assert message.message[0].source_message_id == "789" + + +@pytest.mark.asyncio +async def test_discord_client_acknowledges_component_before_dispatch(monkeypatch): + process_application_commands = AsyncMock() + monkeypatch.setattr( + discord_client.discord.Bot, + "on_interaction", + process_application_commands, + ) + client = DiscordBotClient.__new__(DiscordBotClient) + client._connection = SimpleNamespace(user=SimpleNamespace(id=99)) + client.on_message_received = AsyncMock() + response = SimpleNamespace(is_done=lambda: False, defer=AsyncMock()) + interaction = SimpleNamespace( + id=456, + type=discord_client.discord.InteractionType.component, + data={ + "component_type": discord_client.discord.ComponentType.button.value, + "custom_id": encode_button_callback("approve"), + }, + user=SimpleNamespace(id=42, display_name="tester"), + response=response, + channel_id=123, + guild_id=321, + ) + + await client.on_interaction(interaction) + + process_application_commands.assert_not_awaited() + response.defer.assert_awaited_once_with() + client.on_message_received.assert_awaited_once() diff --git a/tests/test_kook/test_kook_client.py b/tests/test_kook/test_kook_client.py index c8bbeacf65..db0ce39175 100644 --- a/tests/test_kook/test_kook_client.py +++ b/tests/test_kook/test_kook_client.py @@ -12,9 +12,11 @@ At, AtAll, BaseMessageComponent, + ButtonInteraction, Plain, Record, ) +from astrbot.core.platform.button_interaction import encode_button_callback from astrbot.core.platform.sources.kook.kook_client import KookClient from astrbot.core.platform.sources.kook.kook_config import KookConfig from astrbot.core.platform.sources.kook.kook_types import ( @@ -220,3 +222,61 @@ async def test_kook_event_warp_message( assert astrbotMessage.message_str == expected_message_str else: assert get_json_field(raw_event, expected_message_str) + + +@pytest.mark.asyncio +async def test_kook_button_click_enters_event_pipeline(monkeypatch): + monkeypatch.setattr( + "astrbot.core.platform.sources.kook.kook_adapter.KookClient", + mock_kook_client, + ) + monkeypatch.setattr( + "astrbot.core.platform.sources.kook.kook_adapter.KookRolesRecord", + mock_kook_roles_record, + ) + + from astrbot.core.platform.sources.kook.kook_adapter import KookPlatformAdapter + + event_queue = asyncio.Queue() + adapter = KookPlatformAdapter({}, {}, event_queue) + callback_value = encode_button_callback("approve", {"request_id": 42}) + click_event = KookMessageEventData.from_dict( + { + "channel_type": "GROUP", + "type": 255, + "target_id": "guild-1", + "author_id": "1", + "content": "[system]", + "msg_id": "interaction-1", + "msg_timestamp": 1_700_000_000_000, + "nonce": "", + "from_type": 1, + "extra": { + "type": "message_btn_click", + "body": { + "value": callback_value, + "msg_id": "source-message-1", + "user_id": "user-1", + "target_id": "channel-1", + "user_info": {"username": "Alice"}, + }, + }, + } + ) + + await adapter._on_received(click_event) + + event = event_queue.get_nowait() + assert event.is_button_interaction() + assert event.get_sender_id() == "user-1" + assert event.get_group_id() == "channel-1" + interaction = event.get_button_interaction() + assert isinstance(interaction, ButtonInteraction) + assert interaction.action_id == "approve" + assert interaction.data == {"request_id": 42} + assert interaction.interaction_id == "interaction-1" + assert interaction.source_message_id == "source-message-1" + + click_event.extra.body["value"] = "foreign-callback" + await adapter._on_received(click_event) + assert event_queue.empty() diff --git a/tests/test_kook/test_kook_event.py b/tests/test_kook/test_kook_event.py index dc81ebafad..c56ae3faa5 100644 --- a/tests/test_kook/test_kook_event.py +++ b/tests/test_kook/test_kook_event.py @@ -4,15 +4,21 @@ from astrbot.api.platform import PlatformMetadata, Unknown from astrbot.core.message.components import ( + ActionRow, At, AtAll, BaseMessageComponent, + Button, + ButtonStyle, + CallbackAction, Image, Json, Plain, Reply, + UrlAction, Video, ) +from astrbot.core.platform.button_interaction import decode_button_callback from astrbot.core.platform.sources.kook.kook_event import KookEvent from astrbot.core.platform.sources.kook.kook_types import KookMessageType, OrderMessage from tests.test_kook.shared import ( @@ -172,3 +178,73 @@ async def test_kook_event_warp_message( assert result.index == expected_output.index assert result.type == expected_output.type assert result.reply_id == expected_output.reply_id + + +@pytest.mark.asyncio +async def test_kook_event_renders_portable_buttons(): + client = mock_kook_client("", "") + event = KookEvent( + "", + mock_astrbot_message(), + PlatformMetadata(name="test", id="test", description="test"), + "", + client, + ) + row = ActionRow( + buttons=[ + Button( + id="approve", + label="Approve", + style=ButtonStyle.SUCCESS, + action=CallbackAction(data={"request_id": 42}), + ), + Button( + id="docs", + label="Documentation", + style=ButtonStyle.DANGER, + action=UrlAction(url="https://example.com/docs"), + ), + ] + ) + + result = await event._wrap_message(0, row) + + assert result.type == KookMessageType.CARD + card = json.loads(result.text)[0] + callback_button, url_button = card["modules"][0]["elements"] + assert callback_button["click"] == "return-val" + assert callback_button["theme"] == "success" + assert decode_button_callback(callback_button["value"]) == ( + "approve", + {"request_id": 42}, + ) + assert url_button == { + "type": "button", + "text": "Documentation", + "theme": "danger", + "value": "https://example.com/docs", + "click": "link", + } + + +@pytest.mark.asyncio +async def test_kook_event_splits_action_rows_at_four_buttons(): + client = mock_kook_client("", "") + event = KookEvent( + "", + mock_astrbot_message(), + PlatformMetadata(name="test", id="test", description="test"), + "", + client, + ) + row = ActionRow( + buttons=[ + Button(id=f"button-{index}", label=str(index), action=CallbackAction()) + for index in range(5) + ] + ) + + result = await event._wrap_message(0, row) + + modules = json.loads(result.text)[0]["modules"] + assert [len(module["elements"]) for module in modules] == [4, 1] diff --git a/tests/test_lark_buttons.py b/tests/test_lark_buttons.py new file mode 100644 index 0000000000..1a306eb7e8 --- /dev/null +++ b/tests/test_lark_buttons.py @@ -0,0 +1,153 @@ +import asyncio +from unittest.mock import AsyncMock + +import pytest +from lark_oapi.event.callback.model.p2_card_action_trigger import ( + P2CardActionTrigger, + P2CardActionTriggerResponse, +) + +from astrbot.api.message_components import ( + ActionRow, + Button, + ButtonInteraction, + ButtonStyle, + CallbackAction, + Plain, + UrlAction, +) +from astrbot.api.platform import MessageType +from astrbot.core.platform.button_interaction import decode_button_callback +from astrbot.core.platform.sources.lark.lark_adapter import LarkPlatformAdapter +from astrbot.core.platform.sources.lark.lark_event import LarkMessageEvent + + +def test_lark_button_card_maps_callback_and_url_actions(): + card = LarkMessageEvent._build_button_card( + [ + Plain("Choose an action"), + ActionRow( + buttons=[ + Button( + id="approve", + label="Approve", + action=CallbackAction(data={"request_id": 42}), + style=ButtonStyle.SUCCESS, + ), + Button( + id="docs", + label="Docs", + action=UrlAction(url="https://example.com/docs"), + ), + ] + ), + ], + MessageType.GROUP_MESSAGE.value, + ) + + assert card["schema"] == "2.0" + assert card["body"]["elements"][0] == { + "tag": "markdown", + "content": "Choose an action", + } + columns = card["body"]["elements"][1]["columns"] + callback_button = columns[0]["elements"][0] + callback_value = callback_button["behaviors"][0]["value"] + assert callback_button["type"] == "primary" + assert decode_button_callback(callback_value["astrbot_callback"]) == ( + "approve", + {"request_id": 42}, + ) + assert callback_value["astrbot_message_type"] == MessageType.GROUP_MESSAGE.value + + url_button = columns[1]["elements"][0] + assert url_button["behaviors"] == [ + { + "type": "open_url", + "default_url": "https://example.com/docs", + } + ] + + +@pytest.mark.asyncio +async def test_lark_card_callback_becomes_button_interaction(): + adapter = object.__new__(LarkPlatformAdapter) + adapter.event_id_timestamps = {} + adapter.bot_open_id = "ou_bot" + adapter.bot_name = "AstrBot" + adapter.handle_msg = AsyncMock() + card = LarkMessageEvent._build_button_card( + [ + ActionRow( + buttons=[ + Button( + id="approve", + label="Approve", + action=CallbackAction(data={"request_id": 42}), + ) + ] + ) + ], + MessageType.GROUP_MESSAGE.value, + ) + callback_value = card["body"]["elements"][0]["columns"][0]["elements"][0][ + "behaviors" + ][0]["value"] + + await adapter.convert_card_action( + { + "header": { + "event_id": "event-1", + "create_time": "1720000000000000", + }, + "event": { + "operator": {"open_id": "ou_user"}, + "action": {"tag": "button", "value": callback_value}, + "context": { + "open_message_id": "om_source", + "open_chat_id": "oc_group", + }, + }, + } + ) + + adapter.handle_msg.assert_awaited_once() + message = adapter.handle_msg.await_args.args[0] + assert message.type == MessageType.GROUP_MESSAGE + assert message.session_id == "oc_group" + assert message.sender.user_id == "ou_user" + assert message.timestamp == 1720000000 + assert len(message.message) == 1 + interaction = message.message[0] + assert isinstance(interaction, ButtonInteraction) + assert interaction.action_id == "approve" + assert interaction.data == {"request_id": 42} + assert interaction.interaction_id == "event-1" + assert interaction.source_message_id == "om_source" + + +@pytest.mark.asyncio +async def test_lark_socket_card_callback_returns_immediate_ack(): + adapter = LarkPlatformAdapter( + { + "id": "lark-test", + "app_id": "app-id", + "app_secret": "app-secret", + }, + {}, + asyncio.Queue(), + ) + adapter.convert_card_action = AsyncMock() + callback = P2CardActionTrigger( + { + "schema": "2.0", + "header": {"event_id": "event-1"}, + "event": {}, + } + ) + + response = adapter.do_card_action_trigger(callback) + await asyncio.sleep(0) + + assert isinstance(response, P2CardActionTriggerResponse) + adapter.convert_card_action.assert_awaited_once_with(callback) diff --git a/tests/test_line_adapter.py b/tests/test_line_adapter.py new file mode 100644 index 0000000000..cd726664d9 --- /dev/null +++ b/tests/test_line_adapter.py @@ -0,0 +1,131 @@ +import asyncio + +import pytest + +from astrbot.api.message_components import ( + ActionRow, + Button, + ButtonInteraction, + CallbackAction, + UrlAction, +) +from astrbot.core.platform.button_interaction import ( + decode_button_callback, + encode_button_callback, +) +from astrbot.core.platform.sources.line.line_adapter import LinePlatformAdapter +from astrbot.core.platform.sources.line.line_event import LineMessageEvent +from tests.fixtures.helpers import make_platform_config + + +def _build_adapter() -> LinePlatformAdapter: + return LinePlatformAdapter( + make_platform_config( + "line", + channel_access_token="test-token", + channel_secret="test-secret", + ), + {}, + asyncio.Queue(), + ) + + +@pytest.mark.asyncio +async def test_line_action_row_builds_template_actions(): + row = ActionRow( + fallback_text="Choose an action", + buttons=[ + Button( + id="approve", + label="Approve", + action=CallbackAction(data={"request_id": 42}), + ), + Button( + id="docs", + label="Documentation", + action=UrlAction(url="https://example.com/docs"), + ), + ], + ) + + message = await LineMessageEvent._component_to_message_object(row) + + assert message is not None + assert message["type"] == "template" + assert message["altText"] == "Choose an action" + actions = message["template"]["actions"] + assert actions[1] == { + "type": "uri", + "label": "Documentation", + "uri": "https://example.com/docs", + } + action_id, data = decode_button_callback(actions[0]["data"]) + assert action_id == "approve" + assert data == {"request_id": 42} + + +@pytest.mark.asyncio +async def test_line_action_row_uses_compact_postback_token_for_large_data(): + row = ActionRow( + buttons=[ + Button( + id="oversized", + label="Oversized", + action=CallbackAction(data={"value": "x" * 300}), + ) + ] + ) + + message = await LineMessageEvent._component_to_message_object(row) + + callback_data = message["template"]["actions"][0]["data"] + assert len(callback_data.encode("utf-8")) <= 300 + assert decode_button_callback(callback_data) == ( + "oversized", + {"value": "x" * 300}, + ) + + +@pytest.mark.asyncio +async def test_line_postback_becomes_button_interaction(): + adapter = _build_adapter() + callback_data = encode_button_callback("approve", {"request_id": 42}) + + message = await adapter.convert_message( + { + "type": "postback", + "mode": "active", + "timestamp": 1_700_000_000_000, + "webhookEventId": "event-1", + "source": {"type": "group", "groupId": "group-1", "userId": "user-1"}, + "postback": {"data": callback_data}, + } + ) + + assert message is not None + assert message.message_id == "event-1" + assert message.session_id == "group-1" + assert len(message.message) == 1 + interaction = message.message[0] + assert isinstance(interaction, ButtonInteraction) + assert interaction.action_id == "approve" + assert interaction.data == {"request_id": 42} + assert interaction.interaction_id == "event-1" + event = adapter.create_event(message) + assert event.is_button_interaction() + assert event.get_button_interaction() is interaction + + +@pytest.mark.asyncio +async def test_line_ignores_foreign_postback_payload(): + adapter = _build_adapter() + + message = await adapter.convert_message( + { + "type": "postback", + "source": {"type": "user", "userId": "user-1"}, + "postback": {"data": "third-party=value"}, + } + ) + + assert message is None diff --git a/tests/test_mattermost_adapter.py b/tests/test_mattermost_adapter.py index 153bbc6d65..7eb8d00a16 100644 --- a/tests/test_mattermost_adapter.py +++ b/tests/test_mattermost_adapter.py @@ -5,6 +5,11 @@ import pytest import astrbot.api.message_components as Comp +from astrbot.api.event import MessageChain +from astrbot.core.platform.button_interaction import ( + decode_button_callback, + encode_button_callback, +) from astrbot.core.platform.sources.mattermost.client import MattermostClient from astrbot.core.platform.sources.mattermost.mattermost_adapter import ( MattermostPlatformAdapter, @@ -111,3 +116,138 @@ async def to_path(self, **kwargs) -> str: path = Path(temp_path) assert path.exists() assert path.name.endswith(Path(expected_name).suffix) + + +@pytest.mark.asyncio +async def test_mattermost_send_message_chain_maps_portable_buttons(): + callback_url = "https://bot.example.com/api/platform/webhook/mattermost-callback" + client = MattermostClient( + "https://chat.example.com", + "test_token", + action_callback_url=callback_url, + ) + client.create_post = AsyncMock(return_value={"id": "post-1"}) + + await client.send_message_chain( + "channel-1", + MessageChain( + [ + Comp.Plain("Review request"), + Comp.ActionRow( + buttons=[ + Comp.Button( + id="approve", + label="Approve", + action=Comp.CallbackAction(data={"request_id": 42}), + style=Comp.ButtonStyle.SUCCESS, + ), + Comp.Button( + id="details", + label="Details", + action=Comp.UrlAction(url="https://example.com/42"), + style=Comp.ButtonStyle.PRIMARY, + ), + ], + fallback_text="Review actions", + ), + ] + ), + ) + + payload = client.create_post.await_args.kwargs + callback_token = payload["props"]["attachments"][0]["actions"][0]["integration"][ + "context" + ]["astrbot_callback"] + assert payload["props"] == { + "attachments": [ + { + "fallback": "Review actions", + "text": "Review actions\n[Details](https://example.com/42)", + "actions": [ + { + "id": "astrbot1b0", + "type": "button", + "name": "Approve", + "style": "success", + "integration": { + "url": callback_url, + "context": { + "astrbot_callback": callback_token, + }, + }, + } + ], + } + ] + } + assert decode_button_callback(callback_token) == ( + "approve", + {"request_id": 42}, + ) + + +@pytest.mark.asyncio +async def test_mattermost_callback_button_degrades_without_public_callback(): + client = MattermostClient("https://chat.example.com", "test_token") + client.create_post = AsyncMock(return_value={"id": "post-1"}) + + await client.send_message_chain( + "channel-1", + MessageChain( + [ + Comp.ActionRow( + buttons=[ + Comp.Button( + id="approve", + label="Approve", + action=Comp.CallbackAction(), + ) + ], + fallback_text="Choose an action", + ) + ] + ), + ) + + attachment = client.create_post.await_args.kwargs["props"]["attachments"][0] + assert attachment == { + "fallback": "Choose an action", + "text": "Choose an action\nApprove", + } + + +@pytest.mark.asyncio +async def test_mattermost_webhook_acknowledges_then_dispatches_button(): + adapter = _build_adapter() + adapter.client.get_channel = AsyncMock(return_value={"type": "O"}) + callback_token = encode_button_callback( + "approve", + {"request_id": 42}, + ) + + class FakeRequest: + async def get_json(self, *, silent: bool = False): + assert silent is False + return { + "trigger_id": "trigger-1", + "user_id": "user-1", + "user_name": "alice", + "channel_id": "channel-1", + "post_id": "post-1", + "context": {"astrbot_callback": callback_token}, + } + + response = await adapter.webhook_callback(FakeRequest()) + + assert response == {} + await asyncio.sleep(0) + await asyncio.sleep(0) + event = adapter._event_queue.get_nowait() + assert event.get_sender_id() == "user-1" + assert event.get_session_id() == "channel-1" + interaction = event.get_button_interaction() + assert interaction is not None + assert interaction.action_id == "approve" + assert interaction.data == {"request_id": 42} + assert interaction.interaction_id == "trigger-1" + assert interaction.source_message_id == "post-1" diff --git a/tests/test_misskey_buttons.py b/tests/test_misskey_buttons.py new file mode 100644 index 0000000000..482d3d17c4 --- /dev/null +++ b/tests/test_misskey_buttons.py @@ -0,0 +1,77 @@ +import astrbot.api.message_components as Comp +from astrbot.core.platform.sources.misskey.misskey_utils import serialize_message_chain + + +def test_misskey_serializes_url_buttons_as_mfm_links(): + text, has_at = serialize_message_chain( + [ + Comp.Plain("Resources: "), + Comp.ActionRow( + buttons=[ + Comp.Button( + id="docs", + label="Docs", + action=Comp.UrlAction(url="https://example.com/docs"), + ), + Comp.Button( + id="status", + label="Status", + action=Comp.UrlAction(url="https://status.example.com"), + ), + ], + ), + ], + ) + + assert text == ( + "Resources: [Docs](https://example.com/docs) | " + "[Status](https://status.example.com)" + ) + assert has_at is False + + +def test_misskey_callback_buttons_use_row_fallback_text(): + text, has_at = serialize_message_chain( + [ + Comp.ActionRow( + buttons=[ + Comp.Button( + id="confirm", + label="Confirm", + action=Comp.CallbackAction(data={"request_id": "req-1"}), + ), + ], + fallback_text="Reply with 'confirm' to continue.", + ), + ], + ) + + assert text == "Reply with 'confirm' to continue." + assert has_at is False + + +def test_misskey_callback_buttons_fall_back_to_plain_labels(): + text, _ = serialize_message_chain( + [ + Comp.Button( + id="confirm", + label="Confirm", + action=Comp.CallbackAction(), + ), + ], + ) + + assert text == "Confirm" + + +def test_misskey_does_not_serialize_inbound_interactions(): + text, _ = serialize_message_chain( + [ + Comp.ButtonInteraction( + action_id="confirm", + interaction_id="interaction-1", + ), + ], + ) + + assert text == "" diff --git a/tests/test_qqofficial_buttons.py b/tests/test_qqofficial_buttons.py new file mode 100644 index 0000000000..3a0b27f51b --- /dev/null +++ b/tests/test_qqofficial_buttons.py @@ -0,0 +1,303 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import botpy +import botpy.message +import pytest +from botpy.interaction import Interaction + +from astrbot.api.event import MessageChain +from astrbot.api.message_components import ( + ActionRow, + Button, + ButtonInteraction, + ButtonStyle, + CallbackAction, + Plain, + UrlAction, +) +from astrbot.api.platform import ( + AstrBotMessage, + MessageMember, + MessageType, + PlatformMetadata, +) +from astrbot.core.platform.button_interaction import encode_button_callback +from astrbot.core.platform.sources.qqofficial.qqofficial_message_event import ( + QQOfficialMessageEvent, +) +from astrbot.core.platform.sources.qqofficial.qqofficial_platform_adapter import ( + QQOfficialPlatformAdapter, +) +from astrbot.core.platform.sources.qqofficial.qqofficial_platform_adapter import ( + botClient as QQOfficialBotClient, +) +from astrbot.core.platform.sources.qqofficial_webhook.qo_webhook_adapter import ( + botClient as QQOfficialWebhookBotClient, +) + + +def _make_interaction( + *, + scene: str = "group", + button_id: str = "approve", + button_data: str | None = None, +) -> Interaction: + resolved = { + "button_id": button_id, + "button_data": button_data + or encode_button_callback("approve", {"request_id": "req-1"}), + "message_id": "source-message-1", + "user_id": "guild-user-1", + } + return Interaction( + api=None, + event_id="gateway-event-1", + data={ + "id": "interaction-1", + "application_id": "app-1", + "type": 11, + "scene": scene, + "chat_type": 1, + "data": {"type": 11, "resolved": resolved}, + "group_openid": "group-1", + "group_member_openid": "member-1", + "user_openid": "user-1", + "guild_id": "guild-1", + "channel_id": "channel-1", + "timestamp": "2026-08-25T10:00:00+08:00", + "version": 1, + }, + ) + + +def _make_group_event() -> QQOfficialMessageEvent: + raw = botpy.message.GroupMessage( + api=None, + event_id="event-1", + data={ + "id": "msg-1", + "author": {"member_openid": "member-1"}, + "group_openid": "group-1", + "content": "ping", + "timestamp": "0", + }, + ) + abm = AstrBotMessage() + abm.message_id = "msg-1" + abm.session_id = "group-1" + abm.group_id = "group-1" + abm.self_id = "bot-1" + abm.sender = MessageMember(user_id="member-1") + abm.type = MessageType.GROUP_MESSAGE + abm.message_str = "ping" + abm.message = [] + abm.raw_message = raw + meta = PlatformMetadata(name="qq_official", description="test", id="test") + bot = SimpleNamespace(api=SimpleNamespace(post_group_message=AsyncMock())) + return QQOfficialMessageEvent( + message_str="ping", + message_obj=abm, + platform_meta=meta, + session_id="group-1", + bot=bot, + ) + + +def test_qq_keyboard_renders_callback_and_url_buttons() -> None: + chain = MessageChain( + chain=[ + ActionRow( + buttons=[ + Button( + id="approve", + label="Approve request", + action=CallbackAction(data={"request_id": "req-1"}), + style=ButtonStyle.PRIMARY, + ), + Button( + id="docs", + label="Docs", + action=UrlAction(url="https://example.com/docs"), + ), + ] + ) + ] + ) + + keyboard = QQOfficialMessageEvent._parse_keyboard(chain) + + assert keyboard is not None + buttons = keyboard["content"]["rows"][0]["buttons"] + assert buttons[0] == { + "id": "approve", + "render_data": { + "label": "Approve re", + "visited_label": "Approve re", + "style": 3, + }, + "action": { + "type": 1, + "permission": {"type": 2}, + "data": encode_button_callback("approve", {"request_id": "req-1"}), + }, + } + assert buttons[1]["action"] == { + "type": 0, + "permission": {"type": 2}, + "data": "https://example.com/docs", + } + + +def test_qq_keyboard_validates_platform_limits() -> None: + chain = MessageChain( + chain=[ + ActionRow( + buttons=[ + Button( + id=f"button-{index}", + label=str(index), + action=CallbackAction(), + ) + for index in range(6) + ] + ) + ] + ) + + with pytest.raises(ValueError, match="at most 5 buttons"): + QQOfficialMessageEvent._parse_keyboard(chain) + + +@pytest.mark.asyncio +async def test_qq_group_send_includes_rendered_keyboard() -> None: + event = _make_group_event() + chain = MessageChain( + chain=[ + Plain("Choose"), + ActionRow( + buttons=[ + Button( + id="approve", + label="Approve", + action=CallbackAction(data={"request_id": "req-1"}), + ) + ] + ), + ], + use_markdown_=False, + ) + event.send_buffer = chain + + await event._post_send_one(chain) + + kwargs = event.bot.api.post_group_message.await_args.kwargs + assert kwargs["keyboard"]["content"]["rows"][0]["buttons"][0]["id"] == "approve" + assert kwargs["content"] == "Choose" + + +@pytest.mark.asyncio +async def test_qq_interaction_reply_uses_gateway_event_id() -> None: + interaction = _make_interaction(scene="c2c") + abm = QQOfficialPlatformAdapter._parse_interaction_from_qqofficial(interaction) + meta = PlatformMetadata(name="qq_official", description="test", id="test") + event = QQOfficialMessageEvent( + message_str="", + message_obj=abm, + platform_meta=meta, + session_id=abm.session_id, + bot=SimpleNamespace(api=SimpleNamespace()), + ) + event.post_c2c_message = AsyncMock(return_value=SimpleNamespace()) + chain = MessageChain(chain=[Plain("Callback received")], use_markdown_=False) + event.send_buffer = chain + + await event._post_send_one(chain) + + kwargs = event.post_c2c_message.await_args.kwargs + assert kwargs["event_id"] == "gateway-event-1" + assert kwargs["event_id"] != "interaction-1" + + +@pytest.mark.asyncio +async def test_qq_send_fallback_removes_reply_references() -> None: + event = _make_group_event() + sent_payloads = [] + + async def send(payload): + sent_payloads.append(payload) + if len(sent_payloads) == 1: + raise botpy.errors.ServerError("invalid event_id") + return {"ok": True} + + result = await event._send_with_markdown_fallback( + send_func=send, + payload={ + "content": "Callback received", + "msg_id": "source-message-1", + "event_id": "gateway-event-1", + }, + plain_text="Callback received", + ) + + assert result == {"ok": True} + assert sent_payloads[0]["event_id"] == "gateway-event-1" + assert "event_id" not in sent_payloads[1] + assert "msg_id" not in sent_payloads[1] + + +def test_qq_interaction_is_normalized_to_portable_component() -> None: + abm = QQOfficialPlatformAdapter._parse_interaction_from_qqofficial( + _make_interaction() + ) + + assert abm.type == MessageType.GROUP_MESSAGE + assert abm.session_id == "group-1" + assert abm.sender.user_id == "member-1" + assert len(abm.message) == 1 + component = abm.message[0] + assert isinstance(component, ButtonInteraction) + assert component.action_id == "approve" + assert component.data == {"request_id": "req-1"} + assert component.interaction_id == "interaction-1" + assert component.source_message_id == "source-message-1" + + +def test_qq_interaction_falls_back_to_native_button_fields() -> None: + abm = QQOfficialPlatformAdapter._parse_interaction_from_qqofficial( + _make_interaction(button_id="template-button", button_data="native-data") + ) + + component = abm.message[0] + assert isinstance(component, ButtonInteraction) + assert component.action_id == "template-button" + assert component.data == "native-data" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "client_class", + [QQOfficialBotClient, QQOfficialWebhookBotClient], +) +async def test_qq_clients_acknowledge_and_dispatch_button_clicks(client_class) -> None: + client = client_class( + intents=botpy.Intents(interaction=True), + bot_log=False, + ) + try: + client.api.on_interaction_result = AsyncMock() + platform = SimpleNamespace( + remember_session_scene=Mock(), + create_event=Mock(side_effect=lambda message: message), + commit_event=Mock(), + ) + client.set_platform(platform) + + await client.on_interaction_create(_make_interaction()) + + client.api.on_interaction_result.assert_awaited_once_with("interaction-1", 0) + platform.remember_session_scene.assert_called_once_with("group-1", "group") + event = platform.commit_event.call_args.args[0] + assert isinstance(event.message[0], ButtonInteraction) + finally: + await client.close() diff --git a/tests/test_satori_buttons.py b/tests/test_satori_buttons.py new file mode 100644 index 0000000000..bc4a14f52f --- /dev/null +++ b/tests/test_satori_buttons.py @@ -0,0 +1,130 @@ +import asyncio + +import pytest + +from astrbot.api.message_components import ( + ActionRow, + Button, + ButtonInteraction, + ButtonStyle, + CallbackAction, + UrlAction, +) +from astrbot.core.platform.button_interaction import decode_button_callback +from astrbot.core.platform.sources.satori.satori_adapter import ( + SatoriPlatformAdapter, +) +from astrbot.core.platform.sources.satori.satori_event import SatoriPlatformEvent +from tests.fixtures.helpers import make_platform_config + + +def _build_adapter() -> SatoriPlatformAdapter: + return SatoriPlatformAdapter( + make_platform_config("satori", id="test_satori"), + {}, + asyncio.Queue(), + ) + + +@pytest.mark.asyncio +async def test_satori_renders_portable_callback_and_link_buttons(): + rendered = await SatoriPlatformEvent._convert_component_to_satori_static( + ActionRow( + buttons=[ + Button( + id="approve", + label="Approve & continue", + action=CallbackAction(data={"request_id": 42}), + style=ButtonStyle.SUCCESS, + ), + Button( + id="docs", + label="Docs", + action=UrlAction(url="https://example.com/?a=1&b=2"), + style=ButtonStyle.PRIMARY, + ), + ] + ) + ) + + assert rendered.count(" ActionRow: + return ActionRow( + fallback_text="Choose an action", + buttons=[ + Button( + id="approve", + label="Approve", + action=CallbackAction(data={"request_id": 42}), + style=ButtonStyle.SUCCESS, + ), + Button( + id="docs", + label="Docs", + action=UrlAction(url="https://example.com/docs"), + style=ButtonStyle.DANGER, + ), + ], + ) + + +def _interaction_message(stream_id: str) -> AstrBotMessage: + message = AstrBotMessage() + message.type = MessageType.FRIEND_MESSAGE + message.self_id = "bot" + message.session_id = "session" + message.message_id = "event-1" + message.message_str = "" + message.sender = MessageMember(user_id="user-1", nickname="user-1") + message.message = [] + message.raw_message = {"stream_id": stream_id} + return message + + +def test_wecom_button_card_maps_callback_and_url_actions(): + card = build_wecom_button_card([_button_row()], task_id="task-1") + + assert card is not None + assert card["card_type"] == "button_interaction" + assert card["main_title"] == {"title": "Choose an action"} + assert card["task_id"] == "task-1" + callback_button, url_button = card["button_list"] + assert callback_button["type"] == 0 + assert callback_button["style"] == 3 + assert decode_button_callback(callback_button["key"]) == ( + "approve", + {"request_id": 42}, + ) + assert url_button == { + "text": "Docs", + "style": 2, + "type": 1, + "url": "https://example.com/docs", + } + + +@pytest.mark.asyncio +async def test_wecom_webhook_sends_action_row_as_template_card(): + client = WecomAIBotWebhookClient( + "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key" + ) + client.send_payload = AsyncMock() + + await client.send_message_chain(MessageChain([_button_row()])) + + payload = client.send_payload.await_args.args[0] + assert payload["msgtype"] == "template_card" + assert payload["template_card"]["card_type"] == "button_interaction" + + +@pytest.mark.asyncio +async def test_wecom_template_card_callback_becomes_button_interaction(): + adapter = object.__new__(WecomAIBotAdapter) + adapter.bot_name = "AstrBot" + adapter.encoding_aes_key = "" + callback_key = build_wecom_button_card([_button_row()])["button_list"][0]["key"] + + message = await adapter.convert_message( + { + "message_data": { + "msgtype": "event", + "msgid": "event-1", + "create_time": 1720000000, + "chattype": "group", + "chatid": "group-1", + "from": {"userid": "user-1"}, + "event": { + "eventtype": "template_card_event", + "template_card_event": { + "event_key": callback_key, + "task_id": "task-1", + }, + }, + }, + "session_id": "session-1", + "stream_id": "interaction-1", + } + ) + + assert message.message_str == "" + assert message.message_id == "event-1" + assert message.timestamp == 1720000000 + assert message.type == MessageType.GROUP_MESSAGE + assert len(message.message) == 1 + interaction = message.message[0] + assert isinstance(interaction, ButtonInteraction) + assert interaction.action_id == "approve" + assert interaction.data == {"request_id": 42} + assert interaction.interaction_id == "event-1" + assert interaction.source_message_id == "task-1" + + +def test_wecom_button_card_rejects_more_than_six_buttons(): + buttons = [ + Button( + id=f"action-{index}", + label=f"Action {index}", + action=CallbackAction(), + ) + for index in range(7) + ] + + with pytest.raises(ValueError, match="at most 6"): + build_wecom_button_card([ActionRow(buttons=buttons)]) + + +@pytest.mark.asyncio +async def test_wecom_long_connection_click_reply_updates_source_card(): + queue_mgr = WecomAIQueueMgr() + stream_id = "interaction-stream" + queue_mgr.set_pending_response( + stream_id, + { + "req_id": "request-1", + "connection_mode": "long_connection", + "button_interaction": "true", + "task_id": "task-1", + }, + ) + update_sender = AsyncMock(return_value=True) + event = WecomAIBotMessageEvent( + message_str="", + message_obj=_interaction_message(stream_id), + platform_meta=PlatformMetadata( + name="wecom_ai_bot", + description="WeCom AI Bot", + id="wecom-ai-test", + ), + session_id="session", + api_client=None, + queue_mgr=queue_mgr, + long_connection_update_sender=update_sender, + ) + + await event.send(MessageChain([Plain("Approved")])) + + update_sender.assert_awaited_once() + req_id, body = update_sender.await_args.args + assert req_id == "request-1" + assert body["response_type"] == "update_template_card" + assert body["template_card"]["main_title"] == {"title": "Approved"} + assert body["template_card"]["task_id"] == "task-1" + + +@pytest.mark.asyncio +async def test_wecom_webhook_click_can_return_immediate_card_update(): + adapter = object.__new__(WecomAIBotAdapter) + adapter.api_client = SimpleNamespace( + encrypt_message=AsyncMock(side_effect=lambda payload, _nonce, _time: payload) + ) + adapter.queue_mgr = WecomAIQueueMgr() + adapter.only_use_webhook_url_to_send = False + adapter.webhook_client = None + adapter.initial_respond_text = "" + adapter.friend_message_welcome_text = "" + adapter.bot_name = "AstrBot" + adapter.encoding_aes_key = "" + adapter.metadata = PlatformMetadata( + name="wecom_ai_bot", + description="WeCom AI Bot", + id="wecom-ai-test", + ) + callback_key = build_wecom_button_card([_button_row()])["button_list"][0]["key"] + + async def reply_to_click(payload: dict) -> None: + message = await adapter.convert_message(payload) + event = adapter.create_event(message) + await event.send(MessageChain([Plain("Approved")])) + + adapter.queue_mgr.set_listener(reply_to_click) + response = await adapter._process_message( + { + "msgtype": "event", + "msgid": "event-1", + "chattype": "single", + "from": {"userid": "user-1"}, + "event": { + "eventtype": "template_card_event", + "template_card_event": { + "event_key": callback_key, + "task_id": "task-1", + }, + }, + }, + {"nonce": "nonce", "timestamp": "timestamp"}, + ) + + assert response is not None + update = json.loads(response) + assert update["response_type"] == "update_template_card" + assert update["template_card"]["main_title"] == {"title": "Approved"} + assert update["template_card"]["task_id"] == "task-1" diff --git a/tests/test_wecom_buttons.py b/tests/test_wecom_buttons.py new file mode 100644 index 0000000000..c5b538f210 --- /dev/null +++ b/tests/test_wecom_buttons.py @@ -0,0 +1,222 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from wechatpy.enterprise import parse_message + +from astrbot.api.event import MessageChain +from astrbot.api.message_components import ( + ActionRow, + Button, + ButtonInteraction, + ButtonStyle, + CallbackAction, + Plain, + UrlAction, +) +from astrbot.api.platform import ( + AstrBotMessage, + MessageMember, + MessageType, + PlatformMetadata, +) +from astrbot.core.platform.button_interaction import ( + decode_button_callback, + encode_button_callback, +) +from astrbot.core.platform.sources.wecom.wecom_adapter import WecomPlatformAdapter +from astrbot.core.platform.sources.wecom.wecom_event import WecomPlatformEvent +from astrbot.core.platform.sources.wecom.wecom_kf_message import WeChatKFMessage + + +def _message() -> AstrBotMessage: + message = AstrBotMessage() + message.type = MessageType.FRIEND_MESSAGE + message.self_id = "100001" + message.session_id = "alice" + message.message_id = "message-1" + message.sender = MessageMember("alice", "Alice") + message.message = [] + message.message_str = "" + message.raw_message = {} + return message + + +def _event(client) -> WecomPlatformEvent: + message = _message() + return WecomPlatformEvent( + message_str="", + message_obj=message, + platform_meta=PlatformMetadata("wecom", "WeCom", "test-wecom"), + session_id=message.session_id, + client=client, + ) + + +def _row() -> ActionRow: + return ActionRow( + fallback_text="Choose an action", + buttons=[ + Button( + id="approve", + label="Approve", + style=ButtonStyle.SUCCESS, + action=CallbackAction(data={"request_id": 42}), + ), + Button( + id="docs", + label="Documentation", + action=UrlAction(url="https://example.com/docs"), + ), + ], + ) + + +@pytest.mark.asyncio +async def test_wecom_application_sends_action_row_as_template_card(): + class AppClient: + def __init__(self) -> None: + self.message = MagicMock() + + client = AppClient() + + await _event(client).send(MessageChain([_row()])) + + client.message.send.assert_called_once() + args, kwargs = client.message.send.call_args + assert args == ("100001", "alice") + payload = kwargs["msg"] + assert payload["msgtype"] == "template_card" + card = payload["template_card"] + assert card["card_type"] == "button_interaction" + assert card["main_title"] == {"title": "Choose an action"} + assert card["task_id"].startswith("astrbot_") + callback_button, url_button = card["button_list"] + assert callback_button["type"] == 0 + assert callback_button["style"] == 4 + action_id, data = decode_button_callback(callback_button["key"]) + assert action_id == "approve" + assert data == {"request_id": 42} + assert url_button == { + "text": "Documentation", + "style": 1, + "type": 1, + "url": "https://example.com/docs", + } + + +@pytest.mark.asyncio +async def test_wecom_customer_service_sends_action_row_as_menu(): + kf_message = MagicMock(spec=WeChatKFMessage) + + class CustomerServiceClient: + def __init__(self) -> None: + self.kf_message = kf_message + + await _event(CustomerServiceClient()).send(MessageChain([_row()])) + + kf_message.send_msgmenu.assert_called_once() + user_id, open_kfid, head, menu, tail = kf_message.send_msgmenu.call_args.args + assert (user_id, open_kfid, head, tail) == ( + "alice", + "100001", + "Choose an action", + "", + ) + action_id, data = decode_button_callback(menu[0]["click"]["id"]) + assert action_id == "approve" + assert data == {"request_id": 42} + assert menu[1] == { + "type": "view", + "view": { + "url": "https://example.com/docs", + "content": "Documentation", + }, + } + + +@pytest.mark.asyncio +async def test_wecom_template_card_callback_becomes_button_interaction(): + callback = encode_button_callback("approve", {"request_id": 42}) + raw_message = parse_message( + "" + "" + "" + "1700000000" + "" + "" + f"" + "" + "" + "" + "100001" + "" + ) + adapter = object.__new__(WecomPlatformAdapter) + adapter.agent_id = None + adapter.handle_msg = AsyncMock() + + message = await adapter.convert_message(raw_message) + + assert message is not None + assert message.message_str == "approve" + assert message.message_id == "response-1" + assert message.session_id == "alice" + interaction = message.message[0] + assert isinstance(interaction, ButtonInteraction) + assert interaction.action_id == "approve" + assert interaction.data == {"request_id": 42} + assert interaction.interaction_id == "response-1" + assert interaction.source_message_id == "task-1" + adapter.handle_msg.assert_awaited_once_with(message) + + +@pytest.mark.asyncio +async def test_wecom_foreign_customer_service_menu_stays_plain_text(): + adapter = object.__new__(WecomPlatformAdapter) + adapter._wechat_kf_seen_text_messages = {} + adapter.handle_msg = AsyncMock() + + message = await adapter.convert_wechat_kf_message( + { + "msgtype": "text", + "external_userid": "customer-1", + "open_kfid": "kf-1", + "msgid": "click-2", + "text": {"content": "Another menu", "menu_id": "foreign-menu"}, + } + ) + + assert message is not None + assert message.message_str == "Another menu" + assert len(message.message) == 1 + assert isinstance(message.message[0], Plain) + assert message.message[0].text == "Another menu" + adapter.handle_msg.assert_awaited_once_with(message) + + +@pytest.mark.asyncio +async def test_wecom_customer_service_menu_click_becomes_button_interaction(): + adapter = object.__new__(WecomPlatformAdapter) + adapter._wechat_kf_seen_text_messages = {} + adapter.handle_msg = AsyncMock() + callback = encode_button_callback("approve", {"request_id": 42}) + + message = await adapter.convert_wechat_kf_message( + { + "msgtype": "text", + "external_userid": "customer-1", + "open_kfid": "kf-1", + "msgid": "click-1", + "text": {"content": "Approve", "menu_id": callback}, + } + ) + + assert message is not None + assert message.message_str == "approve" + interaction = message.message[0] + assert isinstance(interaction, ButtonInteraction) + assert interaction.action_id == "approve" + assert interaction.data == {"request_id": 42} + assert interaction.interaction_id == "click-1" + assert interaction.source_message_id is None + adapter.handle_msg.assert_awaited_once_with(message) diff --git a/tests/test_weixin_buttons.py b/tests/test_weixin_buttons.py new file mode 100644 index 0000000000..45fcf0ad89 --- /dev/null +++ b/tests/test_weixin_buttons.py @@ -0,0 +1,96 @@ +from unittest.mock import AsyncMock + +import pytest + +from astrbot.api.event import MessageChain +from astrbot.api.message_components import ( + ActionRow, + Button, + CallbackAction, + Plain, + UrlAction, +) +from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType +from astrbot.core.platform.astr_message_event import AstrMessageEvent +from astrbot.core.platform.message_session import MessageSession +from astrbot.core.platform.platform import Platform +from astrbot.core.platform.platform_metadata import PlatformMetadata +from astrbot.core.platform.sources.weixin_oc.weixin_oc_adapter import WeixinOCAdapter +from astrbot.core.platform.sources.weixin_official_account.weixin_offacc_event import ( + WeixinOfficialAccountPlatformEvent, +) + + +def _button_row(*, fallback_text: str | None = None) -> ActionRow: + return ActionRow( + buttons=[ + Button( + id="docs", + label="查看文档", + action=UrlAction(url="https://example.com/docs"), + ), + Button( + id="confirm", + label="确认", + action=CallbackAction(data={"confirmed": True}), + ), + ], + fallback_text=fallback_text, + ) + + +@pytest.mark.asyncio +async def test_weixin_official_account_buttons_fall_back_to_text(monkeypatch): + message = AstrBotMessage() + message.type = MessageType.FRIEND_MESSAGE + message.sender = MessageMember("user", "User") + message.message = [] + message.message_str = "" + message.raw_message = {"active_send_mode": False} + output = {"cached_xml": []} + event = WeixinOfficialAccountPlatformEvent( + message_str="", + message_obj=message, + platform_meta=PlatformMetadata( + "weixin_official_account", + "Weixin Official Account", + "weixin-test", + ), + session_id="user", + client=object(), + message_out=output, + ) + monkeypatch.setattr(AstrMessageEvent, "send", AsyncMock()) + + await event.send( + MessageChain([Plain("正文"), _button_row(fallback_text="请回复 yes 确认")]) + ) + + assert output["cached_xml"] == [ + "正文", + "查看文档: https://example.com/docs\n请回复 yes 确认", + ] + + +@pytest.mark.asyncio +async def test_weixin_oc_buttons_fall_back_to_text(monkeypatch): + adapter = object.__new__(WeixinOCAdapter) + adapter.metadata = PlatformMetadata("weixin_oc", "Weixin OC", "weixin-test") + adapter._send_to_session = AsyncMock(return_value=True) + monkeypatch.setattr(Platform, "send_by_session", AsyncMock()) + session = MessageSession( + platform_name="weixin-test", + message_type=MessageType.FRIEND_MESSAGE, + session_id="user", + ) + + await adapter.send_by_session( + session, + MessageChain([Plain("正文"), _button_row()]), + ) + + adapter._send_to_session.assert_awaited_once_with( + "user", + "正文\n查看文档: https://example.com/docs\n" + "可选操作:确认(当前平台不支持按钮,请发送选项名称)", + ) diff --git a/tests/unit/test_aiocqhttp_buttons.py b/tests/unit/test_aiocqhttp_buttons.py new file mode 100644 index 0000000000..e6f0d23dd0 --- /dev/null +++ b/tests/unit/test_aiocqhttp_buttons.py @@ -0,0 +1,94 @@ +from unittest.mock import AsyncMock + +import pytest + +import astrbot.core.message.components as Comp +from astrbot.core.message.message_event_result import MessageChain +from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import ( + AiocqhttpMessageEvent, +) + + +@pytest.mark.asyncio +async def test_aiocqhttp_action_row_degrades_to_plain_text(): + row = Comp.ActionRow( + fallback_text="Choose an action", + buttons=[ + Comp.Button( + id="approve", + label="Approve", + action=Comp.CallbackAction(data={"request_id": 42}), + ), + Comp.Button( + id="docs", + label="Documentation", + action=Comp.UrlAction(url="https://example.com/docs"), + ), + ], + ) + + data = await AiocqhttpMessageEvent._parse_onebot_json(MessageChain([row])) + + assert data == [ + { + "type": "text", + "data": { + "text": ( + "Choose an action\n" + "[Button unavailable] Approve\n" + "Documentation: https://example.com/docs" + ) + }, + } + ] + + +@pytest.mark.asyncio +async def test_aiocqhttp_callback_button_never_emits_nonstandard_segment(): + button = Comp.Button( + id="retry", + label="Retry", + action=Comp.CallbackAction(), + ) + + data = await AiocqhttpMessageEvent._parse_onebot_json(MessageChain([button])) + + assert data == [ + { + "type": "text", + "data": {"text": "[Button unavailable] Retry"}, + } + ] + assert all(segment["type"] not in {"button", "actionrow"} for segment in data) + + +@pytest.mark.asyncio +async def test_aiocqhttp_sends_url_button_fallback_as_text(): + bot = AsyncMock() + row = Comp.ActionRow( + buttons=[ + Comp.Button( + id="website", + label="Website", + action=Comp.UrlAction(url="https://example.com"), + ) + ] + ) + + await AiocqhttpMessageEvent.send_message( + bot=bot, + message_chain=MessageChain([row]), + event=None, + is_group=True, + session_id="123456", + ) + + bot.send_group_msg.assert_awaited_once_with( + group_id=123456, + message=[ + { + "type": "text", + "data": {"text": "Website: https://example.com"}, + } + ], + ) diff --git a/tests/unit/test_astr_message_event.py b/tests/unit/test_astr_message_event.py index 89087d1cab..4efebe57c3 100644 --- a/tests/unit/test_astr_message_event.py +++ b/tests/unit/test_astr_message_event.py @@ -8,6 +8,7 @@ from astrbot.core.message.components import ( At, AtAll, + ButtonInteraction, Face, Forward, Image, @@ -105,6 +106,18 @@ def test_init_trace(self, astr_message_event): assert astr_message_event.span is not None assert astr_message_event.trace == astr_message_event.span + def test_button_interaction_accessors(self, astr_message_event): + interaction = ButtonInteraction( + action_id="approve", + data={"request_id": "req-1"}, + interaction_id="interaction-1", + source_message_id="message-1", + ) + astr_message_event.message_obj.message.append(interaction) + + assert astr_message_event.is_button_interaction() + assert astr_message_event.get_button_interaction() == interaction + class TestUnifiedMsgOrigin: """Tests for unified_msg_origin property.""" diff --git a/tests/unit/test_button_components.py b/tests/unit/test_button_components.py new file mode 100644 index 0000000000..b76cd20478 --- /dev/null +++ b/tests/unit/test_button_components.py @@ -0,0 +1,116 @@ +import pytest + +from astrbot.core.message.components import ( + ActionRow, + Button, + ButtonStyle, + CallbackAction, + UrlAction, +) +from astrbot.core.platform import button_interaction +from astrbot.core.platform.button_interaction import ( + decode_button_callback, + encode_button_callback, +) + + +def test_action_row_serializes_portable_buttons(): + row = ActionRow( + buttons=[ + Button( + id="confirm", + label="Confirm", + action=CallbackAction(data={"order_id": 42}), + style=ButtonStyle.PRIMARY, + ), + Button( + id="docs", + label="Docs", + action=UrlAction(url="https://example.com/docs"), + ), + ], + fallback_text="Choose an action", + ) + + assert row.toDict() == { + "type": "actionrow", + "data": { + "buttons": [ + { + "id": "confirm", + "label": "Confirm", + "action": { + "type": "callback", + "data": {"order_id": 42}, + }, + "style": "primary", + }, + { + "id": "docs", + "label": "Docs", + "action": { + "type": "url", + "url": "https://example.com/docs", + }, + "style": "default", + }, + ], + "fallback_text": "Choose an action", + }, + } + + +def test_callback_codec_round_trip(): + payload = encode_button_callback( + "approve", + {"request_id": "req-1", "flags": [True, None]}, + ) + + assert decode_button_callback(payload) == ( + "approve", + {"request_id": "req-1", "flags": [True, None]}, + ) + + +@pytest.mark.parametrize("data", [{"invalid": {1, 2}}, float("nan")]) +def test_callback_action_rejects_non_json_data(data): + with pytest.raises(ValueError, match="JSON-compatible"): + CallbackAction(data=data) + + +def test_callback_codec_uses_compact_opaque_token(): + payload = encode_button_callback("approve") + + assert payload.startswith("astrbot:") + assert len(payload.encode("utf-8")) <= 64 + assert "approve" not in payload + assert decode_button_callback(payload) == ("approve", None) + + +def test_callback_registry_survives_process_restart(tmp_path, monkeypatch): + registry = button_interaction._ButtonCallbackRegistry() + registry.configure(tmp_path / "callbacks.db") + monkeypatch.setattr(button_interaction, "_button_callback_registry", registry) + payload = button_interaction.encode_button_callback( + "approve", + {"request_id": "req-1"}, + ) + + restarted_registry = button_interaction._ButtonCallbackRegistry() + restarted_registry.configure(tmp_path / "callbacks.db") + monkeypatch.setattr( + button_interaction, + "_button_callback_registry", + restarted_registry, + ) + + assert button_interaction.decode_button_callback(payload) == ( + "approve", + {"request_id": "req-1"}, + ) + + +@pytest.mark.parametrize("payload", ["approve", "astrbot:{}", "astrbot:not-json"]) +def test_callback_codec_rejects_invalid_payload(payload): + with pytest.raises(ValueError): + decode_button_callback(payload) diff --git a/tests/unit/test_button_interaction_filter.py b/tests/unit/test_button_interaction_filter.py new file mode 100644 index 0000000000..309b5ab01d --- /dev/null +++ b/tests/unit/test_button_interaction_filter.py @@ -0,0 +1,103 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from astrbot.api.event import filter +from astrbot.core.message.components import ButtonInteraction +from astrbot.core.pipeline.waking_check.stage import ( + WakingCheckStage, + star_handlers_registry, +) +from astrbot.core.star.filter.button_interaction import ButtonInteractionFilter +from astrbot.core.star.session_plugin_manager import SessionPluginManager + + +def test_button_interaction_filter_matches_action(): + interaction = ButtonInteraction( + action_id="approve", + interaction_id="interaction-1", + ) + + class Event: + def get_button_interaction(self): + return interaction + + event = Event() + assert ButtonInteractionFilter().filter(event, {}) + assert ButtonInteractionFilter("approve").filter(event, {}) + assert not ButtonInteractionFilter("reject").filter(event, {}) + + +def test_public_button_interaction_decorator_registers_filter(): + assert callable(filter.button_interaction("approve")) + + +@pytest.mark.asyncio +async def test_button_click_only_activates_button_handlers(monkeypatch): + stage = WakingCheckStage() + stage.ctx = SimpleNamespace( + astrbot_config={ + "admins_id": [], + "wake_prefix": [], + "plugin_set": ["*"], + } + ) + stage.unique_session = False + stage.ignore_bot_self_message = False + stage.friend_message_needs_wake_prefix = False + stage.ignore_at_all = False + stage.disable_builtin_commands = False + stage.no_permission_reply = True + + ordinary_filter = MagicMock() + ordinary_filter.filter.return_value = True + ordinary_handler = SimpleNamespace( + event_filters=[ordinary_filter], + handler_module_path="test.ordinary", + handler_full_name="test.ordinary.handler", + ) + button_handler = SimpleNamespace( + event_filters=[ButtonInteractionFilter("approve")], + handler_module_path="test.button", + handler_full_name="test.button.handler", + ) + monkeypatch.setattr( + star_handlers_registry, + "get_handlers_by_event_type", + lambda *_args, **_kwargs: [ordinary_handler, button_handler], + ) + + async def return_handlers(_event, handlers): + return handlers + + monkeypatch.setattr( + SessionPluginManager, + "filter_handlers_by_session", + return_handlers, + ) + + interaction = ButtonInteraction( + action_id="approve", + interaction_id="interaction-1", + ) + event = MagicMock() + event.message_str = "approve" + event.role = "member" + event.plugins_name = None + event.get_sender_id.return_value = "user-1" + event.get_messages.return_value = [interaction] + event.get_extra.side_effect = lambda _key=None, default=None: default + event.get_button_interaction.return_value = interaction + event.is_button_interaction.return_value = True + event.is_private_chat.return_value = True + + await stage.process(event) + + ordinary_filter.filter.assert_not_called() + activated_call = next( + call + for call in event.set_extra.call_args_list + if call.args[0] == "activated_handlers" + ) + assert activated_call.args[1] == [button_handler] diff --git a/tests/unit/test_webchat_message_parts.py b/tests/unit/test_webchat_message_parts.py index 1a8d82912b..01a04dc0d3 100644 --- a/tests/unit/test_webchat_message_parts.py +++ b/tests/unit/test_webchat_message_parts.py @@ -4,11 +4,24 @@ import pytest from astrbot.api.event import MessageChain -from astrbot.api.message_components import File +from astrbot.api.message_components import ( + ActionRow, + Button, + ButtonInteraction, + CallbackAction, + File, + UrlAction, +) +from astrbot.core.platform.button_interaction import ( + decode_button_callback, + encode_button_callback, +) from astrbot.core.platform.sources.webchat import webchat_event from astrbot.core.platform.sources.webchat.message_parts_helper import ( build_webchat_message_parts, create_attachment_part_from_existing_file, + message_chain_to_storage_message_parts, + parse_webchat_message_parts, ) @@ -113,3 +126,120 @@ async def get_attachment_by_id(attachment_id): "stored_filename": "uuid.txt", } ] + + +@pytest.mark.asyncio +async def test_webchat_action_row_send_uses_portable_callback_payload(monkeypatch): + """WebChat should emit compact callback data and retain URL buttons.""" + queue = asyncio.Queue() + + async def put_back_queue(_request_id, payload): + await queue.put(payload) + return True + + monkeypatch.setattr( + webchat_event.webchat_queue_mgr, + "put_back_queue", + put_back_queue, + ) + row = ActionRow( + buttons=[ + Button( + id="approve", + label="Approve", + action=CallbackAction(data={"ticket": 7}), + ), + Button( + id="docs", + label="Docs", + action=UrlAction(url="https://example.com/docs"), + ), + ] + ) + + await webchat_event.WebChatMessageEvent._send( + "message-1", + MessageChain([row]), + "webchat!user!conversation-1", + ) + + payload = await queue.get() + callback_action = payload["data"]["buttons"][0]["action"] + assert payload["type"] == "actionrow" + assert decode_button_callback(callback_action["callback_data"]) == ( + "approve", + {"ticket": 7}, + ) + assert "data" not in callback_action + assert payload["data"]["buttons"][1]["action"] == { + "type": "url", + "url": "https://example.com/docs", + } + + +@pytest.mark.asyncio +async def test_webchat_button_interaction_becomes_portable_component(): + """A WebChat callback click should enter the common interaction model.""" + + async def get_attachment_by_id(_attachment_id): + raise AssertionError("button interactions must not resolve attachments") + + callback_data = encode_button_callback("choose", ["alpha", 2]) + parts = await build_webchat_message_parts( + [ + { + "type": "button_interaction", + "callback_data": callback_data, + "source_message_id": 42, + } + ], + get_attachment_by_id=get_attachment_by_id, + strict=True, + ) + components, text_parts, has_content = await parse_webchat_message_parts( + parts, + strict=True, + ) + + assert has_content is True + assert text_parts == ["choose"] + assert len(components) == 1 + interaction = components[0] + assert isinstance(interaction, ButtonInteraction) + assert interaction.action_id == "choose" + assert interaction.data == ["alpha", 2] + assert interaction.source_message_id == "42" + assert interaction.interaction_id + + +@pytest.mark.asyncio +async def test_webchat_action_row_is_persisted_with_callback_data(tmp_path): + """Proactive WebChat messages should preserve interactive rows in history.""" + + async def insert_attachment(_path, _type, _mime_type): + raise AssertionError("action rows must not create attachments") + + parts = await message_chain_to_storage_message_parts( + MessageChain( + [ + ActionRow( + buttons=[ + Button( + id="retry", + label="Retry", + action=CallbackAction(data="request-1"), + ) + ], + fallback_text="Retry", + ) + ] + ), + insert_attachment=insert_attachment, + attachments_dir=tmp_path, + ) + + assert parts[0]["type"] == "actionrow" + assert parts[0]["fallback_text"] == "Retry" + callback_data = parts[0]["buttons"][0]["action"]["callback_data"] + assert decode_button_callback(callback_data) == ("retry", "request-1") + assert "data" not in parts[0]["buttons"][0]["action"]