diff --git a/astrbot/core/platform/sources/webchat/message_parts_helper.py b/astrbot/core/platform/sources/webchat/message_parts_helper.py index 608e3448a..43072ec1c 100644 --- a/astrbot/core/platform/sources/webchat/message_parts_helper.py +++ b/astrbot/core/platform/sources/webchat/message_parts_helper.py @@ -4,6 +4,7 @@ import shutil import uuid from collections.abc import Awaitable, Callable, Sequence from pathlib import Path +from typing import Any from astrbot.core.db.po import Attachment from astrbot.core.message.components import ( @@ -19,6 +20,10 @@ from astrbot.core.message.message_event_result import MessageChain AttachmentGetter = Callable[[str], Awaitable[Attachment | None]] AttachmentInserter = Callable[[str, str, str], Awaitable[Attachment | None]] +ReplyHistoryGetter = Callable[ + [Any], + Awaitable[tuple[list[dict], str | None, str | None] | None], +] MEDIA_PART_TYPES = {"image", "record", "file", "video"} @@ -35,6 +40,127 @@ def webchat_message_parts_have_content(message_parts: list[dict]) -> bool: ) +async def parse_webchat_message_parts( + message_parts: list, + *, + strict: bool = False, + include_empty_plain: bool = False, + verify_media_path_exists: bool = True, + reply_history_getter: ReplyHistoryGetter | None = None, + current_depth: int = 0, + max_reply_depth: int = 0, + cast_reply_id_to_str: bool = True, +) -> tuple[list, list[str], bool]: + """Parse webchat message parts into components/text parts. + + Returns: + tuple[list, list[str], bool]: + (components, plain_text_parts, has_non_reply_content) + """ + components = [] + text_parts: list[str] = [] + has_content = False + + for part in message_parts: + if not isinstance(part, dict): + if strict: + raise ValueError("message part must be an object") + continue + + part_type = str(part.get("type", "")).strip() + if part_type == "plain": + text = str(part.get("text", "")) + if text or include_empty_plain: + components.append(Plain(text=text)) + text_parts.append(text) + if text: + has_content = True + continue + + if part_type == "reply": + message_id = part.get("message_id") + if message_id is None: + if strict: + raise ValueError("reply part missing message_id") + continue + + reply_chain = [] + reply_message_str = str(part.get("selected_text", "")) + sender_id = None + sender_name = None + + if reply_message_str: + reply_chain = [Plain(text=reply_message_str)] + elif ( + reply_history_getter + and current_depth < max_reply_depth + and message_id is not None + ): + reply_info = await reply_history_getter(message_id) + if reply_info: + reply_parts, sender_id, sender_name = reply_info + ( + reply_chain, + reply_text_parts, + _, + ) = await parse_webchat_message_parts( + reply_parts, + strict=strict, + include_empty_plain=include_empty_plain, + verify_media_path_exists=verify_media_path_exists, + reply_history_getter=reply_history_getter, + current_depth=current_depth + 1, + max_reply_depth=max_reply_depth, + cast_reply_id_to_str=cast_reply_id_to_str, + ) + reply_message_str = "".join(reply_text_parts) + + reply_id = str(message_id) if cast_reply_id_to_str else message_id + components.append( + Reply( + id=reply_id, + message_str=reply_message_str, + chain=reply_chain, + sender_id=sender_id, + sender_nickname=sender_name, + ) + ) + continue + + if part_type not in MEDIA_PART_TYPES: + if strict: + raise ValueError(f"unsupported message part type: {part_type}") + continue + + path = part.get("path") + if not path: + if strict: + raise ValueError(f"{part_type} part missing path") + continue + + file_path = Path(str(path)) + if verify_media_path_exists and not file_path.exists(): + if strict: + raise ValueError(f"file not found: {file_path!s}") + continue + + file_path_str = ( + str(file_path.resolve()) if verify_media_path_exists else str(file_path) + ) + has_content = True + if part_type == "image": + components.append(Image.fromFileSystem(file_path_str)) + elif part_type == "record": + components.append(Record.fromFileSystem(file_path_str)) + elif part_type == "video": + components.append(Video.fromFileSystem(file_path_str)) + else: + filename = str(part.get("filename", "")).strip() or file_path.name + components.append(File(name=filename, file=file_path_str)) + + return components, text_parts, has_content + + async def build_webchat_message_parts( message_payload: str | list, *, @@ -192,7 +318,13 @@ async def build_message_chain_from_payload( get_attachment_by_id=get_attachment_by_id, strict=strict, ) - return webchat_message_parts_to_message_chain(message_parts, strict=strict) + components, _, has_content = await parse_webchat_message_parts( + message_parts, + strict=strict, + ) + if strict and (not components or not has_content): + raise ValueError("Message content is empty (reply only is not allowed)") + return MessageChain(chain=components) async def create_attachment_part_from_existing_file( diff --git a/astrbot/core/platform/sources/webchat/webchat_adapter.py b/astrbot/core/platform/sources/webchat/webchat_adapter.py index e72594d8a..54718fefb 100644 --- a/astrbot/core/platform/sources/webchat/webchat_adapter.py +++ b/astrbot/core/platform/sources/webchat/webchat_adapter.py @@ -9,14 +9,6 @@ from typing import Any from astrbot import logger from astrbot.core import db_helper from astrbot.core.db.po import PlatformMessageHistory -from astrbot.core.message.components import ( - File, - Image, - Plain, - Record, - Reply, - Video, -) from astrbot.core.message.message_event_result import MessageChain from astrbot.core.platform import ( AstrBotMessage, @@ -29,7 +21,10 @@ from astrbot.core.platform.astr_message_event import MessageSesion from astrbot.core.utils.astrbot_path import get_astrbot_data_path from ...register import register_platform_adapter -from .message_parts_helper import message_chain_to_storage_message_parts +from .message_parts_helper import ( + message_chain_to_storage_message_parts, + parse_webchat_message_parts, +) from .webchat_event import WebChatMessageEvent from .webchat_queue_mgr import WebChatQueueMgr, webchat_queue_mgr @@ -175,72 +170,30 @@ class WebChatAdapter(Platform): Returns: tuple[list, list[str]]: (消息组件列表, 纯文本列表) """ - components = [] - text_parts = [] - for part in message_parts: - part_type = part.get("type") - if part_type == "plain": - text = part.get("text", "") - components.append(Plain(text=text)) - text_parts.append(text) - elif part_type == "reply": - message_id = part.get("message_id") - reply_chain = [] - reply_message_str = part.get("selected_text", "") - sender_id = None - sender_name = None + async def get_reply_parts( + message_id: Any, + ) -> tuple[list[dict], str | None, str | None] | None: + history = await self._get_message_history(message_id) + if not history or not history.content: + return None - if reply_message_str: - reply_chain = [Plain(text=reply_message_str)] + reply_parts = history.content.get("message", []) + if not isinstance(reply_parts, list): + return None - # recursively get the content of the referenced message, if selected_text is empty - if not reply_message_str and depth < max_depth and message_id: - history = await self._get_message_history(message_id) - if history and history.content: - reply_parts = history.content.get("message", []) - if isinstance(reply_parts, list): - ( - reply_chain, - reply_text_parts, - ) = await self._parse_message_parts( - reply_parts, - depth=depth + 1, - max_depth=max_depth, - ) - reply_message_str = "".join(reply_text_parts) - sender_id = history.sender_id - sender_name = history.sender_name - - components.append( - Reply( - id=message_id, - chain=reply_chain, - message_str=reply_message_str, - sender_id=sender_id, - sender_nickname=sender_name, - ) - ) - elif part_type == "image": - path = part.get("path") - if path: - components.append(Image.fromFileSystem(path)) - elif part_type == "record": - path = part.get("path") - if path: - components.append(Record.fromFileSystem(path)) - elif part_type == "file": - path = part.get("path") - if path: - filename = part.get("filename") or ( - os.path.basename(path) if path else "file" - ) - components.append(File(name=filename, file=path)) - elif part_type == "video": - path = part.get("path") - if path: - components.append(Video.fromFileSystem(path)) + return reply_parts, history.sender_id, history.sender_name + components, text_parts, _ = await parse_webchat_message_parts( + message_parts, + strict=False, + include_empty_plain=True, + verify_media_path_exists=False, + reply_history_getter=get_reply_parts, + current_depth=depth, + max_reply_depth=max_depth, + cast_reply_id_to_str=False, + ) return components, text_parts async def convert_message(self, data: tuple) -> AstrBotMessage: