feat(webchat): refactor message parsing logic and integrate new parsing function

This commit is contained in:
Soulter
2026-02-25 17:14:02 +08:00
parent 53ae8cd7cf
commit f8f7e6d57a
2 changed files with 157 additions and 72 deletions
@@ -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(
@@ -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: