Merge pull request #1125 from EstrellaXD/refactor/4.0-p5-events-api

feat(4.0-P5): 系统事件上总线、插件路由 / MCP 工具 / 通知模板
This commit is contained in:
Xingyu (Estrella) Pan
2026-10-08 17:53:35 +02:00
committed by GitHub
25 changed files with 1948 additions and 389 deletions

View File

@@ -6,7 +6,10 @@
- **新增(插件扩展点)**:插件可以提供下载器、通知渠道、LLM 解析提供商、搜索站点和定时任务(`ab_sdk.points`)。内置的 qB / aria2 和各通知渠道走同一套扩展注册表。设置页新增「插件」卡片,可查看状态、启停插件,并通过插件配置模型自动生成的表单修改配置;`secret_field` 声明的秘密字段在读取接口中一律掩码。下载器类型和通知渠道下拉框会列出插件提供的选项。新增插件开发文档(开发者指南 → 插件开发)
- **新增(ingest 扩展点)**:RSS 下载流程开放 `torrent.filter`(种子过滤)、`title.parsed`(修正解析结果)、`torrent.adding`(修改保存路径 / 分类 / 标签)、`http.request`(为私有站点添加 Cookie 等请求头)四个钩子,以及 `metadata_provider` 元数据源扩展点:内置 mikan / tmdb 以 `core` 身份登记,插件可按 RSS 订阅的「解析器」取值提供新的元数据源,添加订阅时的解析器下拉框会列出插件选项。插件 SDK 升至 0.3.0,新增 `ab_sdk.ingest`。未安装插件时行为不变
- **新增(种子过滤)**:新增默认启用的内置插件「种子过滤」(`ingest-filters`)。在 设置 → 插件 中填写「包含过滤」后,只下载名称匹配任一表达式的种子(对所有订阅生效);留空时不过滤
- **新增(事件与外部接口)**:系统事件(RSS 订阅异常、种子添加失败、下载器不可用、新版本等)在写入通知中心后发布到插件事件总线,插件可用 `@subscribe("rss_failure")` 等订阅;事件类移入 `ab_sdk.events`(`SystemEvent` 基类,新增 `i18n()` 返回前端 i18n key 与参数)。插件可以提供 REST 路由(挂载于 `/api/v1/plugins/<id>/`,强制登录鉴权,随插件启停出现和消失)、MCP 工具(工具名 `<插件 id>__<id>`)与 MCP 资源(`autobangumi://plugins/<插件 id>/<id>`),并可通过 `message_template` 钩子按事件类型和渠道改写系统通知的标题与正文。`ab_sdk` 升至 0.3.0
- **优化(通知中心推送)**:SSE 的 `notification` 帧改为订阅事件总线,通知中心有新消息、已读或删除时立即推送(原先每 3 秒比较一次修订号),推送格式不变
- **修复(搜索)**:`GET /api/v1/search/provider` 读取的是导入时的站点快照,新保存的搜索站点要重启后才会出现在列表中;现在实时读取
- **修复(MCP)**:MCP 客户端每次断开 SSE 连接后,日志都会出现 `AssertionError: Unexpected message http.response.start`(3.x 起存在)。原因是 `/sse` 端点在 SSE 响应结束后又返回了一个空 `Response`,现在改为裸 ASGI 端点
- **破坏性变更(升级路径)**:只支持从 3.3.x 升级。检测到更早版本的数据(`config/version.info` 低于 3.3,或残留 2.x 的 `data/data.json`)时拒绝启动,并提示先升级到最新 3.3.x 启动一次完成迁移
- **破坏性变更(API)**:移除 3.2 兼容的 GET 控制端点(`/api/v1/restart`、`/start`、`/stop`、`/shutdown`)和 `GET /api/v1/auth/refresh_token`,请改用 POST
- **破坏性变更(配置)**:移除旧版 `experimental_openai` 配置节和通知的单 provider 旧字段(`type` / `token` / `chat_id`),3.3 已把它们迁移到 `llm` 与 `notification.providers`;移除废弃的 `normal` 重命名方式,已有配置自动改为语义相同的 `none`

View File

@@ -27,3 +27,310 @@ class PluginDisabled(Event):
kind: ClassVar[str] = "plugin.disabled"
plugin_id: str
reason: str
# --- P5:系统事件 -------------------------------------------------------------
#
# 以下事件由宿主经 ``NotificationManager.send_event`` 发出:先写入站内通知中心,
# 再发布到事件总线(插件可 ``@subscribe(kind)``),最后推送到外部通知渠道。
# ``kind`` 沿用 3.x 的取值(站内通知中心按 kind 存储与翻译),不带 ``.`` 前缀。
@dataclass(frozen=True, slots=True)
class SystemEvent(Event):
"""可通知事件的基类:同时服务外部推送、站内通知中心与事件总线。
- ``describe()`` 返回默认中文 (标题, 正文),用于外部推送与通知中心兜底展示;
- ``i18n()`` 返回 (i18n key, 参数),前端据此按当前语言渲染,key 形如
``notifications.kind.<kind>``,其下有 ``title`` / ``body`` 两条文案;
- ``severity`` 为 ``info`` / ``warning`` / ``error``;
- ``once=True`` 表示同 ``dedup_key()`` 终生只入库一次(如「新版本可用」)。
插件也可以继承它定义自己的可通知事件(``kind`` 须以 ``<plugin-id>.`` 开头)。
"""
kind: ClassVar[str] = "system"
severity: ClassVar[str] = "info"
once: ClassVar[bool] = False
def dedup_key(self) -> str | None:
"""通知中心的去重键;None 表示每次都新建一条。"""
return None
def payload(self) -> dict:
"""结构化字段,入库为 JSON,也是 i18n 文案的插值参数。"""
from dataclasses import fields
return {f.name: getattr(self, f.name) for f in fields(self)}
def describe(self) -> tuple[str, str]:
"""默认 (标题, 正文)。"""
return (self.kind, "")
def i18n(self) -> tuple[str, dict]:
"""(i18n key, 插值参数)。默认 key 为 ``notifications.kind.<kind>``。"""
return f"notifications.kind.{self.kind}", self.payload()
@dataclass(frozen=True, slots=True)
class RssFailureEvent(SystemEvent):
"""RSS 订阅连接状态从正常变为异常(仅状态翻转时触发一次,而非每个 tick)。"""
kind: ClassVar[str] = "rss_failure"
severity: ClassVar[str] = "error"
once: ClassVar[bool] = False
rss_name: str
rss_url: str
error: str
def dedup_key(self) -> str | None:
return f"rss_failure:{self.rss_url}"
def payload(self) -> dict:
return {"rss_name": self.rss_name, "rss_url": self.rss_url, "error": self.error}
def describe(self) -> tuple[str, str]:
"""返回该事件的默认 (标题, 正文)。"""
return (
"RSS 订阅连接异常",
f"订阅:{self.rss_name}\n地址:{self.rss_url}\n错误:{self.error}",
)
@dataclass(frozen=True, slots=True)
class DownloadFailureEvent(SystemEvent):
"""匹配到的种子在(内置 HTTP 重试后)仍添加下载器失败。"""
kind: ClassVar[str] = "download_failure"
severity: ClassVar[str] = "error"
once: ClassVar[bool] = False
official_title: str
torrent_name: str
def dedup_key(self) -> str | None:
return f"download_failure:{self.official_title}"
def payload(self) -> dict:
return {
"official_title": self.official_title,
"torrent_name": self.torrent_name,
}
def describe(self) -> tuple[str, str]:
return (
"种子添加失败",
f"番剧:{self.official_title}\n种子:{self.torrent_name}\n"
"重试后仍添加失败,请检查下载器连接。",
)
@dataclass(frozen=True, slots=True)
class OffsetReviewEvent(SystemEvent):
"""番剧被标记为需要人工确认季度/集数偏移。"""
kind: ClassVar[str] = "offset_review"
severity: ClassVar[str] = "warning"
once: ClassVar[bool] = False
official_title: str
reason: str
def dedup_key(self) -> str | None:
return f"offset_review:{self.official_title}"
def payload(self) -> dict:
return {"official_title": self.official_title, "reason": self.reason}
def describe(self) -> tuple[str, str]:
return (
"集数偏移待确认",
f"番剧:{self.official_title}\n原因:{self.reason}\n请前往设置页确认偏移量。",
)
@dataclass(frozen=True, slots=True)
class DownloaderUnavailableEvent(SystemEvent):
"""下载器不可用:连不上、用户名/密码错误或 IP 被封禁。
``reason`` 只进 payload 不进 dedup_key——同一台下载器从 unreachable 翻转
到 credentials 时合并为一条(内容以最新为准),不产生两行。
"""
kind: ClassVar[str] = "downloader_unavailable"
severity: ClassVar[str] = "error"
once: ClassVar[bool] = False
host: str
reason: str # unreachable | credentials | banned
def dedup_key(self) -> str | None:
return f"downloader:{self.host}"
def payload(self) -> dict:
return {"host": self.host, "reason": self.reason}
def describe(self) -> tuple[str, str]:
details = {
"credentials": "用户名或密码错误,请在设置中检查下载器凭据。",
"banned": "IP 已被下载器封禁,请在下载器 WebUI 中解封或重启下载器。",
"unreachable": "无法连接下载器,请检查地址、端口和网络。",
}
detail = details.get(self.reason, details["unreachable"])
return ("下载器连接异常", f"下载器:{self.host}\n{detail}")
@dataclass(frozen=True, slots=True)
class UpdateAvailableEvent(SystemEvent):
"""检查到可用的新版本。"""
kind: ClassVar[str] = "update_available"
severity: ClassVar[str] = "info"
once: ClassVar[bool] = True
current: str
latest: str
channel: str
notes: str = ""
def dedup_key(self) -> str | None:
return f"update_available:{self.latest}"
def payload(self) -> dict:
return {
"current": self.current,
"latest": self.latest,
"channel": self.channel,
"notes": self.notes,
}
def describe(self) -> tuple[str, str]:
return (
"发现新版本",
f"当前版本:{self.current}\n最新版本:{self.latest}({self.channel} 频道)\n"
"可前往 设置 → 软件更新 升级。",
)
@dataclass(frozen=True, slots=True)
class UpdateAppliedEvent(SystemEvent):
"""更新应用结果(成功或失败)。每次结果都单独入库,不做去重。"""
once: ClassVar[bool] = False
version: str
success: bool
message: str = ""
# kind / severity 随结果变化,因此用属性覆盖基类的类变量
@property
def kind(self) -> str: # type: ignore[override]
return "update_applied" if self.success else "update_failed"
@property
def severity(self) -> str: # type: ignore[override]
return "info" if self.success else "error"
def dedup_key(self) -> str | None:
return None
def payload(self) -> dict:
return {"version": self.version, "message": self.message}
def describe(self) -> tuple[str, str]:
if self.success:
return ("程序更新完成", f"已更新到 {self.version},重启后生效。")
return ("程序更新失败", f"版本:{self.version}\n原因:{self.message}")
@dataclass(frozen=True, slots=True)
class LLMAuthFailureEvent(SystemEvent):
"""订阅类 LLM 提供商凭据失效(刷新失败),需要用户重新连接。"""
kind: ClassVar[str] = "llm_auth_failure"
severity: ClassVar[str] = "error"
once: ClassVar[bool] = False
provider_id: str
account_label: str = ""
message: str = ""
def dedup_key(self) -> str | None:
return f"llm_auth:{self.provider_id}"
def payload(self) -> dict:
return {
"provider_id": self.provider_id,
"account_label": self.account_label,
"message": self.message,
}
def describe(self) -> tuple[str, str]:
account = f"({self.account_label})" if self.account_label else ""
return (
"LLM 提供商凭据失效",
f"提供商:{self.provider_id}{account}\n{self.message}\n"
"请前往 设置 → LLM 解析器 重新连接。",
)
@dataclass(frozen=True, slots=True)
class LLMPluginInstallFailedEvent(SystemEvent):
"""LLM 提供商插件安装失败(下载/签名校验/兼容性)。"""
kind: ClassVar[str] = "llm_plugin_install_failed"
severity: ClassVar[str] = "error"
once: ClassVar[bool] = False
plugin_id: str
version: str = ""
message: str = ""
def dedup_key(self) -> str | None:
return f"llm_plugin_install:{self.plugin_id}"
def payload(self) -> dict:
return {
"plugin_id": self.plugin_id,
"version": self.version,
"message": self.message,
}
def describe(self) -> tuple[str, str]:
return (
"LLM 插件安装失败",
f"插件:{self.plugin_id} {self.version}\n原因:{self.message}",
)
@dataclass(frozen=True, slots=True)
class RenameConflictEvent(SystemEvent):
"""媒体文件重命名遇到无法自动解决的目标路径冲突。"""
kind: ClassVar[str] = "rename_conflict"
severity: ClassVar[str] = "warning"
once: ClassVar[bool] = False
task_id: str
torrent_name: str
target_path: str
reason: str
def dedup_key(self) -> str | None:
return f"rename_conflict:{self.task_id}:{self.target_path}"
def payload(self) -> dict:
return {
"task_id": self.task_id,
"torrent_name": self.torrent_name,
"target_path": self.target_path,
"reason": self.reason,
}
def describe(self) -> tuple[str, str]:
return (
"媒体文件重命名冲突",
f"种子:{self.torrent_name}\n目标:{self.target_path}\n"
f"原因:{self.reason}",
)

