feat: add portable message buttons

This commit is contained in:
Soulter
2026-08-26 00:40:56 +08:00
parent c99580e6bb
commit 67c6fd3d5d
77 changed files with 6432 additions and 112 deletions
+1
View File
@@ -15,6 +15,7 @@ from astrbot.core.platform import AstrMessageEvent
# 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,
+6
View File
@@ -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 @@ from astrbot.core.star.filter.platform_adapter_type import (
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 @@ from astrbot.core.star.register import register_regex as regex
__all__ = [
"CustomFilter",
"ButtonInteractionFilter",
"EventMessageType",
"EventMessageTypeFilter",
"PermissionType",
@@ -48,6 +53,7 @@ __all__ = [
"PlatformAdapterType",
"PlatformAdapterTypeFilter",
"after_message_sent",
"button_interaction",
"command",
"command_group",
"custom_filter",
+3
View File
@@ -50,6 +50,7 @@ WEBHOOK_SUPPORTED_PLATFORMS = [
"slack",
"lark",
"line",
"mattermost",
]
# 默认配置
@@ -540,6 +541,8 @@ CONFIG_METADATA_2 = {
"mattermost_url": "https://chat.example.com",
"mattermost_bot_token": "",
"mattermost_reconnect_delay": 5.0,
"unified_webhook_mode": True,
"webhook_uuid": "",
},
# "WebChat": {
# "id": "webchat",
+7
View File
@@ -15,6 +15,7 @@ import threading
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.db import BaseDatabase
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 @@ class AstrBotCoreLifecycle:
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()
+114 -2
View File
@@ -29,13 +29,21 @@ import sys
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 @@ class Plain(BaseMessageComponent):
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 @@ ComponentTypes = {
"record": Record,
"video": Video,
"file": File,
"actionrow": ActionRow,
"button": Button,
"buttoninteraction": ButtonInteraction,
# IM-specific Message Segments
"face": Face,
"at": At,
+13 -3
View File
@@ -5,6 +5,7 @@ from astrbot.core.message.components import At, AtAll, Reply
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 @@ class WakingCheckStage(Stage):
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 @@ class WakingCheckStage(Stage):
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
@@ -17,6 +17,7 @@ from astrbot.core.message.components import (
At,
AtAll,
BaseMessageComponent,
ButtonInteraction,
Face,
Forward,
Image,
@@ -181,6 +182,24 @@ class AstrMessageEvent(abc.ABC):
"""获取消息链。"""
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)
+166
View File
@@ -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)
@@ -6,14 +6,18 @@ from aiocqhttp import CQHttp, Event
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 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
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)
@@ -13,7 +13,19 @@ from dingtalk_stream import AckMessage
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.api.platform import (
)
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 @@ from .dingtalk_event import DingtalkMessageEvent
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 @@ class DingtalkPlatformAdapter(Platform):
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 @@ class DingtalkPlatformAdapter(Platform):
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 @@ class DingtalkPlatformAdapter(Platform):
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 @@ class DingtalkPlatformAdapter(Platform):
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 @@ class DingtalkPlatformAdapter(Platform):
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
@@ -2,6 +2,7 @@ from typing import Any
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 @@ class DingtalkMessageEvent(AstrMessageEvent):
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):
@@ -109,6 +109,32 @@ class DiscordBotClient(discord.Bot):
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
@@ -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专用组件
@@ -9,7 +9,7 @@ from discord.channel import DMChannel
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 @@ from astrbot.api.platform import (
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 @@ class DiscordPlatformAdapter(Platform):
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 @@ class DiscordPlatformAdapter(Platform):
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 @@ class DiscordPlatformAdapter(Platform):
)
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)
@@ -10,6 +10,7 @@ from discord.types.interactions import ComponentInteractionData
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 astrbot.core.utils.media_utils import (
)
from .client import DiscordBotClient
from .components import DiscordEmbed, DiscordView
from .components import DiscordEmbed, DiscordView, action_rows_to_discord_view
# 自定义Discord视图组件(兼容旧版本)
@@ -143,6 +144,7 @@ class DiscordPlatformEvent(AstrMessageEvent):
content_parts = []
files = []
view = None
action_rows = []
embeds = []
reference_message_id = None
for i in message.chain: # 遍历消息链
@@ -262,9 +264,18 @@ class DiscordPlatformEvent(AstrMessageEvent):
# 如果消息链中包含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 @@ class DiscordPlatformEvent(AstrMessageEvent):
== 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(
@@ -13,8 +13,15 @@ from astrbot.api.platform import (
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 @@ class KookPlatformAdapter(Platform):
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 @@ class KookPlatformAdapter(Platform):
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
@@ -8,21 +8,29 @@ from astrbot import logger
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 @@ class KookEvent(AstrMessageEvent):
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外层得是一个列表
@@ -13,6 +13,10 @@ from lark_oapi.api.im.v1 import (
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 @@ from astrbot.api.platform import (
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 @@ class LarkPlatformAdapter(Platform):
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 @@ class LarkPlatformAdapter(Platform):
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 @@ class LarkPlatformAdapter(Platform):
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 @@ class LarkPlatformAdapter(Platform):
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:
@@ -26,8 +26,20 @@ from lark_oapi.api.im.v1 import (
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 @@ class LarkMessageEvent(AstrMessageEvent):
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 @@ class LarkMessageEvent(AstrMessageEvent):
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 @@ class LarkMessageEvent(AstrMessageEvent):
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 @@ class LarkMessageEvent(AstrMessageEvent):
):
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 @@ class LarkMessageEvent(AstrMessageEvent):
message,
self.bot,
reply_message_id=self.message_obj.message_id,
message_type=self.message_obj.type.value,
)
await super().send(message)
@@ -7,7 +7,15 @@ from typing import Any, cast
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 @@ from astrbot.api.platform import (
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 @@ class LinePlatformAdapter(Platform):
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 @@ class LinePlatformAdapter(Platform):
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 @@ class LinePlatformAdapter(Platform):
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 @@ class LinePlatformAdapter(Platform):
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()
@@ -8,14 +8,18 @@ from pathlib import Path
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 @@ from .line_api import LineAPIClient
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 @@ class LineMessageEvent(AstrMessageEvent):
"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
@@ -8,15 +8,33 @@ import aiohttp
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 @@ class MattermostClient:
*,
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 @@ class MattermostClient:
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 @@ class MattermostClient:
) -> 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 @@ class MattermostClient:
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,8 +237,74 @@ class MattermostClient:
"".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,
file_ids: list[str],
@@ -2,14 +2,16 @@ import asyncio
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 @@ from astrbot.api.platform import (
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 @@ class MattermostPlatformAdapter(Platform):
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 @@ class MattermostPlatformAdapter(Platform):
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 @@ class MattermostPlatformAdapter(Platform):
)
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 []
@@ -86,6 +86,33 @@ def serialize_message_chain(chain: list[Any]) -> tuple[str, bool]:
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 "[文件]"
@@ -15,6 +15,7 @@ import botpy.types
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 tenacity import (
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 @@ class QQOfficialMessageEvent(AstrMessageEvent):
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 @@ class QQOfficialMessageEvent(AstrMessageEvent):
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 @@ class QQOfficialMessageEvent(AstrMessageEvent):
and not record_file_path
and not video_file_source
and not file_source
and not keyboard
):
return None
@@ -328,16 +361,24 @@ class QQOfficialMessageEvent(AstrMessageEvent):
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 @@ class QQOfficialMessageEvent(AstrMessageEvent):
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 @@ class QQOfficialMessageEvent(AstrMessageEvent):
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 @@ class QQOfficialMessageEvent(AstrMessageEvent):
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 @@ class QQOfficialMessageEvent(AstrMessageEvent):
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 @@ class QQOfficialMessageEvent(AstrMessageEvent):
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 (
@@ -14,10 +14,21 @@ import botpy.message
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.api.platform import (
)
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 _ensure_group_message_create_parser() -> 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 @@ class botClient(Client):
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 @@ class QQOfficialPlatformAdapter(Platform):
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 @@ class QQOfficialPlatformAdapter(Platform):
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 @@ class QQOfficialPlatformAdapter(Platform):
and not record_file_path
and not video_file_source
and not file_source
and not keyboard
):
return
@@ -380,6 +439,8 @@ class QQOfficialPlatformAdapter(Platform):
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 @@ class QQOfficialPlatformAdapter(Platform):
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:
@@ -5,6 +5,7 @@ from typing import Any, cast
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 ...register import register_platform_adapter
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 @@ class botClient(Client):
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 @@ class QQOfficialWebhookPlatformAdapter(Platform):
public_messages=True,
public_guild_messages=True,
direct_message=True,
interaction=True,
)
self.client = botClient(
intents=intents, # 已经无用
@@ -12,6 +12,7 @@ from astrbot.api import logger
from astrbot.api.event import MessageChain
from astrbot.api.message_components import (
At,
ButtonInteraction,
File,
Image,
Plain,
@@ -27,6 +28,7 @@ from astrbot.api.platform import (
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 @@ class SatoriPlatformAdapter(Platform):
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,
@@ -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 @@ from astrbot.api.message_components import (
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 @@ class SatoriPlatformEvent(AstrMessageEvent):
elif isinstance(component, Forward):
return f'<message id="{component.id}" forward/>'
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'<button id="{callback_id}" type="action" '
f'theme="{theme}">{label}</button>'
)
elif isinstance(button.action, UrlAction):
href = html.escape(button.action.url, quote=True)
button_elements.append(
f'<button type="link" href="{href}" '
f'theme="{theme}">{label}</button>'
)
return "".join(button_elements)
# 对于其他未处理的组件类型,返回空字符串
return ""
@@ -378,6 +426,47 @@ class SatoriPlatformEvent(AstrMessageEvent):
elif isinstance(component, Forward):
return f'<message id="{component.id}" forward/>'
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'<button id="{callback_id}" type="action" '
f'theme="{theme}">{label}</button>'
)
elif isinstance(button.action, UrlAction):
href = html.escape(button.action.url, quote=True)
button_elements.append(
f'<button type="link" href="{href}" '
f'theme="{theme}">{label}</button>'
)
return "".join(button_elements)
# 对于其他未处理的组件类型,返回空字符串
return ""
+26 -2
View File
@@ -4,6 +4,7 @@ import hmac
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 @@ class SlackWebhookClient:
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 @@ class SlackWebhookClient:
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 @@ class SlackWebhookClient:
# 处理 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)
@@ -20,6 +20,7 @@ from astrbot.api.platform import (
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 @@ class SlackAdapter(Platform):
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 @@ class SlackAdapter(Platform):
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 @@ class SlackAdapter(Platform):
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", {})
# 忽略机器人自己的消息和消息编辑
@@ -9,12 +9,16 @@ from slack_sdk.web.async_client import AsyncWebClient
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 @@ class SlackMessageEvent(AstrMessageEvent):
"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 @@ class SlackMessageEvent(AstrMessageEvent):
"""解析成 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 @@ class SlackMessageEvent(AstrMessageEvent):
)
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 @@ class SlackMessageEvent(AstrMessageEvent):
{"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(
@@ -11,7 +11,13 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
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 @@ from astrbot.api.platform import (
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 @@ class TelegramPlatformAdapter(Platform):
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 @@ class TelegramPlatformAdapter(Platform):
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,
@@ -5,7 +5,12 @@ from collections.abc import Callable
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 telegram.ext import ExtBot
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 @@ class TelegramPlatformEvent(AstrMessageEvent):
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 @@ class TelegramPlatformEvent(AstrMessageEvent):
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 Markdownusing 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 @@ class TelegramPlatformEvent(AstrMessageEvent):
) -> 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 @@ class TelegramPlatformEvent(AstrMessageEvent):
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 @@ class TelegramPlatformEvent(AstrMessageEvent):
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 @@ class TelegramPlatformEvent(AstrMessageEvent):
**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)
@@ -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 @@ from astrbot.core.message.components import (
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(
@@ -7,7 +7,16 @@ from pathlib import Path, PurePosixPath
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 @@ class WebChatMessageEvent(AstrMessageEvent):
)
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()
@@ -17,7 +17,7 @@ from wechatpy.exceptions import InvalidSignatureException
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.api.platform import (
)
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 @@ class WecomPlatformAdapter(Platform):
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 @@ class WecomPlatformAdapter(Platform):
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 @@ class WecomPlatformAdapter(Platform):
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 @@ class WecomPlatformAdapter(Platform):
logger.warning(f"未实现的微信客服消息事件: {msg}")
return
await self.handle_msg(abm)
return abm
def create_event(self, message: AstrBotMessage) -> WecomPlatformEvent:
"""Creates a WeCom message event.
@@ -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 @@ class WecomPlatformEvent(AstrMessageEvent):
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 @@ class WecomPlatformEvent(AstrMessageEvent):
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}")
@@ -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 typing import Any
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 @@ from astrbot.api.platform import (
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 @@ class WecomAIBotAdapter(Platform):
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 @@ class WecomAIBotAdapter(Platform):
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 @@ class WecomAIBotAdapter(Platform):
)
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 @@ class WecomAIBotAdapter(Platform):
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 @@ class WecomAIBotAdapter(Platform):
)
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 @@ class WecomAIBotAdapter(Platform):
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 @@ class WecomAIBotAdapter(Platform):
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 @@ class WecomAIBotAdapter(Platform):
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 @@ class WecomAIBotAdapter(Platform):
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 @@ class WecomAIBotAdapter(Platform):
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 @@ class WecomAIBotAdapter(Platform):
(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 @@ class WecomAIBotAdapter(Platform):
# 构建 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 @@ class WecomAIBotAdapter(Platform):
# 消息内容
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 @@ class WecomAIBotAdapter(Platform):
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:
@@ -307,6 +307,40 @@ class WecomAIBotStreamMessageBuilder:
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:
"""构建文本消息
@@ -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}",
}
@@ -5,9 +5,10 @@ from collections.abc import Awaitable, Callable
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 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
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 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
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 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
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 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
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 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
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 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
"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 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
)
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 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
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 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
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 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
)
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 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
)
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
@@ -13,9 +13,19 @@ import aiohttp
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 @@ class WecomAIBotWebhookClient:
@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 @@ class WecomAIBotWebhookClient:
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,已跳过",
@@ -17,7 +17,17 @@ import qrcode as qrcode_lib
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 @@ class WeixinOCAdapter(Platform):
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(
@@ -7,7 +7,14 @@ from wechatpy.replies import ImageReply, VoiceReply
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 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
"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 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
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()
@@ -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
)
+2
View File
@@ -2,6 +2,7 @@ from .star import register_star
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 @@ from .star_handler import (
__all__ = [
"register_after_message_sent",
"register_agent",
"register_button_interaction",
"register_command",
"register_command_group",
"register_custom_filter",
@@ -15,6 +15,7 @@ from astrbot.core.message.message_event_result import MessageEventResult
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 register_event_message_type(event_message_type: EventMessageType, **kwargs):
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,
+20 -3
View File
@@ -149,7 +149,12 @@ class BotMessageAccumulator:
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 @@ class ChatService:
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 @@ class ChatService:
"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 @@ class ChatService:
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(
@@ -713,12 +713,12 @@ class LiveChatService:
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 @@ class LiveChatService:
"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 @@ class LiveChatService:
"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":
@@ -0,0 +1,176 @@
<template>
<div class="button-action-row" role="group">
<template v-for="button in buttons" :key="button.id">
<a
v-if="button.action.type === 'url'"
class="message-button"
:class="`style-${button.style}`"
:href="button.action.url"
target="_blank"
rel="noopener noreferrer"
>
<span>{{ button.label }}</span>
<v-icon size="13">mdi-open-in-new</v-icon>
</a>
<button
v-else
class="message-button"
:class="`style-${button.style}`"
type="button"
@click="emit('callback', button.action.callback_data)"
>
{{ button.label }}
</button>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { MessagePart } from "@/composables/useMessages";
type ButtonStyle = "default" | "primary" | "success" | "danger";
interface CallbackButtonAction {
type: "callback";
callback_data: string;
}
interface UrlButtonAction {
type: "url";
url: string;
}
interface MessageButton {
id: string;
label: string;
style: ButtonStyle;
action: CallbackButtonAction | UrlButtonAction;
}
const props = defineProps<{ part: MessagePart }>();
const emit = defineEmits<{ callback: [callbackData: string] }>();
const buttons = computed<MessageButton[]>(() => {
if (!Array.isArray(props.part.buttons)) return [];
const result: MessageButton[] = [];
for (const candidate of props.part.buttons) {
if (!candidate || typeof candidate !== "object") continue;
const button = candidate as Record<string, unknown>;
const action = button.action as Record<string, unknown> | undefined;
const id = typeof button.id === "string" ? button.id : "";
const label = typeof button.label === "string" ? button.label : "";
const style = ["primary", "success", "danger"].includes(
String(button.style),
)
? (button.style as ButtonStyle)
: "default";
if (!id || !label || !action) continue;
if (action.type === "url" && typeof action.url === "string") {
try {
const url = new URL(action.url);
if (url.protocol === "http:" || url.protocol === "https:") {
result.push({
id,
label,
style,
action: { type: "url", url: url.toString() },
});
}
} catch {
// Ignore malformed or relative links from untrusted message content.
}
continue;
}
if (
action.type === "callback" &&
typeof action.callback_data === "string"
) {
result.push({
id,
label,
style,
action: {
type: "callback",
callback_data: action.callback_data,
},
});
}
}
return result;
});
</script>
<style scoped>
.button-action-row {
display: flex;
width: 100%;
gap: 4px;
margin: 5px 0 1px;
}
.message-button {
display: inline-flex;
min-width: 0;
min-height: 34px;
flex: 1 1 0;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 10px;
border: 1px solid rgba(var(--v-theme-primary), 0.16);
border-radius: 8px;
background: rgba(var(--v-theme-primary), 0.09);
color: rgb(var(--v-theme-primary));
font: inherit;
font-size: 0.82rem;
font-weight: 600;
line-height: 1.2;
text-align: center;
text-decoration: none;
cursor: pointer;
transition:
background-color 120ms ease,
border-color 120ms ease,
transform 120ms ease;
}
.message-button:hover {
border-color: rgba(var(--v-theme-primary), 0.3);
background: rgba(var(--v-theme-primary), 0.15);
}
.message-button:active {
transform: translateY(1px);
}
.message-button:focus-visible {
outline: 2px solid rgba(var(--v-theme-primary), 0.45);
outline-offset: 1px;
}
.message-button.style-success {
border-color: rgba(34, 197, 94, 0.2);
background: rgba(34, 197, 94, 0.11);
color: rgb(22, 163, 74);
}
.message-button.style-danger {
border-color: rgba(239, 68, 68, 0.2);
background: rgba(239, 68, 68, 0.1);
color: rgb(220, 38, 38);
}
.message-button.style-primary {
background: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
}
@media (max-width: 520px) {
.message-button {
padding-inline: 7px;
font-size: 0.78rem;
}
}
</style>
+14
View File
@@ -434,6 +434,7 @@
@open-thread="openThreadPanel"
@open-reasoning="openReasoningPanel"
@open-refs="openRefsSidebar"
@button-click="handleButtonClick"
/>
</div>
</section>
@@ -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<TransportMode>(
(localStorage.getItem("chat.transportMode") as TransportMode) === "websocket"
? "websocket"
@@ -281,6 +281,17 @@
</template>
</div>
<ButtonActionRow
v-else-if="part.type === 'actionrow'"
:part="part"
@callback="
emit('buttonClick', {
message: msg,
callbackData: $event,
})
"
/>
<div v-else class="unknown-part">
{{ formatJson(part) }}
</div>
@@ -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();
@@ -155,6 +155,12 @@
</template>
</div>
<ButtonActionRow
v-else-if="part.type === 'actionrow'"
:part="part"
@callback="handleButtonClick(msg, $event)"
/>
<pre v-else class="unknown-part">{{
formatJson(part)
}}</pre>
@@ -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<TransportMode>(() =>
(localStorage.getItem("chat.transportMode") as TransportMode) === "websocket"
? "websocket"
+82 -1
View File
@@ -28,6 +28,7 @@
:is-dark="isDark"
:is-streaming="sending"
variant="thread"
@button-click="handleButtonClick"
/>
</div>
@@ -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<string, unknown>),
});
return;
}
if (["image", "record", "file", "video"].includes(type)) {
markMessageStarted(botRecord);
const rawFilename = String(data)
+68 -2
View File
@@ -131,6 +131,12 @@ interface CreateLocalExchangeOptions {
parts: MessagePart[];
}
interface SendButtonInteractionOptions {
sessionId: string;
sourceMessageId?: string | number;
callbackData: string;
}
interface UseMessagesOptions {
currentSessionId: Ref<string>;
onSessionsChanged?: () => Promise<void> | 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<ChatRecord>({
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<string, unknown>),
});
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,
+3
View File
@@ -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()
+223 -1
View File
@@ -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)",
},
)
]
+121 -1
View File
@@ -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 @@ async def test_discord_send_record_resolves_audio_with_media_resolver(monkeypatc
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()
+60
View File
@@ -12,9 +12,11 @@ from astrbot.core.message.components import (
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()
+76
View File
@@ -4,15 +4,21 @@ import pytest
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]
+153
View File
@@ -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)
+131
View File
@@ -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
+140
View File
@@ -5,6 +5,11 @@ from unittest.mock import AsyncMock, MagicMock, patch
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 test_mattermost_parse_post_attachments_maps_media_types(tmp_path):
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"
+77
View File
@@ -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 == ""
+303
View File
@@ -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()
+130
View File
@@ -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("<button") == 2
assert 'type="action"' in rendered
assert 'theme="success"' in rendered
assert "Approve &amp; continue" in rendered
assert 'type="link"' in rendered
assert 'href="https://example.com/?a=1&amp;b=2"' in rendered
callback_id = rendered.split('id="', 1)[1].split('"', 1)[0]
assert decode_button_callback(callback_id) == (
"approve",
{"request_id": 42},
)
def test_satori_button_interaction_enters_portable_pipeline():
adapter = _build_adapter()
callback_markup = asyncio.run(
SatoriPlatformEvent._convert_component_to_satori_static(
Button(
id="approve",
label="Approve",
action=CallbackAction(data={"request_id": 42}),
)
)
)
callback_id = callback_markup.split('id="', 1)[1].split('"', 1)[0]
message = adapter.convert_satori_button_interaction(
{
"sn": 17,
"type": "interaction/button",
"timestamp": 1_700_000_000,
"button": {"id": callback_id},
"channel": {"id": "channel-1"},
"guild": {"id": "guild-1"},
"operator": {"id": "user-1", "name": "Alice"},
"login": {"platform": "test", "user": {"id": "bot-1"}},
"message": {"id": "source-message-1"},
}
)
assert message is not None
assert message.session_id == "channel-1"
assert message.group_id == "guild-1"
assert message.sender.user_id == "user-1"
assert message.message_str == "approve"
interaction = message.message[0]
assert isinstance(interaction, ButtonInteraction)
assert interaction.action_id == "approve"
assert interaction.data == {"request_id": 42}
assert interaction.interaction_id == "17"
assert interaction.source_message_id == "source-message-1"
@pytest.mark.asyncio
async def test_satori_dispatches_button_interaction(monkeypatch):
adapter = _build_adapter()
rendered = await SatoriPlatformEvent._convert_component_to_satori_static(
Button(
id="approve",
label="Approve",
action=CallbackAction(),
)
)
callback_id = rendered.split('id="', 1)[1].split('"', 1)[0]
dispatched = []
monkeypatch.setattr(adapter, "commit_event", dispatched.append)
await adapter.handle_event(
{
"sn": 18,
"type": "interaction/button",
"button": {"id": callback_id},
"channel": {"id": "channel-1"},
"operator": {"id": "user-1"},
"login": {"platform": "test", "user": {"id": "bot-1"}},
}
)
assert len(dispatched) == 1
assert dispatched[0].is_button_interaction()
+175
View File
@@ -0,0 +1,175 @@
import asyncio
import hashlib
import hmac
import json
from unittest.mock import AsyncMock
from urllib.parse import urlencode
import pytest
from astrbot.api.message_components import (
ActionRow,
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.slack.client import SlackWebhookClient
from astrbot.core.platform.sources.slack.slack_adapter import SlackAdapter
from astrbot.core.platform.sources.slack.slack_event import SlackMessageEvent
@pytest.mark.asyncio
async def test_slack_action_row_renders_block_kit_buttons():
blocks, fallback_text = await SlackMessageEvent._parse_slack_blocks(
MessageChain(
[
Plain("Choose: "),
ActionRow(
buttons=[
Button(
id="approve",
label="Approve",
action=CallbackAction(data={"order_id": 42}),
style=ButtonStyle.SUCCESS,
),
Button(
id="docs",
label="Docs",
action=UrlAction(url="https://example.com/docs"),
),
],
fallback_text="Approve or open docs",
),
]
),
AsyncMock(),
)
assert fallback_text == "Choose: Approve or open docs"
assert blocks[1] == {
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": "Approve"},
"action_id": "approve",
"value": encode_button_callback("approve", {"order_id": 42}),
"style": "primary",
},
{
"type": "button",
"text": {"type": "plain_text", "text": "Docs"},
"action_id": "docs",
"url": "https://example.com/docs",
},
],
}
@pytest.mark.asyncio
async def test_slack_block_action_converts_to_button_interaction():
adapter = SlackAdapter.__new__(SlackAdapter)
adapter.bot_self_id = "B1"
adapter.web_client = AsyncMock()
adapter.web_client.conversations_info.return_value = {"channel": {"is_im": False}}
message = await adapter.convert_button_interaction(
{
"type": "block_actions",
"trigger_id": "trigger-1",
"user": {"id": "U1", "username": "alice"},
"channel": {"id": "C1"},
"container": {"message_ts": "123.456"},
"actions": [
{
"action_id": "approve",
"value": encode_button_callback(
"approve",
{"order_id": 42},
),
}
],
}
)
assert message is not None
assert message.session_id == "C1"
assert message.sender.user_id == "U1"
assert len(message.message) == 1
interaction = message.message[0]
assert isinstance(interaction, ButtonInteraction)
assert interaction.action_id == "approve"
assert interaction.data == {"order_id": 42}
assert interaction.interaction_id == "trigger-1"
assert interaction.source_message_id == "123.456"
@pytest.mark.asyncio
async def test_slack_webhook_acknowledges_block_action_before_processing():
signing_secret = "secret"
processing_started = asyncio.Event()
release_processing = asyncio.Event()
processing_finished = asyncio.Event()
async def handle_event(payload):
assert payload["type"] == "block_actions"
processing_started.set()
await release_processing.wait()
processing_finished.set()
client = SlackWebhookClient(
AsyncMock(),
signing_secret,
event_handler=handle_event,
)
payload = {
"type": "block_actions",
"actions": [
{
"action_id": "approve",
"value": encode_button_callback("approve"),
}
],
}
body = urlencode({"payload": json.dumps(payload)}).encode()
timestamp = "1700000000"
signature = (
"v0="
+ hmac.new(
signing_secret.encode(),
f"v0:{timestamp}:{body.decode()}".encode(),
hashlib.sha256,
).hexdigest()
)
class Request:
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"X-Slack-Request-Timestamp": timestamp,
"X-Slack-Signature": signature,
}
async def get_data(self):
return body
response = await asyncio.wait_for(client.handle_callback(Request()), timeout=0.2)
assert response.status_code == 200
await asyncio.wait_for(processing_started.wait(), timeout=0.2)
assert not processing_finished.is_set()
release_processing.set()
await asyncio.wait_for(processing_finished.wait(), timeout=0.2)
def test_slack_codec_round_trip_used_by_interactive_buttons():
encoded = encode_button_callback("approve", {"order_id": 42})
assert decode_button_callback(encoded) == ("approve", {"order_id": 42})
+190
View File
@@ -6,6 +6,11 @@ from unittest.mock import AsyncMock, MagicMock, patch
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.register import unregister_platform_adapters_by_module
from tests.fixtures.helpers import (
NoopAwaitable,
@@ -392,6 +397,191 @@ async def test_telegram_audio_caption_populates_message_text_and_plain(tmp_path)
assert result.message[1].text == "这首歌是什么"
@pytest.mark.asyncio
async def test_telegram_renders_common_buttons_as_inline_keyboard():
TelegramPlatformEvent = _load_telegram_platform_event()
client = MockTelegramBuilder.create_bot()
inline_button = MagicMock(
side_effect=lambda text, **kwargs: {"text": text, **kwargs}
)
inline_markup = MagicMock(side_effect=lambda rows: {"inline_keyboard": rows})
message = MessageChain(
[
Comp.Plain("Pick one"),
Comp.ActionRow(
buttons=[
Comp.Button(
id="approve",
label="Approve",
action=Comp.CallbackAction(data={"request_id": 7}),
),
Comp.Button(
id="docs",
label="Docs",
action=Comp.UrlAction(url="https://example.com/docs"),
),
]
),
]
)
with patch.dict(
TelegramPlatformEvent._build_inline_keyboard.__func__.__globals__,
{
"InlineKeyboardButton": inline_button,
"InlineKeyboardMarkup": inline_markup,
},
):
await TelegramPlatformEvent.send_with_client(client, message, "123456")
client.send_message.assert_awaited_once()
reply_markup = client.send_message.await_args.kwargs["reply_markup"]
callback_button, url_button = reply_markup["inline_keyboard"][0]
assert callback_button["text"] == "Approve"
assert decode_button_callback(callback_button["callback_data"]) == (
"approve",
{"request_id": 7},
)
assert url_button == {
"text": "Docs",
"url": "https://example.com/docs",
}
@pytest.mark.asyncio
async def test_telegram_callback_data_uses_compact_token_without_data_loss():
TelegramPlatformEvent = _load_telegram_platform_event()
inline_button = MagicMock(
side_effect=lambda text, **kwargs: {"text": text, **kwargs}
)
inline_markup = MagicMock(side_effect=lambda rows: {"inline_keyboard": rows})
row = Comp.ActionRow(
buttons=[
Comp.Button(
id="approve",
label="Approve",
action=Comp.CallbackAction(data={"value": "x" * 100}),
)
]
)
with patch.dict(
TelegramPlatformEvent._build_inline_keyboard.__func__.__globals__,
{
"InlineKeyboardButton": inline_button,
"InlineKeyboardMarkup": inline_markup,
},
):
markup = TelegramPlatformEvent._build_inline_keyboard([row])
assert markup is not None
callback_data = markup["inline_keyboard"][0][0]["callback_data"]
assert len(callback_data.encode("utf-8")) <= 64
assert decode_button_callback(callback_data) == (
"approve",
{"value": "x" * 100},
)
@pytest.mark.asyncio
async def test_telegram_button_only_message_uses_fallback_text():
TelegramPlatformEvent = _load_telegram_platform_event()
client = MockTelegramBuilder.create_bot()
inline_button = MagicMock(
side_effect=lambda text, **kwargs: {"text": text, **kwargs}
)
inline_markup = MagicMock(side_effect=lambda rows: {"inline_keyboard": rows})
message = MessageChain(
[
Comp.ActionRow(
buttons=[
Comp.Button(
id="retry",
label="Retry",
action=Comp.CallbackAction(),
)
],
fallback_text="Try again?",
)
]
)
with patch.dict(
TelegramPlatformEvent._build_inline_keyboard.__func__.__globals__,
{
"InlineKeyboardButton": inline_button,
"InlineKeyboardMarkup": inline_markup,
},
):
await TelegramPlatformEvent.send_with_client(client, message, "123456")
client.send_message.assert_awaited_once()
assert client.send_message.await_args.kwargs["text"] == "Try again?"
assert "reply_markup" in client.send_message.await_args.kwargs
@pytest.mark.asyncio
async def test_telegram_callback_query_becomes_button_interaction():
TelegramPlatformAdapter = _load_telegram_adapter()
adapter = TelegramPlatformAdapter(
make_platform_config("telegram"),
{},
asyncio.Queue(),
)
adapter.handle_msg = AsyncMock()
chat = MagicMock(id=-100123, type="supergroup")
source_message = MagicMock(
chat=chat,
message_id=77,
is_topic_message=False,
message_thread_id=None,
)
sender = MagicMock(id=1001, username="alice")
query = MagicMock(
id="callback-1",
data=encode_button_callback("approve", {"request_id": 7}),
message=source_message,
from_user=sender,
)
query.answer = AsyncMock()
update = MagicMock(callback_query=query, effective_chat=chat)
await adapter.callback_query_handler(update, _build_context())
query.answer.assert_awaited_once_with()
adapter.handle_msg.assert_awaited_once()
message = adapter.handle_msg.await_args.args[0]
assert message.session_id == "-100123"
assert message.group_id == "-100123"
assert message.message_id == "callback-1"
assert len(message.message) == 1
interaction = message.message[0]
assert isinstance(interaction, Comp.ButtonInteraction)
assert interaction.action_id == "approve"
assert interaction.data == {"request_id": 7}
assert interaction.interaction_id == "callback-1"
assert interaction.source_message_id == "77"
@pytest.mark.asyncio
async def test_telegram_foreign_callback_query_is_answered_and_ignored():
TelegramPlatformAdapter = _load_telegram_adapter()
adapter = TelegramPlatformAdapter(
make_platform_config("telegram"),
{},
asyncio.Queue(),
)
adapter.handle_msg = AsyncMock()
query = MagicMock(data="foreign-callback")
query.answer = AsyncMock()
update = MagicMock(callback_query=query)
await adapter.callback_query_handler(update, _build_context())
query.answer.assert_awaited_once_with()
adapter.handle_msg.assert_not_awaited()
@pytest.mark.asyncio
async def test_telegram_final_segment_splits_long_markdown_messages():
TelegramPlatformEvent = _load_telegram_platform_event()
+247
View File
@@ -0,0 +1,247 @@
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
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
from astrbot.core.platform.button_interaction import decode_button_callback
from astrbot.core.platform.platform_metadata import PlatformMetadata
from astrbot.core.platform.sources.wecom_ai_bot.wecomai_adapter import (
WecomAIBotAdapter,
)
from astrbot.core.platform.sources.wecom_ai_bot.wecomai_buttons import (
build_wecom_button_card,
)
from astrbot.core.platform.sources.wecom_ai_bot.wecomai_event import (
WecomAIBotMessageEvent,
)
from astrbot.core.platform.sources.wecom_ai_bot.wecomai_queue_mgr import (
WecomAIQueueMgr,
)
from astrbot.core.platform.sources.wecom_ai_bot.wecomai_webhook import (
WecomAIBotWebhookClient,
)
def _button_row() -> ActionRow:
return ActionRow(
fallback_text="Choose an action",
buttons=[
Button(
id="approve",
label="Approve",
action=CallbackAction(data={"request_id": 42}),
style=ButtonStyle.SUCCESS,
),
Button(
id="docs",
label="Docs",
action=UrlAction(url="https://example.com/docs"),
style=ButtonStyle.DANGER,
),
],
)
def _interaction_message(stream_id: str) -> AstrBotMessage:
message = AstrBotMessage()
message.type = MessageType.FRIEND_MESSAGE
message.self_id = "bot"
message.session_id = "session"
message.message_id = "event-1"
message.message_str = ""
message.sender = MessageMember(user_id="user-1", nickname="user-1")
message.message = []
message.raw_message = {"stream_id": stream_id}
return message
def test_wecom_button_card_maps_callback_and_url_actions():
card = build_wecom_button_card([_button_row()], task_id="task-1")
assert card is not None
assert card["card_type"] == "button_interaction"
assert card["main_title"] == {"title": "Choose an action"}
assert card["task_id"] == "task-1"
callback_button, url_button = card["button_list"]
assert callback_button["type"] == 0
assert callback_button["style"] == 3
assert decode_button_callback(callback_button["key"]) == (
"approve",
{"request_id": 42},
)
assert url_button == {
"text": "Docs",
"style": 2,
"type": 1,
"url": "https://example.com/docs",
}
@pytest.mark.asyncio
async def test_wecom_webhook_sends_action_row_as_template_card():
client = WecomAIBotWebhookClient(
"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key"
)
client.send_payload = AsyncMock()
await client.send_message_chain(MessageChain([_button_row()]))
payload = client.send_payload.await_args.args[0]
assert payload["msgtype"] == "template_card"
assert payload["template_card"]["card_type"] == "button_interaction"
@pytest.mark.asyncio
async def test_wecom_template_card_callback_becomes_button_interaction():
adapter = object.__new__(WecomAIBotAdapter)
adapter.bot_name = "AstrBot"
adapter.encoding_aes_key = ""
callback_key = build_wecom_button_card([_button_row()])["button_list"][0]["key"]
message = await adapter.convert_message(
{
"message_data": {
"msgtype": "event",
"msgid": "event-1",
"create_time": 1720000000,
"chattype": "group",
"chatid": "group-1",
"from": {"userid": "user-1"},
"event": {
"eventtype": "template_card_event",
"template_card_event": {
"event_key": callback_key,
"task_id": "task-1",
},
},
},
"session_id": "session-1",
"stream_id": "interaction-1",
}
)
assert message.message_str == ""
assert message.message_id == "event-1"
assert message.timestamp == 1720000000
assert message.type == MessageType.GROUP_MESSAGE
assert len(message.message) == 1
interaction = message.message[0]
assert isinstance(interaction, ButtonInteraction)
assert interaction.action_id == "approve"
assert interaction.data == {"request_id": 42}
assert interaction.interaction_id == "event-1"
assert interaction.source_message_id == "task-1"
def test_wecom_button_card_rejects_more_than_six_buttons():
buttons = [
Button(
id=f"action-{index}",
label=f"Action {index}",
action=CallbackAction(),
)
for index in range(7)
]
with pytest.raises(ValueError, match="at most 6"):
build_wecom_button_card([ActionRow(buttons=buttons)])
@pytest.mark.asyncio
async def test_wecom_long_connection_click_reply_updates_source_card():
queue_mgr = WecomAIQueueMgr()
stream_id = "interaction-stream"
queue_mgr.set_pending_response(
stream_id,
{
"req_id": "request-1",
"connection_mode": "long_connection",
"button_interaction": "true",
"task_id": "task-1",
},
)
update_sender = AsyncMock(return_value=True)
event = WecomAIBotMessageEvent(
message_str="",
message_obj=_interaction_message(stream_id),
platform_meta=PlatformMetadata(
name="wecom_ai_bot",
description="WeCom AI Bot",
id="wecom-ai-test",
),
session_id="session",
api_client=None,
queue_mgr=queue_mgr,
long_connection_update_sender=update_sender,
)
await event.send(MessageChain([Plain("Approved")]))
update_sender.assert_awaited_once()
req_id, body = update_sender.await_args.args
assert req_id == "request-1"
assert body["response_type"] == "update_template_card"
assert body["template_card"]["main_title"] == {"title": "Approved"}
assert body["template_card"]["task_id"] == "task-1"
@pytest.mark.asyncio
async def test_wecom_webhook_click_can_return_immediate_card_update():
adapter = object.__new__(WecomAIBotAdapter)
adapter.api_client = SimpleNamespace(
encrypt_message=AsyncMock(side_effect=lambda payload, _nonce, _time: payload)
)
adapter.queue_mgr = WecomAIQueueMgr()
adapter.only_use_webhook_url_to_send = False
adapter.webhook_client = None
adapter.initial_respond_text = ""
adapter.friend_message_welcome_text = ""
adapter.bot_name = "AstrBot"
adapter.encoding_aes_key = ""
adapter.metadata = PlatformMetadata(
name="wecom_ai_bot",
description="WeCom AI Bot",
id="wecom-ai-test",
)
callback_key = build_wecom_button_card([_button_row()])["button_list"][0]["key"]
async def reply_to_click(payload: dict) -> None:
message = await adapter.convert_message(payload)
event = adapter.create_event(message)
await event.send(MessageChain([Plain("Approved")]))
adapter.queue_mgr.set_listener(reply_to_click)
response = await adapter._process_message(
{
"msgtype": "event",
"msgid": "event-1",
"chattype": "single",
"from": {"userid": "user-1"},
"event": {
"eventtype": "template_card_event",
"template_card_event": {
"event_key": callback_key,
"task_id": "task-1",
},
},
},
{"nonce": "nonce", "timestamp": "timestamp"},
)
assert response is not None
update = json.loads(response)
assert update["response_type"] == "update_template_card"
assert update["template_card"]["main_title"] == {"title": "Approved"}
assert update["template_card"]["task_id"] == "task-1"
+222
View File
@@ -0,0 +1,222 @@
from unittest.mock import AsyncMock, MagicMock
import pytest
from wechatpy.enterprise import parse_message
from astrbot.api.event import MessageChain
from astrbot.api.message_components import (
ActionRow,
Button,
ButtonInteraction,
ButtonStyle,
CallbackAction,
Plain,
UrlAction,
)
from astrbot.api.platform import (
AstrBotMessage,
MessageMember,
MessageType,
PlatformMetadata,
)
from astrbot.core.platform.button_interaction import (
decode_button_callback,
encode_button_callback,
)
from astrbot.core.platform.sources.wecom.wecom_adapter import WecomPlatformAdapter
from astrbot.core.platform.sources.wecom.wecom_event import WecomPlatformEvent
from astrbot.core.platform.sources.wecom.wecom_kf_message import WeChatKFMessage
def _message() -> AstrBotMessage:
message = AstrBotMessage()
message.type = MessageType.FRIEND_MESSAGE
message.self_id = "100001"
message.session_id = "alice"
message.message_id = "message-1"
message.sender = MessageMember("alice", "Alice")
message.message = []
message.message_str = ""
message.raw_message = {}
return message
def _event(client) -> WecomPlatformEvent:
message = _message()
return WecomPlatformEvent(
message_str="",
message_obj=message,
platform_meta=PlatformMetadata("wecom", "WeCom", "test-wecom"),
session_id=message.session_id,
client=client,
)
def _row() -> ActionRow:
return ActionRow(
fallback_text="Choose an action",
buttons=[
Button(
id="approve",
label="Approve",
style=ButtonStyle.SUCCESS,
action=CallbackAction(data={"request_id": 42}),
),
Button(
id="docs",
label="Documentation",
action=UrlAction(url="https://example.com/docs"),
),
],
)
@pytest.mark.asyncio
async def test_wecom_application_sends_action_row_as_template_card():
class AppClient:
def __init__(self) -> None:
self.message = MagicMock()
client = AppClient()
await _event(client).send(MessageChain([_row()]))
client.message.send.assert_called_once()
args, kwargs = client.message.send.call_args
assert args == ("100001", "alice")
payload = kwargs["msg"]
assert payload["msgtype"] == "template_card"
card = payload["template_card"]
assert card["card_type"] == "button_interaction"
assert card["main_title"] == {"title": "Choose an action"}
assert card["task_id"].startswith("astrbot_")
callback_button, url_button = card["button_list"]
assert callback_button["type"] == 0
assert callback_button["style"] == 4
action_id, data = decode_button_callback(callback_button["key"])
assert action_id == "approve"
assert data == {"request_id": 42}
assert url_button == {
"text": "Documentation",
"style": 1,
"type": 1,
"url": "https://example.com/docs",
}
@pytest.mark.asyncio
async def test_wecom_customer_service_sends_action_row_as_menu():
kf_message = MagicMock(spec=WeChatKFMessage)
class CustomerServiceClient:
def __init__(self) -> None:
self.kf_message = kf_message
await _event(CustomerServiceClient()).send(MessageChain([_row()]))
kf_message.send_msgmenu.assert_called_once()
user_id, open_kfid, head, menu, tail = kf_message.send_msgmenu.call_args.args
assert (user_id, open_kfid, head, tail) == (
"alice",
"100001",
"Choose an action",
"",
)
action_id, data = decode_button_callback(menu[0]["click"]["id"])
assert action_id == "approve"
assert data == {"request_id": 42}
assert menu[1] == {
"type": "view",
"view": {
"url": "https://example.com/docs",
"content": "Documentation",
},
}
@pytest.mark.asyncio
async def test_wecom_template_card_callback_becomes_button_interaction():
callback = encode_button_callback("approve", {"request_id": 42})
raw_message = parse_message(
"<xml>"
"<ToUserName><![CDATA[corp]]></ToUserName>"
"<FromUserName><![CDATA[alice]]></FromUserName>"
"<CreateTime>1700000000</CreateTime>"
"<MsgType><![CDATA[event]]></MsgType>"
"<Event><![CDATA[template_card_event]]></Event>"
f"<EventKey><![CDATA[{callback}]]></EventKey>"
"<TaskId><![CDATA[task-1]]></TaskId>"
"<CardType><![CDATA[button_interaction]]></CardType>"
"<ResponseCode><![CDATA[response-1]]></ResponseCode>"
"<AgentID>100001</AgentID>"
"</xml>"
)
adapter = object.__new__(WecomPlatformAdapter)
adapter.agent_id = None
adapter.handle_msg = AsyncMock()
message = await adapter.convert_message(raw_message)
assert message is not None
assert message.message_str == "approve"
assert message.message_id == "response-1"
assert message.session_id == "alice"
interaction = message.message[0]
assert isinstance(interaction, ButtonInteraction)
assert interaction.action_id == "approve"
assert interaction.data == {"request_id": 42}
assert interaction.interaction_id == "response-1"
assert interaction.source_message_id == "task-1"
adapter.handle_msg.assert_awaited_once_with(message)
@pytest.mark.asyncio
async def test_wecom_foreign_customer_service_menu_stays_plain_text():
adapter = object.__new__(WecomPlatformAdapter)
adapter._wechat_kf_seen_text_messages = {}
adapter.handle_msg = AsyncMock()
message = await adapter.convert_wechat_kf_message(
{
"msgtype": "text",
"external_userid": "customer-1",
"open_kfid": "kf-1",
"msgid": "click-2",
"text": {"content": "Another menu", "menu_id": "foreign-menu"},
}
)
assert message is not None
assert message.message_str == "Another menu"
assert len(message.message) == 1
assert isinstance(message.message[0], Plain)
assert message.message[0].text == "Another menu"
adapter.handle_msg.assert_awaited_once_with(message)
@pytest.mark.asyncio
async def test_wecom_customer_service_menu_click_becomes_button_interaction():
adapter = object.__new__(WecomPlatformAdapter)
adapter._wechat_kf_seen_text_messages = {}
adapter.handle_msg = AsyncMock()
callback = encode_button_callback("approve", {"request_id": 42})
message = await adapter.convert_wechat_kf_message(
{
"msgtype": "text",
"external_userid": "customer-1",
"open_kfid": "kf-1",
"msgid": "click-1",
"text": {"content": "Approve", "menu_id": callback},
}
)
assert message is not None
assert message.message_str == "approve"
interaction = message.message[0]
assert isinstance(interaction, ButtonInteraction)
assert interaction.action_id == "approve"
assert interaction.data == {"request_id": 42}
assert interaction.interaction_id == "click-1"
assert interaction.source_message_id is None
adapter.handle_msg.assert_awaited_once_with(message)
+96
View File
@@ -0,0 +1,96 @@
from unittest.mock import AsyncMock
import pytest
from astrbot.api.event import MessageChain
from astrbot.api.message_components import (
ActionRow,
Button,
CallbackAction,
Plain,
UrlAction,
)
from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.platform.message_session import MessageSession
from astrbot.core.platform.platform import Platform
from astrbot.core.platform.platform_metadata import PlatformMetadata
from astrbot.core.platform.sources.weixin_oc.weixin_oc_adapter import WeixinOCAdapter
from astrbot.core.platform.sources.weixin_official_account.weixin_offacc_event import (
WeixinOfficialAccountPlatformEvent,
)
def _button_row(*, fallback_text: str | None = None) -> ActionRow:
return ActionRow(
buttons=[
Button(
id="docs",
label="查看文档",
action=UrlAction(url="https://example.com/docs"),
),
Button(
id="confirm",
label="确认",
action=CallbackAction(data={"confirmed": True}),
),
],
fallback_text=fallback_text,
)
@pytest.mark.asyncio
async def test_weixin_official_account_buttons_fall_back_to_text(monkeypatch):
message = AstrBotMessage()
message.type = MessageType.FRIEND_MESSAGE
message.sender = MessageMember("user", "User")
message.message = []
message.message_str = ""
message.raw_message = {"active_send_mode": False}
output = {"cached_xml": []}
event = WeixinOfficialAccountPlatformEvent(
message_str="",
message_obj=message,
platform_meta=PlatformMetadata(
"weixin_official_account",
"Weixin Official Account",
"weixin-test",
),
session_id="user",
client=object(),
message_out=output,
)
monkeypatch.setattr(AstrMessageEvent, "send", AsyncMock())
await event.send(
MessageChain([Plain("正文"), _button_row(fallback_text="请回复 yes 确认")])
)
assert output["cached_xml"] == [
"正文",
"查看文档: https://example.com/docs\n请回复 yes 确认",
]
@pytest.mark.asyncio
async def test_weixin_oc_buttons_fall_back_to_text(monkeypatch):
adapter = object.__new__(WeixinOCAdapter)
adapter.metadata = PlatformMetadata("weixin_oc", "Weixin OC", "weixin-test")
adapter._send_to_session = AsyncMock(return_value=True)
monkeypatch.setattr(Platform, "send_by_session", AsyncMock())
session = MessageSession(
platform_name="weixin-test",
message_type=MessageType.FRIEND_MESSAGE,
session_id="user",
)
await adapter.send_by_session(
session,
MessageChain([Plain("正文"), _button_row()]),
)
adapter._send_to_session.assert_awaited_once_with(
"user",
"正文\n查看文档: https://example.com/docs\n"
"可选操作:确认(当前平台不支持按钮,请发送选项名称)",
)
+94
View File
@@ -0,0 +1,94 @@
from unittest.mock import AsyncMock
import pytest
import astrbot.core.message.components as Comp
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import (
AiocqhttpMessageEvent,
)
@pytest.mark.asyncio
async def test_aiocqhttp_action_row_degrades_to_plain_text():
row = Comp.ActionRow(
fallback_text="Choose an action",
buttons=[
Comp.Button(
id="approve",
label="Approve",
action=Comp.CallbackAction(data={"request_id": 42}),
),
Comp.Button(
id="docs",
label="Documentation",
action=Comp.UrlAction(url="https://example.com/docs"),
),
],
)
data = await AiocqhttpMessageEvent._parse_onebot_json(MessageChain([row]))
assert data == [
{
"type": "text",
"data": {
"text": (
"Choose an action\n"
"[Button unavailable] Approve\n"
"Documentation: https://example.com/docs"
)
},
}
]
@pytest.mark.asyncio
async def test_aiocqhttp_callback_button_never_emits_nonstandard_segment():
button = Comp.Button(
id="retry",
label="Retry",
action=Comp.CallbackAction(),
)
data = await AiocqhttpMessageEvent._parse_onebot_json(MessageChain([button]))
assert data == [
{
"type": "text",
"data": {"text": "[Button unavailable] Retry"},
}
]
assert all(segment["type"] not in {"button", "actionrow"} for segment in data)
@pytest.mark.asyncio
async def test_aiocqhttp_sends_url_button_fallback_as_text():
bot = AsyncMock()
row = Comp.ActionRow(
buttons=[
Comp.Button(
id="website",
label="Website",
action=Comp.UrlAction(url="https://example.com"),
)
]
)
await AiocqhttpMessageEvent.send_message(
bot=bot,
message_chain=MessageChain([row]),
event=None,
is_group=True,
session_id="123456",
)
bot.send_group_msg.assert_awaited_once_with(
group_id=123456,
message=[
{
"type": "text",
"data": {"text": "Website: https://example.com"},
}
],
)
+13
View File
@@ -8,6 +8,7 @@ import pytest
from astrbot.core.message.components import (
At,
AtAll,
ButtonInteraction,
Face,
Forward,
Image,
@@ -105,6 +106,18 @@ class TestAstrMessageEventInit:
assert astr_message_event.span is not None
assert astr_message_event.trace == astr_message_event.span
def test_button_interaction_accessors(self, astr_message_event):
interaction = ButtonInteraction(
action_id="approve",
data={"request_id": "req-1"},
interaction_id="interaction-1",
source_message_id="message-1",
)
astr_message_event.message_obj.message.append(interaction)
assert astr_message_event.is_button_interaction()
assert astr_message_event.get_button_interaction() == interaction
class TestUnifiedMsgOrigin:
"""Tests for unified_msg_origin property."""
+116
View File
@@ -0,0 +1,116 @@
import pytest
from astrbot.core.message.components import (
ActionRow,
Button,
ButtonStyle,
CallbackAction,
UrlAction,
)
from astrbot.core.platform import button_interaction
from astrbot.core.platform.button_interaction import (
decode_button_callback,
encode_button_callback,
)
def test_action_row_serializes_portable_buttons():
row = ActionRow(
buttons=[
Button(
id="confirm",
label="Confirm",
action=CallbackAction(data={"order_id": 42}),
style=ButtonStyle.PRIMARY,
),
Button(
id="docs",
label="Docs",
action=UrlAction(url="https://example.com/docs"),
),
],
fallback_text="Choose an action",
)
assert row.toDict() == {
"type": "actionrow",
"data": {
"buttons": [
{
"id": "confirm",
"label": "Confirm",
"action": {
"type": "callback",
"data": {"order_id": 42},
},
"style": "primary",
},
{
"id": "docs",
"label": "Docs",
"action": {
"type": "url",
"url": "https://example.com/docs",
},
"style": "default",
},
],
"fallback_text": "Choose an action",
},
}
def test_callback_codec_round_trip():
payload = encode_button_callback(
"approve",
{"request_id": "req-1", "flags": [True, None]},
)
assert decode_button_callback(payload) == (
"approve",
{"request_id": "req-1", "flags": [True, None]},
)
@pytest.mark.parametrize("data", [{"invalid": {1, 2}}, float("nan")])
def test_callback_action_rejects_non_json_data(data):
with pytest.raises(ValueError, match="JSON-compatible"):
CallbackAction(data=data)
def test_callback_codec_uses_compact_opaque_token():
payload = encode_button_callback("approve")
assert payload.startswith("astrbot:")
assert len(payload.encode("utf-8")) <= 64
assert "approve" not in payload
assert decode_button_callback(payload) == ("approve", None)
def test_callback_registry_survives_process_restart(tmp_path, monkeypatch):
registry = button_interaction._ButtonCallbackRegistry()
registry.configure(tmp_path / "callbacks.db")
monkeypatch.setattr(button_interaction, "_button_callback_registry", registry)
payload = button_interaction.encode_button_callback(
"approve",
{"request_id": "req-1"},
)
restarted_registry = button_interaction._ButtonCallbackRegistry()
restarted_registry.configure(tmp_path / "callbacks.db")
monkeypatch.setattr(
button_interaction,
"_button_callback_registry",
restarted_registry,
)
assert button_interaction.decode_button_callback(payload) == (
"approve",
{"request_id": "req-1"},
)
@pytest.mark.parametrize("payload", ["approve", "astrbot:{}", "astrbot:not-json"])
def test_callback_codec_rejects_invalid_payload(payload):
with pytest.raises(ValueError):
decode_button_callback(payload)
@@ -0,0 +1,103 @@
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from astrbot.api.event import filter
from astrbot.core.message.components import ButtonInteraction
from astrbot.core.pipeline.waking_check.stage import (
WakingCheckStage,
star_handlers_registry,
)
from astrbot.core.star.filter.button_interaction import ButtonInteractionFilter
from astrbot.core.star.session_plugin_manager import SessionPluginManager
def test_button_interaction_filter_matches_action():
interaction = ButtonInteraction(
action_id="approve",
interaction_id="interaction-1",
)
class Event:
def get_button_interaction(self):
return interaction
event = Event()
assert ButtonInteractionFilter().filter(event, {})
assert ButtonInteractionFilter("approve").filter(event, {})
assert not ButtonInteractionFilter("reject").filter(event, {})
def test_public_button_interaction_decorator_registers_filter():
assert callable(filter.button_interaction("approve"))
@pytest.mark.asyncio
async def test_button_click_only_activates_button_handlers(monkeypatch):
stage = WakingCheckStage()
stage.ctx = SimpleNamespace(
astrbot_config={
"admins_id": [],
"wake_prefix": [],
"plugin_set": ["*"],
}
)
stage.unique_session = False
stage.ignore_bot_self_message = False
stage.friend_message_needs_wake_prefix = False
stage.ignore_at_all = False
stage.disable_builtin_commands = False
stage.no_permission_reply = True
ordinary_filter = MagicMock()
ordinary_filter.filter.return_value = True
ordinary_handler = SimpleNamespace(
event_filters=[ordinary_filter],
handler_module_path="test.ordinary",
handler_full_name="test.ordinary.handler",
)
button_handler = SimpleNamespace(
event_filters=[ButtonInteractionFilter("approve")],
handler_module_path="test.button",
handler_full_name="test.button.handler",
)
monkeypatch.setattr(
star_handlers_registry,
"get_handlers_by_event_type",
lambda *_args, **_kwargs: [ordinary_handler, button_handler],
)
async def return_handlers(_event, handlers):
return handlers
monkeypatch.setattr(
SessionPluginManager,
"filter_handlers_by_session",
return_handlers,
)
interaction = ButtonInteraction(
action_id="approve",
interaction_id="interaction-1",
)
event = MagicMock()
event.message_str = "approve"
event.role = "member"
event.plugins_name = None
event.get_sender_id.return_value = "user-1"
event.get_messages.return_value = [interaction]
event.get_extra.side_effect = lambda _key=None, default=None: default
event.get_button_interaction.return_value = interaction
event.is_button_interaction.return_value = True
event.is_private_chat.return_value = True
await stage.process(event)
ordinary_filter.filter.assert_not_called()
activated_call = next(
call
for call in event.set_extra.call_args_list
if call.args[0] == "activated_handlers"
)
assert activated_call.args[1] == [button_handler]
+131 -1
View File
@@ -4,11 +4,24 @@ from types import SimpleNamespace
import pytest
from astrbot.api.event import MessageChain
from astrbot.api.message_components import File
from astrbot.api.message_components import (
ActionRow,
Button,
ButtonInteraction,
CallbackAction,
File,
UrlAction,
)
from astrbot.core.platform.button_interaction import (
decode_button_callback,
encode_button_callback,
)
from astrbot.core.platform.sources.webchat import webchat_event
from astrbot.core.platform.sources.webchat.message_parts_helper import (
build_webchat_message_parts,
create_attachment_part_from_existing_file,
message_chain_to_storage_message_parts,
parse_webchat_message_parts,
)
@@ -113,3 +126,120 @@ async def test_build_webchat_message_parts_preserves_payload_filename(tmp_path):
"stored_filename": "uuid.txt",
}
]
@pytest.mark.asyncio
async def test_webchat_action_row_send_uses_portable_callback_payload(monkeypatch):
"""WebChat should emit compact callback data and retain URL buttons."""
queue = asyncio.Queue()
async def put_back_queue(_request_id, payload):
await queue.put(payload)
return True
monkeypatch.setattr(
webchat_event.webchat_queue_mgr,
"put_back_queue",
put_back_queue,
)
row = ActionRow(
buttons=[
Button(
id="approve",
label="Approve",
action=CallbackAction(data={"ticket": 7}),
),
Button(
id="docs",
label="Docs",
action=UrlAction(url="https://example.com/docs"),
),
]
)
await webchat_event.WebChatMessageEvent._send(
"message-1",
MessageChain([row]),
"webchat!user!conversation-1",
)
payload = await queue.get()
callback_action = payload["data"]["buttons"][0]["action"]
assert payload["type"] == "actionrow"
assert decode_button_callback(callback_action["callback_data"]) == (
"approve",
{"ticket": 7},
)
assert "data" not in callback_action
assert payload["data"]["buttons"][1]["action"] == {
"type": "url",
"url": "https://example.com/docs",
}
@pytest.mark.asyncio
async def test_webchat_button_interaction_becomes_portable_component():
"""A WebChat callback click should enter the common interaction model."""
async def get_attachment_by_id(_attachment_id):
raise AssertionError("button interactions must not resolve attachments")
callback_data = encode_button_callback("choose", ["alpha", 2])
parts = await build_webchat_message_parts(
[
{
"type": "button_interaction",
"callback_data": callback_data,
"source_message_id": 42,
}
],
get_attachment_by_id=get_attachment_by_id,
strict=True,
)
components, text_parts, has_content = await parse_webchat_message_parts(
parts,
strict=True,
)
assert has_content is True
assert text_parts == ["choose"]
assert len(components) == 1
interaction = components[0]
assert isinstance(interaction, ButtonInteraction)
assert interaction.action_id == "choose"
assert interaction.data == ["alpha", 2]
assert interaction.source_message_id == "42"
assert interaction.interaction_id
@pytest.mark.asyncio
async def test_webchat_action_row_is_persisted_with_callback_data(tmp_path):
"""Proactive WebChat messages should preserve interactive rows in history."""
async def insert_attachment(_path, _type, _mime_type):
raise AssertionError("action rows must not create attachments")
parts = await message_chain_to_storage_message_parts(
MessageChain(
[
ActionRow(
buttons=[
Button(
id="retry",
label="Retry",
action=CallbackAction(data="request-1"),
)
],
fallback_text="Retry",
)
]
),
insert_attachment=insert_attachment,
attachments_dir=tmp_path,
)
assert parts[0]["type"] == "actionrow"
assert parts[0]["fallback_text"] == "Retry"
callback_data = parts[0]["buttons"][0]["action"]["callback_data"]
assert decode_button_callback(callback_data) == ("retry", "request-1")
assert "data" not in parts[0]["buttons"][0]["action"]