Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 117 additions & 28 deletions astrbot/core/platform/sources/napcat/napcat_platform_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down
42 changes: 21 additions & 21 deletions docs/en/dev/plugin-platform-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading
Loading