mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-08-31 01:40:25 +08:00
feat: add portable message buttons
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
|
||||
@@ -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 Markdown,using normal text: {e!s}"
|
||||
)
|
||||
await client.send_message(text=chunk, **cast(Any, payload))
|
||||
await client.send_message(text=chunk, **cast(Any, chunk_payload))
|
||||
|
||||
@classmethod
|
||||
def _build_inline_keyboard(
|
||||
cls,
|
||||
rows: list[ActionRow],
|
||||
) -> InlineKeyboardMarkup | None:
|
||||
"""Render common button rows as a Telegram inline keyboard.
|
||||
|
||||
Args:
|
||||
rows: Common action rows to render.
|
||||
|
||||
Returns:
|
||||
Telegram markup, or ``None`` when no valid buttons remain.
|
||||
"""
|
||||
keyboard: list[list[InlineKeyboardButton]] = []
|
||||
for row in rows:
|
||||
telegram_row: list[InlineKeyboardButton] = []
|
||||
for button in row.buttons:
|
||||
if isinstance(button.action, UrlAction):
|
||||
telegram_row.append(
|
||||
InlineKeyboardButton(button.label, url=button.action.url)
|
||||
)
|
||||
continue
|
||||
|
||||
if not isinstance(button.action, CallbackAction):
|
||||
continue
|
||||
callback_data = encode_button_callback(
|
||||
button.id,
|
||||
button.action.data,
|
||||
)
|
||||
if len(callback_data.encode("utf-8")) > 64:
|
||||
logger.warning(
|
||||
"Skipping Telegram button %r because its callback token "
|
||||
"exceeds 64 bytes.",
|
||||
button.id,
|
||||
)
|
||||
continue
|
||||
telegram_row.append(
|
||||
InlineKeyboardButton(
|
||||
button.label,
|
||||
callback_data=callback_data,
|
||||
)
|
||||
)
|
||||
if telegram_row:
|
||||
keyboard.append(telegram_row)
|
||||
|
||||
return InlineKeyboardMarkup(keyboard) if keyboard else None
|
||||
|
||||
@classmethod
|
||||
async def _send_chat_action(
|
||||
@@ -274,6 +334,15 @@ 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,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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+3
@@ -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()
|
||||
|
||||
@@ -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)",
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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 == ""
|
||||
@@ -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()
|
||||
@@ -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 & continue" in rendered
|
||||
assert 'type="link"' in rendered
|
||||
assert 'href="https://example.com/?a=1&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()
|
||||
@@ -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})
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
"可选操作:确认(当前平台不支持按钮,请发送选项名称)",
|
||||
)
|
||||
@@ -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"},
|
||||
}
|
||||
],
|
||||
)
|
||||
@@ -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."""
|
||||
|
||||
@@ -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]
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user