52
backend/src/ab_sdk/mcp.py Normal file
View File

@@ -0,0 +1,52 @@
"""MCP 工具与资源契约。
插件用 ``@provider(points.MCP_TOOL, id="search")`` 返回 :class:`McpTool`,
用 ``@provider(points.MCP_RESOURCE, id="stats")`` 返回 :class:`McpResource`。
``id`` 只需在插件内唯一,宿主对外暴露时加上插件 id 前缀以避免冲突:
- 工具名:``<plugin-id>__<id>``(双下划线。不用 ``.``,因为不少 MCP 客户端
所接的 LLM API 只接受 ``^[a-zA-Z0-9_-]{1,64}$`` 的工具名)
- 资源 URI:``autobangumi://plugins/<plugin-id>/<id>``
MCP 端点(``/mcp``)沿用宿主的访问控制(IP 白名单 / ``scope=mcp`` 令牌),
插件无需自行鉴权。
"""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
DEFAULT_TOOL_TIMEOUT = 60.0
@dataclass(frozen=True)
class McpTool:
"""一个 MCP 工具。
``handler`` 接收客户端传入的参数字典(已按 ``input_schema`` 校验),返回
可 JSON 序列化的结果;宿主把结果序列化后作为文本内容返回给客户端。抛出的
异常会以 ``{"error": "..."}`` 返回,并计入插件熔断。
"""
description: str
handler: Callable[[dict[str, Any]], Awaitable[Any]]
# JSON Schema;默认不接受参数
input_schema: dict[str, Any] = field(
default_factory=lambda: {"type": "object", "properties": {}}
)
# 执行超时(秒)
timeout: float = DEFAULT_TOOL_TIMEOUT
@dataclass(frozen=True)
class McpResource:
"""一个只读的 MCP 资源。
``handler`` 不接受参数,返回字符串(原样返回)或可 JSON 序列化的对象
(宿主序列化为 JSON)。
"""
name: str
handler: Callable[[], Awaitable[Any]]
description: str = ""
mime_type: str = "application/json"

View File

@@ -46,3 +46,15 @@ class Notifier(Protocol):
NotifierFactory = Callable[[NotifierSettings], Notifier]
@dataclass(frozen=True)
class RenderedMessage:
"""``message_template`` 钩子处理的消息:系统事件推送前的标题与正文。
初始值来自事件的 ``describe()``;钩子返回新的 RenderedMessage 即可改写,
返回 None 表示不修改。多个钩子按优先级依次处理,后一个拿到前一个的结果。
"""
title: str
body: str

View File

@@ -32,3 +32,25 @@ TORRENT_FILTER = "torrent.filter"
TITLE_PARSED = "title.parsed"
TORRENT_ADDING = "torrent.adding"
HTTP_REQUEST = "http.request"
# --- P5:外部接口与通知模板 ---
#
# Provider 扩展点(id 只需在插件内唯一,宿主对外暴露时加插件 id 前缀):
#
# - ``API_ROUTER``:返回 ``fastapi.APIRouter``,挂载于
# ``/api/v1/plugins/<plugin-id>/``,强制登录鉴权
# - ``MCP_TOOL``:返回 :class:`ab_sdk.mcp.McpTool`,MCP 工具名为
# ``<plugin-id>__<id>``
# - ``MCP_RESOURCE``:返回 :class:`ab_sdk.mcp.McpResource`,URI 为
# ``autobangumi://plugins/<plugin-id>/<id>``
#
# Transform 钩子(``@hook``):
#
# - ``MESSAGE_TEMPLATE``:签名 ``(message: RenderedMessage, event: SystemEvent,
# channel: str) -> RenderedMessage | None``,在系统事件推送到外部通知渠道前
# 改写标题与正文;``channel`` 为渠道类型(如 ``telegram``)
API_ROUTER = "api_router"
MCP_TOOL = "mcp_tool"
MCP_RESOURCE = "mcp_resource"
MESSAGE_TEMPLATE = "message_template"

View File

@@ -10,6 +10,7 @@ from .log import router as log_router
from .movie import router as movie_router
from .notification import router as notification_router
from .passkey import router as passkey_router
from .plugin_routes import router as plugin_routes_router
from .plugins import router as plugins_router
from .program import router as program_router
from .rss import router as rss_router
@@ -41,3 +42,5 @@ v1.include_router(notification_router)
v1.include_router(update_router)
v1.include_router(llm_router)
v1.include_router(plugins_router)
# 插件路由分发须排在插件管理路由之后(后者的路径更具体)
v1.include_router(plugin_routes_router)

View File

@@ -1,8 +1,13 @@
"""SSE 端点:合并推送 status/downloader/log 更新,取代前端的三个轮询循环
(hooks/useAppInfo.ts:24、pages/index/downloader.vue:28、store/log.ts:25)。
单个连接按 tick 节拍分别推送三种事件,节拍与原轮询间隔保持一致,
前端订阅失败时回退到原有轮询逻辑。
单个连接按 tick 节拍分别推送 status/downloader/log/update,节拍与原轮询间隔
保持一致,前端订阅失败时回退到原有轮询逻辑。这几类是「状态快照」而非事件,
仍按节拍采样。
notification(通知中心未读数)由事件驱动:每个连接在插件事件总线上订阅
``inbox.changed``(通知中心写入/已读/删除时发布),收到后立即查库推送,
不再按节拍比较修订号。连接建立时推送一帧作为初始状态。
"""
import asyncio
@@ -18,7 +23,8 @@ from module.conf import LOG_PATH, VERSION
from module.core import AppContext
from module.database import Database
from module.downloader import DownloadClient
from module.notification.inbox import inbox_revision
from module.notification.inbox import InboxChanged, inbox_revision
from module.plugin.host import get_bus
from module.security.api import get_current_user
from module.update import get_update_progress
@@ -32,7 +38,6 @@ _TICK_SECONDS = 1
_STATUS_EVERY = 3
_DOWNLOADER_EVERY = 5
_LOG_EVERY = 10
_NOTIFICATION_EVERY = 3
# 下载器查询的超时上限(秒)。下载器不可达时 qB/aria2 的认证重试可能阻塞
# 20-30s,而 SSE 是单连接串行推送——不设上限会把 status/log 事件一起卡住。
@@ -87,57 +92,112 @@ async def _log_payload() -> str | None:
return data.decode("utf-8", errors="replace")
class _InboxWatch:
"""一个 SSE 连接对通知中心变化的订阅。
总线回调只置位 ``wake``;``pending`` 表示还欠前端一帧(初始帧、或上次查库
失败需要重试)。帧的 ``revision`` 取推送时的修订号,与旧版轮询一致。
"""
def __init__(self) -> None:
self.wake = asyncio.Event()
self.pending = True
bus = get_bus()
self._unsubscribe = (
bus.subscribe(InboxChanged.kind, self._on_change)
if bus is not None
else None
)
def _on_change(self, _event) -> None:
self.wake.set()
def close(self) -> None:
if self._unsubscribe is not None:
self._unsubscribe()
self._unsubscribe = None
async def frame(self) -> dict | None:
"""有待推送的变化时查库并返回 SSE 帧;没有或查库失败时返回 None。"""
if self.wake.is_set():
self.wake.clear()
self.pending = True
if not self.pending:
return None
rev = inbox_revision()
try:
payload = await _notification_payload()
except Exception:
logger.debug("SSE: notification payload unavailable", exc_info=True)
return None
self.pending = False
payload["revision"] = rev
return {"event": "notification", "data": json.dumps(payload)}
async def wait(self, deadline: float) -> bool:
"""等到 ``deadline``(事件循环时间)或通知中心有变化;有变化返回 True。"""
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
return False
try:
await asyncio.wait_for(self.wake.wait(), remaining)
except TimeoutError:
return False
return True
async def _event_generator(request: Request, ctx: AppContext):
"""按各自节拍推送 status/downloader/log 事件的核心循环。"""
"""按各自节拍推送 status/downloader/log 事件、按需推送 notification 的核心循环。"""
loop = asyncio.get_running_loop()
inbox = _InboxWatch()
tick = 0
last_update: str | None = None
last_inbox_rev: int | None = None
while True:
if await request.is_disconnected():
break
try:
while True:
if await request.is_disconnected():
break
# 更新进度:仅在有进行中的更新(phase != idle)且负载变化时推送,
# 避免重复帧刷屏;每 tick 检查一次以保证下载进度足够实时。
progress = get_update_progress()
if progress.get("phase") != "idle":
payload = json.dumps(progress)
if payload != last_update:
last_update = payload
yield {"event": "update", "data": payload}
# 更新进度:仅在有进行中的更新(phase != idle)且负载变化时推送,
# 避免重复帧刷屏;每 tick 检查一次以保证下载进度足够实时。
progress = get_update_progress()
if progress.get("phase") != "idle":
payload = json.dumps(progress)
if payload != last_update:
last_update = payload
yield {"event": "update", "data": payload}
if tick % _STATUS_EVERY == 0:
yield {"event": "status", "data": json.dumps(_status_payload(ctx))}
if tick % _STATUS_EVERY == 0:
yield {"event": "status", "data": json.dumps(_status_payload(ctx))}
if tick % _DOWNLOADER_EVERY == 0:
# 不可用时推送 null 而非跳过,前端据此感知下载器降级状态
# (store/downloader.ts 对 null 保留旧数据,不会崩溃)。
torrents = await _downloader_payload()
yield {"event": "downloader", "data": json.dumps(torrents)}
if tick % _DOWNLOADER_EVERY == 0:
# 不可用时推送 null 而非跳过,前端据此感知下载器降级状态
# (store/downloader.ts 对 null 保留旧数据,不会崩溃)。
torrents = await _downloader_payload()
yield {"event": "downloader", "data": json.dumps(torrents)}
if tick % _LOG_EVERY == 0:
log_text = await _log_payload()
if log_text is not None:
yield {"event": "log", "data": log_text}
if tick % _LOG_EVERY == 0:
log_text = await _log_payload()
if log_text is not None:
yield {"event": "log", "data": log_text}
# 通知中心:只在修订号变化时查库推送(写入/已读/删除都会 bump),
# 平时每 3 tick 只做一次整数比较,不产生数据库查询。
if tick % _NOTIFICATION_EVERY == 0:
rev = inbox_revision()
if rev != last_inbox_rev:
try:
notif_payload = await _notification_payload()
except Exception:
logger.debug("SSE: notification payload unavailable", exc_info=True)
else:
last_inbox_rev = rev
notif_payload["revision"] = rev
yield {"event": "notification", "data": json.dumps(notif_payload)}
# 通知中心:连接建立时的初始帧,以及此前查库失败后的重试
frame = await inbox.frame()
if frame is not None:
yield frame
await asyncio.sleep(_TICK_SECONDS)
tick += _TICK_SECONDS
# 等待下一个 tick;其间 inbox.changed 到达时立即推送
deadline = loop.time() + _TICK_SECONDS
while await inbox.wait(deadline):
frame = await inbox.frame()
if frame is not None:
yield frame
tick += 1
finally:
inbox.close()
@router.get("/stream", dependencies=[Depends(get_current_user)])
async def event_stream(request: Request, ctx: AppContext = Depends(get_context)):
"""单一 SSE 连接,按各自节拍推送 status/downloader/log 事件。"""
"""单一 SSE 连接,按各自节拍推送 status/downloader/log 事件,
通知中心有变化时推送 notification 事件。"""
return EventSourceResponse(_event_generator(request, ctx))

View File

@@ -0,0 +1,69 @@
"""插件 REST 路由的分发端点:``/api/v1/plugins/{plugin_id}/{path:path}``。
鉴权在这里以路由依赖的形式强制(``get_current_user``),插件路由无法绕过,
也就无法注册匿名端点。请求转发给 ``AppContext.plugin_routes`` 中该插件的
路由;插件未启用或未提供路由时返回 404。
该路由至少需要 ``/plugins/<id>/`` 之后的一段(可为空),因此不会遮蔽
``module/api/plugins.py`` 中的 ``GET /plugins``、``PUT /plugins/settings``、
``GET /plugins/providers`` 与 ``PUT /plugins/{id}``。
"""
from fastapi import APIRouter, Depends, HTTPException
from starlette.responses import Response
from starlette.types import Receive, Scope, Send
from module.core import AppContext
from module.core.plugin_routes import PluginRoutes
from module.security.api import get_current_user
from .deps import get_context
router = APIRouter(prefix="/plugins", tags=["plugins"])
_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
class _Forward(Response):
"""把当前请求原样交给插件路由,由插件路由自己发送响应。
请求体未被读取(端点不声明 body 参数),插件路由可以正常读取。
"""
def __init__(self, routes: PluginRoutes, plugin_id: str, sub_path: str) -> None:
super().__init__()
self._routes = routes
self._plugin_id = plugin_id
self._sub_path = sub_path
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
path: str = scope["path"]
# 与 Starlette Mount 一致:path 保持完整,root_path 指向挂载前缀,
# 插件路由按 ``path - root_path`` 匹配(即 /api/v1/plugins/<id> 之后的部分)
child = {
k: v
for k, v in scope.items()
if k not in ("route", "endpoint", "path_params", "router")
}
child["root_path"] = path[: len(path) - len(self._sub_path)]
handled = await self._routes.dispatch(self._plugin_id, child, receive, send)
if not handled:
raise HTTPException(status_code=404, detail="Not Found")
@router.api_route(
"/{plugin_id}/{path:path}",
methods=_METHODS,
dependencies=[Depends(get_current_user)],
include_in_schema=False,
)
async def plugin_route(
plugin_id: str, path: str, ctx: AppContext = Depends(get_context)
):
"""转发到插件 ``plugin_id`` 通过 ``points.API_ROUTER`` 提供的路由。"""
routes = ctx.plugin_routes
if plugin_id not in routes.plugin_ids():
raise HTTPException(
status_code=404, detail=f"Unknown plugin route: {plugin_id}"
)
return _Forward(routes, plugin_id, "/" + path)

