diff --git a/astrbot/core/platform/sources/napcat/napcat_platform_adapter.py b/astrbot/core/platform/sources/napcat/napcat_platform_adapter.py index 322ca1273e..08bda8c609 100644 --- a/astrbot/core/platform/sources/napcat/napcat_platform_adapter.py +++ b/astrbot/core/platform/sources/napcat/napcat_platform_adapter.py @@ -4,7 +4,7 @@ import re import uuid from collections.abc import Awaitable, Callable, Mapping -from pathlib import PurePosixPath +from pathlib import Path, PurePosixPath from time import monotonic from typing import Any, cast @@ -63,6 +63,11 @@ from astrbot.core.platform.platform_metadata import PlatformMetadata from astrbot.core.platform.register import register_platform_adapter from astrbot.core.utils.error_redaction import safe_error +from astrbot.core.utils.media_utils import ( + MediaResolver, + file_uri_to_path, + is_file_uri, +) from ..aiocqhttp.forward_node_splitter import split_long_text_node from .codec import ( @@ -156,6 +161,65 @@ _EXCLUSIVE_OUTBOUND_SEGMENTS = (Node, Nodes, File, Video, Record) _SPLIT_SEND_INTERVAL_SECONDS = 0.5 +_PORTABLE_MEDIA_PREFIXES = ("http://", "https://", "base64://") +_OutboundMedia = Image | Record | Video | File + + +def _outbound_media_candidates(component: _OutboundMedia) -> list[str]: + if isinstance(component, File): + values = (component.file_, component.url) + else: + values = (component.file, component.url, component.path) + candidates: list[str] = [] + for value in values: + if value and value not in candidates: + candidates.append(value) + return candidates + + +def _local_media_ref_exists(value: str) -> bool: + try: + path = Path(file_uri_to_path(value) if is_file_uri(value) else value) + return path.exists() + except OSError: + return False + + +def _first_prefixed_media_ref( + candidates: list[str], prefixes: tuple[str, ...] +) -> str | None: + for value in candidates: + if value.startswith(prefixes): + return value + return None + + +def _first_readable_media_ref(candidates: list[str]) -> str | None: + for value in candidates: + if value.startswith("data:") or _local_media_ref_exists(value): + return value + return None + + +def _pass_through_media_fields( + component: _OutboundMedia, file_value: str +) -> dict[str, str | None]: + if file_value.startswith("base64://"): + return {} + extra: dict[str, str | None] = {} + url = component.url or None + if url and ( + url.startswith(_PORTABLE_MEDIA_PREFIXES) + or url == file_value + or (is_file_uri(url) and not _local_media_ref_exists(url)) + ): + extra["url"] = url + if isinstance(component, File): + return extra + path = component.path or None + if path and not _local_media_ref_exists(path): + extra["path"] = path + return extra class NapCatOutboundProtocol: @@ -1167,6 +1231,35 @@ def _append_basic_outbound_segments( return True return False + @staticmethod + async def _portable_media_file(component: _OutboundMedia) -> str | None: + """Return a OneBot file value NapCat can consume without AstrBot paths.""" + candidates = _outbound_media_candidates(component) + portable = _first_prefixed_media_ref(candidates, _PORTABLE_MEDIA_PREFIXES) + if portable: + return portable + readable = _first_readable_media_ref(candidates) + if readable is None: + return candidates[0] if candidates else None + try: + encoded = await MediaResolver(readable, media_type="file").to_base64() + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning( + "[NapCat] Failed to encode outbound %s: %s", + component.__class__.__name__, + safe_error("", exc), + ) + return None + if not encoded: + logger.warning( + "[NapCat] Outbound %s encoding produced an empty payload", + component.__class__.__name__, + ) + return None + return f"base64://{encoded}" + async def _append_media_outbound_segment( self, component: BaseMessageComponent, @@ -1175,54 +1268,50 @@ async def _append_media_outbound_segment( ) -> bool: """Convert image, audio, video, and file components.""" if isinstance(component, Image | Record | Video): - file_value = component.file or component.url or component.path + file_value = await self._portable_media_file(component) + if isinstance(component, Image): + label = "[Image]" + elif isinstance(component, Record): + label = "[Record]" + else: + label = "[Video]" if not file_value: - return False + segments.append(self.client.text(label)) + fallback_parts.append(label) + return True + extra = _pass_through_media_fields(component, file_value) if isinstance(component, Image): - segments.append( - self.client.image( - file=file_value, - url=component.url or None, - path=component.path or None, - ) - ) - fallback_parts.append("[Image]") + segments.append(self.client.image(file=file_value, **extra)) elif isinstance(component, Record): - segments.append( - self.client.record( - file=file_value, - url=component.url or None, - path=component.path or None, - ) - ) - fallback_parts.append("[Record]") + segments.append(self.client.record(file=file_value, **extra)) else: segments.append( self.client.video( file=file_value, - url=component.url or None, - path=component.path or None, thumb=component.cover or None, + **extra, ) ) - fallback_parts.append("[Video]") + fallback_parts.append(label) return True if not isinstance(component, File): return False - file_value = await component.get_file(allow_return_url=True) + file_value = await self._portable_media_file(component) + label = f"[File:{component.name}]" if component.name else "[File]" if not file_value: - return False + segments.append(self.client.text(label)) + fallback_parts.append(label) + return True + extra = _pass_through_media_fields(component, file_value) segments.append( self.client.file( file=file_value, - url=component.url or None, name=component.name or None, + **extra, ) ) - fallback_parts.append( - f"[File:{component.name}]" if component.name else "[File]" - ) + fallback_parts.append(label) return True async def _build_outbound_message( diff --git a/docs/en/dev/plugin-platform-adapter.md b/docs/en/dev/plugin-platform-adapter.md index 45f87fc2b5..895211bb55 100644 --- a/docs/en/dev/plugin-platform-adapter.md +++ b/docs/en/dev/plugin-platform-adapter.md @@ -226,27 +226,27 @@ Adapters return immutable `MessageDeliveryCapabilities` from `message_capabiliti The following conservative inventory was checked against this checkout's senders under `astrbot/core/platform/sources/` on 2026-09-11. It does not claim live-account testing on every platform: -| Adapter directory | Deliverable media | Main restriction or handling | -| ------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `aiocqhttp` | Image, audio, video, file | OneBot segments; preserve content order and mapped quotes | -| `napcat` | Image, audio, video, file | OneBot/NapCat segments; native content declared by kind | -| `telegram` | Image, audio, video, file | Bridge sends parts in order; 4096 text limit; never reuse another Bot's file_id | -| `discord` | Image, audio, file | 2000 text limit; cross-session forwarding disables mention notifications; video is delivered as a generic file | -| `kook` | Image, audio, video, file | Separate component sends; quotes and JSON cards | -| `lark` | Image, audio, video, file | Lark sender handles cards and uploads; bridge quotes use summaries | -| `line` | Image, audio, video, file | At most 5 parts per request and 5000 text characters; public HTTPS `callback_api_base` | -| `mattermost` | Image, audio, video, file | Upload media first; quotes use root_id | -| `misskey` | Image, audio, video, file | Media depends on the upload setting; instance-specific text limit; quote summaries | -| `satori` | Image, audio, video, file | Acceptance also depends on the Satori backend and connected platform | -| `slack` | Image, file | Blocks/uploads; bridge text uses plain_text; quote summaries | -| `dingtalk` | Image, audio, video, file | Media-specific uploads and templates; quote summaries | -| `qqofficial` | Image, audio, video, file | Runtime name qq_official; checks cached IDs and proactive mode; channel media conservatively limited to images | -| `qqofficial_webhook` | Image, audio, video, file | Runtime name qq_official_webhook; shares official sending logic and session restrictions | -| `webchat` | Image, audio, video, file | Delivered through the WebChat queue; bridge quote summaries | -| `wecom` | Image, audio, video, file | Application mode needs agent_id; customer-service mode cannot send proactively | -| `wecom_ai_bot` | Image, audio, video, file | Proactive sending needs a push Webhook; video uploads as a file | -| `weixin_oc` | Image, audio, video, file | Depends on the account connection and platform session context | -| `weixin_official_account` | Inbound images and audio can be projected | Current send_by_session rejects proactive delivery; usable as a watch source | +| Adapter directory | Deliverable media | Main restriction or handling | +| ------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `aiocqhttp` | Image, audio, video, file | OneBot segments; preserve content order and mapped quotes | +| `napcat` | Image, audio, video, file | OneBot/NapCat segments; AstrBot-readable local/file:// media is encoded as `base64://`; HTTP, existing base64, and NapCat-side paths pass through; native content declared by kind | +| `telegram` | Image, audio, video, file | Bridge sends parts in order; 4096 text limit; never reuse another Bot's file_id | +| `discord` | Image, audio, file | 2000 text limit; cross-session forwarding disables mention notifications; video is delivered as a generic file | +| `kook` | Image, audio, video, file | Separate component sends; quotes and JSON cards | +| `lark` | Image, audio, video, file | Lark sender handles cards and uploads; bridge quotes use summaries | +| `line` | Image, audio, video, file | At most 5 parts per request and 5000 text characters; public HTTPS `callback_api_base` | +| `mattermost` | Image, audio, video, file | Upload media first; quotes use root_id | +| `misskey` | Image, audio, video, file | Media depends on the upload setting; instance-specific text limit; quote summaries | +| `satori` | Image, audio, video, file | Acceptance also depends on the Satori backend and connected platform | +| `slack` | Image, file | Blocks/uploads; bridge text uses plain_text; quote summaries | +| `dingtalk` | Image, audio, video, file | Media-specific uploads and templates; quote summaries | +| `qqofficial` | Image, audio, video, file | Runtime name qq_official; checks cached IDs and proactive mode; channel media conservatively limited to images | +| `qqofficial_webhook` | Image, audio, video, file | Runtime name qq_official_webhook; shares official sending logic and session restrictions | +| `webchat` | Image, audio, video, file | Delivered through the WebChat queue; bridge quote summaries | +| `wecom` | Image, audio, video, file | Application mode needs agent_id; customer-service mode cannot send proactively | +| `wecom_ai_bot` | Image, audio, video, file | Proactive sending needs a push Webhook; video uploads as a file | +| `weixin_oc` | Image, audio, video, file | Depends on the account connection and platform session context | +| `weixin_official_account` | Inbound images and audio can be projected | Current send_by_session rejects proactive delivery; usable as a watch source | These descriptors apply to cross-session delivery. Existing Stars' `MessageChain` sends still use adapter encoders. The planner splits content in order and enforces text and part limits. Delivery holds independent media copies; asynchronous media fetches use reusable file tokens whose copies are released on expiry. Receipts describe platform acceptance. Native quote mappings require returned message IDs; absent IDs must fall back to summaries. diff --git a/docs/zh/dev/plugin-platform-adapter.md b/docs/zh/dev/plugin-platform-adapter.md index 434b09f844..a7dbd52969 100644 --- a/docs/zh/dev/plugin-platform-adapter.md +++ b/docs/zh/dev/plugin-platform-adapter.md @@ -226,27 +226,27 @@ make napcat-check 以下为 2026-09-11 核对本仓库 `astrbot/core/platform/sources/` 发送实现后的保守投递范围,不表示已用所有平台的真实账号联调: -| 适配器目录 | 可投递媒体 | 主要限制或处理 | -| ------------------------- | ---------------------- | --------------------------------------------------------------------------------------- | -| `aiocqhttp` | 图片、音频、视频、文件 | OneBot 消息段;保留图文顺序和有映射的引用 | -| `napcat` | 图片、音频、视频、文件 | OneBot/NapCat 消息段;原生内容按种类声明 | -| `telegram` | 图片、音频、视频、文件 | 当前桥接按顺序拆发;4096 文本上限;不复用其他 Bot 的 file_id | -| `discord` | 图片、音频、文件 | 2000 文本上限;跨会话转发禁用提及通知;视频作为普通文件投递 | -| `kook` | 图片、音频、视频、文件 | 各组件分别发送;支持引用和 JSON 卡片 | -| `lark` | 图片、音频、视频、文件 | 卡片及上传由飞书发送器处理;桥接引用降级为摘要 | -| `line` | 图片、音频、视频、文件 | 每次最多 5 段,文字 5000;媒体需要可访问的 HTTPS `callback_api_base` | -| `mattermost` | 图片、音频、视频、文件 | 媒体先上传;引用使用 root_id | -| `misskey` | 图片、音频、视频、文件 | 媒体取决于文件上传开关;长度取实例配置;引用摘要 | -| `satori` | 图片、音频、视频、文件 | 能否接受具体内容仍取决于 Satori 后端及所接平台 | -| `slack` | 图片、文件 | blocks/上传路径;桥接文字使用 plain_text;引用摘要 | -| `dingtalk` | 图片、音频、视频、文件 | 不同媒体使用不同上传和消息模板;引用摘要 | -| `qqofficial` | 图片、音频、视频、文件 | 实际类型名为 qq_official;群/频道根据缓存消息 ID 和主动发送模式判断;频道媒体保守限图片 | -| `qqofficial_webhook` | 图片、音频、视频、文件 | 实际类型名为 qq_official_webhook;共用官方发送逻辑及会话限制 | -| `webchat` | 图片、音频、视频、文件 | 通过 WebChat 队列投递;桥接引用摘要 | -| `wecom` | 图片、音频、视频、文件 | 应用模式需 agent_id;客服模式不支持主动发送 | -| `wecom_ai_bot` | 图片、音频、视频、文件 | 主动发送要求配置推送 Webhook;视频作为文件上传 | -| `weixin_oc` | 图片、音频、视频、文件 | 发送依赖当前账号连接及平台会话上下文 | -| `weixin_official_account` | 接收侧图片、音频可投影 | 当前 send_by_session 拒绝主动发送,可作为监听来源 | +| 适配器目录 | 可投递媒体 | 主要限制或处理 | +| ------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `aiocqhttp` | 图片、音频、视频、文件 | OneBot 消息段;保留图文顺序和有映射的引用 | +| `napcat` | 图片、音频、视频、文件 | OneBot/NapCat 消息段;AstrBot 可读的本地/file:// 媒体编码为 `base64://`;HTTP、已有 base64 和 NapCat 侧路径原样传递;原生内容按种类声明 | +| `telegram` | 图片、音频、视频、文件 | 当前桥接按顺序拆发;4096 文本上限;不复用其他 Bot 的 file_id | +| `discord` | 图片、音频、文件 | 2000 文本上限;跨会话转发禁用提及通知;视频作为普通文件投递 | +| `kook` | 图片、音频、视频、文件 | 各组件分别发送;支持引用和 JSON 卡片 | +| `lark` | 图片、音频、视频、文件 | 卡片及上传由飞书发送器处理;桥接引用降级为摘要 | +| `line` | 图片、音频、视频、文件 | 每次最多 5 段,文字 5000;媒体需要可访问的 HTTPS `callback_api_base` | +| `mattermost` | 图片、音频、视频、文件 | 媒体先上传;引用使用 root_id | +| `misskey` | 图片、音频、视频、文件 | 媒体取决于文件上传开关;长度取实例配置;引用摘要 | +| `satori` | 图片、音频、视频、文件 | 能否接受具体内容仍取决于 Satori 后端及所接平台 | +| `slack` | 图片、文件 | blocks/上传路径;桥接文字使用 plain_text;引用摘要 | +| `dingtalk` | 图片、音频、视频、文件 | 不同媒体使用不同上传和消息模板;引用摘要 | +| `qqofficial` | 图片、音频、视频、文件 | 实际类型名为 qq_official;群/频道根据缓存消息 ID 和主动发送模式判断;频道媒体保守限图片 | +| `qqofficial_webhook` | 图片、音频、视频、文件 | 实际类型名为 qq_official_webhook;共用官方发送逻辑及会话限制 | +| `webchat` | 图片、音频、视频、文件 | 通过 WebChat 队列投递;桥接引用摘要 | +| `wecom` | 图片、音频、视频、文件 | 应用模式需 agent_id;客服模式不支持主动发送 | +| `wecom_ai_bot` | 图片、音频、视频、文件 | 主动发送要求配置推送 Webhook;视频作为文件上传 | +| `weixin_oc` | 图片、音频、视频、文件 | 发送依赖当前账号连接及平台会话上下文 | +| `weixin_official_account` | 接收侧图片、音频可投影 | 当前 send_by_session 拒绝主动发送,可作为监听来源 | 这些能力只用于当前跨会话投递路径。现有 Stars 的 `MessageChain` 发送接口仍由各适配器编码。规划器按顺序拆分并执行文本/段数限制,媒体在投递期间持有独立副本,需要异步拉取的目标使用可重复读取、到期释放的文件令牌。发送回执表示平台接受情况;仅有平台返回的消息 ID 才能建立原生引用映射,缺失 ID 时必须退回摘要。 diff --git a/tests/unit/platform/test_napcat_outbound.py b/tests/unit/platform/test_napcat_outbound.py index a4222b8c04..8e2257fa48 100644 --- a/tests/unit/platform/test_napcat_outbound.py +++ b/tests/unit/platform/test_napcat_outbound.py @@ -1,6 +1,8 @@ from __future__ import annotations +import base64 from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest @@ -45,6 +47,265 @@ async def test_napcat_outbound_builder_supports_record_video_and_file_segments() assert payload[2].to_dict()["data"]["name"] == "demo.txt" +@pytest.mark.asyncio +async def test_napcat_outbound_encodes_bridged_local_image_as_base64(tmp_path): + media = tmp_path / "photo.jpg" + image_bytes = b"\xff\xd8\xff\xd9" + media.write_bytes(image_bytes) + queue: asyncio.Queue = asyncio.Queue() + adapter = _make_adapter(queue) + payload = await adapter._build_outbound_message( + MessageChain([Image.fromFileSystem(media)]) + ) + + assert isinstance(payload, list) + assert len(payload) == 1 + data = payload[0].to_dict()["data"] + assert data["file"] == "base64://" + base64.b64encode(image_bytes).decode() + assert "path" not in data + assert "url" not in data + + +@pytest.mark.asyncio +async def test_napcat_outbound_encodes_delivery_file_uri_image_as_base64(tmp_path): + media = tmp_path / "0.jpg" + image_bytes = b"\xff\xd8\xff\xd9" + media.write_bytes(image_bytes) + uri = media.as_uri() + queue: asyncio.Queue = asyncio.Queue() + adapter = _make_adapter(queue) + payload = await adapter._build_outbound_message( + MessageChain([Image(file=uri, path=uri)]) + ) + + assert isinstance(payload, list) + data = payload[0].to_dict()["data"] + assert data["file"] == "base64://" + base64.b64encode(image_bytes).decode() + assert "path" not in data + assert "url" not in data + + +@pytest.mark.asyncio +async def test_napcat_outbound_passes_through_http_and_base64_images(): + queue: asyncio.Queue = asyncio.Queue() + adapter = _make_adapter(queue) + payload = await adapter._build_outbound_message( + MessageChain( + [ + Image.fromURL("https://example.com/a.jpg"), + Image.fromBase64("dGVzdA=="), + ] + ) + ) + + assert [segment.to_dict()["data"]["file"] for segment in payload] == [ + "https://example.com/a.jpg", + "base64://dGVzdA==", + ] + + +@pytest.mark.asyncio +async def test_napcat_outbound_prefers_http_url_over_cache_image_name(): + queue: asyncio.Queue = asyncio.Queue() + adapter = _make_adapter(queue) + payload = await adapter._build_outbound_message( + MessageChain( + [ + Image( + file="0d2bb1468a87d64414f8e563cc61c33c.jpg", + url="https://gchat.qpic.cn/demo.jpg", + ) + ] + ) + ) + + data = payload[0].to_dict()["data"] + assert data["file"] == "https://gchat.qpic.cn/demo.jpg" + assert data["url"] == "https://gchat.qpic.cn/demo.jpg" + + +@pytest.mark.asyncio +async def test_napcat_outbound_passes_through_bare_napcat_cache_names(): + queue: asyncio.Queue = asyncio.Queue() + adapter = _make_adapter(queue) + payload = await adapter._build_outbound_message( + MessageChain( + [ + Image(file="0d2bb1468a87d64414f8e563cc61c33c.jpg"), + Record(file="0d2bb1468a87d64414f8e563cc61c33c.amr"), + ] + ) + ) + + assert [segment.to_dict()["data"] for segment in payload] == [ + {"file": "0d2bb1468a87d64414f8e563cc61c33c.jpg"}, + {"file": "0d2bb1468a87d64414f8e563cc61c33c.amr"}, + ] + + +@pytest.mark.asyncio +async def test_napcat_outbound_passes_through_unreadable_napcat_cache_paths(): + queue: asyncio.Queue = asyncio.Queue() + adapter = _make_adapter(queue) + payload = await adapter._build_outbound_message( + MessageChain( + [ + Image( + file="napcat-image.png", + url="file:///C:/NapCat/cache/napcat-image.png", + ), + Record( + file="napcat-record.amr", + url="file:///C:/NapCat/cache/napcat-record.amr", + path="C:/NapCat/cache/napcat-record.amr", + ), + ] + ) + ) + + assert [segment.to_dict()["data"] for segment in payload] == [ + { + "file": "napcat-image.png", + "url": "file:///C:/NapCat/cache/napcat-image.png", + }, + { + "file": "napcat-record.amr", + "url": "file:///C:/NapCat/cache/napcat-record.amr", + "path": "C:/NapCat/cache/napcat-record.amr", + }, + ] + + +@pytest.mark.asyncio +async def test_napcat_outbound_passes_through_unreadable_file_uri_image(): + queue: asyncio.Queue = asyncio.Queue() + adapter = _make_adapter(queue) + payload = await adapter._build_outbound_message( + MessageChain( + [ + Plain("before"), + Image(file="file:///missing.jpg"), + Plain("after"), + ] + ) + ) + + assert [segment.to_dict()["type"] for segment in payload] == [ + "text", + "image", + "text", + ] + assert payload[1].to_dict()["data"]["file"] == "file:///missing.jpg" + + +@pytest.mark.asyncio +async def test_napcat_outbound_encodes_local_record_video_and_file_as_base64( + tmp_path, +): + record_bytes = b"#!AMR\nlocal-record" + video_bytes = b"\x00\x00\x00\x18ftypmp42" + file_bytes = b"attachment-bytes" + record_path = tmp_path / "voice.amr" + video_path = tmp_path / "clip.mp4" + file_path = tmp_path / "note.txt" + record_path.write_bytes(record_bytes) + video_path.write_bytes(video_bytes) + file_path.write_bytes(file_bytes) + queue: asyncio.Queue = asyncio.Queue() + adapter = _make_adapter(queue) + payload = await adapter._build_outbound_message( + MessageChain( + [ + Record.fromFileSystem(record_path), + Video.fromFileSystem(video_path), + File(name="note.txt", file=str(file_path)), + ] + ) + ) + + assert [segment.to_dict()["type"] for segment in payload] == [ + "record", + "video", + "file", + ] + assert payload[0].to_dict()["data"]["file"] == ( + "base64://" + base64.b64encode(record_bytes).decode() + ) + assert payload[1].to_dict()["data"]["file"] == ( + "base64://" + base64.b64encode(video_bytes).decode() + ) + assert payload[2].to_dict()["data"]["file"] == ( + "base64://" + base64.b64encode(file_bytes).decode() + ) + assert payload[2].to_dict()["data"]["name"] == "note.txt" + assert "path" not in payload[0].to_dict()["data"] + assert "url" not in payload[1].to_dict()["data"] + + +@pytest.mark.asyncio +async def test_napcat_outbound_omits_readable_local_path_when_http_url_is_used( + tmp_path, +): + media = tmp_path / "photo.jpg" + media.write_bytes(b"\xff\xd8\xff\xd9") + queue: asyncio.Queue = asyncio.Queue() + adapter = _make_adapter(queue) + payload = await adapter._build_outbound_message( + MessageChain( + [ + Image( + file="https://example.com/a.jpg", + path=str(media), + ) + ] + ) + ) + + data = payload[0].to_dict()["data"] + assert data["file"] == "https://example.com/a.jpg" + assert "path" not in data + + +@pytest.mark.asyncio +async def test_napcat_outbound_skips_image_when_base64_encoding_fails( + monkeypatch, + tmp_path, + caplog, +): + media = tmp_path / "photo.jpg" + media.write_bytes(b"\xff\xd8\xff\xd9") + monkeypatch.setattr( + napcat_adapter.MediaResolver, + "to_base64", + AsyncMock(side_effect=ValueError("unreadable media")), + ) + queue: asyncio.Queue = asyncio.Queue() + adapter = _make_adapter(queue) + with caplog.at_level("WARNING"): + payload = await adapter._build_outbound_message( + MessageChain( + [ + Plain("before"), + Image.fromFileSystem(media), + Plain("after"), + ] + ) + ) + + assert isinstance(payload, list) + assert [segment.to_dict()["type"] for segment in payload] == [ + "text", + "text", + "text", + ] + assert payload[0].to_dict()["data"]["text"] == "before" + assert payload[1].to_dict()["data"]["text"] == "[Image]" + assert payload[2].to_dict()["data"]["text"] == "after" + assert any( + "Failed to encode outbound Image" in message for message in caplog.messages + ) + + @pytest.mark.asyncio async def test_napcat_outbound_builder_supports_face_contact_location_poke_and_json(): queue: asyncio.Queue = asyncio.Queue()