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("