View File

@@ -20,8 +20,7 @@ from module.parser.analyser.mikan_parser import reset_cache as reset_mikan_cache
from module.parser.analyser.tmdb_parser import reset_cache as reset_tmdb_cache
from module.parser.title_parser import reset_cache as reset_llm_parser
from module.plugin import PluginManager
from module.plugin import host as plugin_host
from module.plugin.host import get_registry
from module.plugin.host import get_registry, set_bus, set_runner
from module.rss import RSSAnalyser
from module.searcher.searcher import reset_cache as reset_poster_cache
from module.update import (
@@ -38,6 +37,7 @@ from .loops import (
rss_tick,
update_check_tick,
)
from .plugin_routes import PluginRoutes
from .plugin_tasks import PluginTasks
from .scheduler import PeriodicTask, Scheduler
@@ -90,10 +90,12 @@ class AppContext:
self._plugin_tasks = PluginTasks(
scheduler, self.plugins.registry, self.plugins.breaker
)
self.plugin_routes = PluginRoutes(self.plugins.registry, self.plugins.breaker)
self.plugins.on_change = self._on_plugins_changed
# 模块级代码(RSS 引擎、下载门面、网络层)没有 ctx,经进程级访问器
# 拿到 runner 执行 ingest 钩子
plugin_host.set_runner(self.plugins.runner)
# 宿主各处(通知管理器、站内通知中心、流水线)经 module.plugin.host 发布
# 事件与执行钩子,统一指向本进程的插件管理器
set_bus(self.plugins.bus)
set_runner(self.plugins.runner)
# Downloader-status TTL cache (was ProgramStatus.check_downloader_status).
self._downloader_status = False
self._downloader_reason: str | None = None
@@ -112,6 +114,7 @@ class AppContext:
async def _on_plugins_changed(self) -> None:
"""插件加载/重载/停用后,重建依赖插件 Provider 的派生状态。"""
await self._plugin_tasks.sync()
self.plugin_routes.sync()
# 插件提供的通知渠道在插件加载后才可解析;LLM 解析器可能缓存了旧的
# 插件适配器。下载器客户端缓存键包含 Provider 登记项,会自动换新。
self.notifier.rebuild()

View File

@@ -0,0 +1,92 @@
"""插件 REST 路由(points.API_ROUTER)的挂载表。
FastAPI 不支持在运行时卸载路由,因此宿主只注册一个固定的分发路由
``/api/v1/plugins/{plugin_id}/{path:path}``(见 ``module/api/plugin_routes.py``,
鉴权在那里强制),再按 plugin_id 转发给这里维护的、由插件 ``APIRouter`` 组成的
ASGI 应用。插件加载/重载/停用后 :meth:`PluginRoutes.sync` 重建该表,路由随插件
出现和消失。
"""
import logging
from fastapi import APIRouter
from starlette.exceptions import HTTPException
from starlette.types import ASGIApp, Receive, Scope, Send
from ab_sdk import points
from module.plugin.registry import ExtensionRegistry, ProviderEntry
from module.plugin.runner import CircuitBreaker
logger = logging.getLogger(__name__)
class PluginRoutes:
def __init__(self, registry: ExtensionRegistry, breaker: CircuitBreaker) -> None:
self._registry = registry
self._breaker = breaker
# plugin_id -> (构建时的 Provider 登记项, 合并后的路由)
self._apps: dict[str, tuple[tuple[ProviderEntry, ...], APIRouter]] = {}
def sync(self) -> None:
"""按注册表重建:插件重载后登记项是新对象,据此替换为新实例的路由。"""
grouped: dict[str, list[ProviderEntry]] = {}
for entry in self._registry.providers(points.API_ROUTER).values():
grouped.setdefault(entry.plugin_id, []).append(entry)
apps: dict[str, tuple[tuple[ProviderEntry, ...], APIRouter]] = {}
for plugin_id, entries in grouped.items():
key = tuple(sorted(entries, key=lambda e: e.id))
current = self._apps.get(plugin_id)
if current is not None and current[0] == key:
apps[plugin_id] = current
continue
router = self._build(plugin_id, key)
if router is not None:
apps[plugin_id] = (key, router)
self._apps = apps
def plugin_ids(self) -> list[str]:
return sorted(self._apps)
def _build(
self, plugin_id: str, entries: tuple[ProviderEntry, ...]
) -> APIRouter | None:
combined = APIRouter()
for entry in entries:
try:
router = entry.factory()
if not isinstance(router, APIRouter):
raise TypeError(f"应返回 fastapi.APIRouter,收到 {type(router)}")
combined.include_router(router)
except Exception as e:
reason = f"{points.API_ROUTER} {entry.id}: {type(e).__name__}: {e}"
logger.warning("[Plugin:%s] 路由创建失败:%s", plugin_id, reason)
self._breaker.record_failure(plugin_id, reason)
return None
logger.debug("[Plugin:%s] 已挂载 %d 条路由", plugin_id, len(combined.routes))
return combined
async def dispatch(
self, plugin_id: str, scope: Scope, receive: Receive, send: Send
) -> bool:
"""把请求交给插件路由处理;插件没有路由时返回 False(由调用方回 404)。
``scope`` 应已按 Starlette ``Mount`` 的约定设置好 ``root_path``,插件
路由据此只看到 ``/api/v1/plugins/<id>`` 之后的路径。插件处理函数抛出的
异常计入熔断后继续向上抛,由宿主的异常中间件返回 500。
"""
found = self._apps.get(plugin_id)
if found is None:
return False
app: ASGIApp = found[1]
try:
await app(scope, receive, send)
except HTTPException:
# 未匹配的路径(404/405)或插件主动抛出的 HTTP 错误,不算插件故障
raise
except Exception as e:
self._breaker.record_failure(
plugin_id, f"{points.API_ROUTER}: {type(e).__name__}: {e}"
)
raise
self._breaker.record_success(plugin_id)
return True

View File

@@ -0,0 +1,182 @@
"""插件提供的 MCP 工具与资源(points.MCP_TOOL / points.MCP_RESOURCE)。
每次 list / call 都从扩展注册表现取,插件加载、重载、停用后立即生效。对外
名称加插件 id 前缀以避免与内置及其它插件冲突:
- 工具名 ``<plugin-id>__<id>``:不少 MCP 客户端会把工具名原样交给 LLM API,
而 Anthropic / OpenAI 的工具名只接受 ``^[a-zA-Z0-9_-]{1,64}$``,所以不用 ``.``。
插件 id 只含小写字母、数字与连字符,第一个 ``__`` 即为分隔符,不会歧义。
- 资源 URI ``autobangumi://plugins/<plugin-id>/<id>``。
插件处理函数失败或超时会计入该插件的熔断计数。
"""
import asyncio
import logging
import re
from collections.abc import Callable
from typing import Any
from mcp import types
from pydantic import AnyUrl
from ab_sdk import points
from ab_sdk.mcp import DEFAULT_TOOL_TIMEOUT, McpResource, McpTool
from module.plugin.host import get_registry
from module.plugin.registry import ProviderEntry
from .runtime import get_context
logger = logging.getLogger(__name__)
TOOL_SEPARATOR = "__"
RESOURCE_PREFIX = "autobangumi://plugins/"
_TOOL_NAME = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
def _record(plugin_id: str, reason: str | None) -> None:
"""把调用结果计入插件熔断(MCP 运行时未绑定 AppContext 时跳过)。"""
ctx = get_context()
if ctx is None:
return
breaker = ctx.plugins.breaker
if reason is None:
breaker.record_success(plugin_id)
else:
breaker.record_failure(plugin_id, reason)
def _resolve(
point: str, expected: type, spec: Callable[[ProviderEntry, Any], Any]
) -> list[tuple[ProviderEntry, Any, Any]]:
"""调用该扩展点的全部插件工厂并用 ``spec`` 生成对外的 MCP 描述,
跳过失败、类型不符或字段不合法(MCP 模型校验失败)的条目。"""
resolved = []
for entry in get_registry().providers(point).values():
try:
impl = entry.factory()
if not isinstance(impl, expected):
raise TypeError(f"应返回 {expected.__name__},收到 {type(impl)}")
described = spec(entry, impl)
except Exception as e:
reason = f"{point} {entry.id}: {type(e).__name__}: {e}"
logger.warning("[Plugin:%s] %s", entry.plugin_id, reason)
_record(entry.plugin_id, reason)
continue
resolved.append((entry, impl, described))
return resolved
# ------------------------------------------------------------------ tools
def tool_name(plugin_id: str, tool_id: str) -> str:
return f"{plugin_id}{TOOL_SEPARATOR}{tool_id}"
def _tool_spec(entry: ProviderEntry, tool: McpTool) -> types.Tool:
return types.Tool(
name=tool_name(entry.plugin_id, entry.id),
description=tool.description,
inputSchema=tool.input_schema,
)
def _plugin_tools() -> dict[str, tuple[ProviderEntry, McpTool, types.Tool]]:
tools = {}
for entry, tool, spec in _resolve(points.MCP_TOOL, McpTool, _tool_spec):
name = spec.name
if not _TOOL_NAME.match(name):
logger.warning(
"[Plugin:%s] MCP 工具名 %r 不合法(仅限字母、数字、_、-,"
"不超过 64 字符),已跳过",
entry.plugin_id,
name,
)
continue
tools[name] = (entry, tool, spec)
return tools
def list_plugin_tools() -> list[types.Tool]:
return [spec for _, (_, _, spec) in sorted(_plugin_tools().items())]
async def call_plugin_tool(name: str, arguments: dict) -> tuple[bool, Any]:
"""调用插件工具;``name`` 不是插件工具时返回 ``(False, None)``。
插件处理函数的异常会继续抛出(由 ``handle_tool`` 统一转成错误 JSON),
在此之前计入熔断。
"""
if TOOL_SEPARATOR not in name:
return False, None
found = _plugin_tools().get(name)
if found is None:
return False, None
entry, tool, _ = found
try:
result = await asyncio.wait_for(tool.handler(arguments), tool.timeout)
except Exception as e:
reason = (
f"MCP 工具 {entry.id} 超时({tool.timeout}s)"
if isinstance(e, TimeoutError)
else f"MCP 工具 {entry.id}: {type(e).__name__}: {e}"
)
_record(entry.plugin_id, reason)
raise
_record(entry.plugin_id, None)
return True, result
# ------------------------------------------------------------------ resources
def resource_uri(plugin_id: str, resource_id: str) -> str:
return f"{RESOURCE_PREFIX}{plugin_id}/{resource_id}"
def _resource_spec(entry: ProviderEntry, resource: McpResource) -> types.Resource:
return types.Resource(
uri=AnyUrl(resource_uri(entry.plugin_id, entry.id)),
name=resource.name,
description=resource.description or None,
mimeType=resource.mime_type,
)
def _plugin_resources() -> dict[str, tuple[ProviderEntry, McpResource, types.Resource]]:
# 以 AnyUrl 规范化后的 URI 为键:含空格或非 ASCII 字符的 id 会被百分号编码,
# 客户端读取时传回的正是列表里的编码形式
return {
str(spec.uri): (entry, resource, spec)
for entry, resource, spec in _resolve(
points.MCP_RESOURCE, McpResource, _resource_spec
)
}
def list_plugin_resources() -> list[types.Resource]:
return [spec for _, (_, _, spec) in sorted(_plugin_resources().items())]
async def read_plugin_resource(uri: str) -> tuple[bool, Any]:
"""读取插件资源;``uri`` 不是插件资源时返回 ``(False, None)``。"""
if not uri.startswith(RESOURCE_PREFIX):
return False, None
found = _plugin_resources().get(uri)
if found is None:
return False, None
entry, resource, _ = found
# McpResource 没有超时字段,沿用工具的默认超时
try:
result = await asyncio.wait_for(resource.handler(), DEFAULT_TOOL_TIMEOUT)
except Exception as e:
reason = (
f"MCP 资源 {entry.id} 超时({DEFAULT_TOOL_TIMEOUT}s)"
if isinstance(e, TimeoutError)
else f"MCP 资源 {entry.id}: {type(e).__name__}: {e}"
)
_record(entry.plugin_id, reason)
raise
_record(entry.plugin_id, None)
return True, result

View File

@@ -17,6 +17,7 @@ from module.manager import TorrentManager
from module.models import Bangumi
from module.rss import RSSEngine
from .plugins import list_plugin_resources, read_plugin_resource
from .runtime import get_context
from .tools import _bangumi_to_dict
@@ -53,6 +54,11 @@ RESOURCE_TEMPLATES = [
]
def all_resources() -> list[types.Resource]:
"""内置资源 + 当前已启用插件提供的资源(``autobangumi://plugins/<id>/...``)。"""
return RESOURCES + list_plugin_resources()
async def handle_resource(uri: str) -> str:
"""Return a JSON string for the given MCP resource URI.
@@ -61,6 +67,7 @@ async def handle_resource(uri: str) -> str:
- ``autobangumi://status`` - program version and running state
- ``autobangumi://rss/feeds`` - configured RSS feeds
- ``autobangumi://anime/{id}`` - single anime by integer ID
- ``autobangumi://plugins/<plugin-id>/<id>`` - 插件提供的资源
"""
if uri == "autobangumi://anime/list":
async with Database() as db:
@@ -108,4 +115,14 @@ async def handle_resource(uri: str) -> str:
return json.dumps(_bangumi_to_dict(result), ensure_ascii=False)
return json.dumps({"error": result.msg_en})
try:
handled, content = await read_plugin_resource(uri)
except Exception as e:
logger.warning("Plugin resource %s failed: %s", uri, e)
return json.dumps({"error": str(e)}, ensure_ascii=False)
if handled:
if isinstance(content, str):
return content
return json.dumps(content, ensure_ascii=False, default=str)
return json.dumps({"error": f"Unknown resource: {uri}"})

View File

@@ -13,15 +13,15 @@ import logging
from mcp import types
from mcp.server import Server
from mcp.server.sse import SseServerTransport
from pydantic import AnyUrl
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import Mount, Route
from starlette.types import Receive, Scope, Send
from .resources import RESOURCE_TEMPLATES, RESOURCES, handle_resource
from .resources import RESOURCE_TEMPLATES, all_resources, handle_resource
from .runtime import set_context
from .security import McpAccessMiddleware
from .tools import TOOLS, handle_tool
from .tools import all_tools, handle_tool
logger = logging.getLogger(__name__)
@@ -31,7 +31,7 @@ sse = SseServerTransport("/messages/")
@server.list_tools()
async def list_tools() -> list[types.Tool]:
return TOOLS
return all_tools()
@server.call_tool()
@@ -42,7 +42,7 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
@server.list_resources()
async def list_resources() -> list[types.Resource]:
return RESOURCES
return all_resources()
@server.list_resource_templates()
@@ -51,22 +51,30 @@ async def list_resource_templates() -> list[types.ResourceTemplate]:
@server.read_resource()
async def read_resource(uri: str) -> str:
async def read_resource(uri: AnyUrl) -> str:
logger.debug("Resource read: %s", uri)
return await handle_resource(uri)
# 底层 read_resource 传入的是 pydantic AnyUrl,handle_resource 按字符串匹配
return await handle_resource(str(uri))
async def handle_sse(request: Request):
"""Accept an SSE connection, run the MCP session until the client disconnects."""
async with sse.connect_sse(
request.scope, request.receive, request._send
) as streams:
await server.run(
streams[0],
streams[1],
server.create_initialization_options(),
)
return Response()
class _SseEndpoint:
"""``GET /sse`` 的裸 ASGI 端点:建立 SSE 连接并运行 MCP 会话直到客户端断开。
``connect_sse`` 自己经 ``send`` 写出整个 HTTP 响应,所以这里不能是返回
``Response`` 的普通 endpoint 函数——Starlette 会在 SSE 结束后再发一次
``http.response.start``,每次断开都触发 ``AssertionError``。
"""
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
async with sse.connect_sse(scope, receive, send) as streams:
await server.run(
streams[0],
streams[1],
server.create_initialization_options(),
)
handle_sse = _SseEndpoint()
def create_mcp_starlette_app(ctx=None) -> Starlette:

View File

@@ -1,5 +1,6 @@
import json
import logging
from typing import Any
from mcp import types
@@ -11,6 +12,7 @@ from module.models import Bangumi, BangumiUpdate, RSSItem
from module.rss import RSSAnalyser, RSSEngine
from module.searcher import SearchTorrent
from .plugins import call_plugin_tool, list_plugin_tools
from .runtime import get_context
logger = logging.getLogger(__name__)
@@ -175,6 +177,11 @@ TOOLS = [
]
def all_tools() -> list[types.Tool]:
"""内置工具 + 当前已启用插件提供的工具(``<plugin-id>__<id>``)。"""
return TOOLS + list_plugin_tools()
def _bangumi_to_dict(b: Bangumi) -> dict:
return {
"id": b.id,
@@ -202,7 +209,9 @@ async def handle_tool(name: str, arguments: dict) -> list[types.TextContent]:
try:
result = await _dispatch(name, arguments)
return [
types.TextContent(type="text", text=json.dumps(result, ensure_ascii=False))
types.TextContent(
type="text", text=json.dumps(result, ensure_ascii=False, default=str)
)
]
except Exception as e:
logger.exception("Tool %s failed", name)
@@ -213,7 +222,7 @@ async def handle_tool(name: str, arguments: dict) -> list[types.TextContent]:
]
async def _dispatch(name: str, args: dict) -> dict | list:
async def _dispatch(name: str, args: dict) -> Any:
if name == "list_anime":
return await _list_anime(args.get("active_only", False))
elif name == "get_anime":
@@ -234,8 +243,10 @@ async def _dispatch(name: str, args: dict) -> dict | list:
return await _refresh_feeds()
elif name == "update_anime":
return await _update_anime(args)
else:
return {"error": f"Unknown tool: {name}"}
handled, result = await call_plugin_tool(name, args)
if handled:
return result
return {"error": f"Unknown tool: {name}"}
async def _list_anime(active_only: bool) -> list[dict]:

View File

@@ -7,6 +7,7 @@ from module.conf import settings
from module.models.bangumi import Notification
from module.network import RequestContent
from module.notification.events import SystemEvent
from module.notification.template import render_event
if TYPE_CHECKING:
from module.models.config import NotificationProvider as ProviderConfig
@@ -26,6 +27,8 @@ class NotificationProvider(ABC):
# 单集通知模板({{title}}/{{season}}/{{episode}}/{{poster_url}});
# 未设置时 ``_format_message`` 回退到默认中文文案。
self.template = config.template
# 渠道类型(如 telegram),传给 message_template 钩子按渠道定制文案
self.channel = str(config.type).lower()
async def __aenter__(self) -> "NotificationProvider":
await self._http.__aenter__()
@@ -62,7 +65,8 @@ class NotificationProvider(ABC):
System events use each provider's default title/body delivery
(:meth:`_deliver_text`) -- the per-episode template only covers
{{title}}/{{season}}/{{episode}}/{{poster_url}}, which don't apply
here.
here. 标题与正文默认取 ``event.describe()``,插件可通过
``message_template`` 钩子按事件类型与渠道改写。
Args:
event: The system event to send.
@@ -70,7 +74,7 @@ class NotificationProvider(ABC):
Returns:
True if the event was delivered successfully, False otherwise.
"""
title, body = event.describe()
title, body = await render_event(event, self.channel)
return await self._deliver_text(title, body)
@abstractmethod

View File

@@ -1,294 +1,46 @@
"""非「新集数」通知的类型化事件负载。
刻意与 ``Notification``(module/models/bangumi.py,仅描述「新集数下载完成」)
分开:RSS 订阅失败、下载添加失败、偏移量待确认三类事件携带的字段与集数通知完全
分开:RSS 订阅失败、下载添加失败、偏移量待确认等事件携带的字段与集数通知完全
无关,硬塞进 ``Notification`` 只会制造出没有意义的 season/episode 占位值。
每个事件同时服务两个消费方:
4.0 起事件类定义在 ``ab_sdk.events``(插件订阅时需要 import 它们),这里只做
再导出,宿主代码的 import 路径保持不变。每个事件同时服务三个消费方:
- 外部推送(Telegram/Bark 等):``describe() -> (标题, 正文)`` 渲染中文文案;
- 外部推送(Telegram/Bark 等):``describe() -> (标题, 正文)`` 渲染中文文案,
插件可通过 ``message_template`` 钩子改写;
- 站内通知中心:``kind``/``severity``/``once``/``dedup_key()``/``payload()``
提供结构化字段,前端按 kind + payload 做多语言渲染,describe() 文案作为
未知 kind 的兜底展示。``once=True`` 表示同 dedup_key 终生只入库一次
(如"新版本可用",已读后同一版本不再提醒)。
提供结构化字段,前端按 kind + payload 做多语言渲染(``i18n()`` 给出 key),
describe() 文案作为未知 kind 的兜底展示。``once=True`` 表示同 dedup_key
终生只入库一次(如"新版本可用",已读后同一版本不再提醒);
- 事件总线:``NotificationManager.send_event`` 把事件发布到插件事件总线。
``SystemEvent`` 从封闭的 Union 改为基类:插件定义的可通知事件同样可以送进
``send_event``。
"""
from dataclasses import dataclass
from typing import ClassVar, Optional
@dataclass(slots=True, frozen=True)
class RssFailureEvent:
"""RSS 订阅连接状态从正常变为异常(仅状态翻转时触发一次,而非每个 tick)。"""
kind: ClassVar[str] = "rss_failure"
severity: ClassVar[str] = "error"
once: ClassVar[bool] = False
rss_name: str
rss_url: str
error: str
def dedup_key(self) -> Optional[str]:
return f"rss_failure:{self.rss_url}"
def payload(self) -> dict:
return {"rss_name": self.rss_name, "rss_url": self.rss_url, "error": self.error}
def describe(self) -> tuple[str, str]:
"""返回该事件的默认 (标题, 正文)。"""
return (
"RSS 订阅连接异常",
f"订阅:{self.rss_name}\n地址:{self.rss_url}\n错误:{self.error}",
)
@dataclass(slots=True, frozen=True)
class DownloadFailureEvent:
"""匹配到的种子在(内置 HTTP 重试后)仍添加下载器失败。"""
kind: ClassVar[str] = "download_failure"
severity: ClassVar[str] = "error"
once: ClassVar[bool] = False
official_title: str
torrent_name: str
def dedup_key(self) -> Optional[str]:
return f"download_failure:{self.official_title}"
def payload(self) -> dict:
return {
"official_title": self.official_title,
"torrent_name": self.torrent_name,
}
def describe(self) -> tuple[str, str]:
return (
"种子添加失败",
f"番剧:{self.official_title}\n种子:{self.torrent_name}\n"
"重试后仍添加失败,请检查下载器连接。",
)
@dataclass(slots=True, frozen=True)
class OffsetReviewEvent:
"""番剧被标记为需要人工确认季度/集数偏移。"""
kind: ClassVar[str] = "offset_review"
severity: ClassVar[str] = "warning"
once: ClassVar[bool] = False
official_title: str
reason: str
def dedup_key(self) -> Optional[str]:
return f"offset_review:{self.official_title}"
def payload(self) -> dict:
return {"official_title": self.official_title, "reason": self.reason}
def describe(self) -> tuple[str, str]:
return (
"集数偏移待确认",
f"番剧:{self.official_title}\n原因:{self.reason}\n请前往设置页确认偏移量。",
)
@dataclass(slots=True, frozen=True)
class DownloaderUnavailableEvent:
"""下载器不可用:连不上、用户名/密码错误或 IP 被封禁。
``reason`` 只进 payload 不进 dedup_key——同一台下载器从 unreachable 翻转
到 credentials 时合并为一条(内容以最新为准),不产生两行。
"""
kind: ClassVar[str] = "downloader_unavailable"
severity: ClassVar[str] = "error"
once: ClassVar[bool] = False
host: str
reason: str # unreachable | credentials | banned
def dedup_key(self) -> Optional[str]:
return f"downloader:{self.host}"
def payload(self) -> dict:
return {"host": self.host, "reason": self.reason}
def describe(self) -> tuple[str, str]:
details = {
"credentials": "用户名或密码错误,请在设置中检查下载器凭据。",
"banned": "IP 已被下载器封禁,请在下载器 WebUI 中解封或重启下载器。",
"unreachable": "无法连接下载器,请检查地址、端口和网络。",
}
detail = details.get(self.reason, details["unreachable"])
return ("下载器连接异常", f"下载器:{self.host}\n{detail}")
@dataclass(slots=True, frozen=True)
class UpdateAvailableEvent:
"""检查到可用的新版本。"""
kind: ClassVar[str] = "update_available"
severity: ClassVar[str] = "info"
once: ClassVar[bool] = True
current: str
latest: str
channel: str
notes: str = ""
def dedup_key(self) -> Optional[str]:
return f"update_available:{self.latest}"
def payload(self) -> dict:
return {
"current": self.current,
"latest": self.latest,
"channel": self.channel,
"notes": self.notes,
}
def describe(self) -> tuple[str, str]:
return (
"发现新版本",
f"当前版本:{self.current}\n最新版本:{self.latest}({self.channel} 频道)\n"
"可前往 设置 → 软件更新 升级。",
)
@dataclass(slots=True, frozen=True)
class UpdateAppliedEvent:
"""更新应用结果(成功或失败)。每次结果都单独入库,不做去重。"""
once: ClassVar[bool] = False
version: str
success: bool
message: str = ""
@property
def kind(self) -> str:
return "update_applied" if self.success else "update_failed"
@property
def severity(self) -> str:
return "info" if self.success else "error"
def dedup_key(self) -> Optional[str]:
return None
def payload(self) -> dict:
return {"version": self.version, "message": self.message}
def describe(self) -> tuple[str, str]:
if self.success:
return ("程序更新完成", f"已更新到 {self.version},重启后生效。")
return ("程序更新失败", f"版本:{self.version}\n原因:{self.message}")
@dataclass(slots=True, frozen=True)
class LLMAuthFailureEvent:
"""订阅类 LLM 提供商凭据失效(刷新失败),需要用户重新连接。"""
kind: ClassVar[str] = "llm_auth_failure"
severity: ClassVar[str] = "error"
once: ClassVar[bool] = False
provider_id: str
account_label: str = ""
message: str = ""
def dedup_key(self) -> Optional[str]:
return f"llm_auth:{self.provider_id}"
def payload(self) -> dict:
return {
"provider_id": self.provider_id,
"account_label": self.account_label,
"message": self.message,
}
def describe(self) -> tuple[str, str]:
account = f"({self.account_label})" if self.account_label else ""
return (
"LLM 提供商凭据失效",
f"提供商:{self.provider_id}{account}\n{self.message}\n"
"请前往 设置 → LLM 解析器 重新连接。",
)
@dataclass(slots=True, frozen=True)
class LLMPluginInstallFailedEvent:
"""LLM 提供商插件安装失败(下载/签名校验/兼容性)。"""
kind: ClassVar[str] = "llm_plugin_install_failed"
severity: ClassVar[str] = "error"
once: ClassVar[bool] = False
plugin_id: str
version: str = ""
message: str = ""
def dedup_key(self) -> Optional[str]:
return f"llm_plugin_install:{self.plugin_id}"
def payload(self) -> dict:
return {
"plugin_id": self.plugin_id,
"version": self.version,
"message": self.message,
}
def describe(self) -> tuple[str, str]:
return (
"LLM 插件安装失败",
f"插件:{self.plugin_id} {self.version}\n原因:{self.message}",
)
@dataclass(slots=True, frozen=True)
class RenameConflictEvent:
"""A media rename reached a durable target-path conflict."""
kind: ClassVar[str] = "rename_conflict"
severity: ClassVar[str] = "warning"
once: ClassVar[bool] = False
task_id: str
torrent_name: str
target_path: str
reason: str
def dedup_key(self) -> Optional[str]:
return f"rename_conflict:{self.task_id}:{self.target_path}"
def payload(self) -> dict:
return {
"task_id": self.task_id,
"torrent_name": self.torrent_name,
"target_path": self.target_path,
"reason": self.reason,
}
def describe(self) -> tuple[str, str]:
return (
"媒体文件重命名冲突",
f"种子:{self.torrent_name}\n目标:{self.target_path}\n"
f"原因:{self.reason}",
)
# 除「新集数」以外的通知事件的联合类型。
SystemEvent = (
RssFailureEvent
| DownloadFailureEvent
| OffsetReviewEvent
| DownloaderUnavailableEvent
| UpdateAvailableEvent
| UpdateAppliedEvent
| LLMAuthFailureEvent
| LLMPluginInstallFailedEvent
| RenameConflictEvent
from ab_sdk.events import (
DownloaderUnavailableEvent,
DownloadFailureEvent,
LLMAuthFailureEvent,
LLMPluginInstallFailedEvent,
OffsetReviewEvent,
RenameConflictEvent,
RssFailureEvent,
SystemEvent,
UpdateAppliedEvent,
UpdateAvailableEvent,
)
__all__ = [
"DownloadFailureEvent",
"DownloaderUnavailableEvent",
"LLMAuthFailureEvent",
"LLMPluginInstallFailedEvent",
"OffsetReviewEvent",
"RenameConflictEvent",
"RssFailureEvent",
"SystemEvent",
"UpdateAppliedEvent",
"UpdateAvailableEvent",
]

View File

@@ -2,15 +2,20 @@
``record_event`` 把 SystemEvent 落库(``async with Database()``,
session-per-operation);``inbox_revision`` 是进程内单调递增的修订号,
任何入库/已读/删除操作都会 bump,SSE 循环据此决定是否需要查库推送,
避免每个 tick 都打一次数据库。AB 为单进程部署,进程内计数即可。
任何入库/已读/删除操作都会 bump,并在事件总线上发布 :class:`InboxChanged`。
SSE 连接订阅该事件,收到后才查库推送未读数(取代原先每 3s 比较修订号的轮询)。
AB 为单进程部署,进程内计数即可。
"""
import json
import logging
from dataclasses import dataclass
from typing import ClassVar
from ab_sdk import Event
from module.database import Database
from module.notification.events import SystemEvent
from module.plugin.host import publish
logger = logging.getLogger(__name__)
@@ -19,6 +24,14 @@ INBOX_KEEP = 500
_revision: int = 1
@dataclass(frozen=True, slots=True)
class InboxChanged(Event):
"""站内通知中心有变化(新消息、已读、删除),``revision`` 为新的修订号。"""
kind: ClassVar[str] = "inbox.changed"
revision: int
def inbox_revision() -> int:
return _revision
@@ -26,6 +39,7 @@ def inbox_revision() -> int:
def bump_inbox_revision() -> None:
global _revision
_revision += 1
publish(InboxChanged(revision=_revision))
async def record_event(event: SystemEvent) -> int:

View File

@@ -9,6 +9,7 @@ from module.database import Database
from module.models.bangumi import Notification
from module.notification.events import SystemEvent
from module.notification.inbox import record_event
from module.plugin.host import publish
if TYPE_CHECKING:
from module.models.config import NotificationProvider as ProviderConfig
@@ -118,8 +119,9 @@ class NotificationManager:
async def send_event(self, event: SystemEvent):
"""Persist a system event to the in-app inbox, then broadcast it.
持久化不受 ``settings.notification.enable`` 影响(该开关只管外部
推送),失败也不阻塞外部广播。
顺序:写入站内通知中心 → 发布到插件事件总线 → 推送到外部渠道。
持久化与总线发布不受 ``settings.notification.enable`` 影响(该开关只管
外部推送),持久化失败也不阻塞后两步。
Args:
event: The system event to send.
@@ -132,6 +134,8 @@ class NotificationManager:
type(event).__name__,
exc_info=True,
)
# 插件 @subscribe(event.kind) 的订阅者在各自队列中异步处理,不阻塞这里
publish(event)
if not settings.notification.enable:
return

View File

@@ -0,0 +1,37 @@
"""系统事件推送文案:默认 ``describe()``,经 ``message_template`` 钩子改写。"""
import logging
from ab_sdk import points
from ab_sdk.notify import RenderedMessage
from module.notification.events import SystemEvent
from module.plugin.host import get_runner
logger = logging.getLogger(__name__)
async def render_event(event: SystemEvent, channel: str) -> tuple[str, str]:
"""返回推送到 ``channel`` 渠道的 (标题, 正文)。
没有插件挂 ``message_template`` 钩子时与 ``event.describe()`` 完全一致;
钩子失败、超时或返回值不是 ``RenderedMessage`` 时沿用上一步的结果(并计入
该插件的熔断)。
"""
title, body = event.describe()
runner = get_runner()
if runner is None:
return title, body
try:
message = await runner.transform(
points.MESSAGE_TEMPLATE,
RenderedMessage(title, body),
event,
channel,
expect=RenderedMessage,
)
except Exception:
# 单个钩子的失败已在 runner 内隔离;这里兜底的是运行时本身的异常
# (如扩展点未声明),任何情况下都不能让通知因模板而发不出去
logger.warning("[Notification] 渲染通知模板失败,使用默认文案", exc_info=True)
return title, body
return message.title, message.body

View File

@@ -12,9 +12,10 @@ import logging
from collections.abc import Callable
from typing import Any
from ab_sdk import points
from ab_sdk import Event, points
from ab_sdk.downloader import DownloaderConnection
from .bus import EventBus
from .registry import ExtensionPoint, ExtensionRegistry, ProviderEntry
from .runner import HookRunner
@@ -40,10 +41,23 @@ POINTS = (
ExtensionPoint(points.TITLE_PARSED, "transform", "修正标题解析结果"),
ExtensionPoint(points.TORRENT_ADDING, "transform", "修改发给下载器的添加请求"),
ExtensionPoint(points.HTTP_REQUEST, "transform", "修改宿主 GET 请求的请求头"),
# --- P5 events/api ---
ExtensionPoint(
points.API_ROUTER,
"provider",
"插件 REST 路由(/api/v1/plugins/<id>/)",
scoped=True,
),
ExtensionPoint(points.MCP_TOOL, "provider", "MCP 工具", scoped=True),
ExtensionPoint(points.MCP_RESOURCE, "provider", "MCP 资源", scoped=True),
ExtensionPoint(points.MESSAGE_TEMPLATE, "transform", "系统事件通知文案"),
)
_registry: ExtensionRegistry | None = None
# 由 AppContext 在构建时设置;未设置时(测试、CLI)钩子一律跳过,只执行宿主逻辑
# 进程级事件总线与钩子执行器,由 AppContext 在构造时设置(即 PluginManager
# 持有的那一份);未设置时(单元测试、脚本、CLI)发布事件为空操作、钩子一律
# 跳过,只执行宿主逻辑
_bus: EventBus | None = None
_runner: HookRunner | None = None
@@ -159,3 +173,30 @@ def _register_core(registry: ExtensionRegistry) -> None:
points.METADATA_PROVIDER,
ProviderEntry(CORE, provider_id, metadata(provider_id)),
)
# --- P5 events/api ---------------------------------------------------------
def get_bus() -> EventBus | None:
return _bus
def set_bus(bus: EventBus | None) -> None:
global _bus
_bus = bus
def publish(event: Event) -> None:
"""把宿主事件发布到进程级总线;总线未设置时什么也不做。
发布只负责入队,订阅者的失败不会影响调用方。没有运行中的事件循环时
(同步上下文)无法投递,记录日志后忽略。
"""
bus = _bus
if bus is None:
return
try:
bus.publish(event)
except RuntimeError:
logger.debug("[EventBus] 无事件循环,丢弃事件 %s", event.kind)

View File

@@ -18,6 +18,9 @@ class ExtensionPoint:
description: str = ""
# 仅 filter:钩子出错/超时时是否放行(True)还是视为拒绝(False)
fail_open: bool = True
# 仅 provider:True 时 Provider id 只需在插件内唯一(宿主对外暴露时会加
# 插件 id 前缀,如插件路由、MCP 工具),注册表按 ``<plugin_id>/<id>`` 存储
scoped: bool = False
@dataclass(frozen=True, slots=True)
@@ -68,13 +71,14 @@ class ExtensionRegistry:
if point.kind != "provider":
raise RegistryError(f"{point_name} 是 {point.kind} 扩展点,请使用 @hook")
slot = self._providers.setdefault(point_name, {})
owner = slot.get(entry.id)
key = f"{entry.plugin_id}/{entry.id}" if point.scoped else entry.id
owner = slot.get(key)
if owner is not None:
raise RegistryError(
f"{point_name} 的 Provider id {entry.id!r} 已被插件 "
f"{owner.plugin_id} 占用"
)
slot[entry.id] = entry
slot[key] = entry
def remove_plugin(self, plugin_id: str) -> None:
for name, entries in self._hooks.items():
@@ -100,5 +104,6 @@ class ExtensionRegistry:
return bool(self._hooks.get(point_name))
def providers(self, point_name: str) -> dict[str, ProviderEntry]:
"""按 Provider id 索引;``scoped`` 扩展点的键为 ``<plugin_id>/<id>``。"""
self.point(point_name)
return dict(self._providers.get(point_name, {}))

View File

@@ -214,7 +214,18 @@ class TestLogPayload:
class TestNotificationEvent:
def _generator(self, tmp_path):
"""notification 帧由事件总线上的 inbox.changed 驱动(P5),线格式不变。"""
@pytest.fixture
def bus(self, monkeypatch):
from module.plugin import host
from module.plugin.bus import EventBus
bus = EventBus()
monkeypatch.setattr(host, "_bus", bus)
return bus
def _generator(self, tmp_path, *, tick=0.01, payload=None):
from module.api.events import _event_generator
request = AsyncMock()
@@ -222,17 +233,17 @@ class TestNotificationEvent:
ctx = MagicMock()
ctx.is_running = True
ctx.first_run = False
notification = payload or AsyncMock(
return_value={"unread_count": 2, "latest_id": 9}
)
patches = (
patch(
"module.api.events._downloader_payload",
new=AsyncMock(return_value=None),
),
patch("module.api.events.LOG_PATH", tmp_path / "missing.log"),
patch(
"module.api.events._notification_payload",
new=AsyncMock(return_value={"unread_count": 2, "latest_id": 9}),
),
patch("module.api.events.asyncio.sleep", new=AsyncMock()),
patch("module.api.events._notification_payload", new=notification),
patch("module.api.events._TICK_SECONDS", tick),
)
return _event_generator(request, ctx), patches
@@ -242,7 +253,7 @@ class TestNotificationEvent:
events.append(await asyncio.wait_for(gen.__anext__(), timeout=1.0))
return events
async def test_first_tick_emits_notification_frame(self, tmp_path):
async def test_first_tick_emits_notification_frame(self, tmp_path, bus):
from module.notification.inbox import inbox_revision
gen, patches = self._generator(tmp_path)
@@ -252,14 +263,12 @@ class TestNotificationEvent:
finally:
await gen.aclose()
by_name = {e["event"]: e for e in events}
assert set(by_name) == {"status", "downloader", "notification"}
data = json.loads(by_name["notification"]["data"])
assert data["unread_count"] == 2
assert data["latest_id"] == 9
assert data["revision"] == inbox_revision()
assert [e["event"] for e in events] == ["status", "downloader", "notification"]
# 线格式与 3.x 一致:unread_count / latest_id / revision 三个字段
data = json.loads(events[2]["data"])
assert data == {"unread_count": 2, "latest_id": 9, "revision": inbox_revision()}
async def test_no_frame_when_revision_unchanged(self, tmp_path):
async def test_no_frame_without_inbox_change(self, tmp_path, bus):
gen, patches = self._generator(tmp_path)
with patches[0], patches[1], patches[2], patches[3]:
try:
@@ -271,18 +280,55 @@ class TestNotificationEvent:
assert [e["event"] for e in more] == ["status", "downloader"]
async def test_frame_emitted_after_revision_bump(self, tmp_path):
async def test_inbox_change_pushes_frame_immediately(self, tmp_path, bus):
from module.notification.inbox import bump_inbox_revision, inbox_revision
gen, patches = self._generator(tmp_path)
# tick 设为 30s:若不是事件推送,1s 内不可能收到下一帧
gen, patches = self._generator(tmp_path, tick=30)
with patches[0], patches[1], patches[2], patches[3]:
try:
await self._collect(gen, 3) # 第一个 tick
bump_inbox_revision()
# tick3:status + notification(修订号变了)
more = await self._collect(gen, 2)
[frame] = await self._collect(gen, 1)
finally:
await gen.aclose()
assert [e["event"] for e in more] == ["status", "notification"]
assert json.loads(more[1]["data"])["revision"] == inbox_revision()
assert frame["event"] == "notification"
assert json.loads(frame["data"])["revision"] == inbox_revision()
async def test_unsubscribes_when_stream_closes(self, tmp_path, bus):
gen, patches = self._generator(tmp_path)
with patches[0], patches[1], patches[2], patches[3]:
await self._collect(gen, 3)
assert len(bus._subscribers) == 1
await gen.aclose()
assert bus._subscribers == []
async def test_failed_query_is_retried_next_tick(self, tmp_path, bus):
payload = AsyncMock(
side_effect=[RuntimeError("db busy"), {"unread_count": 1, "latest_id": 3}]
)
gen, patches = self._generator(tmp_path, payload=payload)
with patches[0], patches[1], patches[2], patches[3]:
try:
events = await self._collect(gen, 3)
finally:
await gen.aclose()
# tick0:status + downloader(notification 查库失败);tick1 重试成功
assert [e["event"] for e in events] == ["status", "downloader", "notification"]
assert json.loads(events[2]["data"])["latest_id"] == 3
assert payload.await_count == 2
async def test_without_bus_only_initial_frame(self, tmp_path, monkeypatch):
from module.plugin import host
monkeypatch.setattr(host, "_bus", None)
gen, patches = self._generator(tmp_path)
with patches[0], patches[1], patches[2], patches[3]:
try:
events = await self._collect(gen, 5)
finally:
await gen.aclose()
assert [e["event"] for e in events].count("notification") == 1

View File

@@ -0,0 +1,695 @@
"""P5:系统事件上总线、message_template、插件 REST 路由与 MCP 工具/资源。"""
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import APIRouter, FastAPI, HTTPException
from fastapi.testclient import TestClient
from pydantic import BaseModel
from ab_sdk import Event, Plugin, hook, points, provider, subscribe
from ab_sdk.events import (
RssFailureEvent,
SystemEvent,
UpdateAppliedEvent,
)
from ab_sdk.mcp import McpResource, McpTool
from ab_sdk.notify import RenderedMessage
from module.api import v1
from module.api.deps import get_context
from module.core.plugin_routes import PluginRoutes
from module.models.config import NotificationProvider as ProviderConfig
from module.models.config import Plugins
from module.notification import NotificationManager
from module.notification.inbox import InboxChanged, bump_inbox_revision
from module.notification.template import render_event
from module.plugin import host
from module.plugin.bus import EventBus
from module.plugin.loader import PluginCandidate
from module.plugin.manager import PluginManager
from module.plugin.manifest import parse_manifest
from module.plugin.registry import ExtensionRegistry, HookEntry, ProviderEntry
from module.plugin.runner import CircuitBreaker, HookRunner
from module.security.api import get_current_user
@pytest.fixture
def registry(monkeypatch):
monkeypatch.setattr(host, "_registry", None)
return host.get_registry()
@pytest.fixture
def bus(monkeypatch):
bus = EventBus()
monkeypatch.setattr(host, "_bus", bus)
return bus
@pytest.fixture
def breaker():
return CircuitBreaker()
@pytest.fixture
def runner(monkeypatch, registry, breaker):
runner = HookRunner(registry, breaker)
monkeypatch.setattr(host, "_runner", runner)
return runner
def add(registry, point, provider_id, impl, plugin_id="ext"):
registry.add_provider(point, ProviderEntry(plugin_id, provider_id, lambda: impl))
# ---------------------------------------------------------------- system events
class TestSystemEvents:
def test_system_events_are_sdk_events(self):
event = RssFailureEvent(rss_name="Feed", rss_url="http://x", error="boom")
assert isinstance(event, Event)
assert isinstance(event, SystemEvent)
assert event.kind == "rss_failure"
# 宿主旧的 import 路径仍然可用,且是同一个类
from module.notification import RssFailureEvent as HostRssFailureEvent
assert HostRssFailureEvent is RssFailureEvent
def test_i18n_key_matches_frontend_and_payload_is_params(self):
event = RssFailureEvent(rss_name="Feed", rss_url="http://x", error="boom")
key, params = event.i18n()
assert key == "notifications.kind.rss_failure"
assert params == event.payload()
failed = UpdateAppliedEvent(version="4.0.0", success=False, message="x")
assert failed.i18n()[0] == "notifications.kind.update_failed"
def test_plugin_defined_system_event_defaults(self):
from dataclasses import dataclass
from typing import ClassVar
@dataclass(frozen=True, slots=True)
class Synced(SystemEvent):
kind: ClassVar[str] = "demo.synced"
count: int
event = Synced(count=3)
assert event.payload() == {"count": 3}
assert event.dedup_key() is None
assert event.severity == "info"
assert event.describe() == ("demo.synced", "")
async def test_send_event_publishes_to_bus_even_when_push_disabled(self, bus):
received: list[Event] = []
bus.subscribe("rss_failure", received.append)
manager = NotificationManager(load_providers=False)
event = RssFailureEvent(rss_name="Feed", rss_url="http://x", error="boom")
with (
patch(
"module.notification.manager.record_event", new_callable=AsyncMock
) as record,
patch("module.notification.manager.settings") as mock_settings,
):
mock_settings.notification.enable = False
await manager.send_event(event)
await bus.drain()
record.assert_awaited_once_with(event)
assert received == [event]
async def test_send_event_publishes_even_if_inbox_write_fails(self, bus):
received: list[Event] = []
bus.subscribe("*", received.append)
manager = NotificationManager(load_providers=False)
event = RssFailureEvent(rss_name="Feed", rss_url="http://x", error="boom")
with (
patch(
"module.notification.manager.record_event",
new=AsyncMock(side_effect=RuntimeError("db")),
),
patch("module.notification.manager.settings") as mock_settings,
):
mock_settings.notification.enable = False
await manager.send_event(event)
await bus.drain()
assert received == [event]
async def test_bump_publishes_inbox_changed(self, bus):
received: list[Event] = []
bus.subscribe(InboxChanged.kind, received.append)
bump_inbox_revision()
await bus.drain()
[event] = received
assert isinstance(event, InboxChanged)
def test_publish_without_bus_is_noop(self, monkeypatch):
monkeypatch.setattr(host, "_bus", None)
host.publish(InboxChanged(revision=1)) # 不抛错
# ---------------------------------------------------------------- message_template
class TestMessageTemplate:
EVENT = RssFailureEvent(rss_name="Feed", rss_url="http://x", error="boom")
async def test_default_is_describe_without_runner(self, monkeypatch):
monkeypatch.setattr(host, "_runner", None)
assert await render_event(self.EVENT, "telegram") == self.EVENT.describe()
async def test_default_is_describe_without_hooks(self, runner):
assert await render_event(self.EVENT, "telegram") == self.EVENT.describe()
async def test_hook_rewrites_per_kind_and_channel(self, runner, registry):
def render(message, event, channel):
if event.kind != "rss_failure":
return None
return RenderedMessage(f"[{channel}] {message.title}", event.rss_name)
registry.add_hook(points.MESSAGE_TEMPLATE, HookEntry("ext", render, 100, None))
title, body = await render_event(self.EVENT, "bark")
assert title == f"[bark] {self.EVENT.describe()[0]}"
assert body == "Feed"
other = UpdateAppliedEvent(version="1", success=True)
assert await render_event(other, "bark") == other.describe()
async def test_bad_return_falls_back(self, runner, registry):
registry.add_hook(
points.MESSAGE_TEMPLATE,
HookEntry("ext", lambda m, e, c: "not a message", 100, None),
)
assert await render_event(self.EVENT, "bark") == self.EVENT.describe()
async def test_failing_hook_falls_back_and_counts(self, runner, registry, breaker):
def boom(message, event, channel):
raise RuntimeError("template bug")
registry.add_hook(points.MESSAGE_TEMPLATE, HookEntry("ext", boom, 100, None))
assert await render_event(self.EVENT, "bark") == self.EVENT.describe()
assert breaker._failures["ext"] == 1
async def test_render_event_bad_return_counts_against_its_own_plugin(
self, runner, registry, breaker
):
def rewrite(message, event, channel):
return RenderedMessage(message.title, "rewritten")
registry.add_hook(
points.MESSAGE_TEMPLATE,
HookEntry("bad", lambda m, e, c: "not a message", 10, None),
)
registry.add_hook(
points.MESSAGE_TEMPLATE, HookEntry("good", rewrite, 100, None)
)
title, body = await render_event(self.EVENT, "bark")
assert (title, body) == (self.EVENT.describe()[0], "rewritten")
assert breaker._failures.get("bad") == 1
assert not breaker._failures.get("good")
async def test_runtime_error_never_blocks_delivery(self, monkeypatch):
bad = MagicMock()
bad.transform = AsyncMock(side_effect=RuntimeError("undeclared point"))
monkeypatch.setattr(host, "_runner", bad)
assert await render_event(self.EVENT, "bark") == self.EVENT.describe()
async def test_provider_send_event_uses_template(self, runner, registry):
from module.notification.providers.bark import BarkProvider
registry.add_hook(
points.MESSAGE_TEMPLATE,
HookEntry("ext", lambda m, e, c: RenderedMessage("T", c), 100, None),
)
provider_obj = BarkProvider(
ProviderConfig(type="bark", enabled=True, device_key="k")
)
with patch.object(
provider_obj, "_deliver_text", new=AsyncMock(return_value=True)
) as deliver:
assert await provider_obj.send_event(self.EVENT)
deliver.assert_awaited_once_with("T", "bark")
# ---------------------------------------------------------------- registry
def test_scoped_points_allow_same_id_in_different_plugins(registry):
add(registry, points.MCP_TOOL, "search", object(), plugin_id="a")
add(registry, points.MCP_TOOL, "search", object(), plugin_id="b")
assert set(registry.providers(points.MCP_TOOL)) == {"a/search", "b/search"}
with pytest.raises(Exception, match="已被插件"):
add(registry, points.MCP_TOOL, "search", object(), plugin_id="a")
# ---------------------------------------------------------------- api_router
def demo_router() -> APIRouter:
router = APIRouter()
@router.get("/")
async def root():
return {"root": True}
@router.get("/hello")
async def hello(name: str = "world"):
return {"hello": name}
class Body(BaseModel):
value: int
@router.post("/echo")
async def echo(body: Body):
return {"value": body.value * 2}
@router.get("/teapot")
async def teapot():
raise HTTPException(status_code=418, detail="teapot")
@router.get("/boom")
async def boom():
raise RuntimeError("plugin bug")
return router
@pytest.fixture
def routes_app(registry, breaker):
routes = PluginRoutes(registry, breaker)
plugins_mgr = MagicMock()
plugins_mgr.statuses.return_value = []
ctx = SimpleNamespace(plugin_routes=routes, plugins=plugins_mgr)
app = FastAPI()
app.include_router(v1, prefix="/api")
app.dependency_overrides[get_context] = lambda: ctx
return app, routes
@pytest.fixture
def routes_client(routes_app):
app, routes = routes_app
async def user():
return "testuser"
app.dependency_overrides[get_current_user] = user
return TestClient(app, raise_server_exceptions=False), routes
class TestApiRouter:
def test_routes_follow_plugin_lifecycle(self, routes_client, registry):
client, routes = routes_client
assert client.get("/api/v1/plugins/demo/hello").status_code == 404
add(registry, points.API_ROUTER, "api", demo_router(), plugin_id="demo")
routes.sync()
resp = client.get("/api/v1/plugins/demo/hello", params={"name": "ab"})
assert resp.status_code == 200
assert resp.json() == {"hello": "ab"}
assert client.get("/api/v1/plugins/demo/").json() == {"root": True}
registry.remove_plugin("demo")
routes.sync()
assert client.get("/api/v1/plugins/demo/hello").status_code == 404
def test_body_and_errors_forwarded(self, routes_client, registry, breaker):
client, routes = routes_client
add(registry, points.API_ROUTER, "api", demo_router(), plugin_id="demo")
routes.sync()
assert client.post("/api/v1/plugins/demo/echo", json={"value": 21}).json() == {
"value": 42
}
assert (
client.post("/api/v1/plugins/demo/echo", json={"value": "x"}).status_code
== 422
)
assert client.get("/api/v1/plugins/demo/teapot").status_code == 418
assert client.get("/api/v1/plugins/demo/missing").status_code == 404
assert breaker._failures == {}
assert client.get("/api/v1/plugins/demo/boom").status_code == 500
assert breaker._failures["demo"] == 1
def test_multiple_routers_are_merged(self, routes_client, registry):
client, routes = routes_client
extra = APIRouter()
@extra.get("/extra")
async def extra_route():
return {"extra": True}
add(registry, points.API_ROUTER, "api", demo_router(), plugin_id="demo")
add(registry, points.API_ROUTER, "more", extra, plugin_id="demo")
routes.sync()
assert client.get("/api/v1/plugins/demo/extra").json() == {"extra": True}
assert client.get("/api/v1/plugins/demo/hello").status_code == 200
def test_bad_factory_is_skipped(self, routes_client, registry, breaker):
client, routes = routes_client
add(registry, points.API_ROUTER, "api", "not a router", plugin_id="demo")
routes.sync()
assert routes.plugin_ids() == []
assert breaker._failures["demo"] == 1
def test_management_routes_not_shadowed(self, routes_client, registry):
client, routes = routes_client
add(registry, points.API_ROUTER, "api", demo_router(), plugin_id="settings")
add(registry, points.API_ROUTER, "api2", demo_router(), plugin_id="providers")
routes.sync()
assert client.get("/api/v1/plugins").status_code == 200
providers = client.get("/api/v1/plugins/providers")
assert providers.status_code == 200
assert "downloader" in providers.json()
# PUT /plugins/{id} 仍走插件管理路由(未知插件 → 404 且 detail 来自管理路由)
resp = client.put("/api/v1/plugins/demo", json={})
assert resp.status_code == 404
assert resp.json()["detail"] == "Unknown plugin: demo"
# 插件 id 恰好是 settings/providers 时,其子路径仍可访问
assert client.get("/api/v1/plugins/settings/hello").status_code == 200
@patch("module.security.api.DEV_AUTH_BYPASS", False)
def test_auth_is_enforced(self, routes_app, registry):
app, routes = routes_app
add(registry, points.API_ROUTER, "api", demo_router(), plugin_id="demo")
routes.sync()
client = TestClient(app)
assert client.get("/api/v1/plugins/demo/hello").status_code == 401
assert client.get("/api/v1/plugins/unknown/x").status_code == 401
# ---------------------------------------------------------------- MCP
async def _search(args):
return {"q": args.get("q"), "hits": [1, 2]}
class TestMcp:
def test_plugin_tools_listed_with_namespace(self, registry):
from module.mcp.tools import TOOLS, all_tools
add(
registry,
points.MCP_TOOL,
"search",
McpTool("Search", _search, {"type": "object"}),
plugin_id="demo",
)
names = [t.name for t in all_tools()]
assert names[: len(TOOLS)] == [t.name for t in TOOLS]
assert "demo__search" in names
async def test_call_dispatches_to_plugin(self, registry):
from module.mcp.tools import handle_tool
add(registry, points.MCP_TOOL, "search", McpTool("S", _search), "demo")
[content] = await handle_tool("demo__search", {"q": "x"})
assert json.loads(content.text) == {"q": "x", "hits": [1, 2]}
async def test_unknown_names_keep_error(self, registry):
from module.mcp.tools import handle_tool
add(registry, points.MCP_TOOL, "search", McpTool("S", _search), "demo")
for name in ("nope", "demo__nope", "other__search"):
[content] = await handle_tool(name, {})
assert json.loads(content.text) == {"error": f"Unknown tool: {name}"}
async def test_failure_returns_error_and_counts(self, registry, breaker):
from module.mcp.tools import handle_tool
async def boom(args):
raise RuntimeError("plugin bug")
add(registry, points.MCP_TOOL, "boom", McpTool("B", boom), "demo")
ctx = SimpleNamespace(plugins=SimpleNamespace(breaker=breaker))
with patch("module.mcp.plugins.get_context", return_value=ctx):
[content] = await handle_tool("demo__boom", {})
assert json.loads(content.text) == {"error": "plugin bug"}
assert breaker._failures["demo"] == 1
def test_invalid_names_and_bad_factories_skipped(self, registry):
from module.mcp.plugins import list_plugin_tools
add(registry, points.MCP_TOOL, "has.dot", McpTool("x", _search), "demo")
add(registry, points.MCP_TOOL, "wrong", "not a tool", "demo")
add(registry, points.MCP_TOOL, "ok", McpTool("x", _search), "demo")
assert [t.name for t in list_plugin_tools()] == ["demo__ok"]
async def test_plugin_resources(self, registry):
from module.mcp.resources import RESOURCES, all_resources, handle_resource
async def stats():
return {"count": 3}
async def text():
return "plain"
add(registry, points.MCP_RESOURCE, "stats", McpResource("Stats", stats), "demo")
add(registry, points.MCP_RESOURCE, "text", McpResource("Text", text), "demo")
uris = [str(r.uri) for r in all_resources()]
assert uris[: len(RESOURCES)] == [str(r.uri) for r in RESOURCES]
assert "autobangumi://plugins/demo/stats" in uris
stats_json = await handle_resource("autobangumi://plugins/demo/stats")
assert json.loads(stats_json) == {"count": 3}
assert await handle_resource("autobangumi://plugins/demo/text") == "plain"
missing = await handle_resource("autobangumi://plugins/demo/none")
assert json.loads(missing) == {
"error": "Unknown resource: autobangumi://plugins/demo/none"
}
@pytest.mark.parametrize("resource_id", ["stats", "Weekly Stats", "统计"])
async def test_read_resource_via_protocol_returns_plugin_content(
self, registry, resource_id
):
from mcp import types
from module.mcp import server
async def stats():
return {"count": 3}
add(
registry,
points.MCP_RESOURCE,
resource_id,
McpResource("Stats", stats),
"demo",
)
[listed] = [
r
for r in await server.list_resources()
if str(r.uri).startswith("autobangumi://plugins/")
]
handler = server.server.request_handlers[types.ReadResourceRequest]
result = await handler(
types.ReadResourceRequest(
method="resources/read",
params=types.ReadResourceRequestParams(uri=listed.uri),
)
)
assert isinstance(result.root, types.ReadResourceResult)
[content] = result.root.contents
assert isinstance(content, types.TextResourceContents)
assert json.loads(content.text) == {"count": 3}
@pytest.mark.parametrize(
"point, impl",
[
(points.MCP_TOOL, McpTool("bad", _search, None)), # type: ignore[arg-type]
(points.MCP_RESOURCE, McpResource(None, _search)), # type: ignore[arg-type]
],
)
def test_list_malformed_plugin_spec_skipped_and_counted(
self, registry, breaker, point, impl
):
from module.mcp.resources import RESOURCES, all_resources
from module.mcp.tools import TOOLS, all_tools
add(registry, point, "bad", impl, "demo")
ctx = SimpleNamespace(plugins=SimpleNamespace(breaker=breaker))
with patch("module.mcp.plugins.get_context", return_value=ctx):
assert len(all_tools()) == len(TOOLS)
assert len(all_resources()) == len(RESOURCES)
assert breaker._failures["demo"] == 1
async def test_read_plugin_resource_hung_handler_times_out_and_counts(
self, registry, breaker, monkeypatch
):
import asyncio
from module.mcp import plugins
from module.mcp.resources import handle_resource
async def hang():
await asyncio.Event().wait()
monkeypatch.setattr(plugins, "DEFAULT_TOOL_TIMEOUT", 0.01)
add(registry, points.MCP_RESOURCE, "hang", McpResource("H", hang), "demo")
ctx = SimpleNamespace(plugins=SimpleNamespace(breaker=breaker))
with patch("module.mcp.plugins.get_context", return_value=ctx):
raw = await handle_resource("autobangumi://plugins/demo/hang")
assert "error" in json.loads(raw)
assert breaker._failures["demo"] == 1
async def test_mcp_server_handlers_include_plugins(self, registry):
from module.mcp import server
add(registry, points.MCP_TOOL, "search", McpTool("S", _search), "demo")
assert "demo__search" in [t.name for t in await server.list_tools()]
# ---------------------------------------------------------------- end to end
class Options(BaseModel):
greeting: str = "hi"
class FullPlugin(Plugin[Options]):
"""一个同时使用全部 P5 扩展点的插件。"""
config_model = Options
seen: list[Event] = []
@provider(points.API_ROUTER, id="api")
def api(self):
router = APIRouter()
@router.get("/greet")
async def greet():
return {"greeting": self.config.greeting}
return router
@provider(points.MCP_TOOL, id="greet")
def greet_tool(self):
async def handler(args):
return {"greeting": self.config.greeting}
return McpTool("Greet", handler)
@subscribe("rss_failure")
async def on_rss_failure(self, event):
FullPlugin.seen.append(event)
@hook(points.MESSAGE_TEMPLATE)
def template(self, message, event, channel):
return RenderedMessage(f"{self.config.greeting}: {message.title}", message.body)
MANIFEST = """
[plugin]
id = "full"
name = "Full"
version = "1.0.0"
sdk = ">=0.3,<1"
entry = "x:Y"
"""
async def test_plugin_end_to_end(registry, monkeypatch, tmp_path):
from module.core import AppContext
from module.core.scheduler import Scheduler
from module.mcp.tools import all_tools, handle_tool
# AppContext 会设置进程级 bus/runner;用 monkeypatch 让测试结束后复原
monkeypatch.setattr(host, "_bus", None)
monkeypatch.setattr(host, "_runner", None)
candidate = PluginCandidate(
manifest=parse_manifest(MANIFEST),
source="builtin",
load=lambda: FullPlugin, # type: ignore[arg-type]
)
settings_obj = SimpleNamespace(
plugins=Plugins(options={"full": {"greeting": "yo"}})
)
manager = PluginManager(
settings_obj,
registry=registry,
discover_fn=lambda: ([candidate], []),
data_root=tmp_path,
)
notifier = NotificationManager(load_providers=False)
ctx = AppContext(
settings_obj, notifier, Scheduler([]), MagicMock(), plugins=manager
)
assert host.get_bus() is manager.bus
assert host.get_runner() is manager.runner
FullPlugin.seen = []
try:
with patch("module.core.context.reset_llm_parser"):
await manager.start()
assert manager.statuses()[0].state == "active"
# 事件:send_event → 总线 → 插件订阅者
event = RssFailureEvent(rss_name="Feed", rss_url="http://x", error="boom")
with patch("module.notification.manager.record_event", new_callable=AsyncMock):
await notifier.send_event(event)
await manager.bus.drain()
assert FullPlugin.seen == [event]
# 模板
title, _ = await render_event(event, "bark")
assert title == f"yo: {event.describe()[0]}"
# MCP
assert "full__greet" in [t.name for t in all_tools()]
[content] = await handle_tool("full__greet", {})
assert json.loads(content.text) == {"greeting": "yo"}
# 路由
app = FastAPI()
app.include_router(v1, prefix="/api")
app.dependency_overrides[get_context] = lambda: ctx
async def user():
return "u"
app.dependency_overrides[get_current_user] = user
client = TestClient(app)
assert client.get("/api/v1/plugins/full/greet").json() == {"greeting": "yo"}
# 停用后全部消失
settings_obj.plugins.enabled["full"] = False
await manager.apply_settings()
assert client.get("/api/v1/plugins/full/greet").status_code == 404
assert "full__greet" not in [t.name for t in all_tools()]
assert await render_event(event, "bark") == event.describe()
finally:
await manager.stop()
# ---------------------------------------------------------------- MCP SSE
async def test_mcp_sse_endpoint_sends_response_only_once(monkeypatch):
"""/sse 结束后不能再发第二次 http.response.start(3.x 起每次断开都报
AssertionError)。"""
from contextlib import asynccontextmanager
from module.mcp import server as mcp_server
@asynccontextmanager
async def fake_connect_sse(scope, receive, send):
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": b"", "more_body": False})
yield (object(), object())
async def fake_run(*args, **kwargs):
return None
monkeypatch.setattr(mcp_server.sse, "connect_sse", fake_connect_sse)
monkeypatch.setattr(mcp_server.server, "run", fake_run)
sent: list[dict] = []
async def receive():
return {"type": "http.disconnect"}
async def send(message):
sent.append(message)
await mcp_server.handle_sse(
{"type": "http", "method": "GET", "path": "/sse", "headers": []},
receive,
send,
)
assert [m["type"] for m in sent].count("http.response.start") == 1

View File

@@ -14,6 +14,9 @@
| 搜索站点 | `points.SEARCH_SITE` | `SearchSite` | 搜索框的站点列表 |
| 定时任务 | `points.SCHEDULED_TASK` | `ScheduledTask` | 插件启用即生效 |
| 元数据源 | `points.METADATA_PROVIDER` | `MetadataProvider` | RSS 订阅的「解析器」 |
| REST 路由 | `points.API_ROUTER` | `fastapi.APIRouter` | 插件启用即生效 |
| MCP 工具 | `points.MCP_TOOL` | `McpTool` | 插件启用即生效 |
| MCP 资源 | `points.MCP_RESOURCE` | `McpResource` | 插件启用即生效 |
以及以下钩子(`@hook`,插件启用即生效):
@@ -23,8 +26,9 @@
| `title.parsed` | `points.TITLE_PARSED` | transform | 修正标题解析结果 |
| `torrent.adding` | `points.TORRENT_ADDING` | transform | 修改发给下载器的保存路径、分类、标签 |
| `http.request` | `points.HTTP_REQUEST` | transform | 修改 AB 发出的 GET 请求头(私有站 Cookie 等) |
| `message_template` | `points.MESSAGE_TEMPLATE` | transform | 改写系统事件推送到外部渠道的文案 |
此外,插件可以订阅事件(`@subscribe`),并使用私有的键值存储和数据目录。重命名等整理流水线的扩展点会在后续版本开放。
此外,插件可以订阅系统事件(`@subscribe`),并使用私有的键值存储和数据目录。重命名等整理流水线的扩展点会在后续版本开放。
## 目录结构
@@ -296,7 +300,112 @@ class MyPlugin(Plugin):
return BangumiTv()
```
`request.kind` 区分番剧与电影,`request.torrent.homepage` 是种子的详情页。返回值是完整的新元数据,通常基于 `request.current` 修改。失败或超时时保留原值,并计入熔断。目前 WebUI 的订阅表单只提供内置取值,插件元数据源需要通过 API 设置订阅的 `parser` 字段。
`request.kind` 区分番剧与电影,`request.torrent.homepage` 是种子的详情页。返回值是完整的新元数据,通常基于 `request.current` 修改。失败或超时时保留原值,并计入熔断。添加订阅时,「解析器」下拉框会列出插件提供的元数据源。
### REST 路由
```python
from fastapi import APIRouter
@provider(points.API_ROUTER, id="api")
def api(self):
router = APIRouter()
@router.get("/stats")
async def stats():
return {"count": await self.ctx.kv.get("count", 0)}
return router
```
- 路由挂载在 `/api/v1/plugins/<插件 id>/` 下,上例即 `GET /api/v1/plugins/my-plugin/stats`。
- **所有插件路由都强制登录鉴权**,与 WebUI 其它接口使用同一套凭据(会话 Cookie 或 `scope=api` 的 API 令牌)。插件无法注册匿名端点;未登录返回 401。
- 路由随插件启用出现、随停用消失,无需重启。同一插件可以提供多个 `API_ROUTER`,它们会合并到同一前缀下。
- 处理函数抛出 `HTTPException` 正常返回对应状态码;抛出其它异常返回 500,并计入熔断。
- 插件路由不出现在 `/docs` 的 OpenAPI 文档中。
### MCP 工具与资源
```python
from ab_sdk.mcp import McpResource, McpTool
@provider(points.MCP_TOOL, id="search")
def search_tool(self):
async def handler(args: dict):
return {"results": [...], "keyword": args["keyword"]}
return McpTool(
description="在私有站点搜索",
handler=handler,
input_schema={
"type": "object",
"properties": {"keyword": {"type": "string"}},
"required": ["keyword"],
},
)
@provider(points.MCP_RESOURCE, id="stats")
def stats_resource(self):
async def handler():
return {"count": 3}
return McpResource(name="统计", handler=handler)
```
- 对外名称自动加插件 id 前缀,避免冲突:工具名为 `<插件 id>__<id>`(双下划线),资源 URI 为 `autobangumi://plugins/<插件 id>/<id>`。不用 `.` 分隔,是因为不少 MCP 客户端会把工具名原样交给 LLM API,而后者通常只接受 `^[a-zA-Z0-9_-]{1,64}$`;不合规的工具名会被跳过并记录日志。
- 工具处理函数返回可 JSON 序列化的对象;资源处理函数返回字符串(原样返回)或可 JSON 序列化的对象。抛出的异常以 `{"error": "..."}` 返回给客户端,并计入熔断。工具默认超时 60 秒(`McpTool(timeout=...)`)。
- MCP 端点沿用 AB 的访问控制(IP 白名单或 `scope=mcp` 令牌),插件无需自行鉴权。
### 订阅系统事件
```python
from ab_sdk import subscribe
from ab_sdk.events import RssFailureEvent
@subscribe("rss_failure")
async def on_rss_failure(self, event: RssFailureEvent):
self.ctx.log.warning("订阅 %s 失败:%s", event.rss_name, event.error)
```
AB 发出的、会进入通知中心的事件都是 `ab_sdk.events.SystemEvent` 的子类,带有 `severity`、`payload()`、`describe()`(默认中文标题与正文)和 `i18n()`(前端 i18n key 与参数)。它们先写入通知中心,再发布到事件总线,最后推送到外部通知渠道;关闭「通知」开关只影响外部推送,不影响插件收到事件。
| kind | 事件类 | 何时发布 |
| --- | --- | --- |
| `rss_failure` | `RssFailureEvent` | RSS 订阅从正常变为连接异常 |
| `download_failure` | `DownloadFailureEvent` | 种子重试后仍添加失败 |
| `offset_review` | `OffsetReviewEvent` | 番剧的季度 / 集数偏移需要人工确认 |
| `downloader_unavailable` | `DownloaderUnavailableEvent` | 下载器连不上、凭据错误或 IP 被封 |
| `update_available` | `UpdateAvailableEvent` | 检查到新版本 |
| `update_applied` / `update_failed` | `UpdateAppliedEvent` | 在线更新成功 / 失败(`kind` 随 `success` 变化) |
| `llm_auth_failure` | `LLMAuthFailureEvent` | 订阅类 LLM 提供商凭据失效 |
| `llm_plugin_install_failed` | `LLMPluginInstallFailedEvent` | LLM 插件安装失败 |
| `rename_conflict` | `RenameConflictEvent` | 媒体文件重命名遇到目标路径冲突 |
| `plugin.loaded` | `PluginLoaded` | 插件加载成功 |
| `plugin.disabled` | `PluginDisabled` | 插件加载失败或被熔断 |
| `inbox.changed` | — | 通知中心有新消息、已读或删除(宿主内部使用,供 SSE 推送) |
系统事件的 `kind` 沿用 3.x 的取值(通知中心按它存储和翻译),因此不带 `.` 前缀;插件自己的事件仍必须以 `<插件 id>.` 开头。后续版本会陆续增加流水线事件(如 `torrent.added`、`file.renamed`)。
### 通知文案模板
系统事件推送到外部渠道前,会依次经过 `message_template` 钩子。钩子拿到默认文案、事件和渠道类型,返回新的 `RenderedMessage` 即可改写,返回 `None` 表示不修改:
```python
from ab_sdk import hook, points
from ab_sdk.notify import RenderedMessage
@hook(points.MESSAGE_TEMPLATE)
def template(self, message: RenderedMessage, event, channel: str):
if event.kind != "rss_failure":
return None
if channel == "telegram":
return RenderedMessage(f"[告警] {message.title}", f"<b>{event.rss_name}</b>\n{event.error}")
return None
```
- 只影响外部推送;通知中心里的文案由前端按 `i18n()` 渲染,不受影响。
- 钩子对每个启用的渠道各调用一次。钩子出错或超时时沿用上一步的文案,并计入熔断;返回值类型不对时整条消息回退到默认文案。
- 「新集数」通知暂不经过该钩子,仍使用通知渠道里配置的单集模板。
## 测试

View File

@@ -34,6 +34,17 @@
- **推迟**:`feed_source`(与 `FeedConfig.source` 替代 `RSSItem.parser` 一起设计)、`title_parser` 链、`admission_policy`、`bangumi_matcher`、`release_ranker`、`rule.created`、`save_path` Provider,以及第 11 节的批量 `accept_many`。
- `module/plugin/context.py` 改为延迟 import `module.database`,否则网络层引用 `module.plugin.host` 会形成循环依赖。
### 实施中的调整(P5)
- **系统事件类移入 `ab_sdk.events`**,`SystemEvent` 由封闭 Union 改为基类(插件也可定义可通知事件);`module.notification.events` 只做再导出。`kind` 沿用 3.x 取值(`rss_failure` 等,通知中心按它存储与翻译),不改成 `rss.failure` 式的点分名。
- **通知管理器仍是事件入口**:第 4.2 节设想的「通知管理器、inbox 都作为总线订阅者」未实施。`NotificationManager.send_event` 依次写通知中心、发布到总线、推送外部渠道,调用方与外部行为不变;改成纯总线驱动要等 P3/P4 的流水线事件稳定后再统一。
- **SSE 只有 `notification` 改为订阅总线**(`inbox.changed`,通知中心写入 / 已读 / 删除时发布,立即推送)。status / downloader / log / update 是状态快照而非事件,仍按节拍采样;`inbox_revision` 计数保留,作为帧里的 `revision` 字段。
- **插件路由用分发路由实现**:FastAPI 不能卸载路由,宿主只注册 `/api/v1/plugins/{plugin_id}/{path:path}`(鉴权依赖在此强制),按插件 id 转发给由插件 `APIRouter` 合并成的 ASGI 应用,插件变更时重建。插件路由不进 OpenAPI。
- **按插件作用域的 Provider id**:`ExtensionPoint` 新增 `scoped`,`api_router` / `mcp_tool` / `mcp_resource` 的 id 只需在插件内唯一(注册表键为 `<plugin_id>/<id>`)。MCP 工具名用 `<plugin-id>__<id>` 而非 `.`,因为 Anthropic / OpenAI 等 LLM API 的工具名只接受 `^[a-zA-Z0-9_-]{1,64}$`。
- **`message_template` 只作用于系统事件的外部推送**,钩子签名 `(RenderedMessage, event, channel)`,按渠道各调用一次;「新集数」通知仍用渠道里的单集模板(`_format_message` 是同步接口,改造留到通知渠道迁到 `config_model` 时)。
- **i18n(第 11 节风险 3)先给出 key**:`SystemEvent.i18n()` 返回 `notifications.kind.<kind>` 与 `payload()`,与前端现有文案键一致;外部推送与通知中心的中文兜底文案不变。插件事件的前端翻译依赖 P6 的 `host.i18n`。
- 进程级访问器 `module.plugin.host.get_bus/set_bus`、`get_runner/set_runner`、`publish()`,由 `AppContext` 构造时设置,未设置时为空操作。MCP 的 `tools/list_changed` 通知未实现(客户端重新 list 即可看到插件工具变化)。
## 1. 背景与目标
AB 目前只有 **LLM 提供商** 是真正的运行时插件系统:签名下载、目录加载、懒导入、热重载。
@@ -581,7 +592,7 @@ organize: downloader.completed → media_files.classify → file_parser
| **P2.5 多下载器** | 下载器多实例;`downloader_id` 列与迁移;按实例路由 add / rename / delete;organize 逐实例扫描 | qb + aria2 并存的 e2e 用例;单实例行为不变 |
| **P3 流水线插件化:ingest** | `torrent.filter`、`title.parsed`、`torrent.adding`、`http.request` 钩子;`metadata_provider`(mikan / tmdb 以 `core` 登记);内置插件 `ingest-filters`(包含过滤) | 已完成;无插件时行为不变(全量测试)。`feed_source`、`title_parser` 链、`admission_policy`、`matcher`、`ranker`、`save_path`、size 过滤、按订阅覆盖推迟,见第 0 节 |
| **P4 流水线插件化:organize** | `media_files`、`file_parser`、`rename_strategy`(含 `template`)、`conflict_policy`、`file.renamed` 等事件 | 模板重命名;硬链接示例插件 |
| **P5 事件与外部接口** | SSE 改订阅 bus;`api_router`、`mcp_tool` 扩展点;`message_template` | 删掉 SSE 轮询;插件 MCP 工具 |
| **P5 事件与外部接口** | SSE 改订阅 bus;`api_router`、`mcp_tool` 扩展点;`message_template` | 已完成:系统事件上总线、通知中心 SSE 改为事件推送、插件路由 / MCP 工具与资源 / 通知模板;status 等快照类 SSE 仍按节拍采样。调整见第 0 节 |
| **P6 前端插件** | Web Component 挂载点、`AbHost` 桥接、错误边界、`/plugins/<id>/web` 静态资源、`@autobangumi/plugin-ui` 包 | 示例插件「手动选种」以详情页标签形式可用 |
| **P7 生态** | 插件管理页(安装、启停、日志、错误)、签名目录发布流程、模板仓库(含前端模板)、`ab-plugin` CLI、文档(中 / 英 / 日) | 6 个以上示例插件上架 |
| **P8 发布** | beta 测试、性能对比(RSS 刷新耗时、内存)、升级指南、`docs/changelog/4.0.md` | `4.0.0-beta.1` → `4.0.0` |