diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index b3f876b684..ca5b97f8a5 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -441,6 +441,22 @@ async def convert_to_file_path(self) -> str: pass raise Exception(f"not a valid file: {file_source}") + async def convert_to_base64(self) -> str: + """将视频统一转换为 base64 编码。 + + Returns: + str: 视频的 base64 编码,不以 base64:// 开头。 + + """ + file_source = await self._resolve_file_source() + if not file_source: + raise Exception(f"not a valid file: {self.file}") + return await MediaResolver( + file_source, + media_type="video", + default_suffix=".mp4", + ).to_base64() + async def register_to_file_service(self) -> str: """将视频注册到文件服务。 @@ -793,8 +809,7 @@ def __init__(self, content: list[BaseMessageComponent], **_) -> None: async def to_dict(self) -> dict: data_content = [] for comp in self.content: - if isinstance(comp, Image | Record): - # For Image and Record segments, we convert them to base64 + if isinstance(comp, Image | Record | Video | File): bs64 = await comp.convert_to_base64() data_content.append( { @@ -1043,6 +1058,18 @@ async def get_file(self, allow_return_url: bool = False) -> str: return "" + async def convert_to_base64(self) -> str: + """将文件统一转换为 base64 编码。 + + Returns: + str: 文件的 base64 编码,不以 base64:// 开头。 + + """ + file_source = await self.get_file(allow_return_url=True) + if not file_source: + raise Exception(f"not a valid file: {self.file_ or self.url}") + return await MediaResolver(file_source, media_type="file").to_base64() + async def _download_file(self) -> None: """下载文件""" if not self.url: diff --git a/astrbot/core/platform/message_capabilities.py b/astrbot/core/platform/message_capabilities.py index d6faf6567a..67c7699466 100644 --- a/astrbot/core/platform/message_capabilities.py +++ b/astrbot/core/platform/message_capabilities.py @@ -148,7 +148,7 @@ media=_MEDIA, mixed_parts=False, quote=False, - forward=True, + forward=False, mention=True, native_namespaces=frozenset({"webchat"}), ), diff --git a/astrbot/core/platform/message_delivery.py b/astrbot/core/platform/message_delivery.py index 8472a6fa28..8f740f463e 100644 --- a/astrbot/core/platform/message_delivery.py +++ b/astrbot/core/platform/message_delivery.py @@ -2,24 +2,33 @@ from __future__ import annotations -from collections.abc import Iterable, Mapping +from collections.abc import Iterable, Mapping, Sequence from dataclasses import replace from astrbot.core.message.components import ( BaseMessageComponent, ComponentTypes, + Contact, + Face, File, Image, + Json, + Location, Mention, MentionAll, + MFace, + Node, + Nodes, Plain, Record, Reply, Video, ) +from astrbot.core.message.json_card import format_json_card_prompt from astrbot.core.message.message_event_result import MessageChain +from astrbot.core.message.qq_face import format_qq_face -from .message_i18n import DEFAULT_LOCALE +from .message_i18n import DEFAULT_LOCALE, LOCALES, localize, message_key from .message_protocol import ( ContentKind, DeliveryBatch, @@ -28,9 +37,34 @@ MessageEnvelope, NativeContent, PortablePart, + QuoteReference, + SenderSnapshot, plan_delivery, ) +_REPLAY_NATIVE_KINDS = frozenset( + { + "face", + "mface", + "json", + "poke", + "markdown", + "miniapp", + "xml", + "dice", + "rps", + "shake", + "share", + "music", + } +) +_UNKNOWN_AUTHOR_TEXTS = frozenset( + { + message_key("unknown_author"), + *(bundle["astrbot.msg.unknown_author"] for bundle in LOCALES.values()), + } +) + def _media_component(kind: ContentKind, value: MediaReference) -> BaseMessageComponent: """Create a transport-neutral media component from a resolved reference.""" @@ -79,6 +113,239 @@ def _native_component(native: NativeContent) -> BaseMessageComponent | None: return None +def _item_sender(item: PortablePart | NativeContent) -> SenderSnapshot | None: + return item.sender + + +def _is_author_separator(item: PortablePart | NativeContent, locale: str) -> bool: + if ( + not isinstance(item, PortablePart) + or item.kind != ContentKind.TEXT + or item.sender is None + ): + return False + value = str(item.value) + if value in _UNKNOWN_AUTHOR_TEXTS: + return True + if value == localize(locale, "astrbot.msg.unknown_author"): + return True + sender = item.sender + return value in {f"[{sender.name}]\n", f"[{sender.id}]\n"} + + +def _split_sender_islands( + content: Sequence[PortablePart | NativeContent], +) -> list[tuple[bool, list[PortablePart | NativeContent]]]: + islands: list[tuple[bool, list[PortablePart | NativeContent]]] = [] + for item in content: + has_sender = _item_sender(item) is not None + if islands and islands[-1][0] == has_sender: + islands[-1][1].append(item) + else: + islands.append((has_sender, [item])) + return islands + + +def _native_flatten_text(native: NativeContent) -> str | None: + component = _native_component(native) + if isinstance(component, Face): + return format_qq_face(component.id) + if isinstance(component, Json): + return format_json_card_prompt(component) + if isinstance(component, MFace): + return component.summary or "[MFace]" + return None + + +def _enrich_native_fallbacks(envelope: MessageEnvelope) -> MessageEnvelope: + content: list[PortablePart | NativeContent] = [] + for item in envelope.content: + if isinstance(item, NativeContent): + richer = _native_flatten_text(item) + if richer is not None and richer != item.fallback: + item = replace(item, fallback=richer) + content.append(item) + return replace(envelope, content=tuple(content)) + + +def _plan_flat( + envelope: MessageEnvelope, + capabilities: MessageDeliveryCapabilities, + *, + quote_id: str | None, + locale: str, +) -> tuple[MessageChain, ...]: + envelope = _enrich_native_fallbacks(envelope) + return tuple( + batch_to_message_chain(batch, capabilities=capabilities, quote_id=quote_id) + for batch in plan_delivery(envelope, capabilities, locale=locale) + ) + + +def _node_identity(sender: SenderSnapshot, locale: str) -> tuple[str, str]: + uin = sender.id or "0" + name = sender.name or localize(locale, "astrbot.msg.unknown_sender") + return uin, name + + +def _as_float(value: object) -> float | None: + if isinstance(value, bool) or not isinstance(value, int | float | str): + return None + try: + return float(value) + except ValueError: + return None + + +def _as_int(value: object) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, str) and value: + try: + return int(value) + except ValueError: + return None + return None + + +def _part_mapping(value: object) -> Mapping[str, object]: + return value if isinstance(value, Mapping) else {} + + +def _location_component(value: object) -> Location | None: + data = _part_mapping(value) + lat = _as_float(data.get("lat")) + lon = _as_float(data.get("lon")) + if lat is None or lon is None: + return None + return Location(lat=lat, lon=lon, title=str(data.get("title") or "")) + + +def _contact_component(value: object) -> Contact | None: + data = _part_mapping(value) + raw_id = data.get("id") + if raw_id is None or raw_id == "": + contact_id = None + else: + contact_id = _as_int(raw_id) + if contact_id is None: + return None + return Contact(_type=str(data.get("type") or ""), id=contact_id) + + +def _portable_node_component( + part: PortablePart, *, cross_session: bool +) -> BaseMessageComponent | None: + if part.kind == ContentKind.LOCATION: + return _location_component(part.value) + if part.kind == ContentKind.CONTACT: + return _contact_component(part.value) + if part.kind == ContentKind.MENTION and cross_session: + data = ( + part.value if isinstance(part.value, Mapping) else {"id": str(part.value)} + ) + name = str(data.get("name") or "") or str(data.get("id") or "") + return Plain(f"@{name}") + if part.kind == ContentKind.MENTION_ALL and cross_session: + return Plain("@all") + return _portable_component(part) + + +def _item_to_node_component( + item: PortablePart | NativeContent, *, cross_session: bool +) -> BaseMessageComponent | None: + if isinstance(item, NativeContent): + if item.kind not in _REPLAY_NATIVE_KINDS: + return None + return _native_component(item) + return _portable_node_component(item, cross_session=cross_session) + + +def _reconstruct_forward_chain( + items: Sequence[PortablePart | NativeContent], + *, + locale: str, + cross_session: bool, +) -> MessageChain | None: + nodes: list[Node] = [] + current: Node | None = None + + def close_node() -> None: + nonlocal current + if current is not None and current.content: + nodes.append(current) + current = None + + def open_node(sender: SenderSnapshot) -> None: + nonlocal current + close_node() + uin, name = _node_identity(sender, locale) + current = Node(content=[], uin=uin, name=name) + + for item in items: + sender = _item_sender(item) + if _is_author_separator(item, locale): + if sender is not None: + open_node(sender) + continue + if current is None: + if sender is None: + continue + open_node(sender) + component = _item_to_node_component(item, cross_session=cross_session) + if component is not None and current is not None: + current.content.append(component) + close_node() + if not nodes: + return None + return MessageChain([Nodes(nodes=nodes)]).use_markdown(False) + + +def _with_quote( + chain: MessageChain, + quote: QuoteReference | None, + *, + capabilities: MessageDeliveryCapabilities, + quote_id: str | None, +) -> MessageChain: + if quote is None: + return chain + if capabilities.quote and quote_id: + return MessageChain([Reply(id=quote_id), *chain.chain]).use_markdown(False) + preview = quote.preview or quote.message_id + return MessageChain([Plain(f"> {preview}\n"), *chain.chain]).use_markdown(False) + + +def _deliver_island( + items: Sequence[PortablePart | NativeContent], + *, + envelope: MessageEnvelope, + capabilities: MessageDeliveryCapabilities, + quote_id: str | None, + locale: str, + cross_session: bool, + has_sender: bool, +) -> tuple[MessageChain, ...]: + sub_envelope = replace(envelope, content=tuple(items)) + if not has_sender: + return _plan_flat(sub_envelope, capabilities, quote_id=quote_id, locale=locale) + reconstructed = _reconstruct_forward_chain( + items, locale=locale, cross_session=cross_session + ) + if reconstructed is None: + return _plan_flat(sub_envelope, capabilities, quote_id=quote_id, locale=locale) + return ( + _with_quote( + reconstructed, + sub_envelope.quote, + capabilities=capabilities, + quote_id=quote_id, + ), + ) + + def batch_to_message_chain( batch: DeliveryBatch, *, @@ -118,11 +385,13 @@ def plan_message_delivery( locale: str = DEFAULT_LOCALE, ) -> tuple[MessageChain, ...]: """Return ordered target chains, preserving Planner batch boundaries.""" - if target_umo != envelope.source_route.as_origin(): + cross_session = target_umo != envelope.source_route.as_origin() + if cross_session: capabilities = replace(capabilities, mention=False) + root_capabilities = capabilities if target_umo and target_umo.split(":", 1)[0] != envelope.source_route.platform_id: - capabilities = replace(capabilities, native_namespaces=frozenset()) - if envelope.quote and not (capabilities.quote and quote_id): + root_capabilities = replace(capabilities, native_namespaces=frozenset()) + if envelope.quote and not (root_capabilities.quote and quote_id): preview = envelope.quote.preview or envelope.quote.message_id envelope = replace( envelope, @@ -132,10 +401,33 @@ def plan_message_delivery( *envelope.content, ), ) - return tuple( - batch_to_message_chain(batch, capabilities=capabilities, quote_id=quote_id) - for batch in plan_delivery(envelope, capabilities, locale=locale) - ) + has_forwarded = any(_item_sender(item) is not None for item in envelope.content) + if not capabilities.forward or not has_forwarded: + return _plan_flat(envelope, root_capabilities, quote_id=quote_id, locale=locale) + + chains: list[MessageChain] = [] + first = True + remaining_quote = envelope.quote + remaining_quote_id = quote_id + for has_sender, items in _split_sender_islands(envelope.content): + chains.extend( + _deliver_island( + items, + envelope=replace( + envelope, + quote=remaining_quote if first else None, + ), + capabilities=root_capabilities, + quote_id=remaining_quote_id if first else None, + locale=locale, + cross_session=cross_session, + has_sender=has_sender, + ) + ) + first = False + remaining_quote = None + remaining_quote_id = None + return tuple(chains) def iter_delivery_parts(batches: Iterable[DeliveryBatch]) -> Iterable[PortablePart]: diff --git a/astrbot/core/platform/message_media.py b/astrbot/core/platform/message_media.py index a7030f116e..2fcff53a8f 100644 --- a/astrbot/core/platform/message_media.py +++ b/astrbot/core/platform/message_media.py @@ -127,6 +127,7 @@ async def materialize_message_media( PortablePart( ContentKind.TEXT, localize(locale, "astrbot.msg.unavailable", label=label), + sender=part.sender, ) ) yield replace(envelope, content=tuple(content)) diff --git a/astrbot/core/platform/message_projection.py b/astrbot/core/platform/message_projection.py index 0dd6c97950..979be35bab 100644 --- a/astrbot/core/platform/message_projection.py +++ b/astrbot/core/platform/message_projection.py @@ -207,6 +207,8 @@ def envelope_from_event(event: AstrMessageEvent) -> MessageEnvelope: part = replace(part, sender=forwarded_sender) content.append(part) if native_component is not None: + if forwarded_sender is not None: + native_component = replace(native_component, sender=forwarded_sender) content.append(native_component) sender = SenderSnapshot( diff --git a/astrbot/core/platform/message_protocol.py b/astrbot/core/platform/message_protocol.py index 7819b30675..49a448913b 100644 --- a/astrbot/core/platform/message_protocol.py +++ b/astrbot/core/platform/message_protocol.py @@ -91,6 +91,7 @@ class NativeContent: kind: str payload: str fallback: str = "" + sender: SenderSnapshot | None = None @dataclass(frozen=True, slots=True) diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py index 395c40b4b6..3a878cf247 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py @@ -27,6 +27,19 @@ from .aiocqhttp_message_event import AiocqhttpMessageEvent +def _mface_package_id(value: object) -> int | float | None: + if isinstance(value, bool): + return None + if isinstance(value, int | float): + return value + if isinstance(value, str): + try: + return float(value) if "." in value else int(value) + except ValueError: + return None + return None + + def _payload_field(payload: Any, name: str) -> Any: if payload is None: return None @@ -373,7 +386,28 @@ async def _resolve_file_url( message_str += "".join(at_parts) elif t == "mface": - continue + for m in m_group: + data = m.get("data") + if not isinstance(data, dict): + continue + emoji_id = data.get("emoji_id") + key = data.get("key") + summary = data.get("summary") + emoji_package_id = _mface_package_id(data.get("emoji_package_id")) + if ( + isinstance(emoji_id, str) + and isinstance(key, str) + and isinstance(summary, str) + and emoji_package_id is not None + ): + abm.message.append( + MFace( + emoji_package_id=emoji_package_id, + emoji_id=emoji_id, + key=key, + summary=summary, + ) + ) elif t == "markdown": for m in m_group: text = m["data"].get("markdown") or m["data"].get("content", "") diff --git a/docs/en/dev/plugin-platform-adapter.md b/docs/en/dev/plugin-platform-adapter.md index 895211bb55..c8d51687cc 100644 --- a/docs/en/dev/plugin-platform-adapter.md +++ b/docs/en/dev/plugin-platform-adapter.md @@ -220,7 +220,7 @@ make napcat-check ## Cross-platform message delivery -Cross-session watches capture inbound semantics in `MessageEnvelope`. Its `content` is the only ordered sequence. `source_route` retains the transport route, while `source_umo` retains the inbound session identity. The source adapter resolves private file IDs and deferred media; the target owns uploads and protocol encoding. Forwarded transcripts are expanded with author labels and their original media order. +Cross-session watches capture inbound semantics in `MessageEnvelope`. Its `content` is the only ordered sequence. `source_route` retains the transport route, while `source_umo` retains the inbound session identity. The source adapter resolves private file IDs and deferred media; the target owns uploads and protocol encoding. Projection flattens merged forwards. When the target `MessageDeliveryCapabilities.forward` is true, the delivery planner reconstructs sender-tagged fragments as one-level `Nodes`. Otherwise delivery stays a labeled transcript and node-internal Face / Json / MFace become summaries. WebChat sets `forward` to false and does not receive merged-forward cards. Adapters return immutable `MessageDeliveryCapabilities` from `message_capabilities(session)`. Plugin adapters can import these types from `astrbot.api.platform` and override the method. Describe the current sender, account mode, and target session instead of copying a platform protocol's theoretical capabilities. Native JSON snapshots must match both namespace and content kind. Delivery across Bot instances defaults to fallback; matching platform names alone does not make private payloads reusable. diff --git a/docs/en/use/command.md b/docs/en/use/command.md index fc37749179..0d5fa2adbd 100644 --- a/docs/en/use/command.md +++ b/docs/en/use/command.md @@ -85,7 +85,7 @@ The user ID from `/session info` can be granted current-session `session_admin` Watching, connecting, pairing, and sending require the current identity to hold `instance_operator` permission in the configuration shared by both sessions. Group admin or private-session ownership does not grant this access. Forwarded content is visible to everyone in the receiving session. Watches, links, and pairs forward new incoming messages only, without reading history. Empty match and except filters forward every human message. `watch` / `connect` / `pair` do not accept filter flags; set filters afterwards with `/session filter`. Subject filters rebuild `Subject.im` from the source route platform id, the source adapter `self_id`, and `SenderSnapshot.id`. Text filters concatenate `PortablePart` string values and ignore media and `NativeContent`; a media-only body fails `match.text` and does not trip `except.text`. Rules are stored in SQLite, so unexpired watches, all connects, and all pairs survive process restart; expired watches are cleared at startup or when they elapse, and the listener is notified. Limits remain 16 watches, 16 links, and 8 pairs per actor. Authorization is checked again for each forwarded message, so revocation stops delivery. Running `/session watch` again on the same endpoints keeps the `rule_id` and resets the duration. A watch and a connect on the same direction replace each other. If either target direction is already a pair, `watch` / `connect` is refused and asks for `unpair` first; the pair is not split. -Text and media retain their message-chain order; targets without mixed-content delivery receive separate messages. Cross-platform mentions become text. Quotes use accepted-message ID mappings when available and otherwise become a quote summary. Unavailable attachments and unsupported native content leave text placeholders. Platform cards, private syntax, and mini apps cannot be guaranteed to reproduce on another platform. +Text and media retain their message-chain order; targets without mixed-content delivery receive separate messages. Cross-platform mentions become text. Quotes use accepted-message ID mappings when available and otherwise become a quote summary. Unavailable attachments and unsupported native content leave text placeholders. Platform cards, private syntax, and mini apps cannot be guaranteed to reproduce on another platform. When the source is an expanded merged forward and the target declares `forward`, the far side sees a merged-forward card: node authors are the original node `uin` / `name`, and the speaking account is still the destination Bot. Targets that do not support it still receive the labeled transcript. `send` arguments still follow the Orbit syntax above. Its body is taken from the original message chain after removing the command header, preserving original text and attachment positions. Results distinguish platform acceptance, partial acceptance, rejection, and unknown status. Acceptance does not imply a read receipt. Check the target session before retrying partial or unknown submissions. diff --git a/docs/zh/dev/plugin-platform-adapter.md b/docs/zh/dev/plugin-platform-adapter.md index a7dbd52969..a4b022894a 100644 --- a/docs/zh/dev/plugin-platform-adapter.md +++ b/docs/zh/dev/plugin-platform-adapter.md @@ -220,7 +220,7 @@ make napcat-check ## 跨平台消息投递 -跨会话监听使用 `MessageEnvelope` 保存入站语义快照。`content` 是唯一的有序内容序列;`source_route` 保留传输路由,`source_umo` 保留入站会话身份,不能混为一个可变字段。源适配器负责解析私有文件 ID 和延迟媒体引用,目标适配器负责上传和协议编码。合并转发记录展开后保留作者标签和媒体顺序。 +跨会话监听使用 `MessageEnvelope` 保存入站语义快照。`content` 是唯一的有序内容序列;`source_route` 保留传输路由,`source_umo` 保留入站会话身份,不能混为一个可变字段。源适配器负责解析私有文件 ID 和延迟媒体引用,目标适配器负责上传和协议编码。投影层会把合并转发展平;目标 `MessageDeliveryCapabilities.forward` 为真时,投递规划器把带发送者快照的片段重建为单层 `Nodes`。否则保持带作者标签的线性转录,并把节点内 Face / Json / MFace 做成摘要。WebChat 的 `forward` 为假,不接收合并转发卡片。 适配器通过 `message_capabilities(session)` 返回不可变的 `MessageDeliveryCapabilities`。插件适配器可以从 `astrbot.api.platform` 导入这些类型并覆写该方法。描述必须反映当前发送路径、账号模式和目标会话,不应直接照搬平台协议能力。支持的原生消息需要同时匹配命名空间与内容种类;跨 Bot 实例默认降级,不能仅凭平台名称复用私有 payload。 diff --git a/docs/zh/use/command.md b/docs/zh/use/command.md index d1bc82c9cb..47fb448d76 100644 --- a/docs/zh/use/command.md +++ b/docs/zh/use/command.md @@ -85,7 +85,7 @@ Orbit 不执行变量、命令、算术或波浪号展开,也不执行 glob、 监听、连接、配对和发送要求当前身份拥有同一配置下的 `instance_operator` 权限。群管理员、私聊会话所有者身份不能替代它。监听内容对接收会话的所有成员可见;仅转发开始监听之后收到的消息,不读取历史。空的 match 与 except 会转发全部真人消息。`watch` / `connect` / `pair` 创建指令不解析过滤 flag,事后用 `/session filter` 设置。主体过滤用 `Subject.im` 重建:平台实例取源路由 `platform_id`,`bot_account_id` 取源适配器 `self_id`,`sender_id` 取 `SenderSnapshot.id`。正文只拼接 `PortablePart` 文本,忽略媒体和 `NativeContent`;纯媒体在写了 `match.text` 时不转,只写 `except.text` 则放行。规则写入 SQLite,进程重启后未过期的监听、全部连接和全部 pair 仍在;过期监听会在启动或到期时清除并通知监听端。每人最多 16 条监听、16 条连接和 8 对 pair。每次转发都会重新检查权限,撤权后停止投递。对同一对会话再次 `/session watch` 会保留 `rule_id` 并重置时长。同一方向的 watch 与 connect 会互相替换。任一条目标方向已是 pair 时,`watch` / `connect` 会拒绝并提示先 `unpair`,不会拆成半对。 -正文保留消息链中的图文先后顺序;不能混排的目标拆成多条消息。跨平台提及转成文字,引用优先通过已接受消息的 ID 映射还原;映射不存在时附引用摘要。无法解析的附件和不支持的原生内容会保留文字占位。平台自己的卡片、私有语法和小程序不能保证在别的平台重现。 +正文保留消息链中的图文先后顺序;不能混排的目标拆成多条消息。跨平台提及转成文字,引用优先通过已接受消息的 ID 映射还原;映射不存在时附引用摘要。无法解析的附件和不支持的原生内容会保留文字占位。平台自己的卡片、私有语法和小程序不能保证在别的平台重现。源消息是已展开的合并转发、且目标声明 `forward` 时,对岸看到合并转发卡片:节点作者是原节点的 `uin` / `name`,发言账号仍是目标 Bot。目标不支持时仍为带作者标签的线性转录。 `send` 的参数仍遵循上文 Orbit 语法;正文从消息链中移除指令头后提取,保留原文和附件位置。返回结果区分平台接受、部分接受、拒绝和状态未知;“接受”不表示收件人已读。部分接受或未知时,请先检查目标会话再决定是否重发。 diff --git a/tests/unit/test_aiocqhttp_adapter.py b/tests/unit/test_aiocqhttp_adapter.py index 0e616f0814..d988e4e715 100644 --- a/tests/unit/test_aiocqhttp_adapter.py +++ b/tests/unit/test_aiocqhttp_adapter.py @@ -8,7 +8,7 @@ import pytest from astrbot.core.command import CommandCatalogStore -from astrbot.core.message.components import Mention, MentionAll, Plain, Reply +from astrbot.core.message.components import Mention, MentionAll, MFace, Plain, Reply from astrbot.core.pipeline.waking_check.stage import WakingCheckStage from astrbot.core.platform.astrbot_message import AstrBotMessage, MessageMember from astrbot.core.platform.message_type import MessageType @@ -465,3 +465,103 @@ async def test_aiocqhttp_outbound_mention_target_all_is_plain_text(): assert everyone == {"type": "at", "data": {"qq": "all"}} assert sentinel == {"type": "text", "data": {"text": "@all"}} assert user == {"type": "at", "data": {"qq": "10001"}} + + +@pytest.mark.asyncio +async def test_aiocqhttp_mface_conversion_keeps_market_face(monkeypatch): + from tests.fixtures.mocks.aiocqhttp import create_mock_aiocqhttp_modules + + mock_aiocqhttp = create_mock_aiocqhttp_modules() + mock_aiocqhttp.CQHttp = _FakeCQHttp + monkeypatch.setitem(sys.modules, "aiocqhttp", mock_aiocqhttp) + monkeypatch.setitem(sys.modules, "aiocqhttp.exceptions", mock_aiocqhttp.exceptions) + + from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_platform_adapter import ( + AiocqhttpAdapter, + ) + + adapter = AiocqhttpAdapter.__new__(AiocqhttpAdapter) + event = _FakeEvent( + { + "post_type": "message", + "message_type": "group", + "group_id": 654321, + "self_id": 123456, + "message_id": 780, + "message": [ + { + "type": "mface", + "data": { + "emoji_package_id": 1, + "emoji_id": "eid", + "key": "key", + "summary": "wow", + }, + } + ], + "sender": { + "user_id": 111222, + "nickname": "tester", + "card": "tester-card", + }, + } + ) + + abm = await adapter._convert_handle_message_event(event) + + assert len(abm.message) == 1 + face = abm.message[0] + assert isinstance(face, MFace) + assert face.emoji_package_id == 1 + assert face.emoji_id == "eid" + assert face.key == "key" + assert face.summary == "wow" + + +@pytest.mark.asyncio +async def test_aiocqhttp_mface_conversion_accepts_string_package_id(monkeypatch): + from tests.fixtures.mocks.aiocqhttp import create_mock_aiocqhttp_modules + + mock_aiocqhttp = create_mock_aiocqhttp_modules() + mock_aiocqhttp.CQHttp = _FakeCQHttp + monkeypatch.setitem(sys.modules, "aiocqhttp", mock_aiocqhttp) + monkeypatch.setitem(sys.modules, "aiocqhttp.exceptions", mock_aiocqhttp.exceptions) + + from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_platform_adapter import ( + AiocqhttpAdapter, + ) + + adapter = AiocqhttpAdapter.__new__(AiocqhttpAdapter) + event = _FakeEvent( + { + "post_type": "message", + "message_type": "group", + "group_id": 654321, + "self_id": 123456, + "message_id": 781, + "message": [ + { + "type": "mface", + "data": { + "emoji_package_id": "1", + "emoji_id": "eid", + "key": "key", + "summary": "wow", + }, + }, + {"type": "mface", "data": "bad"}, + ], + "sender": { + "user_id": 111222, + "nickname": "tester", + "card": "tester-card", + }, + } + ) + + abm = await adapter._convert_handle_message_event(event) + + assert len(abm.message) == 1 + face = abm.message[0] + assert isinstance(face, MFace) + assert face.emoji_package_id == 1 diff --git a/tests/unit/test_message_component_serialization.py b/tests/unit/test_message_component_serialization.py index 778abd751d..c79e781ce8 100644 --- a/tests/unit/test_message_component_serialization.py +++ b/tests/unit/test_message_component_serialization.py @@ -7,6 +7,7 @@ Anonymous, BaseMessageComponent, ComponentTypes, + File, FlashTransfer, Forward, Mention, @@ -17,6 +18,7 @@ Plain, Poke, Reply, + Video, ) from astrbot.core.message.message_event_result import MessageEventResult @@ -158,3 +160,26 @@ async def test_node_to_dict_does_not_map_mention_target_all_to_everyone(): {"type": "text", "data": {"text": "@all"}}, {"type": "text", "data": {"text": "hi"}}, ] + + +@pytest.mark.asyncio +async def test_node_to_dict_encodes_video_and_file_as_base64(tmp_path): + video_payload = "aGVsbG8=" + file_path = tmp_path / "clip.bin" + file_path.write_bytes(b"hello") + node = Node( + uin="10001", + name="Mock Sender", + content=[ + Video.fromBase64(video_payload), + File(name="clip.bin", file=str(file_path)), + ], + ) + + payload = await node.to_dict() + content = payload["data"]["content"] + + assert content[0]["type"] == "video" + assert content[0]["data"]["file"].startswith("base64://") + assert content[1]["type"] == "file" + assert content[1]["data"]["file"].startswith("base64://") diff --git a/tests/unit/test_message_protocol.py b/tests/unit/test_message_protocol.py index bd068ce382..39c7f5bfeb 100644 --- a/tests/unit/test_message_protocol.py +++ b/tests/unit/test_message_protocol.py @@ -221,6 +221,7 @@ def test_bundled_platform_capabilities_cover_all_adapter_families() -> None: "weixin_official_account", } assert set(MESSAGE_CAPABILITIES) == expected + assert MESSAGE_CAPABILITIES["webchat"].forward is False def test_line_and_slack_capabilities_match_message_payload_limits() -> None: @@ -690,6 +691,44 @@ async def test_media_resolution_failure_preserves_order_and_hides_credentials(): ] +@pytest.mark.asyncio +async def test_media_resolution_failure_preserves_sender(): + from astrbot.core.message.components import Plain + from astrbot.core.platform.message_media import materialize_message_media + + sender = SenderSnapshot("1001", "Alice", "napcat") + resolver = AsyncMock(side_effect=ValueError("https://private/?token=secret")) + envelope = MessageEnvelope( + _route(), + content=( + PortablePart(ContentKind.TEXT, "[Alice]\n", sender=sender), + PortablePart( + ContentKind.IMAGE, + MediaReference("", resolve_source=resolver), + sender=sender, + ), + PortablePart(ContentKind.TEXT, "after", sender=sender), + ), + ) + async with materialize_message_media( + envelope, MessageDeliveryCapabilities(media=frozenset({"image"})) + ) as result: + assert result.content[1].kind == ContentKind.TEXT + assert result.content[1].sender == sender + chains = plan_message_delivery( + result, + MESSAGE_CAPABILITIES["napcat"], + target_umo="napcat:GroupMessage:room", + ) + nodes = _forward_nodes(chains) + assert len(nodes) == 1 + texts = [ + part.text for part in nodes[0].nodes[0].content if isinstance(part, Plain) + ] + assert any("无法获取" in text for text in texts) + assert "after" in texts + + def test_forwarded_transcripts_preserve_authors_and_nested_media(): from astrbot.core.message.components import Image, Node, Nodes, Plain from astrbot.core.platform.message_projection import envelope_from_event @@ -1084,3 +1123,491 @@ async def translate(_event, key, **_kwargs): "session.bridge.denied", "session.bridge.denied", ] + + +def _forward_nodes(chains): + from astrbot.core.message.components import Nodes + + found = [] + for chain in chains: + for component in chain.chain: + if isinstance(component, Nodes): + found.append(component) + return found + + +def _napcat_event(components): + event = _event(components=components) + event.get_platform_name = lambda: "napcat" + return event + + +def test_projection_stamps_sender_on_native_forward_parts(): + from astrbot.core.message.components import Face, Json, MFace, Node, Nodes, Plain + from astrbot.core.platform.message_projection import envelope_from_event + + envelope = envelope_from_event( + _napcat_event( + [ + Nodes( + nodes=[ + Node( + name="Alice", + uin="1001", + content=[ + Face(id=111), + MFace( + emoji_package_id=1, + emoji_id="eid", + key="key", + summary="s", + ), + Json(data={"app": "x"}), + Plain("hi"), + ], + ) + ] + ) + ] + ) + ) + natives = [item for item in envelope.content if isinstance(item, NativeContent)] + assert {item.kind for item in natives} == {"face", "mface", "json"} + assert all( + item.sender is not None and item.sender.name == "Alice" for item in natives + ) + + +def test_plan_message_delivery_reconstructs_nodes_when_target_supports_forward(): + from astrbot.core.message.components import Node, Nodes, Plain + from astrbot.core.platform.message_projection import envelope_from_event + from astrbot.core.platform.message_renderers import render_source_header + + envelope = envelope_from_event( + _napcat_event( + [ + Nodes( + nodes=[ + Node(name="Alice", uin="1001", content=[Plain("one")]), + Node(name="Alice", uin="1001", content=[Plain("two")]), + Node(name="Bob", uin="1002", content=[Plain("three")]), + ] + ) + ] + ) + ) + header = PortablePart( + ContentKind.TEXT, + render_source_header(envelope, "napcat"), + ) + chains = plan_message_delivery( + replace_content(envelope, (header, *envelope.content)), + MESSAGE_CAPABILITIES["napcat"], + target_umo="napcat:GroupMessage:room", + ) + nodes = _forward_nodes(chains) + assert len(nodes) == 1 + assert [(node.name, node.uin) for node in nodes[0].nodes] == [ + ("Alice", "1001"), + ("Alice", "1001"), + ("Bob", "1002"), + ] + bodies = [ + "".join(part.text for part in node.content if isinstance(part, Plain)) + for node in nodes[0].nodes + ] + assert bodies == ["one", "two", "three"] + header_text = chains[0].get_plain_text() + assert "来自" in header_text + assert all("[Alice]\n" not in body and "[Bob]\n" not in body for body in bodies) + + +def replace_content(envelope, content): + from dataclasses import replace + + return replace(envelope, content=tuple(content)) + + +def test_plan_message_delivery_splits_sender_islands(): + from astrbot.core.message.components import Node, Nodes, Plain + from astrbot.core.platform.message_projection import envelope_from_event + + forwarded = envelope_from_event( + _napcat_event( + [ + Nodes( + nodes=[ + Node(name="Alice", uin="1001", content=[Plain("first")]), + Node(name="Bob", uin="1002", content=[Plain("second")]), + ] + ) + ] + ) + ) + middle = PortablePart(ContentKind.TEXT, "between") + envelope = replace_content( + forwarded, (*forwarded.content[:2], middle, *forwarded.content[2:]) + ) + chains = plan_message_delivery( + envelope, + MESSAGE_CAPABILITIES["napcat"], + target_umo="napcat:GroupMessage:room", + ) + nodes = _forward_nodes(chains) + assert len(nodes) == 2 + assert [chain.get_plain_text() for chain in chains if chain.get_plain_text()] == [ + "between" + ] + assert nodes[0].nodes[0].name == "Alice" + assert nodes[1].nodes[0].name == "Bob" + + +def test_plan_message_delivery_strips_unknown_author_separator(): + from astrbot.core.message.components import Node, Nodes, Plain + from astrbot.core.platform.message_i18n import message_key + from astrbot.core.platform.message_projection import envelope_from_event + + envelope = envelope_from_event( + _napcat_event([Nodes(nodes=[Node(name="", uin="", content=[Plain("hi")])])]) + ) + assert envelope.content[0].value == message_key("unknown_author") + chains = plan_message_delivery( + envelope, + MESSAGE_CAPABILITIES["napcat"], + target_umo="napcat:GroupMessage:room", + locale="zh-CN", + ) + node = _forward_nodes(chains)[0].nodes[0] + assert node.name == "未知" + assert node.uin == "0" + texts = [part.text for part in node.content if isinstance(part, Plain)] + assert texts == ["hi"] + assert all("[未知]\n" not in text for text in texts) + + +def test_plan_message_delivery_does_not_wrap_ordinary_messages(): + chains = plan_message_delivery( + MessageEnvelope(_route(), content=(PortablePart(ContentKind.TEXT, "hello"),)), + MESSAGE_CAPABILITIES["napcat"], + target_umo="napcat:GroupMessage:room", + ) + assert _forward_nodes(chains) == [] + assert chains[0].get_plain_text() == "hello" + + +def test_plan_message_delivery_keeps_qq_node_segments(): + from astrbot.core.message.components import ( + RPS, + Contact, + Dice, + Face, + Json, + Location, + Markdown, + Mention, + MentionAll, + MFace, + MiniApp, + Music, + Node, + Nodes, + Plain, + Poke, + Shake, + Share, + Xml, + ) + from astrbot.core.platform.message_projection import envelope_from_event + + envelope = envelope_from_event( + _napcat_event( + [ + Nodes( + nodes=[ + Node( + name="Alice", + uin="1001", + content=[ + Face(id=111), + MFace( + emoji_package_id=1, + emoji_id="eid", + key="key", + summary="wow", + ), + Json(data={"app": "x"}), + Poke(id="42"), + Markdown("md"), + MiniApp(data="mini"), + Xml(data=""), + Dice(), + RPS(), + Shake(), + Share(url="https://example.com", title="t"), + Music(_type="qq", id=1), + Location(lat=1.5, lon=2.5, title="park"), + Contact(_type="qq", id=99), + Mention(target="7", name="Bob"), + MentionAll(), + Plain("end"), + ], + ) + ] + ) + ] + ) + ) + chains = plan_message_delivery( + envelope, + MESSAGE_CAPABILITIES["napcat"], + target_umo="napcat:GroupMessage:room", + ) + content = _forward_nodes(chains)[0].nodes[0].content + types = [type(part) for part in content] + assert types[:12] == [ + Face, + MFace, + Json, + Poke, + Markdown, + MiniApp, + Xml, + Dice, + RPS, + Shake, + Share, + Music, + ] + assert isinstance(content[12], Location) + assert content[12].lat == 1.5 + assert content[12].lon == 2.5 + assert content[12].title == "park" + assert content[12].content == "" + assert isinstance(content[13], Contact) + assert content[13].sub_type == "qq" + assert [part.text for part in content if isinstance(part, Plain)] == [ + "@Bob", + "@all", + "end", + ] + + +def test_plan_message_delivery_contact_uses_sub_type(): + from astrbot.core.message.components import Contact, Node, Nodes + from astrbot.core.platform.message_projection import envelope_from_event + + envelope = envelope_from_event( + _napcat_event( + [ + Nodes( + nodes=[ + Node( + name="Alice", + uin="1001", + content=[Contact(_type="group", id=8)], + ) + ] + ) + ] + ) + ) + contact = ( + _forward_nodes( + plan_message_delivery( + envelope, + MESSAGE_CAPABILITIES["napcat"], + target_umo="napcat:GroupMessage:room", + ) + )[0] + .nodes[0] + .content[0] + ) + assert isinstance(contact, Contact) + assert contact.sub_type == "group" + + +def test_plan_message_delivery_omits_invalid_location_and_contact(): + from astrbot.core.message.components import Location, Node, Nodes, Plain + from astrbot.core.platform.message_projection import envelope_from_event + + envelope = envelope_from_event( + _napcat_event( + [ + Nodes( + nodes=[ + Node( + name="Alice", + uin="1001", + content=[ + Location(lat=1.5, lon=2.5, title="park"), + Plain("kept"), + ], + ) + ] + ) + ] + ) + ) + broken = replace_content( + envelope, + ( + envelope.content[0], + PortablePart( + ContentKind.LOCATION, + {"lat": None, "lon": 2}, + sender=envelope.content[1].sender, + ), + PortablePart( + ContentKind.CONTACT, + {"type": "qq", "id": "not-a-number"}, + sender=envelope.content[1].sender, + ), + envelope.content[1], + envelope.content[2], + ), + ) + content = ( + _forward_nodes( + plan_message_delivery( + broken, + MESSAGE_CAPABILITIES["napcat"], + target_umo="napcat:GroupMessage:room", + ) + )[0] + .nodes[0] + .content + ) + assert [type(part) for part in content] == [Location, Plain] + assert content[0].lat == 1.5 + assert [part.text for part in content if isinstance(part, Plain)] == ["kept"] + + +def test_plan_message_delivery_contact_coerces_string_id(): + from astrbot.core.message.components import Contact + + sender = SenderSnapshot("1001", "Alice", "napcat") + envelope = MessageEnvelope( + _route(), + content=( + PortablePart(ContentKind.TEXT, "[Alice]\n", sender=sender), + PortablePart( + ContentKind.CONTACT, {"type": "qq", "id": "99"}, sender=sender + ), + ), + ) + contact = ( + _forward_nodes( + plan_message_delivery( + envelope, + MESSAGE_CAPABILITIES["napcat"], + target_umo="napcat:GroupMessage:room", + ) + )[0] + .nodes[0] + .content[0] + ) + assert isinstance(contact, Contact) + assert contact.sub_type == "qq" + assert contact.id == 99 + + +def test_plan_message_delivery_cross_platform_id_replays_node_faces(): + from astrbot.core.message.components import Face, Json, MFace, Node, Nodes + from astrbot.core.platform.message_projection import envelope_from_event + + envelope = envelope_from_event( + _napcat_event( + [ + Nodes( + nodes=[ + Node( + name="Alice", + uin="1001", + content=[ + Face(id=111), + MFace( + emoji_package_id=1, + emoji_id="eid", + key="key", + summary="s", + ), + Json(data={"app": "x"}), + ], + ) + ] + ) + ] + ) + ) + content = ( + _forward_nodes( + plan_message_delivery( + envelope, + MESSAGE_CAPABILITIES["napcat"], + target_umo="other-napcat:GroupMessage:room", + ) + )[0] + .nodes[0] + .content + ) + assert [type(part) for part in content] == [Face, MFace, Json] + + +def test_plan_message_delivery_does_not_emit_root_level_poke(): + from astrbot.core.message.components import Poke + from astrbot.core.platform.message_projection import envelope_from_event + + envelope = envelope_from_event(_napcat_event([Poke(id="42")])) + chains = plan_message_delivery( + envelope, + MESSAGE_CAPABILITIES["napcat"], + target_umo="napcat:GroupMessage:room", + ) + assert _forward_nodes(chains) == [] + assert all(not isinstance(part, Poke) for chain in chains for part in chain.chain) + assert "[Poke]" in chains[0].get_plain_text() + + +def test_plan_message_delivery_flattens_native_summaries_when_forward_false(): + from astrbot.core.message.components import Face, Json, MFace, Node, Nodes, Plain + from astrbot.core.message.json_card import format_json_card_prompt + from astrbot.core.message.qq_face import format_qq_face + from astrbot.core.platform.message_projection import envelope_from_event + + json_card = Json(data={"app": "com.example.unknown"}) + envelope = envelope_from_event( + _napcat_event( + [ + Nodes( + nodes=[ + Node( + name="Alice", + uin="1001", + content=[ + Face(id=111), + MFace( + emoji_package_id=1, + emoji_id="eid", + key="key", + summary="wow", + ), + json_card, + Plain("hi"), + ], + ) + ] + ) + ] + ) + ) + chains = plan_message_delivery( + envelope, + MessageDeliveryCapabilities(), + target_umo="telegram:FriendMessage:1", + ) + assert _forward_nodes(chains) == [] + text = chains[0].get_plain_text() + assert text.startswith("[Alice]\n") + assert format_qq_face(111) in text + assert "wow" in text + assert format_json_card_prompt(json_card) in text diff --git a/tests/unit/test_session_bridge.py b/tests/unit/test_session_bridge.py index 60b1326a65..f873821291 100644 --- a/tests/unit/test_session_bridge.py +++ b/tests/unit/test_session_bridge.py @@ -1493,3 +1493,107 @@ async def miss(_rule_id: str, **_kwargs): live = await manager.get_filter(event, watch.rule_id) assert live == ({"text": ["alpha", "beta"]}, {}) await manager.terminate() + + +@pytest.mark.asyncio +async def test_observe_delivers_nodes_when_target_supports_forward(): + from astrbot.core.message.components import Face, Node, Nodes, Plain + from astrbot.core.platform.message_capabilities import MESSAGE_CAPABILITIES + from astrbot.core.platform.message_projection import envelope_from_event + + sent = [] + + async def send(session, chain): + sent.append(chain) + return PlatformSendResult(session.platform_id, True, str(session)) + + def capabilities(umo: str): + family = umo.split(":", 1)[0] + if family in MESSAGE_CAPABILITIES: + return MESSAGE_CAPABILITIES[family] + return MessageDeliveryCapabilities(quote=True, media=frozenset({"image"})) + + authorization = SimpleNamespace( + authorize=AsyncMock( + return_value=SimpleNamespace(allowed=True, effective_role=None) + ) + ) + manager = SessionBridgeManager( + send, + capabilities, + authorization=authorization, + get_config_id=lambda _: "default", + get_self_id=lambda _: "bot", + store=FakeSessionBridgeStore(), + ) + source_event = _event() + source_event.get_platform_name = lambda: "napcat" + source_event.get_messages = lambda: [ + Nodes( + nodes=[ + Node( + name="Alice", + uin="1001", + content=[Plain("hi"), Face(id=111)], + ) + ] + ) + ] + envelope = envelope_from_event(source_event) + + napcat_listener = _event(umo="napcat:GroupMessage:room") + await manager.watch( + napcat_listener, source_event.unified_msg_origin, ttl_seconds=60 + ) + await manager.observe(envelope) + napcat_chains = list(sent) + assert any( + isinstance(part, Nodes) and any(isinstance(item, Face) for item in node.content) + for chain in napcat_chains + for part in chain.chain + for node in (part.nodes if isinstance(part, Nodes) else []) + ) + assert any( + chain.get_plain_text() and "来自" in chain.get_plain_text() + for chain in napcat_chains + ) + assert not any( + isinstance(part, Nodes) + and any("[Alice]\n" in getattr(item, "text", "") for item in node.content) + for chain in napcat_chains + for part in chain.chain + for node in (part.nodes if isinstance(part, Nodes) else []) + ) + + sent.clear() + aiocqhttp_listener = _event(umo="aiocqhttp:GroupMessage:room") + await manager.watch( + aiocqhttp_listener, source_event.unified_msg_origin, ttl_seconds=60 + ) + await manager.observe(envelope) + assert any(isinstance(part, Nodes) for chain in sent for part in chain.chain) + assert any( + chain.get_plain_text() and "来自" in chain.get_plain_text() for chain in sent + ) + + sent.clear() + webchat_listener = _event(umo="webchat:FriendMessage:user") + await manager.watch( + webchat_listener, source_event.unified_msg_origin, ttl_seconds=60 + ) + await manager.observe(envelope) + assert all( + not any(isinstance(part, Nodes) for part in chain.chain) for chain in sent + ) + assert any("[Alice]" in chain.get_plain_text() for chain in sent) + + sent.clear() + telegram_listener = _event(umo="telegram:FriendMessage:user") + await manager.watch( + telegram_listener, source_event.unified_msg_origin, ttl_seconds=60 + ) + await manager.observe(envelope) + assert all( + not any(isinstance(part, Nodes) for part in chain.chain) for chain in sent + ) + await manager.terminate()