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
54 changes: 45 additions & 9 deletions _conf_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,46 @@
}
}
},
"ffmpeg": {
"media": {
"type": "object",
"description": "FFmpeg 配置",
"hint": "控制视频转码和 GIF 转换的底层策略",
"description": "媒体配置",
"hint": "控制 Telegraph 代理、媒体反代、预下载并发、表格转图和 FFmpeg 转码",
"items": {
"telegraph_proxy": {
"type": "string",
"description": "Telegraph API 代理",
"hint": "仅用于 Telegraph createPage API,留空表示直连且不继承 HTTP 网络代理;裸 host:port 会按 http://host:port 处理",
"default": ""
},
"image_relay_base_url": {
"type": "string",
"description": "图片反代 Base URL",
"hint": "用于图片预下载的反代地址,支持 https://wsrv.nl/ 或 https://wsrv.nl/?url=;留空关闭",
"default": ""
},
"media_relay_base_url": {
"type": "string",
"description": "媒体反代 Base URL",
"hint": "用于非图片媒体或图片反代未配置时的通用反代地址,例如 https://relay.example/;留空关闭",
"default": ""
},
"media_download_concurrency": {
"type": "int",
"description": "媒体预下载并发数",
"hint": "同一条推送内远程媒体预下载并发数,1 表示保持串行",
"default": 1,
"slider": {
"min": 1,
"max": 32,
"step": 1
}
},
"table_to_image": {
"type": "bool",
"description": "表格转图片",
"hint": "开启后 HTML 表格优先渲染为图片;关闭后直接使用文本表格 fallback",
"default": true
},
"video_transcode": {
"type": "bool",
"description": "视频发送前自动转码",
Expand Down Expand Up @@ -244,13 +279,14 @@
},
"onebot_strategy": {
"name": "OneBot 策略",
"hint": "OneBot 视频来源策略",
"hint": "OneBot / NapCat 视频发送策略",
"items": {
"prefer_local_video": {
"type": "bool",
"description": "优先本地视频",
"hint": "默认开启,避免 OneBot / NapCat 端自行拉取远程视频失败;关闭后普通视频会优先使用原始 URL",
"default": true
"napcat_stream_mode": {
"type": "string",
"description": "NapCat 流式上传",
"hint": "disabled=不用流式上传,fallback=普通发送失败后流式重试,always=发送前优先流式上传。",
"default": "fallback",
"options": ["disabled", "fallback", "always"]
}
}
},
Expand Down
51 changes: 44 additions & 7 deletions bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
from .src.infrastructure.messaging import (
DefaultMessageSender,
InfrastructureMessageSenderProvider,
set_bot_client_provider,
)
from .src.infrastructure.persistence import (
get_database,
Expand All @@ -69,6 +70,7 @@
get_subscription_repository,
get_user_repository,
)
from .src.infrastructure.pipeline import EntryTextFormatter
from .src.infrastructure.schedule import RSSScheduler
from .src.infrastructure.utils import (
get_logger,
Expand Down Expand Up @@ -146,6 +148,7 @@ async def create_plugin_runtime(
try:
plugin_config, app_settings = _init_config(config)
_configure_message_senders(app_settings)
_register_bot_client_provider(context)
await _init_database(plugin_config)

queue = push_job_queue or SessionPushQueue()
Expand Down Expand Up @@ -214,21 +217,54 @@ def _init_config(
return plugin_config, app_settings


def _register_bot_client_provider(context: Context) -> None:
"""注册 bot client provider,供主动推送场景下的 NapCat stream 使用。

主动推送没有消息事件,sender 无法从 event 取 bot 客户端。
这里通过 AstrBot platform_manager 按平台名解析出底层 bot 客户端
(如 aiocqhttp 的 CQHttp 实例),用于调用 NapCat stream action。
"""

def _resolve_bot_client(platform_name: str) -> object | None:
if not platform_name:
return None
try:
platform_manager = getattr(context, "platform_manager", None)
if platform_manager is None:
return None
for inst in platform_manager.get_insts():
meta = inst.meta() if hasattr(inst, "meta") else None
inst_name = getattr(meta, "name", None) or getattr(
inst, "platform_name", None
)
if inst_name == platform_name and hasattr(inst, "get_client"):
return inst.get_client()
except Exception as exc:
logger.debug(
"解析 bot client 失败: platform=%s, err=%s", platform_name, exc
)
return None

set_bot_client_provider(_resolve_bot_client)


def _configure_message_senders(app_settings: ApplicationSettings) -> None:
"""Apply runtime config consumed by concrete message senders."""
DefaultMessageSender.configure_runtime(
timeout_seconds=app_settings.http.media_timeout,
proxy=app_settings.http.proxy,
telegraph_proxy=app_settings.media.telegraph_proxy,
image_relay_base_url=app_settings.media.image_relay_base_url,
media_relay_base_url=app_settings.media.media_relay_base_url,
media_download_concurrency=app_settings.media.media_download_concurrency,
)
DefaultMessageSender.configure_behavior(
video_transcode=app_settings.ffmpeg.video_transcode,
video_transcode_timeout=app_settings.ffmpeg.video_transcode_timeout,
gif_transcode=app_settings.ffmpeg.gif_transcode,
gif_transcode_timeout=app_settings.ffmpeg.gif_transcode_timeout,
video_transcode=app_settings.media.video_transcode,
video_transcode_timeout=app_settings.media.video_transcode_timeout,
gif_transcode=app_settings.media.gif_transcode,
gif_transcode_timeout=app_settings.media.gif_transcode_timeout,
telegram_photo_max_bytes=app_settings.media_config.telegram_photo_max_bytes,
onebot_prefer_local_video_default=(
app_settings.media_config.onebot_prefer_local_video_default
),
onebot_napcat_stream_mode=(app_settings.media_config.onebot_napcat_stream_mode),
qq_official_media_threshold=app_settings.media_config.qq_official_media_threshold,
qq_official_degrade_strategy=(
app_settings.media_config.qq_official_degrade_strategy
Expand All @@ -240,6 +276,7 @@ def _configure_message_senders(app_settings: ApplicationSettings) -> None:
gc_grace_seconds=app_settings.media_config.cache_gc_grace_seconds,
)
configure_media_integrity(min_valid_bytes=app_settings.media_config.min_valid_bytes)
EntryTextFormatter.configure_table_to_image(app_settings.media.table_to_image)


async def _init_database(config: RsshubPluginConfig) -> None:
Expand Down
13 changes: 8 additions & 5 deletions docs/project/platforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

| 平台 / sender | 当前状态 | 明确覆盖点 | 备注 |
| --- | --- | --- | --- |
| OneBot / aiocqhttp | 专门 sender,明确测试覆盖 | 合并转发、原始顺序、媒体预下载、本地视频优先、失败 fallback | NapCat/OneBot 远程拉取视频不稳定,因此成功预下载后默认优先本地视频。 |
| OneBot / aiocqhttp | 专门 sender,明确测试覆盖 | 合并转发、原始顺序、媒体预下载、NapCat 流式上传、失败 fallback | NapCat 支持流式上传大文件,默认 fallback 模式(失败后重试)。 |
| QQ Official | 专门 sender,明确测试覆盖 | 单图文本合链、多媒体拆发、Markdown 开关边界、媒体失败 partial 语义 | Markdown 必须走 AstrBot `MessageChain.use_markdown_`,不得绕过 core 手写 botpy payload。 |
| Telegram | 专门 sender,明确测试覆盖 | Telegraph 多图路由、大图片转文件、MarkdownV2 文本边界 | 不假设插件能控制媒体 caption Markdown。 |
| Weixin OC | 专门 sender,明确测试覆盖 | 顺序发送、original style 顺序调整、不做图文合一 | 平台能力不适合强行合链。 |
Expand All @@ -28,7 +28,7 @@

| 平台 / sender | 文本与媒体顺序 | 媒体策略 | Markdown / Telegraph | 关键风险 |
| --- | --- | --- | --- | --- |
| OneBot / aiocqhttp | auto/classic 使用合并转发;original 按 layout fragments 发送 | 成功预下载后默认优先本地视频;合并转发失败后回退纯文本 Nodes | 不使用 Telegraph;Markdown 不作为承诺面 | 合并转发失败、远程视频拉取失败、媒体顺序回退。 |
| OneBot / aiocqhttp | auto/classic 使用合并转发;original 按 layout fragments 发送 | 媒体预下载后使用本地文件;支持 NapCat 流式上传(disabled/fallback/always);合并转发失败后回退纯文本 Nodes | 不使用 Telegraph;Markdown 不作为承诺面 | 合并转发失败、媒体发送失败、媒体顺序回退。 |
| QQ Official | 单图 + 文本合成一条 `Image + Plain`;视频和多媒体仍拆发 | 图片/视频先按平台媒体组件发送,失败后按内置策略降级 | `qq_official_strategy.markdown_mode=auto|force|plain` 语义保留;当前主动推送链路暂时显式关闭 Markdown | Markdown 原文暴露、媒体 + markdown payload 畸形、partial send 难排障。 |
| Telegram | 文本和媒体按 Telegram sender 策略发送 | 本地图片超过内置 photo 阈值时按文件发送 | Telegraph 是 Telegram sender 级自动路由,不是 `send_mode`;Plain 文本可走 AstrBot MarkdownV2 | Bot API photo 大小拒绝、caption Markdown 不一致。 |
| Weixin OC | 始终逐条发送;original 只影响顺序 | 不尝试图文合一 | 无 Telegraph / Markdown 承诺 | 强行图文合链会吞文本或失败。 |
Expand All @@ -49,7 +49,7 @@

这些数值统一按“软阈值”管理,只用于发送前分流、日志解释和降级判断,不应让正常可发送媒体被静默丢弃。软阈值不能被当作平台硬限制,也不能在数据层截断、丢弃或提前判定推送失败;真实发送失败仍应暴露平台错误,并进入可观测的降级链路。新增硬拒绝前必须有平台文档、稳定实测或用户配置作为依据。

软阈值集中由 `src/shared/constants.py` 归口,发送候选链路由 `MediaSendPlanner` 统一消费,不要散落在具体 sender 或文档表格里复制多份。当前固定口径包括:Telegram photo 默认 10 MiB 后改按文件发送、OneBot 默认优先本地视频、QQ Official 默认不按媒体数量预先降级。QQ Official 表格里的 20 MiB / 10 MiB / 100 MiB 是 2026-05-27 的调查与实测参考,不是平台硬门槛。
软阈值集中由 `src/shared/constants.py` 归口,发送候选链路由 `MediaSendPlanner` 统一消费,不要散落在具体 sender 或文档表格里复制多份。当前固定口径包括:Telegram photo 默认 10 MiB 后改按文件发送、OneBot 支持 NapCat 流式上传(默认 fallback 模式)、QQ Official 默认不按媒体数量预先降级。QQ Official 表格里的 20 MiB / 10 MiB / 100 MiB 是 2026-05-27 的调查与实测参考,不是平台硬门槛。

## 媒体下载与缓存

Expand All @@ -64,6 +64,8 @@
| m3u8 / HLS | 使用 FFmpeg 下载合并,并用 ffprobe 校验输出 | 拒绝零时长、无视频流或损坏输出。 |
| 失败语义 | 媒体失败不阻断 RSS 推送 | 失败媒体原始链接会作为降级信息保留。 |
| 本地生成媒体 | `<table>` 图片使用 `rsshub-generated://table/<hash>` 标识并直接映射 cache PNG | 它不是远程 URL,不经过 HTTP 下载,也不会在失败链接里暴露本地 cache 标识。 |
| 媒体反代 | `media.image_relay_base_url` / `media.media_relay_base_url` 默认关闭,开启后先尝试反代再回源 | 图片优先走图片反代;非图片或未配置图片反代时走通用媒体反代。缓存 key 和失败展示链接保持原始 URL。 |
| 下载并发 | `media.media_download_concurrency=1` 保持串行,大于 1 时同一条推送内并发预下载远程媒体 | 返回顺序保持输入顺序;重复 URL 仍只下载一次,重复项保留原有占位语义。 |

```text
远程媒体 URL
Expand All @@ -75,15 +77,16 @@
-> 平台 sender 构造 MessageChain
```

本地生成媒体的入口不同:`HTMLParser` 生成 `GeneratedImageContent` 后,sender 会通过 `infrastructure.rendering` 的表格图片解析器把 `rsshub-generated://table/<hash>` 映射回 `cache/table_images/table_<hash>.png`,直接构造 `PreparedMedia`。如果 cache 文件缺失,会按媒体失败处理但不会把内部标识追加给用户,也不会在 original layout 中把内部标识当作图片文件发送;layout 会携带不可见的表格纯文本 fallback,供 cache 缺失或平台图片发送失败时补回正文。表格解析阶段本身失败时则直接回退为纯文本表格
本地生成媒体的入口不同:`HTMLParser` 生成 `GeneratedImageContent` 后,sender 会通过 `infrastructure.rendering` 的表格图片解析器把 `rsshub-generated://table/<hash>` 映射回 `cache/table_images/table_<hash>.png`,直接构造 `PreparedMedia`。如果 cache 文件缺失,会按媒体失败处理但不会把内部标识追加给用户,也不会在 original layout 中把内部标识当作图片文件发送;layout 会携带不可见的表格纯文本 fallback,供 cache 缺失或平台图片发送失败时补回正文。`media.table_to_image=false` 时表格解析阶段直接使用纯文本表格;渲染失败时也会回退为纯文本表格

无声视频转 GIF 时会保留原始视频 variant。若 GIF 超出当前内置的跨平台压缩目标,插件会按固定 FFmpeg 档位尝试压缩 GIF;发送时再按目标平台软阈值选择原 GIF、压缩 GIF、原视频、文件或原始链接。

## 代理与超时

| 配置 | 作用范围 | 备注 |
| --- | --- | --- |
| `http_config.proxy` | 媒体预下载、FFmpeg 下载和 Telegram Telegraph API | 这是全局 HTTP/SOCKS 代理来源,不存在 content 级代理配置。 |
| `http_config.proxy` | RSS 拉取、普通媒体预下载和 FFmpeg 下载 | 这是全局 HTTP/SOCKS 代理来源,不影响 Telegraph API。 |
| `media.telegraph_proxy` | Telegram Telegraph API | 留空表示直连,不继承 `http_config.proxy`。 |
| 裸 `host:port` 代理 | 标准化为 `http://host:port` | 避免不同 HTTP 客户端对无 scheme 值表现不一致。 |
| SOCKS 代理 | `socks4://` / `socks5://` / `socks5h://` | Telegraph API 通过 `aiohttp-socks` 连接;SOCKS 代理必须带明确 scheme。 |
| `http_config.media_timeout` | 媒体预下载和 FFmpeg 下载超时 | 上限和默认值属于配置模型 / schema 约束。 |
Expand Down
4 changes: 2 additions & 2 deletions src/infrastructure/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@
ContentHandlerSettings,
FeedFetchSettings,
FFmpegConfig,
FFmpegSettings,
GlobalConfig,
HttpConfig,
HttpSettings,
MediaConfig,
MediaRuntimeSettings,
MediaSettings,
PlatformSenderStrategyConfig,
PlatformStrategySettings,
Expand Down Expand Up @@ -57,7 +57,7 @@
"MediaConfig",
"MediaSettings",
"FFmpegConfig",
"FFmpegSettings",
"MediaRuntimeSettings",
"SenderStrategiesConfig",
"SenderStrategySettings",
"FeedFetchSettings",
Expand Down
4 changes: 2 additions & 2 deletions src/infrastructure/config/datamodels.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
ContentHandlerSettings,
FeedFetchSettings,
FFmpegConfig,
FFmpegSettings,
GlobalConfig,
HttpConfig,
HttpSettings,
MediaConfig,
MediaRuntimeSettings,
MediaSettings,
PlatformSenderStrategyConfig,
PlatformStrategySettings,
Expand All @@ -36,7 +36,7 @@
"ContentHandlerSettings",
"FeedFetchSettings",
"FFmpegConfig",
"FFmpegSettings",
"MediaRuntimeSettings",
"GlobalConfig",
"HttpConfig",
"HttpSettings",
Expand Down
60 changes: 54 additions & 6 deletions src/infrastructure/config/legacy_migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@

from .models import SenderStrategiesConfig

_LEGACY_FFMPEG_KEYS: tuple[str, ...] = (
"video_transcode",
"video_transcode_timeout",
"gif_transcode",
"gif_transcode_timeout",
)


def record_config_heal(changes: list[str], path: str, reason: str) -> None:
changes.append(f"{path}: {reason}")
Expand Down Expand Up @@ -35,6 +42,33 @@ def migrate_http_value(source_path: str, value: Any, target_key: str) -> None:
f"migrated to http_config.{target_key}",
)

def ensure_media_config() -> dict[str, Any]:
media = normalized.setdefault("media", {})
if not isinstance(media, dict):
media = {}
normalized["media"] = media
record_config_heal(changes, "media", "reset invalid object")
return media

ffmpeg_config = normalized.get("ffmpeg")
if isinstance(ffmpeg_config, dict):
media = ensure_media_config()
migrated = False
for key in _LEGACY_FFMPEG_KEYS:
if key not in ffmpeg_config or key in media:
continue
media[key] = ffmpeg_config[key]
migrated = True
normalized.pop("ffmpeg", None)
record_config_heal(
changes,
"ffmpeg",
"migrated to media" if migrated else "removed legacy key",
)
elif "ffmpeg" in normalized:
normalized.pop("ffmpeg", None)
record_config_heal(changes, "ffmpeg", "removed invalid legacy key")

if "download_image_before_send" in normalized:
normalized.pop("download_image_before_send", None)
record_config_heal(changes, "download_image_before_send", "removed legacy key")
Expand Down Expand Up @@ -69,12 +103,26 @@ def migrate_http_value(source_path: str, value: Any, target_key: str) -> None:
)

media_config = normalized.get("media_config")
if isinstance(media_config, dict) and "download_media_timeout" in media_config:
migrate_http_value(
"media_config.download_media_timeout",
media_config.get("download_media_timeout"),
"media_timeout",
)
if isinstance(media_config, dict):
if "download_media_timeout" in media_config:
migrate_http_value(
"media_config.download_media_timeout",
media_config.get("download_media_timeout"),
"media_timeout",
)

# 迁移 onebot_prefer_local_video 到 onebot_napcat_stream_mode
if "onebot_prefer_local_video" in media_config:
old_value = media_config.pop("onebot_prefer_local_video")
# true -> "fallback" (默认行为,失败后重试)
# false -> "disabled" (不使用流式上传)
new_value = "fallback" if old_value else "disabled"
media_config["onebot_napcat_stream_mode"] = new_value
record_config_heal(
changes,
"media_config.onebot_prefer_local_video",
f"migrated to onebot_napcat_stream_mode={new_value}",
)

sender_strategies = normalized.get("sender_strategies")
if isinstance(sender_strategies, (str, list, tuple, set)) or (
Expand Down
4 changes: 2 additions & 2 deletions src/infrastructure/config/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
BasicSettings,
ContentHandlerSettings,
FeedFetchSettings,
FFmpegSettings,
HttpSettings,
MediaRuntimeSettings,
MediaSettings,
PlatformStrategySettings,
RouteKnowledgeSettings,
Expand All @@ -40,11 +40,11 @@
"ContentHandlerSettings",
"FeedFetchSettings",
"FFmpegConfig",
"FFmpegSettings",
"GlobalConfig",
"HttpConfig",
"HttpSettings",
"MediaConfig",
"MediaRuntimeSettings",
"MediaSettings",
"PlatformSenderStrategyConfig",
"PlatformStrategySettings",
Expand Down
Loading
Loading