From a8b9ba928c12cfdc8c23e75664518ad5a1e34c93 Mon Sep 17 00:00:00 2001 From: "Begonia, HE" <163421589+BegoniaHe@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:08:04 +0200 Subject: [PATCH 1/3] fix(napcat): encode bridged images as base64 Convert outbound Image and Record components to base64:// so session bridge file:// copies reach NapCat without sharing AstrBot's filesystem. Fixes #190 AI-Generated: true Generated-At: 2026-09-14T15:07:58Z --- .../sources/napcat/napcat_platform_adapter.py | 57 +++++++------- docs/en/dev/plugin-platform-adapter.md | 42 +++++------ docs/zh/dev/plugin-platform-adapter.md | 42 +++++------ tests/unit/platform/test_napcat_outbound.py | 74 ++++++++++++++++++- 4 files changed, 144 insertions(+), 71 deletions(-) diff --git a/astrbot/core/platform/sources/napcat/napcat_platform_adapter.py b/astrbot/core/platform/sources/napcat/napcat_platform_adapter.py index 322ca1273e..6a02f5160b 100644 --- a/astrbot/core/platform/sources/napcat/napcat_platform_adapter.py +++ b/astrbot/core/platform/sources/napcat/napcat_platform_adapter.py @@ -1167,6 +1167,19 @@ def _append_basic_outbound_segments( return True return False + @staticmethod + async def _portable_media_file(component: Image | Record) -> str | None: + """Encode image and audio as self-contained OneBot payloads.""" + try: + encoded = await component.convert_to_base64() + except asyncio.CancelledError: + raise + except Exception: + return None + if not encoded: + return None + return f"base64://{encoded}" + async def _append_media_outbound_segment( self, component: BaseMessageComponent, @@ -1174,38 +1187,30 @@ async def _append_media_outbound_segment( fallback_parts: list[str], ) -> bool: """Convert image, audio, video, and file components.""" - if isinstance(component, Image | Record | Video): - file_value = component.file or component.url or component.path + if isinstance(component, Image | Record): + file_value = await self._portable_media_file(component) if not file_value: return False if isinstance(component, Image): - segments.append( - self.client.image( - file=file_value, - url=component.url or None, - path=component.path or None, - ) - ) + segments.append(self.client.image(file=file_value)) fallback_parts.append("[Image]") - 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]") else: - segments.append( - self.client.video( - file=file_value, - url=component.url or None, - path=component.path or None, - thumb=component.cover or None, - ) + segments.append(self.client.record(file=file_value)) + fallback_parts.append("[Record]") + return True + if isinstance(component, Video): + file_value = component.file or component.url or component.path + if not file_value: + return False + segments.append( + self.client.video( + file=file_value, + url=component.url or None, + path=component.path or None, + thumb=component.cover or None, ) - fallback_parts.append("[Video]") + ) + fallback_parts.append("[Video]") return True if not isinstance(component, File): diff --git a/docs/en/dev/plugin-platform-adapter.md b/docs/en/dev/plugin-platform-adapter.md index 45f87fc2b5..5c59eaffd6 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; outbound images and audio are `base64://` so NapCat need not read AstrBot local paths; 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..0042fcc173 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 消息段;图片和语音出站编码为 `base64://`,不要求 NapCat 读取 AstrBot 本地路径;原生内容按种类声明 | +| `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..6a45bbeb59 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 @@ -11,17 +13,36 @@ pytestmark = pytest.mark.platform +_IMAGE_BASE64 = "dGVzdA==" +_RECORD_BASE64 = "cmVjb3Jk" + def _zero_split_send_interval(monkeypatch) -> None: monkeypatch.setattr(napcat_adapter, "_SPLIT_SEND_INTERVAL_SECONDS", 0.0) +def _patch_media_converters(monkeypatch) -> None: + monkeypatch.setattr( + Image, + "convert_to_base64", + AsyncMock(return_value=_IMAGE_BASE64), + ) + monkeypatch.setattr( + Record, + "convert_to_base64", + AsyncMock(return_value=_RECORD_BASE64), + ) + + def _napcat_types(call) -> list[str]: return [segment.to_dict()["type"] for segment in call.kwargs["message"]] @pytest.mark.asyncio -async def test_napcat_outbound_builder_supports_record_video_and_file_segments(): +async def test_napcat_outbound_builder_supports_record_video_and_file_segments( + monkeypatch, +): + _patch_media_converters(monkeypatch) queue: asyncio.Queue = asyncio.Queue() adapter = _make_adapter(queue) payload = await adapter._build_outbound_message( @@ -40,11 +61,55 @@ async def test_napcat_outbound_builder_supports_record_video_and_file_segments() "video", "file", ] - assert payload[0].to_dict()["data"]["file"] == "https://example.com/demo.wav" + assert payload[0].to_dict()["data"] == {"file": f"base64://{_RECORD_BASE64}"} assert payload[1].to_dict()["data"]["thumb"] == "thumb://cover" 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_skips_image_when_base64_encoding_fails(monkeypatch): + monkeypatch.setattr( + Image, + "convert_to_base64", + AsyncMock(side_effect=ValueError("unreadable media")), + ) + 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 isinstance(payload, list) + assert [segment.to_dict()["type"] for segment in payload] == ["text", "text"] + assert payload[0].to_dict()["data"]["text"] == "before" + assert payload[1].to_dict()["data"]["text"] == "after" + + @pytest.mark.asyncio async def test_napcat_outbound_builder_supports_face_contact_location_poke_and_json(): queue: asyncio.Queue = asyncio.Queue() @@ -362,6 +427,7 @@ async def test_napcat_send_by_session_supports_forward_nodes(monkeypatch): @pytest.mark.asyncio async def test_napcat_send_by_session_splits_video_from_text_and_image(monkeypatch): _zero_split_send_interval(monkeypatch) + _patch_media_converters(monkeypatch) queue: asyncio.Queue = asyncio.Queue() adapter = _make_adapter(queue) adapter.client.send_group_message = AsyncMock() @@ -398,6 +464,7 @@ async def test_napcat_send_by_session_splits_video_from_text_and_image(monkeypat @pytest.mark.asyncio async def test_napcat_send_by_session_splits_record_from_text_and_image(monkeypatch): _zero_split_send_interval(monkeypatch) + _patch_media_converters(monkeypatch) queue: asyncio.Queue = asyncio.Queue() adapter = _make_adapter(queue) adapter.client.send_group_message = AsyncMock() @@ -425,7 +492,7 @@ async def test_napcat_send_by_session_splits_record_from_text_and_image(monkeypa assert _napcat_types(record) == ["record"] assert ( record.kwargs["message"][0].to_dict()["data"]["file"] - == "https://example.com/a.wav" + == f"base64://{_RECORD_BASE64}" ) assert _napcat_types(last) == ["text"] assert last.kwargs["message"][0].to_dict()["data"]["text"] == "after" @@ -436,6 +503,7 @@ async def test_napcat_send_by_session_keeps_mixable_neighbors_around_file( monkeypatch, ): _zero_split_send_interval(monkeypatch) + _patch_media_converters(monkeypatch) queue: asyncio.Queue = asyncio.Queue() adapter = _make_adapter(queue) adapter.client.send_group_message = AsyncMock() From 91e0239c8c6982ec66925f3cf82b52e3ae9788b3 Mon Sep 17 00:00:00 2001 From: "Begonia, HE" <163421589+BegoniaHe@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:03:07 +0200 Subject: [PATCH 2/3] fix(napcat): encode only local outbound media as base64 Prefer HTTP, existing base64, and NapCat cache names so native echo still works. Encode readable local copies for session bridge, log failures, and omit unreadable filesystem refs. Fixes #190 AI-Generated: true Generated-At: 2026-09-14T16:03:00Z --- .../sources/napcat/napcat_platform_adapter.py | 90 ++++++++-- docs/en/dev/plugin-platform-adapter.md | 42 ++--- docs/zh/dev/plugin-platform-adapter.md | 42 ++--- tests/unit/platform/test_napcat_outbound.py | 169 ++++++++++++++---- 4 files changed, 250 insertions(+), 93 deletions(-) diff --git a/astrbot/core/platform/sources/napcat/napcat_platform_adapter.py b/astrbot/core/platform/sources/napcat/napcat_platform_adapter.py index 6a02f5160b..0dfad12aa2 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,7 @@ 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 file_uri_to_path, is_file_uri from ..aiocqhttp.forward_node_splitter import split_long_text_node from .codec import ( @@ -156,6 +157,30 @@ _EXCLUSIVE_OUTBOUND_SEGMENTS = (Node, Nodes, File, Video, Record) _SPLIT_SEND_INTERVAL_SECONDS = 0.5 +_PORTABLE_MEDIA_PREFIXES = ("http://", "https://", "base64://") + + +def _outbound_media_candidates(component: Image | Record) -> list[str]: + candidates: list[str] = [] + for value in (component.file, component.url, component.path): + 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 _is_raw_filesystem_ref(value: str) -> bool: + if is_file_uri(value): + return True + path = Path(value) + return path.is_absolute() or "/" in value or "\\" in value class NapCatOutboundProtocol: @@ -1169,16 +1194,43 @@ def _append_basic_outbound_segments( @staticmethod async def _portable_media_file(component: Image | Record) -> str | None: - """Encode image and audio as self-contained OneBot payloads.""" - try: - encoded = await component.convert_to_base64() - except asyncio.CancelledError: - raise - except Exception: - return None - if not encoded: - return None - return f"base64://{encoded}" + """Return a OneBot file value NapCat can consume without AstrBot paths.""" + candidates = _outbound_media_candidates(component) + for value in candidates: + if value.startswith(_PORTABLE_MEDIA_PREFIXES): + return value + + if any( + value.startswith("data:") or _local_media_ref_exists(value) + for value in candidates + ): + try: + encoded = await component.convert_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}" + + for value in candidates: + if _is_raw_filesystem_ref(value): + logger.warning( + "[NapCat] Omitting unreadable outbound %s", + component.__class__.__name__, + ) + return None + return candidates[0] if candidates else None async def _append_media_outbound_segment( self, @@ -1189,14 +1241,20 @@ async def _append_media_outbound_segment( """Convert image, audio, video, and file components.""" if isinstance(component, Image | Record): file_value = await self._portable_media_file(component) + label = "[Image]" if isinstance(component, Image) else "[Record]" if not file_value: - return False + segments.append(self.client.text(label)) + fallback_parts.append(label) + return True + extra: dict[str, str | None] = {} + if not file_value.startswith("base64://"): + extra["url"] = component.url or None + extra["path"] = component.path or None if isinstance(component, Image): - segments.append(self.client.image(file=file_value)) - fallback_parts.append("[Image]") + segments.append(self.client.image(file=file_value, **extra)) else: - segments.append(self.client.record(file=file_value)) - fallback_parts.append("[Record]") + segments.append(self.client.record(file=file_value, **extra)) + fallback_parts.append(label) return True if isinstance(component, Video): file_value = component.file or component.url or component.path diff --git a/docs/en/dev/plugin-platform-adapter.md b/docs/en/dev/plugin-platform-adapter.md index 5c59eaffd6..b12f60a1d7 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; outbound images and audio are `base64://` so NapCat need not read AstrBot local paths; 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; local/file:// images and audio are encoded as `base64://`; HTTP and existing base64 pass through; AstrBot paths are never forwarded; 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 0042fcc173..9af4cb0904 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 消息段;图片和语音出站编码为 `base64://`,不要求 NapCat 读取 AstrBot 本地路径;原生内容按种类声明 | -| `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 消息段;本地/file:// 图片和语音编码为 `base64://`,HTTP 与已有 base64 原样传递,不把 AstrBot 路径转给 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 6a45bbeb59..193cdcb0ea 100644 --- a/tests/unit/platform/test_napcat_outbound.py +++ b/tests/unit/platform/test_napcat_outbound.py @@ -13,36 +13,17 @@ pytestmark = pytest.mark.platform -_IMAGE_BASE64 = "dGVzdA==" -_RECORD_BASE64 = "cmVjb3Jk" - def _zero_split_send_interval(monkeypatch) -> None: monkeypatch.setattr(napcat_adapter, "_SPLIT_SEND_INTERVAL_SECONDS", 0.0) -def _patch_media_converters(monkeypatch) -> None: - monkeypatch.setattr( - Image, - "convert_to_base64", - AsyncMock(return_value=_IMAGE_BASE64), - ) - monkeypatch.setattr( - Record, - "convert_to_base64", - AsyncMock(return_value=_RECORD_BASE64), - ) - - def _napcat_types(call) -> list[str]: return [segment.to_dict()["type"] for segment in call.kwargs["message"]] @pytest.mark.asyncio -async def test_napcat_outbound_builder_supports_record_video_and_file_segments( - monkeypatch, -): - _patch_media_converters(monkeypatch) +async def test_napcat_outbound_builder_supports_record_video_and_file_segments(): queue: asyncio.Queue = asyncio.Queue() adapter = _make_adapter(queue) payload = await adapter._build_outbound_message( @@ -61,7 +42,7 @@ async def test_napcat_outbound_builder_supports_record_video_and_file_segments( "video", "file", ] - assert payload[0].to_dict()["data"] == {"file": f"base64://{_RECORD_BASE64}"} + assert payload[0].to_dict()["data"]["file"] == "https://example.com/demo.wav" assert payload[1].to_dict()["data"]["thumb"] == "thumb://cover" assert payload[2].to_dict()["data"]["name"] == "demo.txt" @@ -86,28 +67,149 @@ async def test_napcat_outbound_encodes_bridged_local_image_as_base64(tmp_path): @pytest.mark.asyncio -async def test_napcat_outbound_skips_image_when_base64_encoding_fails(monkeypatch): - monkeypatch.setattr( - Image, - "convert_to_base64", - AsyncMock(side_effect=ValueError("unreadable media")), +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( [ - Plain("before"), - Image(file="file:///missing.jpg"), - Plain("after"), + 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_skips_unreadable_file_uri_image(caplog): + queue: asyncio.Queue = asyncio.Queue() + adapter = _make_adapter(queue) + with caplog.at_level("WARNING"): + payload = await adapter._build_outbound_message( + MessageChain( + [ + Plain("before"), + Image(file="file:///missing.jpg"), + 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( + "Omitting unreadable outbound Image" in message for message in caplog.messages + ) + + +@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( + Image, + "convert_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"] + 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"] == "after" + 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 @@ -427,7 +529,6 @@ async def test_napcat_send_by_session_supports_forward_nodes(monkeypatch): @pytest.mark.asyncio async def test_napcat_send_by_session_splits_video_from_text_and_image(monkeypatch): _zero_split_send_interval(monkeypatch) - _patch_media_converters(monkeypatch) queue: asyncio.Queue = asyncio.Queue() adapter = _make_adapter(queue) adapter.client.send_group_message = AsyncMock() @@ -464,7 +565,6 @@ async def test_napcat_send_by_session_splits_video_from_text_and_image(monkeypat @pytest.mark.asyncio async def test_napcat_send_by_session_splits_record_from_text_and_image(monkeypatch): _zero_split_send_interval(monkeypatch) - _patch_media_converters(monkeypatch) queue: asyncio.Queue = asyncio.Queue() adapter = _make_adapter(queue) adapter.client.send_group_message = AsyncMock() @@ -492,7 +592,7 @@ async def test_napcat_send_by_session_splits_record_from_text_and_image(monkeypa assert _napcat_types(record) == ["record"] assert ( record.kwargs["message"][0].to_dict()["data"]["file"] - == f"base64://{_RECORD_BASE64}" + == "https://example.com/a.wav" ) assert _napcat_types(last) == ["text"] assert last.kwargs["message"][0].to_dict()["data"]["text"] == "after" @@ -503,7 +603,6 @@ async def test_napcat_send_by_session_keeps_mixable_neighbors_around_file( monkeypatch, ): _zero_split_send_interval(monkeypatch) - _patch_media_converters(monkeypatch) queue: asyncio.Queue = asyncio.Queue() adapter = _make_adapter(queue) adapter.client.send_group_message = AsyncMock() From a3ff36618c97b44932c4ce7a782edda4f485c38d Mon Sep 17 00:00:00 2001 From: BegoniaHe Date: Tue, 15 Sep 2026 00:09:15 +0200 Subject: [PATCH 3/3] fix(napcat): pass through napcat-only media paths Encode only AstrBot-readable local copies as base64. Unreadable file:// and cache paths belong to NapCat and stay pass-through. Fixes #190 AI-Generated: true Generated-At: 2026-09-14T22:08:23Z --- .../sources/napcat/napcat_platform_adapter.py | 168 ++++++++++-------- docs/en/dev/plugin-platform-adapter.md | 42 ++--- docs/zh/dev/plugin-platform-adapter.md | 42 ++--- tests/unit/platform/test_napcat_outbound.py | 132 ++++++++++++-- 4 files changed, 252 insertions(+), 132 deletions(-) diff --git a/astrbot/core/platform/sources/napcat/napcat_platform_adapter.py b/astrbot/core/platform/sources/napcat/napcat_platform_adapter.py index 0dfad12aa2..08bda8c609 100644 --- a/astrbot/core/platform/sources/napcat/napcat_platform_adapter.py +++ b/astrbot/core/platform/sources/napcat/napcat_platform_adapter.py @@ -63,7 +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 file_uri_to_path, is_file_uri +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 ( @@ -158,11 +162,16 @@ _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: Image | Record) -> list[str]: +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 (component.file, component.url, component.path): + for value in values: if value and value not in candidates: candidates.append(value) return candidates @@ -176,11 +185,41 @@ def _local_media_ref_exists(value: str) -> bool: return False -def _is_raw_filesystem_ref(value: str) -> bool: - if is_file_uri(value): - return True - path = Path(value) - return path.is_absolute() or "/" in value or "\\" in value +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: @@ -1193,44 +1232,33 @@ def _append_basic_outbound_segments( return False @staticmethod - async def _portable_media_file(component: Image | Record) -> str | None: + 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) - for value in candidates: - if value.startswith(_PORTABLE_MEDIA_PREFIXES): - return value - - if any( - value.startswith("data:") or _local_media_ref_exists(value) - for value in candidates - ): - try: - encoded = await component.convert_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}" - - for value in candidates: - if _is_raw_filesystem_ref(value): - logger.warning( - "[NapCat] Omitting unreadable outbound %s", - component.__class__.__name__, - ) - return None - return candidates[0] if candidates else None + 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, @@ -1239,53 +1267,51 @@ async def _append_media_outbound_segment( fallback_parts: list[str], ) -> bool: """Convert image, audio, video, and file components.""" - if isinstance(component, Image | Record): + if isinstance(component, Image | Record | Video): file_value = await self._portable_media_file(component) - label = "[Image]" if isinstance(component, Image) else "[Record]" + if isinstance(component, Image): + label = "[Image]" + elif isinstance(component, Record): + label = "[Record]" + else: + label = "[Video]" if not file_value: segments.append(self.client.text(label)) fallback_parts.append(label) return True - extra: dict[str, str | None] = {} - if not file_value.startswith("base64://"): - extra["url"] = component.url or None - extra["path"] = component.path or None + extra = _pass_through_media_fields(component, file_value) if isinstance(component, Image): segments.append(self.client.image(file=file_value, **extra)) - else: + elif isinstance(component, Record): segments.append(self.client.record(file=file_value, **extra)) - fallback_parts.append(label) - return True - if isinstance(component, Video): - file_value = component.file or component.url or component.path - if not file_value: - return False - segments.append( - self.client.video( - file=file_value, - url=component.url or None, - path=component.path or None, - thumb=component.cover or None, + else: + segments.append( + self.client.video( + file=file_value, + 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 b12f60a1d7..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; local/file:// images and audio are encoded as `base64://`; HTTP and existing base64 pass through; AstrBot paths are never forwarded; 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 9af4cb0904..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 消息段;本地/file:// 图片和语音编码为 `base64://`,HTTP 与已有 base64 原样传递,不把 AstrBot 路径转给 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 193cdcb0ea..8e2257fa48 100644 --- a/tests/unit/platform/test_napcat_outbound.py +++ b/tests/unit/platform/test_napcat_outbound.py @@ -144,32 +144,126 @@ async def test_napcat_outbound_passes_through_bare_napcat_cache_names(): @pytest.mark.asyncio -async def test_napcat_outbound_skips_unreadable_file_uri_image(caplog): +async def test_napcat_outbound_passes_through_unreadable_napcat_cache_paths(): queue: asyncio.Queue = asyncio.Queue() adapter = _make_adapter(queue) - with caplog.at_level("WARNING"): - payload = await adapter._build_outbound_message( - MessageChain( - [ - Plain("before"), - Image(file="file:///missing.jpg"), - Plain("after"), - ] - ) + 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 isinstance(payload, list) assert [segment.to_dict()["type"] for segment in payload] == [ "text", - "text", + "image", "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( - "Omitting unreadable outbound Image" in message for message in caplog.messages + 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 @@ -181,8 +275,8 @@ async def test_napcat_outbound_skips_image_when_base64_encoding_fails( media = tmp_path / "photo.jpg" media.write_bytes(b"\xff\xd8\xff\xd9") monkeypatch.setattr( - Image, - "convert_to_base64", + napcat_adapter.MediaResolver, + "to_base64", AsyncMock(side_effect=ValueError("unreadable media")), ) queue: asyncio.Queue = asyncio.Queue()