feat(satori): 添加对合并转发消息功能的支持 (#3050)

* Update satori_event.py

* Update satori_event.py

* Update satori_event.py

* Update satori_adapter.py

* style: format code for better readability in satori_adapter.py and satori_event.py

---------

Co-authored-by: Soulter <905617992@qq.com>
This commit is contained in:
shangxue
2025-10-19 20:05:03 +08:00
committed by GitHub
parent 5c5b0f4fde
commit dc71c04b67
2 changed files with 293 additions and 96 deletions
@@ -499,10 +499,36 @@ class SatoriPlatformAdapter(Platform):
}
return None
except ET.ParseError as e:
logger.warning(f"XML解析失败,使用正则提取: {e}")
return await self._extract_quote_with_regex(content)
except Exception as e:
logger.error(f"提取<quote>标签时发生错误: {e}")
return None
async def _extract_quote_with_regex(self, content: str) -> Optional[dict]:
"""使用正则表达式提取quote标签信息"""
import re
quote_pattern = r"<quote\s+([^>]*)>(.*?)</quote>"
match = re.search(quote_pattern, content, re.DOTALL)
if not match:
return None
attrs_str = match.group(1)
inner_content = match.group(2)
id_match = re.search(r'id\s*=\s*["\']([^"\']*)["\']', attrs_str)
quote_id = id_match.group(1) if id_match else ""
content_without_quote = content.replace(match.group(0), "")
content_without_quote = content_without_quote.strip()
return {
"quote": {"id": quote_id, "content": inner_content},
"content_without_quote": content_without_quote,
}
async def _convert_quote_message(self, quote: dict) -> Optional[AstrBotMessage]:
"""转换引用消息"""
try:
@@ -574,7 +600,7 @@ class SatoriPlatformAdapter(Platform):
root = ET.fromstring(processed_content)
await self._parse_xml_node(root, elements)
except ET.ParseError as e:
logger.error(f"解析 Satori 元素时发生解析错误: {e}, 错误内容: {content}")
logger.warning(f"解析 Satori 元素时发生解析错误: {e}, 错误内容: {content}")
# 如果解析失败,将整个内容当作纯文本
if content.strip():
elements.append(Plain(text=content))
@@ -2,7 +2,18 @@ from typing import TYPE_CHECKING
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.api.message_components import Plain, Image, At, File, Record, Video, Reply
from astrbot.api.message_components import (
Plain,
Image,
At,
File,
Record,
Video,
Reply,
Forward,
Node,
Nodes,
)
if TYPE_CHECKING:
from .satori_adapter import SatoriPlatformAdapter
@@ -48,55 +59,24 @@ class SatoriPlatformEvent(AstrMessageEvent):
content_parts = []
for component in message.chain:
if isinstance(component, Plain):
text = (
component.text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
content_parts.append(text)
component_content = await cls._convert_component_to_satori_static(
component
)
if component_content:
content_parts.append(component_content)
elif isinstance(component, At):
if component.qq:
content_parts.append(f'<at id="{component.qq}"/>')
elif component.name:
content_parts.append(f'<at name="{component.name}"/>')
# 特殊处理 Node 和 Nodes 组件
if isinstance(component, Node):
# 单个转发节点
node_content = await cls._convert_node_to_satori_static(component)
if node_content:
content_parts.append(node_content)
elif isinstance(component, Image):
try:
image_base64 = await component.convert_to_base64()
if image_base64:
content_parts.append(
f'<img src="data:image/jpeg;base64,{image_base64}"/>'
)
except Exception as e:
logger.error(f"图片转换为base64失败: {e}")
elif isinstance(component, File):
content_parts.append(
f'<file src="{component.file}" name="{component.name or "文件"}"/>'
)
elif isinstance(component, Record):
try:
record_base64 = await component.convert_to_base64()
if record_base64:
content_parts.append(
f'<audio src="data:audio/wav;base64,{record_base64}"/>'
)
except Exception as e:
logger.error(f"语音转换为base64失败: {e}")
elif isinstance(component, Reply):
content_parts.append(f'<reply id="{component.id}"/>')
elif isinstance(component, Video):
try:
video_path_url = await component.convert_to_file_path()
if video_path_url:
content_parts.append(f'<video src="{video_path_url}"/>')
except Exception as e:
logger.error(f"视频文件转换失败: {e}")
elif isinstance(component, Nodes):
# 合并转发消息
node_content = await cls._convert_nodes_to_satori_static(component)
if node_content:
content_parts.append(node_content)
content = "".join(content_parts)
channel_id = session_id
@@ -138,55 +118,22 @@ class SatoriPlatformEvent(AstrMessageEvent):
content_parts = []
for component in message.chain:
if isinstance(component, Plain):
text = (
component.text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
content_parts.append(text)
component_content = await self._convert_component_to_satori(component)
if component_content:
content_parts.append(component_content)
elif isinstance(component, At):
if component.qq:
content_parts.append(f'<at id="{component.qq}"/>')
elif component.name:
content_parts.append(f'<at name="{component.name}"/>')
# 特殊处理 Node 和 Nodes 组件
if isinstance(component, Node):
# 单个转发节点
node_content = await self._convert_node_to_satori(component)
if node_content:
content_parts.append(node_content)
elif isinstance(component, Image):
try:
image_base64 = await component.convert_to_base64()
if image_base64:
content_parts.append(
f'<img src="data:image/jpeg;base64,{image_base64}"/>'
)
except Exception as e:
logger.error(f"图片转换为base64失败: {e}")
elif isinstance(component, File):
content_parts.append(
f'<file src="{component.file}" name="{component.name or "文件"}"/>'
)
elif isinstance(component, Record):
try:
record_base64 = await component.convert_to_base64()
if record_base64:
content_parts.append(
f'<audio src="data:audio/wav;base64,{record_base64}"/>'
)
except Exception as e:
logger.error(f"语音转换为base64失败: {e}")
elif isinstance(component, Reply):
content_parts.append(f'<reply id="{component.id}"/>')
elif isinstance(component, Video):
try:
video_path_url = await component.convert_to_file_path()
if video_path_url:
content_parts.append(f'<video src="{video_path_url}"/>')
except Exception as e:
logger.error(f"视频文件转换失败: {e}")
elif isinstance(component, Nodes):
# 合并转发消息
node_content = await self._convert_nodes_to_satori(component)
if node_content:
content_parts.append(node_content)
content = "".join(content_parts)
channel_id = self.session_id
@@ -250,3 +197,227 @@ class SatoriPlatformEvent(AstrMessageEvent):
logger.error(f"Satori 流式消息发送异常: {e}")
return await super().send_streaming(generator, use_fallback)
async def _convert_component_to_satori(self, component) -> str:
"""将单个消息组件转换为 Satori 格式"""
try:
if isinstance(component, Plain):
text = (
component.text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
return text
elif isinstance(component, At):
if component.qq:
return f'<at id="{component.qq}"/>'
elif component.name:
return f'<at name="{component.name}"/>'
elif isinstance(component, Image):
try:
image_base64 = await component.convert_to_base64()
if image_base64:
return f'<img src="data:image/jpeg;base64,{image_base64}"/>'
except Exception as e:
logger.error(f"图片转换为base64失败: {e}")
elif isinstance(component, File):
return (
f'<file src="{component.file}" name="{component.name or "文件"}"/>'
)
elif isinstance(component, Record):
try:
record_base64 = await component.convert_to_base64()
if record_base64:
return f'<audio src="data:audio/wav;base64,{record_base64}"/>'
except Exception as e:
logger.error(f"语音转换为base64失败: {e}")
elif isinstance(component, Reply):
return f'<reply id="{component.id}"/>'
elif isinstance(component, Video):
try:
video_path_url = await component.convert_to_file_path()
if video_path_url:
return f'<video src="{video_path_url}"/>'
except Exception as e:
logger.error(f"视频文件转换失败: {e}")
elif isinstance(component, Forward):
return f'<message id="{component.id}" forward/>'
# 对于其他未处理的组件类型,返回空字符串
return ""
except Exception as e:
logger.error(f"转换消息组件失败: {e}")
return ""
async def _convert_node_to_satori(self, node: Node) -> str:
"""将单个转发节点转换为 Satori 格式"""
try:
content_parts = []
if node.content:
for content_component in node.content:
component_content = await self._convert_component_to_satori(
content_component
)
if component_content:
content_parts.append(component_content)
content = "".join(content_parts)
# 如果内容为空,添加默认内容
if not content.strip():
content = "[转发消息]"
# 构建 Satori 格式的转发节点
author_attrs = []
if node.uin:
author_attrs.append(f'id="{node.uin}"')
if node.name:
author_attrs.append(f'name="{node.name}"')
author_attr_str = " ".join(author_attrs)
return f"<message><author {author_attr_str}/>{content}</message>"
except Exception as e:
logger.error(f"转换转发节点失败: {e}")
return ""
@classmethod
async def _convert_component_to_satori_static(cls, component) -> str:
"""将单个消息组件转换为 Satori 格式"""
try:
if isinstance(component, Plain):
text = (
component.text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
return text
elif isinstance(component, At):
if component.qq:
return f'<at id="{component.qq}"/>'
elif component.name:
return f'<at name="{component.name}"/>'
elif isinstance(component, Image):
try:
image_base64 = await component.convert_to_base64()
if image_base64:
return f'<img src="data:image/jpeg;base64,{image_base64}"/>'
except Exception as e:
logger.error(f"图片转换为base64失败: {e}")
elif isinstance(component, File):
return (
f'<file src="{component.file}" name="{component.name or "文件"}"/>'
)
elif isinstance(component, Record):
try:
record_base64 = await component.convert_to_base64()
if record_base64:
return f'<audio src="data:audio/wav;base64,{record_base64}"/>'
except Exception as e:
logger.error(f"语音转换为base64失败: {e}")
elif isinstance(component, Reply):
return f'<reply id="{component.id}"/>'
elif isinstance(component, Video):
try:
video_path_url = await component.convert_to_file_path()
if video_path_url:
return f'<video src="{video_path_url}"/>'
except Exception as e:
logger.error(f"视频文件转换失败: {e}")
elif isinstance(component, Forward):
return f'<message id="{component.id}" forward/>'
# 对于其他未处理的组件类型,返回空字符串
return ""
except Exception as e:
logger.error(f"转换消息组件失败: {e}")
return ""
@classmethod
async def _convert_node_to_satori_static(cls, node: Node) -> str:
"""将单个转发节点转换为 Satori 格式"""
try:
content_parts = []
if node.content:
for content_component in node.content:
component_content = await cls._convert_component_to_satori_static(
content_component
)
if component_content:
content_parts.append(component_content)
content = "".join(content_parts)
# 如果内容为空,添加默认内容
if not content.strip():
content = "[转发消息]"
author_attrs = []
if node.uin:
author_attrs.append(f'id="{node.uin}"')
if node.name:
author_attrs.append(f'name="{node.name}"')
author_attr_str = " ".join(author_attrs)
return f"<message><author {author_attr_str}/>{content}</message>"
except Exception as e:
logger.error(f"转换转发节点失败: {e}")
return ""
async def _convert_nodes_to_satori(self, nodes: Nodes) -> str:
"""将多个转发节点转换为 Satori 格式的合并转发"""
try:
node_parts = []
for node in nodes.nodes:
node_content = await self._convert_node_to_satori(node)
if node_content:
node_parts.append(node_content)
if node_parts:
return f"<message forward>{''.join(node_parts)}</message>"
else:
return ""
except Exception as e:
logger.error(f"转换合并转发消息失败: {e}")
return ""
@classmethod
async def _convert_nodes_to_satori_static(cls, nodes: Nodes) -> str:
"""将多个转发节点转换为 Satori 格式的合并转发"""
try:
node_parts = []
for node in nodes.nodes:
node_content = await cls._convert_node_to_satori_static(node)
if node_content:
node_parts.append(node_content)
if node_parts:
return f"<message forward>{''.join(node_parts)}</message>"
else:
return ""
except Exception as e:
logger.error(f"转换合并转发消息失败: {e}")
return ""