From 403cf94572819849ef89c0ffdd71dbdde544af5a Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Wed, 21 Oct 2026 08:53:28 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(plugins):=20=E6=96=B0=E5=A2=9E=20WPUSH?= =?UTF-8?q?=E3=80=81OneBot=E3=80=81Apprise=20=E5=AE=98=E6=96=B9=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E6=8F=92=E4=BB=B6=E7=A4=BA=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wpush-notifier:POST JSON 到 WPUSH 接口,code == 0 才算成功 - onebot-notifier:OneBot v11 私聊 / 群消息,可附带 poster_url 海报 - apprise-notifier:调用 Apprise API 服务器,支持 URL 列表与配置键 - 宿主加载测试与示例文档登记三个新渠道 --- backend/src/test/test_plugin_examples.py | 7 +- docs/dev/plugins/examples.md | 3 + examples/plugins/apprise-notifier/README.md | 45 +++++++ .../apprise_notifier/__init__.py | 91 ++++++++++++++ examples/plugins/apprise-notifier/plugin.toml | 10 ++ .../plugins/apprise-notifier/pyproject.toml | 13 ++ .../tests/test_apprise_notifier.py | 63 ++++++++++ examples/plugins/onebot-notifier/README.md | 40 +++++++ .../onebot_notifier/__init__.py | 113 ++++++++++++++++++ examples/plugins/onebot-notifier/plugin.toml | 10 ++ .../plugins/onebot-notifier/pyproject.toml | 13 ++ .../tests/test_onebot_notifier.py | 93 ++++++++++++++ examples/plugins/wpush-notifier/README.md | 33 +++++ examples/plugins/wpush-notifier/plugin.toml | 10 ++ .../plugins/wpush-notifier/pyproject.toml | 13 ++ .../tests/test_wpush_notifier.py | 65 ++++++++++ .../wpush-notifier/wpush_notifier/__init__.py | 84 +++++++++++++ 17 files changed, 705 insertions(+), 1 deletion(-) create mode 100644 examples/plugins/apprise-notifier/README.md create mode 100644 examples/plugins/apprise-notifier/apprise_notifier/__init__.py create mode 100644 examples/plugins/apprise-notifier/plugin.toml create mode 100644 examples/plugins/apprise-notifier/pyproject.toml create mode 100644 examples/plugins/apprise-notifier/tests/test_apprise_notifier.py create mode 100644 examples/plugins/onebot-notifier/README.md create mode 100644 examples/plugins/onebot-notifier/onebot_notifier/__init__.py create mode 100644 examples/plugins/onebot-notifier/plugin.toml create mode 100644 examples/plugins/onebot-notifier/pyproject.toml create mode 100644 examples/plugins/onebot-notifier/tests/test_onebot_notifier.py create mode 100644 examples/plugins/wpush-notifier/README.md create mode 100644 examples/plugins/wpush-notifier/plugin.toml create mode 100644 examples/plugins/wpush-notifier/pyproject.toml create mode 100644 examples/plugins/wpush-notifier/tests/test_wpush_notifier.py create mode 100644 examples/plugins/wpush-notifier/wpush_notifier/__init__.py diff --git a/backend/src/test/test_plugin_examples.py b/backend/src/test/test_plugin_examples.py index 5c78b17f..2c283f0f 100644 --- a/backend/src/test/test_plugin_examples.py +++ b/backend/src/test/test_plugin_examples.py @@ -117,7 +117,12 @@ async def test_all_examples_load_in_host_with_expected_providers(tmp_path): try: assert {s.id: s.state for s in mgr.statuses()} == dict.fromkeys(ids, "active") assert set(registry.providers(points.RENAME_STRATEGY)) == {"mini-template"} - assert set(registry.providers(points.NOTIFIER)) == {"ntfy"} + assert set(registry.providers(points.NOTIFIER)) == { + "ntfy", + "wpush", + "onebot", + "apprise", + } assert set(registry.providers(points.SEARCH_SITE)) == {"my-tracker"} assert [h.plugin_id for h in registry.hooks(points.HTTP_REQUEST)] == [ "custom-rss-site" diff --git a/docs/dev/plugins/examples.md b/docs/dev/plugins/examples.md index 95860a1e..c4ef5847 100644 --- a/docs/dev/plugins/examples.md +++ b/docs/dev/plugins/examples.md @@ -9,6 +9,9 @@ | `template-rename` | `rename_strategy` Provider,自带过滤器的文件名模板;`RenameSkipped`;`RenameStrategyContract` | | `nfo-writer` | 订阅 `torrent.organized`,在正片旁写 `.nfo`;路径映射;幂等 | | `ntfy-notifier` | `notifier` Provider;后端拒绝时返回 `False`;`NotifierContract` | +| `wpush-notifier` | `notifier` Provider;WPUSH 多渠道推送,按返回 JSON 的 `code` 判断成败 | +| `onebot-notifier` | `notifier` Provider;OneBot v11 私聊 / 群消息,消息段附带海报 `poster_url` | +| `apprise-notifier` | `notifier` Provider;调用 Apprise API 服务器,支持 URL 列表与配置键两种方式 | | `manual-pick` | 前端挂载点 `bangumi.detail.tab`、插件路由、插件 KV、事件 | ## 安装一个示例 diff --git a/examples/plugins/apprise-notifier/README.md b/examples/plugins/apprise-notifier/README.md new file mode 100644 index 00000000..2077cea2 --- /dev/null +++ b/examples/plugins/apprise-notifier/README.md @@ -0,0 +1,45 @@ +# Apprise 通知 + +通过 [Apprise API](https://github.com/caronc/apprise-api) 服务器把通知转发到 Telegram、Discord、邮件等上百种服务。插件不依赖 `apprise` 库,只调用 HTTP 接口。 + +- `@provider(points.NOTIFIER, id="apprise")`:返回 `NotifierFactory` +- 填写了 Apprise URL:`POST {server}/notify/`,请求体 `{"urls", "title", "body"}`,多个 URL 以逗号拼接 +- 否则使用配置键:`POST {server}/notify/{config_key}`,请求体 `{"title", "body"}`,URL 保存在服务器上 +- 两者都没填时 `send` 返回 `False`,「测试」提示需要填写其中之一 +- HTTP 2xx 即成功 + +## 准备 Apprise API 服务器 + +自行运行 `caronc/apprise` 容器,例如: + +```bash +docker run -d --name apprise -p 8000:8000 caronc/apprise:latest +``` + +与 AutoBangumi 在同一 Docker 网络时,服务器地址可填 `http://apprise:8000`。使用配置键时,先在 Apprise 的网页界面(`http://<服务器>:8000/cfg/<配置键>`)保存通知 URL。 + +## 配置 + +| 字段 | 说明 | +| --- | --- | +| `server` | Apprise API 服务器地址,默认 `http://apprise:8000` | +| `urls` | 可选:Apprise URL 列表,如 `tgram://bottoken/ChatID`;填写后忽略配置键 | +| `config_key` | 可选:服务器上保存的配置键 | + +## 安装 + +```bash +cp -r examples/plugins/apprise-notifier config/plugins/local/ +``` + +在「设置 → 插件」中开启「允许未签名插件」,启用并填写服务器与 URL(或配置键);再到「设置 → 通知 → 添加渠道」选择类型 `apprise`。 + +## 测试 + +```bash +cd examples/plugins/apprise-notifier && uv run pytest +``` + +--- + +基于 @LTSlw 在 [#942](https://github.com/EstrellaXD/Auto_Bangumi/pull/942) 中的实现 diff --git a/examples/plugins/apprise-notifier/apprise_notifier/__init__.py b/examples/plugins/apprise-notifier/apprise_notifier/__init__.py new file mode 100644 index 00000000..9b5e6e20 --- /dev/null +++ b/examples/plugins/apprise-notifier/apprise_notifier/__init__.py @@ -0,0 +1,91 @@ +"""Apprise 通知渠道。 + +不依赖 apprise 库,而是调用用户自建的 Apprise API 服务器(caronc/apprise 镜像): + +- 填了 ``urls``:无状态调用 ``POST {server}/notify/``,请求体带上 Apprise URL; +- 否则用 ``config_key``:``POST {server}/notify/{config_key}``,URL 存在服务器上。 + +HTTP 2xx 即成功。接口文档:https://github.com/caronc/apprise-api +""" + +from collections.abc import Callable +from typing import Any + +import httpx2 +from pydantic import BaseModel, Field + +from ab_sdk import Plugin, points, provider +from ab_sdk.notify import NotificationMessage, NotifierFactory, NotifierSettings + + +class Options(BaseModel): + server: str = Field( + "http://apprise:8000", title="服务器", description="Apprise API 服务器地址" + ) + urls: list[str] = Field( + default_factory=list, + title="Apprise URL", + description="如 tgram://bottoken/ChatID;填写后忽略配置键", + ) + config_key: str = Field( + "", + title="配置键", + description="未填 Apprise URL 时,使用服务器上保存的这份配置", + ) + + +class Channel: + def __init__( + self, + options: Options, + settings: NotifierSettings, + client_factory: Callable[[], httpx2.AsyncClient], + ) -> None: + self.options = options + self.settings = settings + self.client_factory = client_factory + + async def _post(self, message: NotificationMessage) -> tuple[bool, str]: + opts = self.options + server = opts.server.rstrip("/") + payload: dict[str, Any] = {"title": message.title, "body": message.body} + if opts.urls: + url = f"{server}/notify/" + # Apprise 接受逗号分隔的 URL 字符串 + payload["urls"] = ", ".join(opts.urls) + elif opts.config_key: + url = f"{server}/notify/{opts.config_key}" + else: + return False, "需填写 Apprise URL 或配置键" + try: + async with self.client_factory() as client: + resp = await client.post(url, json=payload) + except httpx2.HTTPError as e: + return False, f"请求失败:{e}" + if resp.is_success: + return True, "ok" + return False, f"Apprise 返回 HTTP {resp.status_code}:{resp.text[:200]}" + + async def send(self, message: NotificationMessage) -> bool: + ok, _ = await self._post(message) + return ok + + async def test(self) -> tuple[bool, str]: + return await self._post( + NotificationMessage( + kind="event", title="AutoBangumi 通知测试", body="通知测试成功!" + ) + ) + + +class AppriseNotifier(Plugin[Options]): + config_model = Options + + # 测试可替换为带 MockTransport 的客户端 + client_factory: Callable[[], httpx2.AsyncClient] = staticmethod( + lambda: httpx2.AsyncClient(timeout=10) + ) + + @provider(points.NOTIFIER, id="apprise") + def channel(self) -> NotifierFactory: + return lambda settings: Channel(self.config, settings, self.client_factory) diff --git a/examples/plugins/apprise-notifier/plugin.toml b/examples/plugins/apprise-notifier/plugin.toml new file mode 100644 index 00000000..9bf860ec --- /dev/null +++ b/examples/plugins/apprise-notifier/plugin.toml @@ -0,0 +1,10 @@ +[plugin] +id = "apprise-notifier" +name = "Apprise 通知" +version = "0.1.0" +sdk = ">=0.5,<1" +entry = "apprise_notifier:AppriseNotifier" +description = "通过 Apprise API 服务器(caronc/apprise)转发通知到上百种服务。在「设置 → 通知 → 添加渠道」选择类型 apprise" +authors = ["AutoBangumi"] +permissions = ["network"] +extension_points = ["notifier"] diff --git a/examples/plugins/apprise-notifier/pyproject.toml b/examples/plugins/apprise-notifier/pyproject.toml new file mode 100644 index 00000000..74ea2cee --- /dev/null +++ b/examples/plugins/apprise-notifier/pyproject.toml @@ -0,0 +1,13 @@ +# 只用于本地开发与测试,不会被打进插件包(ab-plugin pack 排除它) +[project] +name = "ab-plugin-apprise-notifier" +version = "0.1.0" +requires-python = ">=3.14" +dependencies = ["autobangumi-sdk"] + +[dependency-groups] +dev = ["pytest>=8.0.0"] + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/examples/plugins/apprise-notifier/tests/test_apprise_notifier.py b/examples/plugins/apprise-notifier/tests/test_apprise_notifier.py new file mode 100644 index 00000000..1d2057d1 --- /dev/null +++ b/examples/plugins/apprise-notifier/tests/test_apprise_notifier.py @@ -0,0 +1,63 @@ +import asyncio +import json + +import httpx2 +import pytest +from apprise_notifier import AppriseNotifier + +from ab_sdk.notify import NotificationMessage, NotifierSettings +from ab_sdk.testing import NotifierContract, create_plugin + +MESSAGE = NotificationMessage(kind="event", title="标题", body="正文") + + +def channel(status: int, options: dict, seen=None): + def handler(request: httpx2.Request) -> httpx2.Response: + if seen is not None: + seen.append(request) + return httpx2.Response(status) + + plugin, _ = create_plugin(AppriseNotifier, options) + plugin.client_factory = lambda: httpx2.AsyncClient( # type: ignore[method-assign] + transport=httpx2.MockTransport(handler) + ) + return plugin.channel()(NotifierSettings()) + + +class TestChannel(NotifierContract): + def create(self): + return channel(200, {"config_key": "ab"}) + + def create_failing(self): + return channel(424, {"config_key": "ab"}) + + +@pytest.mark.parametrize( + ("options", "path", "payload"), + [ + ( + {"server": "http://ap:8000/", "urls": ["tgram://t/1", "mailto://u"]}, + "/notify/", + {"title": "标题", "body": "正文", "urls": "tgram://t/1, mailto://u"}, + ), + ( + {"server": "http://ap:8000", "config_key": "ab"}, + "/notify/ab", + {"title": "标题", "body": "正文"}, + ), + ], +) +def test_send_posts_stateless_urls_or_config_key(options, path, payload): + seen: list[httpx2.Request] = [] + assert asyncio.run(channel(200, options, seen).send(MESSAGE)) is True + [request] = seen + assert request.url.path == path + assert json.loads(request.content) == payload + + +def test_missing_urls_and_config_key_fails_without_request(): + seen: list[httpx2.Request] = [] + ch = channel(200, {}, seen) + assert asyncio.run(ch.send(MESSAGE)) is False + assert asyncio.run(ch.test()) == (False, "需填写 Apprise URL 或配置键") + assert seen == [] diff --git a/examples/plugins/onebot-notifier/README.md b/examples/plugins/onebot-notifier/README.md new file mode 100644 index 00000000..1dfb2844 --- /dev/null +++ b/examples/plugins/onebot-notifier/README.md @@ -0,0 +1,40 @@ +# OneBot 通知 + +通过 [OneBot v11](https://github.com/botuniverse/onebot-11) HTTP API 把通知发到 QQ 私聊或群。NapCat、Lagrange.OneBot、LLOneBot 等实现都提供这个接口。 + +- `@provider(points.NOTIFIER, id="onebot")`:返回 `NotifierFactory` +- 向 `{url}/send_private_msg`(`user_id`)或 `{url}/send_group_msg`(`group_id`)POST JSON,消息为消息段数组 +- 填写了 Access Token 时附带 `Authorization: Bearer ` +- HTTP 2xx 且返回 `status == "ok"` 或 `retcode == 0` 才算成功 + +## 配置 + +| 字段 | 说明 | +| --- | --- | +| `url` | OneBot HTTP API 地址,如 `http://napcat:3000` | +| `access_token` | 可选:Access Token(秘密字段) | +| `message_type` | `private`(私聊)或 `group`(群聊) | +| `target` | 私聊填 QQ 号,群聊填群号 | +| `send_poster` | 是否附带海报图片,默认开启 | + +## 海报 + +插件只能拿到宿主给出的公开海报地址,拿不到本地海报文件。因此要发送海报,需要在「设置 → 通知」中配置 `base_url`,并保证 OneBot 实现能访问这个地址;它会自行下载图片。未配置时只发送文字。 + +## 安装 + +```bash +cp -r examples/plugins/onebot-notifier config/plugins/local/ +``` + +在「设置 → 插件」中开启「允许未签名插件」,启用并填写 API 地址与目标;再到「设置 → 通知 → 添加渠道」选择类型 `onebot`。 + +## 测试 + +```bash +cd examples/plugins/onebot-notifier && uv run pytest +``` + +--- + +基于 @AMYdd00 在 [#1033](https://github.com/EstrellaXD/Auto_Bangumi/pull/1033) 中的实现 diff --git a/examples/plugins/onebot-notifier/onebot_notifier/__init__.py b/examples/plugins/onebot-notifier/onebot_notifier/__init__.py new file mode 100644 index 00000000..c93b8919 --- /dev/null +++ b/examples/plugins/onebot-notifier/onebot_notifier/__init__.py @@ -0,0 +1,113 @@ +"""OneBot v11 通知渠道。 + +POST JSON 到 ``{url}/send_private_msg`` 或 ``{url}/send_group_msg``,消息为消息段 +数组:文字,以及(开启海报且宿主给出海报地址时)一张图片。HTTP 2xx 且返回 +``status == "ok"`` 或 ``retcode == 0`` 才算成功。 +协议文档:https://github.com/botuniverse/onebot-11 +""" + +from collections.abc import Callable +from typing import Any, Literal + +import httpx2 +from pydantic import BaseModel, Field + +from ab_sdk import Plugin, points, provider, secret_field +from ab_sdk.notify import NotificationMessage, NotifierFactory, NotifierSettings + + +class Options(BaseModel): + url: str = Field( + "", + title="API 地址", + description="OneBot v11 HTTP API 地址,如 http://napcat:3000", + ) + access_token: str = secret_field( + title="Access Token", description="可选:以 Authorization: Bearer 发送" + ) + message_type: Literal["private", "group"] = Field("private", title="消息类型") + target: int = Field(0, title="目标", description="私聊填 QQ 号,群聊填群号") + send_poster: bool = Field( + True, + title="发送海报", + description="需在通知设置里配置 base_url,宿主才会给出可访问的海报地址", + ) + + +class Channel: + def __init__( + self, + options: Options, + settings: NotifierSettings, + client_factory: Callable[[], httpx2.AsyncClient], + ) -> None: + self.options = options + self.settings = settings + self.client_factory = client_factory + + def _segments(self, message: NotificationMessage) -> list[dict[str, Any]]: + # 单集通知的正文已含番剧名;系统事件的标题与正文分开,拼在一起发 + text = ( + message.body + if message.kind == "episode" + else f"{message.title}\n{message.body}" + ) + segments: list[dict[str, Any]] = [] + # SDK 只给出公开海报 URL(没有本地路径),由 OneBot 实现自行下载 + if self.options.send_poster and message.poster_url: + segments.append({"type": "image", "data": {"file": message.poster_url}}) + segments.append({"type": "text", "data": {"text": text}}) + return segments + + async def _post(self, message: NotificationMessage) -> tuple[bool, str]: + opts = self.options + if not opts.url or not opts.target: + return False, "未填写 API 地址或目标" + id_key = "group_id" if opts.message_type == "group" else "user_id" + headers = {} + if opts.access_token: + headers["Authorization"] = f"Bearer {opts.access_token}" + try: + async with self.client_factory() as client: + resp = await client.post( + f"{opts.url.rstrip('/')}/send_{opts.message_type}_msg", + json={id_key: opts.target, "message": self._segments(message)}, + headers=headers, + ) + body = resp.json() + except (httpx2.HTTPError, ValueError) as e: + return False, f"请求失败:{e}" + if ( + resp.is_success + and isinstance(body, dict) + and (body.get("status") == "ok" or body.get("retcode") == 0) + ): + return True, "ok" + detail = ( + (body.get("wording") or body.get("msg")) if isinstance(body, dict) else None + ) + return False, f"OneBot 返回错误:{detail or resp.status_code}" + + async def send(self, message: NotificationMessage) -> bool: + ok, _ = await self._post(message) + return ok + + async def test(self) -> tuple[bool, str]: + return await self._post( + NotificationMessage( + kind="event", title="AutoBangumi 通知测试", body="通知测试成功!" + ) + ) + + +class OneBotNotifier(Plugin[Options]): + config_model = Options + + # 测试可替换为带 MockTransport 的客户端 + client_factory: Callable[[], httpx2.AsyncClient] = staticmethod( + lambda: httpx2.AsyncClient(timeout=10) + ) + + @provider(points.NOTIFIER, id="onebot") + def channel(self) -> NotifierFactory: + return lambda settings: Channel(self.config, settings, self.client_factory) diff --git a/examples/plugins/onebot-notifier/plugin.toml b/examples/plugins/onebot-notifier/plugin.toml new file mode 100644 index 00000000..7fb32e56 --- /dev/null +++ b/examples/plugins/onebot-notifier/plugin.toml @@ -0,0 +1,10 @@ +[plugin] +id = "onebot-notifier" +name = "OneBot 通知" +version = "0.1.0" +sdk = ">=0.5,<1" +entry = "onebot_notifier:OneBotNotifier" +description = "通过 OneBot v11 HTTP API(NapCat、Lagrange 等)把通知发到 QQ 私聊或群。在「设置 → 通知 → 添加渠道」选择类型 onebot" +authors = ["AutoBangumi"] +permissions = ["network"] +extension_points = ["notifier"] diff --git a/examples/plugins/onebot-notifier/pyproject.toml b/examples/plugins/onebot-notifier/pyproject.toml new file mode 100644 index 00000000..f02ae942 --- /dev/null +++ b/examples/plugins/onebot-notifier/pyproject.toml @@ -0,0 +1,13 @@ +# 只用于本地开发与测试,不会被打进插件包(ab-plugin pack 排除它) +[project] +name = "ab-plugin-onebot-notifier" +version = "0.1.0" +requires-python = ">=3.14" +dependencies = ["autobangumi-sdk"] + +[dependency-groups] +dev = ["pytest>=8.0.0"] + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/examples/plugins/onebot-notifier/tests/test_onebot_notifier.py b/examples/plugins/onebot-notifier/tests/test_onebot_notifier.py new file mode 100644 index 00000000..a48a54ff --- /dev/null +++ b/examples/plugins/onebot-notifier/tests/test_onebot_notifier.py @@ -0,0 +1,93 @@ +import asyncio +import json + +import httpx2 +import pytest +from onebot_notifier import OneBotNotifier + +from ab_sdk.notify import NotificationMessage, NotifierSettings +from ab_sdk.testing import NotifierContract, create_plugin + +BASE = {"url": "http://bot:3000/", "target": 123} +POSTER = "https://ab.example.com/posters/a.jpg" + + +def channel(status: int, body: dict, options: dict | None = None, seen=None): + def handler(request: httpx2.Request) -> httpx2.Response: + if seen is not None: + seen.append(request) + return httpx2.Response(status, json=body) + + plugin, _ = create_plugin(OneBotNotifier, BASE if options is None else options) + plugin.client_factory = lambda: httpx2.AsyncClient( # type: ignore[method-assign] + transport=httpx2.MockTransport(handler) + ) + return plugin.channel()(NotifierSettings()) + + +def episode(poster_url: str | None = None) -> NotificationMessage: + return NotificationMessage( + kind="episode", title="番剧", body="番剧 第1集", poster_url=poster_url + ) + + +class TestChannel(NotifierContract): + def create(self): + return channel(200, {"status": "ok", "retcode": 0}) + + def create_failing(self): + return channel(200, {"status": "failed", "retcode": 100, "wording": "不是好友"}) + + +@pytest.mark.parametrize( + ("message_type", "path", "id_key"), + [ + ("private", "/send_private_msg", "user_id"), + ("group", "/send_group_msg", "group_id"), + ], +) +def test_send_posts_to_endpoint_with_target(message_type, path, id_key): + seen: list[httpx2.Request] = [] + ch = channel(200, {"retcode": 0}, {**BASE, "message_type": message_type}, seen) + assert asyncio.run(ch.send(episode())) is True + [request] = seen + assert request.url.path == path + assert json.loads(request.content) == { + id_key: 123, + "message": [{"type": "text", "data": {"text": "番剧 第1集"}}], + } + + +@pytest.mark.parametrize( + ("send_poster", "poster_url", "with_image"), + [(True, POSTER, True), (False, POSTER, False), (True, None, False)], +) +def test_send_adds_image_segment_only_with_poster(send_poster, poster_url, with_image): + seen: list[httpx2.Request] = [] + ch = channel(200, {"status": "ok"}, {**BASE, "send_poster": send_poster}, seen) + assert asyncio.run(ch.send(episode(poster_url))) is True + segments = json.loads(seen[0].content)["message"] + image = {"type": "image", "data": {"file": POSTER}} + assert (segments[0] == image) is with_image + assert segments[-1]["type"] == "text" + + +@pytest.mark.parametrize(("token", "header"), [("t0k", "Bearer t0k"), ("", None)]) +def test_send_sets_bearer_header_only_with_token(token, header): + seen: list[httpx2.Request] = [] + ch = channel(200, {"status": "ok"}, {**BASE, "access_token": token}, seen) + assert asyncio.run(ch.send(episode())) is True + assert seen[0].headers.get("Authorization") == header + + +def test_test_reports_failure_on_http_error(): + ok, detail = asyncio.run(channel(401, {"msg": "token 错误"}).test()) + assert ok is False + assert "token 错误" in detail + + +def test_send_without_target_returns_false_without_request(): + seen: list[httpx2.Request] = [] + ch = channel(200, {"status": "ok"}, {"url": "http://bot:3000"}, seen) + assert asyncio.run(ch.send(episode())) is False + assert seen == [] diff --git a/examples/plugins/wpush-notifier/README.md b/examples/plugins/wpush-notifier/README.md new file mode 100644 index 00000000..a6ba964f --- /dev/null +++ b/examples/plugins/wpush-notifier/README.md @@ -0,0 +1,33 @@ +# WPUSH 通知 + +把通知发到 [WPUSH](https://wpush.cn),支持微信、短信、邮件等渠道。 + +- `@provider(points.NOTIFIER, id="wpush")`:返回 `NotifierFactory` +- 向 `https://api.wpush.cn/api/v1/send` POST JSON:`apikey`、`title`、`content`、`channel`,填写了群组编码时附带 `topic_code` +- HTTP 2xx 且返回的 `code` 为 0 才算成功;否则 `send` 返回 `False`,「测试」显示 WPUSH 返回的错误信息 + +## 配置 + +| 字段 | 说明 | +| --- | --- | +| `apikey` | WPUSH 控制台中的 apikey(秘密字段) | +| `channel` | 推送渠道,默认 `wechat` | +| `topic_code` | 可选:群组编码 | + +## 安装 + +```bash +cp -r examples/plugins/wpush-notifier config/plugins/local/ +``` + +在「设置 → 插件」中开启「允许未签名插件」,启用并填写 apikey;再到「设置 → 通知 → 添加渠道」选择类型 `wpush`。 + +## 测试 + +```bash +cd examples/plugins/wpush-notifier && uv run pytest +``` + +--- + +基于 @anhao 在 [#1116](https://github.com/EstrellaXD/Auto_Bangumi/pull/1116) 中的实现 diff --git a/examples/plugins/wpush-notifier/plugin.toml b/examples/plugins/wpush-notifier/plugin.toml new file mode 100644 index 00000000..b371d3f8 --- /dev/null +++ b/examples/plugins/wpush-notifier/plugin.toml @@ -0,0 +1,10 @@ +[plugin] +id = "wpush-notifier" +name = "WPUSH 通知" +version = "0.1.0" +sdk = ">=0.5,<1" +entry = "wpush_notifier:WPushNotifier" +description = "把通知发到 WPUSH(微信、短信、邮件等多渠道)。在「设置 → 通知 → 添加渠道」选择类型 wpush" +authors = ["AutoBangumi"] +permissions = ["network"] +extension_points = ["notifier"] diff --git a/examples/plugins/wpush-notifier/pyproject.toml b/examples/plugins/wpush-notifier/pyproject.toml new file mode 100644 index 00000000..f2b6fef0 --- /dev/null +++ b/examples/plugins/wpush-notifier/pyproject.toml @@ -0,0 +1,13 @@ +# 只用于本地开发与测试,不会被打进插件包(ab-plugin pack 排除它) +[project] +name = "ab-plugin-wpush-notifier" +version = "0.1.0" +requires-python = ">=3.14" +dependencies = ["autobangumi-sdk"] + +[dependency-groups] +dev = ["pytest>=8.0.0"] + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/examples/plugins/wpush-notifier/tests/test_wpush_notifier.py b/examples/plugins/wpush-notifier/tests/test_wpush_notifier.py new file mode 100644 index 00000000..aecff6d8 --- /dev/null +++ b/examples/plugins/wpush-notifier/tests/test_wpush_notifier.py @@ -0,0 +1,65 @@ +import asyncio +import json + +import httpx2 +from wpush_notifier import API_URL, WPushNotifier + +from ab_sdk.notify import NotificationMessage, NotifierSettings +from ab_sdk.testing import NotifierContract, create_plugin + + +def channel(status: int, body: dict, options: dict | None = None, seen=None): + def handler(request: httpx2.Request) -> httpx2.Response: + if seen is not None: + seen.append(request) + return httpx2.Response(status, json=body) + + plugin, _ = create_plugin( + WPushNotifier, {"apikey": "k"} if options is None else options + ) + plugin.client_factory = lambda: httpx2.AsyncClient( # type: ignore[method-assign] + transport=httpx2.MockTransport(handler) + ) + return plugin.channel()(NotifierSettings()) + + +class TestChannel(NotifierContract): + def create(self): + return channel(200, {"code": 0, "message": "ok"}) + + def create_failing(self): + return channel(200, {"code": 1, "message": "apikey 无效"}) + + +def test_send_posts_payload_with_topic_code(): + seen: list[httpx2.Request] = [] + ch = channel( + 200, + {"code": 0}, + {"apikey": "k", "channel": "sms", "topic_code": "g1"}, + seen, + ) + msg = NotificationMessage(kind="event", title="标题", body="正文") + assert asyncio.run(ch.send(msg)) is True + [request] = seen + assert str(request.url) == API_URL + assert json.loads(request.content) == { + "apikey": "k", + "title": "标题", + "content": "正文", + "channel": "sms", + "topic_code": "g1", + } + + +def test_test_reports_api_message_on_http_error(): + ok, detail = asyncio.run(channel(500, {"message": "内部错误"}).test()) + assert ok is False + assert "内部错误" in detail + + +def test_send_without_apikey_returns_false_without_request(): + seen: list[httpx2.Request] = [] + ch = channel(200, {"code": 0}, {}, seen) + assert asyncio.run(ch.test()) == (False, "未填写 apikey") + assert seen == [] diff --git a/examples/plugins/wpush-notifier/wpush_notifier/__init__.py b/examples/plugins/wpush-notifier/wpush_notifier/__init__.py new file mode 100644 index 00000000..e2a3f55a --- /dev/null +++ b/examples/plugins/wpush-notifier/wpush_notifier/__init__.py @@ -0,0 +1,84 @@ +"""WPUSH 通知渠道。 + +POST JSON 到 WPUSH 接口;HTTP 2xx 且返回 JSON 的 ``code == 0`` 才算成功。 +接口文档:https://wpush.cn/docs +""" + +from collections.abc import Callable + +import httpx2 +from pydantic import BaseModel, Field + +from ab_sdk import Plugin, points, provider, secret_field +from ab_sdk.notify import NotificationMessage, NotifierFactory, NotifierSettings + +API_URL = "https://api.wpush.cn/api/v1/send" + + +class Options(BaseModel): + apikey: str = secret_field(title="API Key", description="WPUSH 控制台中的 apikey") + channel: str = Field( + "wechat", title="推送渠道", description="如 wechat、sms、mail,见 WPUSH 文档" + ) + topic_code: str = Field( + "", title="群组编码", description="可选:推送到群组时填写 topic_code" + ) + + +class Channel: + def __init__( + self, + options: Options, + settings: NotifierSettings, + client_factory: Callable[[], httpx2.AsyncClient], + ) -> None: + self.options = options + self.settings = settings + self.client_factory = client_factory + + async def _post(self, message: NotificationMessage) -> tuple[bool, str]: + opts = self.options + if not opts.apikey: + return False, "未填写 apikey" + payload = { + "apikey": opts.apikey, + "title": message.title, + "content": message.body, + "channel": opts.channel.strip() or "wechat", + } + if opts.topic_code.strip(): + payload["topic_code"] = opts.topic_code.strip() + try: + async with self.client_factory() as client: + resp = await client.post(API_URL, json=payload) + body = resp.json() + except (httpx2.HTTPError, ValueError) as e: + return False, f"请求失败:{e}" + if resp.is_success and isinstance(body, dict) and body.get("code") == 0: + return True, "ok" + detail = body.get("message") if isinstance(body, dict) else None + return False, f"WPUSH 返回错误:{detail or resp.status_code}" + + async def send(self, message: NotificationMessage) -> bool: + ok, _ = await self._post(message) + return ok + + async def test(self) -> tuple[bool, str]: + return await self._post( + NotificationMessage( + kind="event", title="AutoBangumi 通知测试", body="通知测试成功!" + ) + ) + + +class WPushNotifier(Plugin[Options]): + config_model = Options + + # 测试可替换为带 MockTransport 的客户端 + client_factory: Callable[[], httpx2.AsyncClient] = staticmethod( + lambda: httpx2.AsyncClient(timeout=10) + ) + + @provider(points.NOTIFIER, id="wpush") + def channel(self) -> NotifierFactory: + return lambda settings: Channel(self.config, settings, self.client_factory) From 8312f47769c2159b3ee8d1507442974877a65c1b Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Wed, 21 Oct 2026 09:04:40 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat(plugins):=20=E6=96=B0=E5=A2=9E=20run-c?= =?UTF-8?q?ommand=E3=80=81strm-writer=E3=80=81synology-chat-notifier?= =?UTF-8?q?=E3=80=81slack-notifier=20=E7=A4=BA=E4=BE=8B=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - run-command:torrent.organized / file.renamed 后运行外部命令,参数列表直接 exec, 事件字段以 AB_* 环境变量与 {占位符} 传入,超时终止(#897、#668) - strm-writer:按媒体目录结构写 .strm,内容为地址前缀加相对路径(#428) - synology-chat-notifier:Synology Chat 传入 Webhook,payload= 表单 JSON(#887) - slack-notifier:Slack Incoming Webhook,带海报时附图片块(#277) --- backend/src/test/test_plugin_examples.py | 6 +- docs/dev/plugins/examples.md | 4 + docs/en/dev/plugins/examples.md | 4 + docs/ja/dev/plugins/examples.md | 4 + examples/plugins/run-command/README.md | 49 ++++++++ examples/plugins/run-command/plugin.toml | 9 ++ examples/plugins/run-command/pyproject.toml | 13 ++ .../run-command/run_command/__init__.py | 119 ++++++++++++++++++ .../run-command/tests/test_run_command.py | 98 +++++++++++++++ examples/plugins/slack-notifier/README.md | 28 +++++ examples/plugins/slack-notifier/plugin.toml | 10 ++ .../plugins/slack-notifier/pyproject.toml | 13 ++ .../slack-notifier/slack_notifier/__init__.py | 85 +++++++++++++ .../tests/test_slack_notifier.py | 63 ++++++++++ examples/plugins/strm-writer/README.md | 35 ++++++ examples/plugins/strm-writer/plugin.toml | 9 ++ examples/plugins/strm-writer/pyproject.toml | 13 ++ .../strm-writer/strm_writer/__init__.py | 84 +++++++++++++ .../strm-writer/tests/test_strm_writer.py | 63 ++++++++++ .../plugins/synology-chat-notifier/README.md | 30 +++++ .../synology-chat-notifier/plugin.toml | 10 ++ .../synology-chat-notifier/pyproject.toml | 13 ++ .../synology_chat_notifier/__init__.py | 78 ++++++++++++ .../tests/test_synology_chat_notifier.py | 63 ++++++++++ 24 files changed, 902 insertions(+), 1 deletion(-) create mode 100644 examples/plugins/run-command/README.md create mode 100644 examples/plugins/run-command/plugin.toml create mode 100644 examples/plugins/run-command/pyproject.toml create mode 100644 examples/plugins/run-command/run_command/__init__.py create mode 100644 examples/plugins/run-command/tests/test_run_command.py create mode 100644 examples/plugins/slack-notifier/README.md create mode 100644 examples/plugins/slack-notifier/plugin.toml create mode 100644 examples/plugins/slack-notifier/pyproject.toml create mode 100644 examples/plugins/slack-notifier/slack_notifier/__init__.py create mode 100644 examples/plugins/slack-notifier/tests/test_slack_notifier.py create mode 100644 examples/plugins/strm-writer/README.md create mode 100644 examples/plugins/strm-writer/plugin.toml create mode 100644 examples/plugins/strm-writer/pyproject.toml create mode 100644 examples/plugins/strm-writer/strm_writer/__init__.py create mode 100644 examples/plugins/strm-writer/tests/test_strm_writer.py create mode 100644 examples/plugins/synology-chat-notifier/README.md create mode 100644 examples/plugins/synology-chat-notifier/plugin.toml create mode 100644 examples/plugins/synology-chat-notifier/pyproject.toml create mode 100644 examples/plugins/synology-chat-notifier/synology_chat_notifier/__init__.py create mode 100644 examples/plugins/synology-chat-notifier/tests/test_synology_chat_notifier.py diff --git a/backend/src/test/test_plugin_examples.py b/backend/src/test/test_plugin_examples.py index 5c78b17f..deb20d06 100644 --- a/backend/src/test/test_plugin_examples.py +++ b/backend/src/test/test_plugin_examples.py @@ -117,7 +117,11 @@ async def test_all_examples_load_in_host_with_expected_providers(tmp_path): try: assert {s.id: s.state for s in mgr.statuses()} == dict.fromkeys(ids, "active") assert set(registry.providers(points.RENAME_STRATEGY)) == {"mini-template"} - assert set(registry.providers(points.NOTIFIER)) == {"ntfy"} + assert set(registry.providers(points.NOTIFIER)) == { + "ntfy", + "slack", + "synology-chat", + } assert set(registry.providers(points.SEARCH_SITE)) == {"my-tracker"} assert [h.plugin_id for h in registry.hooks(points.HTTP_REQUEST)] == [ "custom-rss-site" diff --git a/docs/dev/plugins/examples.md b/docs/dev/plugins/examples.md index 95860a1e..20f6ca9b 100644 --- a/docs/dev/plugins/examples.md +++ b/docs/dev/plugins/examples.md @@ -9,6 +9,10 @@ | `template-rename` | `rename_strategy` Provider,自带过滤器的文件名模板;`RenameSkipped`;`RenameStrategyContract` | | `nfo-writer` | 订阅 `torrent.organized`,在正片旁写 `.nfo`;路径映射;幂等 | | `ntfy-notifier` | `notifier` Provider;后端拒绝时返回 `False`;`NotifierContract` | +| `run-command` | 订阅 `torrent.organized` / `file.renamed`,运行外部命令;参数列表不经过 shell;环境变量与占位符;超时终止 | +| `strm-writer` | 订阅 `torrent.organized`,按媒体目录结构写 `.strm`;幂等 | +| `synology-chat-notifier` | `notifier` Provider;表单字段 `payload=`;HTTP 200 但 `success: false` 时返回 `False` | +| `slack-notifier` | `notifier` Provider;mrkdwn 转义;带海报时附图片块 | | `manual-pick` | 前端挂载点 `bangumi.detail.tab`、插件路由、插件 KV、事件 | ## 安装一个示例 diff --git a/docs/en/dev/plugins/examples.md b/docs/en/dev/plugins/examples.md index e630ba6f..da3d3726 100644 --- a/docs/en/dev/plugins/examples.md +++ b/docs/en/dev/plugins/examples.md @@ -9,6 +9,10 @@ The examples are in [`examples/plugins/`](https://github.com/EstrellaXD/Auto_Ban | `template-rename` | A `rename_strategy` Provider with a file-name template that has its own filters. `RenameSkipped`. `RenameStrategyContract` | | `nfo-writer` | Subscribes to `torrent.organized` and writes an `.nfo` next to each media file. Path mapping. Idempotent | | `ntfy-notifier` | A `notifier` Provider. Returns `False` when the backend refuses. `NotifierContract` | +| `run-command` | Subscribes to `torrent.organized` / `file.renamed` and runs an external command. The argument list does not go through a shell. Environment variables and placeholders. Kill on timeout | +| `strm-writer` | Subscribes to `torrent.organized` and writes `.strm` files that mirror the media folders. Idempotent | +| `synology-chat-notifier` | A `notifier` Provider. Sends the form field `payload=`. Returns `False` on HTTP 200 with `success: false` | +| `slack-notifier` | A `notifier` Provider. Escapes mrkdwn. Adds an image block when a poster is available | | `manual-pick` | The frontend slot `bangumi.detail.tab`, plugin routes, plugin KV, events | ## Install an example diff --git a/docs/ja/dev/plugins/examples.md b/docs/ja/dev/plugins/examples.md index 98c32303..fb215a2d 100644 --- a/docs/ja/dev/plugins/examples.md +++ b/docs/ja/dev/plugins/examples.md @@ -9,6 +9,10 @@ | `template-rename` | `rename_strategy` Provider。独自フィルター付きのファイル名テンプレート。`RenameSkipped`。`RenameStrategyContract` | | `nfo-writer` | `torrent.organized` を購読し、本編の隣に `.nfo` を書きます。パス変換。冪等 | | `ntfy-notifier` | `notifier` Provider。バックエンドが拒否したときは `False` を返します。`NotifierContract` | +| `run-command` | `torrent.organized` / `file.renamed` を購読し、外部コマンドを実行します。引数リストはシェルを通しません。環境変数とプレースホルダー。タイムアウトで終了 | +| `strm-writer` | `torrent.organized` を購読し、メディアのフォルダー構成どおりに `.strm` を書きます。冪等 | +| `synology-chat-notifier` | `notifier` Provider。フォームフィールド `payload=` を送ります。HTTP 200 でも `success: false` なら `False` を返します | +| `slack-notifier` | `notifier` Provider。mrkdwn をエスケープします。ポスターがあるときは画像ブロックを付けます | | `manual-pick` | フロントエンドスロット `bangumi.detail.tab`、プラグインのルート、プラグイン KV、イベント | ## サンプルをインストールする diff --git a/examples/plugins/run-command/README.md b/examples/plugins/run-command/README.md new file mode 100644 index 00000000..48794538 --- /dev/null +++ b/examples/plugins/run-command/README.md @@ -0,0 +1,49 @@ +# 运行外部命令(示例插件) + +种子整理完成(`torrent.organized`)或文件被重命名(`file.renamed`)后运行一条外部命令,例如自己的清理脚本、FileBot、hlink。演示: + +- `@subscribe("*", timeout=...)` 订阅全部事件,按配置过滤;处理超时放宽到命令超时之上 +- `asyncio.create_subprocess_exec` 直接执行参数列表,不经过 shell +- 命令超时被杀掉,退出码非 0 时记录 stderr 末尾;失败只记日志,不让插件被熔断 + +## 配置 + +| 字段 | 说明 | +| --- | --- | +| 命令 | 程序及其参数,每项一个,如 `/config/scripts/link.sh`、`{files}`。不经过 shell,管道、重定向、`$变量` 都不会展开;需要时把命令写成脚本 | +| 事件 | 在哪些事件后运行,默认只有 `torrent.organized` | +| 超时(秒) | 默认 300,最大 3600。超时后杀掉命令 | + +## 传给命令的字段 + +每个字段既是环境变量,也可以在参数里写成占位符(环境变量名去掉 `AB_` 后的小写,如 `{torrent_hash}`)。未知的占位符原样保留。 + +| 环境变量 | 事件 | 内容 | +| --- | --- | --- | +| `AB_EVENT` | 全部 | 事件名 | +| `AB_BANGUMI_ID` | 全部 | 番剧 id;旧种子没有 `ab:` 标签时为空 | +| `AB_DOWNLOADER_ID` | 全部 | 下载器实例 id | +| `AB_TORRENT_HASH` | `torrent.organized` | 种子 hash | +| `AB_FILES` | `torrent.organized` | 正片与字幕的最终路径,每行一个 | +| `AB_OLD_PATH` / `AB_NEW_PATH` | `file.renamed` | 重命名前后的路径 | +| `AB_FILE_KIND` | `file.renamed` | `media` 或 `subtitle` | + +事件里没有种子的保存目录,需要时从 `AB_FILES` 取。路径是**下载器视角**,AutoBangumi 与下载器的挂载不同时,脚本要自己换算。命令按事件顺序逐个运行,一个命令运行时后面的事件排队。 + +`torrent.organized` 的投递是「至少一次」:每次 AutoBangumi 重启,已整理的种子会再发布一次。脚本必须幂等。 + +## 安装 + +```bash +cp -r examples/plugins/run-command config/plugins/local/ +``` + +在「设置 → 插件」中开启「允许未签名插件」,启用并填写命令。Docker 中命令在 AutoBangumi 的容器里运行,只能使用镜像里有的程序和挂载进来的脚本。 + +## 测试 + +```bash +cd examples/plugins/run-command && uv run pytest +``` + +对应 issue:[#897](https://github.com/EstrellaXD/Auto_Bangumi/issues/897)、[#668](https://github.com/EstrellaXD/Auto_Bangumi/issues/668) diff --git a/examples/plugins/run-command/plugin.toml b/examples/plugins/run-command/plugin.toml new file mode 100644 index 00000000..cd5d65b8 --- /dev/null +++ b/examples/plugins/run-command/plugin.toml @@ -0,0 +1,9 @@ +[plugin] +id = "run-command" +name = "运行外部命令" +version = "0.1.0" +sdk = ">=0.5,<1" +entry = "run_command:RunCommand" +description = "示例插件:种子整理完成(或文件重命名)后运行一条外部命令,事件字段以环境变量和参数占位符传入。演示子进程与超时" +authors = ["AutoBangumi"] +permissions = ["subprocess"] diff --git a/examples/plugins/run-command/pyproject.toml b/examples/plugins/run-command/pyproject.toml new file mode 100644 index 00000000..81b34cb8 --- /dev/null +++ b/examples/plugins/run-command/pyproject.toml @@ -0,0 +1,13 @@ +# 只用于本地开发与测试,不会被打进插件包(ab-plugin pack 排除它) +[project] +name = "ab-plugin-run-command" +version = "0.1.0" +requires-python = ">=3.14" +dependencies = ["autobangumi-sdk"] + +[dependency-groups] +dev = ["pytest>=8.0.0"] + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/examples/plugins/run-command/run_command/__init__.py b/examples/plugins/run-command/run_command/__init__.py new file mode 100644 index 00000000..f415f339 --- /dev/null +++ b/examples/plugins/run-command/run_command/__init__.py @@ -0,0 +1,119 @@ +"""示例插件:整理完成后运行外部命令。 + +订阅全部事件(``@subscribe("*")``),只对配置里列出的 ``torrent.organized`` / +``file.renamed`` 运行 ``command``。命令是参数列表,直接 ``exec``,不经过 shell, +所以参数里的空格、``$``、``;`` 都按字面传给程序。 + +事件字段以 ``AB_*`` 环境变量传给命令,参数里也可以写 ``{字段}`` 占位符: + +- 两个事件都有:``AB_EVENT``、``AB_BANGUMI_ID``(旧种子可能为空)、``AB_DOWNLOADER_ID`` +- ``torrent.organized``:``AB_TORRENT_HASH``、``AB_FILES``(正片与字幕的路径,每行一个) +- ``file.renamed``:``AB_OLD_PATH``、``AB_NEW_PATH``、``AB_FILE_KIND`` + +路径是下载器视角。命令超时被杀掉;退出码非 0 时记录 stderr 末尾。命令的失败只记日志, +不抛给宿主:外部脚本出错不应让插件被熔断。 +""" + +import asyncio +import os +import re +from subprocess import DEVNULL, PIPE + +from pydantic import BaseModel, Field + +from ab_sdk import Event, Plugin, subscribe +from ab_sdk.events import FileRenamed, TorrentOrganized + +MAX_TIMEOUT = 3600 +_PLACEHOLDER = re.compile(r"\{(\w+)\}") + + +class Options(BaseModel): + command: list[str] = Field( + default_factory=list, + title="命令", + description="程序及其参数,每项一个,如 /config/scripts/link.sh、{files};不经过 shell", + ) + events: list[str] = Field( + default_factory=lambda: [TorrentOrganized.kind], + title="事件", + description="在哪些事件后运行:torrent.organized、file.renamed", + ) + timeout: int = Field( + 300, ge=1, le=MAX_TIMEOUT, title="超时(秒)", description="超时后杀掉命令" + ) + + +def event_fields(event: Event) -> dict[str, str] | None: + """事件 → 字段(键为小写,不带 ``AB_`` 前缀);不支持的事件返回 None。""" + match event: + case TorrentOrganized(): + specific = { + "torrent_hash": event.torrent_hash, + "files": "\n".join(f.path for f in event.files), + } + case FileRenamed(): + specific = { + "old_path": event.old_path, + "new_path": event.new_path, + "file_kind": event.file_kind, + } + case _: + return None + bangumi_id = "" if event.bangumi_id is None else str(event.bangumi_id) + return { + "event": event.kind, + "bangumi_id": bangumi_id, + "downloader_id": event.downloader_id, + **specific, + } + + +def build_argv(command: list[str], fields: dict[str, str]) -> list[str]: + """替换参数里的 ``{字段}``;未知的占位符原样保留。""" + return [_PLACEHOLDER.sub(lambda m: fields.get(m[1], m[0]), arg) for arg in command] + + +def build_env(fields: dict[str, str]) -> dict[str, str]: + """继承宿主环境(否则子进程没有 PATH),再加上 ``AB_*``。""" + return {**os.environ, **{f"AB_{k.upper()}": v for k, v in fields.items()}} + + +class RunCommand(Plugin[Options]): + config_model = Options + + @subscribe("*", timeout=MAX_TIMEOUT + 10) + async def on_event(self, event: Event) -> None: + cfg = self.config + if not cfg.command or event.kind not in cfg.events: + return + fields = event_fields(event) + if fields is None: + return + argv = build_argv(cfg.command, fields) + try: + proc = await asyncio.create_subprocess_exec( + *argv, stdin=DEVNULL, stdout=DEVNULL, stderr=PIPE, env=build_env(fields) + ) + except OSError as e: + self.ctx.log.warning("无法启动命令 %s:%s", argv[0], e) + return + try: + async with asyncio.timeout(cfg.timeout): + _, stderr = await proc.communicate() + except TimeoutError: + self.ctx.log.warning("命令 %s 超过 %s 秒,已终止", argv[0], cfg.timeout) + return + finally: + # 超时或宿主取消处理时,不留下孤儿进程 + # ponytail: 只杀直接子进程;命令自己再派生的进程需由脚本负责 + if proc.returncode is None: + proc.kill() + await proc.wait() + tail = stderr[-2000:].decode(errors="replace").strip() + if proc.returncode: + self.ctx.log.warning( + "命令 %s 退出码 %s:%s", argv[0], proc.returncode, tail + ) + else: + self.ctx.log.info("命令 %s 已完成(%s)", argv[0], event.kind) diff --git a/examples/plugins/run-command/tests/test_run_command.py b/examples/plugins/run-command/tests/test_run_command.py new file mode 100644 index 00000000..771e5068 --- /dev/null +++ b/examples/plugins/run-command/tests/test_run_command.py @@ -0,0 +1,98 @@ +import asyncio +import json +import logging +import sys +import time + +import pytest +from run_command import RunCommand + +from ab_sdk.events import FileRenamed, OrganizedFile, TorrentOrganized +from ab_sdk.testing import create_plugin + +ORGANIZED = TorrentOrganized( + torrent_hash="abc", + bangumi_id=None, + files=( + OrganizedFile("/dl/Show/Season 1/Show S01E01.mkv", "media"), + OrganizedFile("/dl/Show/Season 1/Show S01E01.ass", "subtitle"), + ), +) +RENAMED = FileRenamed( + bangumi_id=7, old_path="/dl/a.mkv", new_path="/dl/b.mkv", file_kind="media" +) + +# 子进程把收到的参数与 AB_* 环境变量写进 argv[1] 指定的文件 +DUMP = ( + "import json, os, sys; " + "json.dump({'argv': sys.argv[2:], " + "'env': {k: v for k, v in os.environ.items() if k.startswith('AB_')}}, " + "open(sys.argv[1], 'w'))" +) + + +def run(options: dict, event=ORGANIZED) -> None: + plugin, _ = create_plugin(RunCommand, options) + asyncio.run(plugin.on_event(event)) + + +def test_on_event_organized_passes_env_and_placeholders_without_shell(tmp_path): + out = tmp_path / "out.json" + command = [sys.executable, "-c", DUMP, str(out), "{torrent_hash}", "$HOME; {x}"] + run({"command": command}) + got = json.loads(out.read_text()) + # $HOME 与 ; 按字面传入:没有经过 shell;未知占位符原样保留 + assert got["argv"] == ["abc", "$HOME; {x}"] + assert got["env"] == { + "AB_EVENT": "torrent.organized", + "AB_BANGUMI_ID": "", + "AB_DOWNLOADER_ID": "default", + "AB_TORRENT_HASH": "abc", + "AB_FILES": "/dl/Show/Season 1/Show S01E01.mkv\n" + "/dl/Show/Season 1/Show S01E01.ass", + } + + +def test_on_event_renamed_when_selected_passes_rename_fields(tmp_path): + out = tmp_path / "out.json" + command = [sys.executable, "-c", DUMP, str(out), "{old_path}"] + run({"command": command, "events": ["file.renamed"]}, RENAMED) + got = json.loads(out.read_text()) + assert got["argv"] == ["/dl/a.mkv"] + assert got["env"]["AB_BANGUMI_ID"] == "7" + assert got["env"]["AB_NEW_PATH"] == "/dl/b.mkv" + assert got["env"]["AB_FILE_KIND"] == "media" + + +@pytest.mark.parametrize( + ("options", "event"), + [({"command": []}, ORGANIZED), ({}, RENAMED)], +) +def test_on_event_unwanted_runs_nothing(tmp_path, options, event): + out = tmp_path / "out.json" + options.setdefault("command", [sys.executable, "-c", DUMP, str(out)]) + run(options, event) + assert not out.exists() + + +def test_on_event_timeout_kills_command(caplog): + command = [sys.executable, "-c", "import time; time.sleep(30)"] + plugin, _ = create_plugin(RunCommand, {"command": command, "timeout": 1}) + start = time.monotonic() + with caplog.at_level(logging.WARNING): + asyncio.run(plugin.on_event(ORGANIZED)) + assert time.monotonic() - start < 10 + assert "已终止" in caplog.text + + +def test_on_event_nonzero_exit_logs_code_and_stderr(caplog): + script = "import sys; sys.stderr.write('boom'); sys.exit(3)" + with caplog.at_level(logging.WARNING): + run({"command": [sys.executable, "-c", script]}) + assert "退出码 3" in caplog.text and "boom" in caplog.text + + +def test_on_event_missing_program_is_logged_not_raised(caplog): + with caplog.at_level(logging.WARNING): + run({"command": ["/nonexistent/ab-run-command-test"]}) + assert "无法启动命令" in caplog.text diff --git a/examples/plugins/slack-notifier/README.md b/examples/plugins/slack-notifier/README.md new file mode 100644 index 00000000..cfd630ea --- /dev/null +++ b/examples/plugins/slack-notifier/README.md @@ -0,0 +1,28 @@ +# Slack 通知(示例插件) + +把通知发到 Slack 的 [Incoming Webhook](https://api.slack.com/messaging/webhooks)。演示: + +- `@provider(points.NOTIFIER, id="slack")`:返回 `NotifierFactory` +- Webhook 地址含密钥,用 `secret_field` 声明,读取配置时只返回掩码 +- 只有 HTTP 2xx 且响应体为 `ok` 才算成功 +- 契约套件 `NotifierContract` + +## 消息格式 + +请求体为 `{"text": "*标题*\n正文"}`(Slack 的 mrkdwn,`&`、`<`、`>` 已转义)。配置了 `notification.base_url` 时单集通知带海报:再加 `blocks`(一段文字和一张图片),`text` 作为手机通知的摘要。海报地址必须能被 Slack 访问。 + +## 安装 + +```bash +cp -r examples/plugins/slack-notifier config/plugins/local/ +``` + +在 Slack 中创建 App,开启 Incoming Webhooks 并添加到频道,复制 Webhook 地址。在「设置 → 插件」中开启「允许未签名插件」,启用并填写地址;再到「设置 → 通知 → 添加渠道」选择类型 `slack`。 + +## 测试 + +```bash +cd examples/plugins/slack-notifier && uv run pytest +``` + +对应 issue:[#277](https://github.com/EstrellaXD/Auto_Bangumi/issues/277) diff --git a/examples/plugins/slack-notifier/plugin.toml b/examples/plugins/slack-notifier/plugin.toml new file mode 100644 index 00000000..9c074808 --- /dev/null +++ b/examples/plugins/slack-notifier/plugin.toml @@ -0,0 +1,10 @@ +[plugin] +id = "slack-notifier" +name = "Slack 通知" +version = "0.1.0" +sdk = ">=0.5,<1" +entry = "slack_notifier:SlackNotifier" +description = "示例插件:把通知发到 Slack 的 Incoming Webhook,带海报时附图片。在「设置 → 通知 → 添加渠道」选择类型 slack" +authors = ["AutoBangumi"] +permissions = ["network"] +extension_points = ["notifier"] diff --git a/examples/plugins/slack-notifier/pyproject.toml b/examples/plugins/slack-notifier/pyproject.toml new file mode 100644 index 00000000..9469acd7 --- /dev/null +++ b/examples/plugins/slack-notifier/pyproject.toml @@ -0,0 +1,13 @@ +# 只用于本地开发与测试,不会被打进插件包(ab-plugin pack 排除它) +[project] +name = "ab-plugin-slack-notifier" +version = "0.1.0" +requires-python = ">=3.14" +dependencies = ["autobangumi-sdk"] + +[dependency-groups] +dev = ["pytest>=8.0.0"] + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/examples/plugins/slack-notifier/slack_notifier/__init__.py b/examples/plugins/slack-notifier/slack_notifier/__init__.py new file mode 100644 index 00000000..5ea09723 --- /dev/null +++ b/examples/plugins/slack-notifier/slack_notifier/__init__.py @@ -0,0 +1,85 @@ +"""示例插件:Slack 通知渠道。 + +向 Incoming Webhook POST ``{"text": "*标题*\\n正文"}``(Slack 的 mrkdwn)。带海报时 +再加 ``blocks``:一段文字加一张图片,``text`` 留作手机通知里的摘要。Slack 成功时 +返回 HTTP 200 与响应体 ``ok``,失败时返回 4xx 与错误码(如 ``invalid_token``)。 +""" + +from collections.abc import Callable +from typing import Any + +import httpx2 +from pydantic import BaseModel + +from ab_sdk import Plugin, points, provider, secret_field +from ab_sdk.notify import NotificationMessage, NotifierFactory, NotifierSettings + + +class Options(BaseModel): + webhook_url: str = secret_field( + title="Webhook 地址", + description="Slack App 的 Incoming Webhook 地址,形如 https://hooks.slack.com/services/...", + ) + + +def escape(text: str) -> str: + """mrkdwn 只要求转义这三个字符(番剧名里常有 &)。""" + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + +def slack_body(message: NotificationMessage) -> dict[str, Any]: + text = f"*{escape(message.title)}*\n{escape(message.body)}" + body: dict[str, Any] = {"text": text} + if message.poster_url: + body["blocks"] = [ + {"type": "section", "text": {"type": "mrkdwn", "text": text}}, + { + "type": "image", + "image_url": message.poster_url, + "alt_text": message.title, + }, + ] + return body + + +class Channel: + def __init__( + self, + options: Options, + settings: NotifierSettings, + client_factory: Callable[[], httpx2.AsyncClient], + ) -> None: + self.options = options + self.settings = settings + self.client_factory = client_factory + + async def send(self, message: NotificationMessage) -> bool: + if not self.options.webhook_url: + return False + try: + async with self.client_factory() as client: + resp = await client.post( + self.options.webhook_url, json=slack_body(message) + ) + except httpx2.HTTPError: + return False + return resp.is_success and resp.text == "ok" + + async def test(self) -> tuple[bool, str]: + ok = await self.send( + NotificationMessage(kind="event", title="AutoBangumi", body="通知测试") + ) + return ok, "ok" if ok else "发送失败" + + +class SlackNotifier(Plugin[Options]): + config_model = Options + + # 测试可替换为带 MockTransport 的客户端 + client_factory: Callable[[], httpx2.AsyncClient] = staticmethod( + lambda: httpx2.AsyncClient(timeout=10) + ) + + @provider(points.NOTIFIER, id="slack") + def channel(self) -> NotifierFactory: + return lambda settings: Channel(self.config, settings, self.client_factory) diff --git a/examples/plugins/slack-notifier/tests/test_slack_notifier.py b/examples/plugins/slack-notifier/tests/test_slack_notifier.py new file mode 100644 index 00000000..0f1b1f74 --- /dev/null +++ b/examples/plugins/slack-notifier/tests/test_slack_notifier.py @@ -0,0 +1,63 @@ +import asyncio +import json + +import httpx2 +import pytest +from slack_notifier import SlackNotifier + +from ab_sdk.notify import NotificationMessage, NotifierSettings +from ab_sdk.testing import NotifierContract, create_plugin + +URL = "https://hooks.slack.com/services/T/B/x" + + +def channel(response: httpx2.Response, seen: list | None = None): + plugin, _ = create_plugin(SlackNotifier, {"webhook_url": URL}) + + def handler(request: httpx2.Request) -> httpx2.Response: + if seen is not None: + seen.append(request) + return response + + plugin.client_factory = lambda: httpx2.AsyncClient( # type: ignore[method-assign] + transport=httpx2.MockTransport(handler) + ) + return plugin.channel()(NotifierSettings()) + + +class TestChannel(NotifierContract): + def create(self): + return channel(httpx2.Response(200, text="ok")) + + def create_failing(self): + return channel(httpx2.Response(403, text="invalid_token")) + + +def sent_body(message: NotificationMessage) -> dict: + seen: list[httpx2.Request] = [] + assert asyncio.run(channel(httpx2.Response(200, text="ok"), seen).send(message)) + return json.loads(seen[0].content) + + +def test_send_escapes_mrkdwn_and_bolds_title(): + body = sent_body(NotificationMessage(kind="event", title="A & ", body="x")) + assert body == {"text": "*A & <B>*\nx"} + + +def test_send_with_poster_adds_image_block(): + message = NotificationMessage( + kind="episode", title="T", body="b", poster_url="https://p/1.jpg" + ) + body = sent_body(message) + assert body["text"] == "*T*\nb" + assert body["blocks"][1] == { + "type": "image", + "image_url": "https://p/1.jpg", + "alt_text": "T", + } + + +@pytest.mark.parametrize("text", ["", "no_service"]) +def test_send_2xx_without_ok_body_is_failure(text): + message = NotificationMessage(kind="event", title="t", body="b") + assert asyncio.run(channel(httpx2.Response(200, text=text)).send(message)) is False diff --git a/examples/plugins/strm-writer/README.md b/examples/plugins/strm-writer/README.md new file mode 100644 index 00000000..e89cf363 --- /dev/null +++ b/examples/plugins/strm-writer/README.md @@ -0,0 +1,35 @@ +# STRM 写入(示例插件) + +种子整理完成后,为每个正片写一个 `.strm`。把 STRM 目录加入 Emby / Jellyfin 媒体库,再把媒体文件放到网盘或对象存储,客户端播放时直接从该地址读取,不经过媒体服务器。演示: + +- `@subscribe(TorrentOrganized.kind)`:只处理 `kind == "media"` 的文件 +- 投递为「至少一次」:内容相同就不重写,`.strm` 的修改时间不变 +- 文件操作放进 `asyncio.to_thread`,不阻塞事件循环 + +## 配置 + +| 字段 | 示例 | 说明 | +| --- | --- | --- | +| 媒体根目录 | `/downloads/Bangumi` | 下载器中要去掉的路径前缀(与事件路径同为下载器视角)。不在其下的文件被跳过并记录警告 | +| STRM 目录 | `/downloads/STRM` | 写入 `.strm` 的目录,是 AutoBangumi 看到的路径 | +| 地址前缀 | `http://nas:8096/media/` | 拼在相对路径前。含 `://` 时,路径的每一段做百分号编码(空格变为 `%20`);不含时(如本地路径 `/mnt/cloud/`)原样拼接 | + +例:正片 `/downloads/Bangumi/A/Season 1/A S01E01.mkv`,按上表配置写入 `/downloads/STRM/A/Season 1/A S01E01.strm`,内容为 `http://nas:8096/media/A/Season%201/A%20S01E01.mkv`。 + +前缀以 `/` 结尾时直接拼接相对路径;插件不补 `/`。字幕不写 `.strm`。 + +## 安装 + +```bash +cp -r examples/plugins/strm-writer config/plugins/local/ +``` + +在「设置 → 插件」中开启「允许未签名插件」,启用并填写三个字段。 + +## 测试 + +```bash +cd examples/plugins/strm-writer && uv run pytest +``` + +对应 issue:[#428](https://github.com/EstrellaXD/Auto_Bangumi/issues/428) diff --git a/examples/plugins/strm-writer/plugin.toml b/examples/plugins/strm-writer/plugin.toml new file mode 100644 index 00000000..23a3547d --- /dev/null +++ b/examples/plugins/strm-writer/plugin.toml @@ -0,0 +1,9 @@ +[plugin] +id = "strm-writer" +name = "STRM 写入" +version = "0.1.0" +sdk = ">=0.5,<1" +entry = "strm_writer:StrmWriter" +description = "示例插件:种子整理完成后,按媒体目录结构在 STRM 目录写 .strm,内容为地址前缀加相对路径。演示 torrent.organized 事件与幂等写入" +authors = ["AutoBangumi"] +permissions = ["fs.write"] diff --git a/examples/plugins/strm-writer/pyproject.toml b/examples/plugins/strm-writer/pyproject.toml new file mode 100644 index 00000000..eed5adbe --- /dev/null +++ b/examples/plugins/strm-writer/pyproject.toml @@ -0,0 +1,13 @@ +# 只用于本地开发与测试,不会被打进插件包(ab-plugin pack 排除它) +[project] +name = "ab-plugin-strm-writer" +version = "0.1.0" +requires-python = ">=3.14" +dependencies = ["autobangumi-sdk"] + +[dependency-groups] +dev = ["pytest>=8.0.0"] + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/examples/plugins/strm-writer/strm_writer/__init__.py b/examples/plugins/strm-writer/strm_writer/__init__.py new file mode 100644 index 00000000..0cbe34c4 --- /dev/null +++ b/examples/plugins/strm-writer/strm_writer/__init__.py @@ -0,0 +1,84 @@ +"""示例插件:STRM 写入。 + +订阅 ``torrent.organized``。对事件里每个正片,去掉路径前缀 ``media_root`` 得到 +相对路径 ``rel``,写 ``/``,内容为 ``url_prefix + rel``。 +Emby / Jellyfin 播放 ``.strm`` 时直接读取这个地址,媒体文件可以放在网盘或对象存储里。 + +- ``media_root`` 与事件路径同为下载器视角,所以不需要路径映射;``strm_root`` 是 + AutoBangumi 看到的本地目录。 +- ``url_prefix`` 含 ``://`` 时按 URL 处理,路径的每一段做百分号编码;否则 + (本地路径)原样拼接。 +- 投递为「至少一次」:内容相同就不重写;``url_prefix`` 改了会在下次事件时更新。 +""" + +import asyncio +import posixpath +from pathlib import Path +from urllib.parse import quote + +from pydantic import BaseModel, Field + +from ab_sdk import Plugin, subscribe +from ab_sdk.events import TorrentOrganized + + +class Options(BaseModel): + strm_root: str = Field( + "", title="STRM 目录", description="写入 .strm 的目录(AutoBangumi 中的路径)" + ) + url_prefix: str = Field( + "", + title="地址前缀", + description="拼在相对路径前,如 http://nas:8096/media/ 或本地路径 /mnt/cloud/", + ) + media_root: str = Field( + "", + title="媒体根目录", + description="下载器中要去掉的路径前缀,如 /downloads/Bangumi;其下的目录结构原样映射", + ) + + +def relative(path: str, media_root: str) -> str | None: + """``path`` 不在 ``media_root`` 下时返回 None。""" + root = media_root.rstrip("/") + if not root or not path.startswith(root + "/"): + return None + return posixpath.normpath(path[len(root) + 1 :]) + + +def strm_target(rel: str, options: Options) -> tuple[Path, str]: + """(.strm 路径, 文件内容)。""" + link = quote(rel, safe="/") if "://" in options.url_prefix else rel + strm = Path(options.strm_root, *rel.split("/")).with_suffix(".strm") + return strm, options.url_prefix + link + + +def write_strm(strm: Path, content: str) -> bool: + """内容相同时不重写,返回 False。""" + if strm.is_file() and strm.read_text(encoding="utf-8") == content: + return False + strm.parent.mkdir(parents=True, exist_ok=True) + strm.write_text(content, encoding="utf-8") + return True + + +class StrmWriter(Plugin[Options]): + config_model = Options + + @subscribe(TorrentOrganized.kind) + async def on_organized(self, event: TorrentOrganized) -> None: + cfg = self.config + if not cfg.strm_root or not cfg.url_prefix: + return + for file in event.files: + if file.kind != "media": + continue + rel = relative(file.path, cfg.media_root) + if rel is None or rel.startswith("../"): + self.ctx.log.warning( + "%s 不在媒体根目录 %s 下", file.path, cfg.media_root + ) + continue + strm, content = strm_target(rel, cfg) + if await asyncio.to_thread(write_strm, strm, content): + self.ctx.log.info("已写入 %s", strm) diff --git a/examples/plugins/strm-writer/tests/test_strm_writer.py b/examples/plugins/strm-writer/tests/test_strm_writer.py new file mode 100644 index 00000000..5cf7b64b --- /dev/null +++ b/examples/plugins/strm-writer/tests/test_strm_writer.py @@ -0,0 +1,63 @@ +import asyncio +import logging + +import pytest +from strm_writer import StrmWriter + +from ab_sdk.events import OrganizedFile, TorrentOrganized +from ab_sdk.testing import create_plugin + +MEDIA = "/downloads/Bangumi/A/Season 1/A S01E01.mkv" + + +def organized(*files: OrganizedFile) -> TorrentOrganized: + return TorrentOrganized(torrent_hash="h", bangumi_id=1, files=files) + + +def run(tmp_path, url_prefix: str, *files: OrganizedFile) -> None: + options = { + "strm_root": str(tmp_path / "STRM"), + "url_prefix": url_prefix, + "media_root": "/downloads/Bangumi/", + } + plugin, _ = create_plugin(StrmWriter, options) + asyncio.run(plugin.on_organized(organized(*files))) + + +@pytest.mark.parametrize( + ("url_prefix", "content"), + [ + ( + "http://nas:8096/media/", + "http://nas:8096/media/A/Season%201/A%20S01E01.mkv", + ), + ("/mnt/cloud/", "/mnt/cloud/A/Season 1/A S01E01.mkv"), + ], +) +def test_on_organized_mirrors_media_path_into_strm(tmp_path, url_prefix, content): + run( + tmp_path, + url_prefix, + OrganizedFile(MEDIA, "media"), + OrganizedFile(MEDIA.replace(".mkv", ".ass"), "subtitle"), + ) + season = tmp_path / "STRM" / "A" / "Season 1" + assert [p.name for p in season.iterdir()] == ["A S01E01.strm"] + assert (season / "A S01E01.strm").read_text(encoding="utf-8") == content + + +def test_on_organized_prefix_change_rewrites_strm(tmp_path): + run(tmp_path, "http://old/", OrganizedFile(MEDIA, "media")) + run(tmp_path, "http://new/", OrganizedFile(MEDIA, "media")) + strm = tmp_path / "STRM" / "A" / "Season 1" / "A S01E01.strm" + assert strm.read_text(encoding="utf-8").startswith("http://new/") + + +@pytest.mark.parametrize( + "path", ["/other/A S01E01.mkv", "/downloads/Bangumi/../../etc/x.mkv"] +) +def test_on_organized_path_outside_media_root_is_skipped(tmp_path, caplog, path): + with caplog.at_level(logging.WARNING): + run(tmp_path, "http://nas/", OrganizedFile(path, "media")) + assert not (tmp_path / "STRM").exists() + assert "不在媒体根目录" in caplog.text diff --git a/examples/plugins/synology-chat-notifier/README.md b/examples/plugins/synology-chat-notifier/README.md new file mode 100644 index 00000000..c536ce19 --- /dev/null +++ b/examples/plugins/synology-chat-notifier/README.md @@ -0,0 +1,30 @@ +# Synology Chat 通知(示例插件) + +把通知发到群晖 Synology Chat 的传入 Webhook。演示: + +- `@provider(points.NOTIFIER, id="synology-chat")`:返回 `NotifierFactory` +- Webhook 地址含 token,用 `secret_field` 声明,读取配置时只返回掩码 +- 后端返回 HTTP 200 但 `{"success": false}` 时,`send` 同样返回 `False` +- 契约套件 `NotifierContract` + +## 获取 Webhook 地址 + +在 Synology Chat 中打开「整合 → 传入 Webhook」,新建一个并复制地址(形如 `https://:5001/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming&version=2&token=...`)。 + +请求为表单字段 `payload=`,JSON 为 `{"text": "标题\n正文"}`;配置了 `notification.base_url` 时单集通知带海报地址 `file_url`,Chat 会下载并显示该图片,所以海报地址必须能被 NAS 访问。 + +## 安装 + +```bash +cp -r examples/plugins/synology-chat-notifier config/plugins/local/ +``` + +在「设置 → 插件」中开启「允许未签名插件」,启用并填写 Webhook 地址;再到「设置 → 通知 → 添加渠道」选择类型 `synology-chat`。 + +## 测试 + +```bash +cd examples/plugins/synology-chat-notifier && uv run pytest +``` + +对应 issue:[#887](https://github.com/EstrellaXD/Auto_Bangumi/issues/887) diff --git a/examples/plugins/synology-chat-notifier/plugin.toml b/examples/plugins/synology-chat-notifier/plugin.toml new file mode 100644 index 00000000..4dee5504 --- /dev/null +++ b/examples/plugins/synology-chat-notifier/plugin.toml @@ -0,0 +1,10 @@ +[plugin] +id = "synology-chat-notifier" +name = "Synology Chat 通知" +version = "0.1.0" +sdk = ">=0.5,<1" +entry = "synology_chat_notifier:SynologyChatNotifier" +description = "示例插件:把通知发到 Synology Chat 的传入 Webhook。在「设置 → 通知 → 添加渠道」选择类型 synology-chat" +authors = ["AutoBangumi"] +permissions = ["network"] +extension_points = ["notifier"] diff --git a/examples/plugins/synology-chat-notifier/pyproject.toml b/examples/plugins/synology-chat-notifier/pyproject.toml new file mode 100644 index 00000000..57e64acc --- /dev/null +++ b/examples/plugins/synology-chat-notifier/pyproject.toml @@ -0,0 +1,13 @@ +# 只用于本地开发与测试,不会被打进插件包(ab-plugin pack 排除它) +[project] +name = "ab-plugin-synology-chat-notifier" +version = "0.1.0" +requires-python = ">=3.14" +dependencies = ["autobangumi-sdk"] + +[dependency-groups] +dev = ["pytest>=8.0.0"] + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/examples/plugins/synology-chat-notifier/synology_chat_notifier/__init__.py b/examples/plugins/synology-chat-notifier/synology_chat_notifier/__init__.py new file mode 100644 index 00000000..c728c104 --- /dev/null +++ b/examples/plugins/synology-chat-notifier/synology_chat_notifier/__init__.py @@ -0,0 +1,78 @@ +"""示例插件:Synology Chat 通知渠道。 + +向 Chat 的传入 Webhook 发送表单字段 ``payload=``(Synology Chat 只接受这种 +格式),JSON 为 ``{"text": ...}``,带海报时加 ``file_url``。Chat 即使拒绝也常返回 +HTTP 200,结果在响应体的 ``success`` 里,所以两者都要检查。 +""" + +import json +from collections.abc import Callable + +import httpx2 +from pydantic import BaseModel + +from ab_sdk import Plugin, points, provider, secret_field +from ab_sdk.notify import NotificationMessage, NotifierFactory, NotifierSettings + + +class Options(BaseModel): + webhook_url: str = secret_field( + title="Webhook 地址", + description="Chat → 整合 → 传入 Webhook 中的地址,含 token", + ) + + +def accepted(resp: httpx2.Response) -> bool: + """2xx,且响应体是 JSON 时其中的 success 不为 false。""" + if not resp.is_success: + return False + try: + body = resp.json() + except ValueError: + return True + return not isinstance(body, dict) or body.get("success", True) is not False + + +class Channel: + def __init__( + self, + options: Options, + settings: NotifierSettings, + client_factory: Callable[[], httpx2.AsyncClient], + ) -> None: + self.options = options + self.settings = settings + self.client_factory = client_factory + + async def send(self, message: NotificationMessage) -> bool: + if not self.options.webhook_url: + return False + payload: dict[str, str] = {"text": f"{message.title}\n{message.body}"} + if message.poster_url: + payload["file_url"] = message.poster_url + data = {"payload": json.dumps(payload, ensure_ascii=False)} + try: + async with self.client_factory() as client: + resp = await client.post(self.options.webhook_url, data=data) + except httpx2.HTTPError: + return False + return accepted(resp) + + async def test(self) -> tuple[bool, str]: + ok = await self.send( + NotificationMessage(kind="event", title="AutoBangumi", body="通知测试") + ) + return ok, "ok" if ok else "发送失败" + + +class SynologyChatNotifier(Plugin[Options]): + config_model = Options + + # 测试可替换为带 MockTransport 的客户端 + client_factory: Callable[[], httpx2.AsyncClient] = staticmethod( + lambda: httpx2.AsyncClient(timeout=10) + ) + + @provider(points.NOTIFIER, id="synology-chat") + def channel(self) -> NotifierFactory: + return lambda settings: Channel(self.config, settings, self.client_factory) diff --git a/examples/plugins/synology-chat-notifier/tests/test_synology_chat_notifier.py b/examples/plugins/synology-chat-notifier/tests/test_synology_chat_notifier.py new file mode 100644 index 00000000..67a4e07d --- /dev/null +++ b/examples/plugins/synology-chat-notifier/tests/test_synology_chat_notifier.py @@ -0,0 +1,63 @@ +import asyncio +import json +from urllib.parse import parse_qs + +import httpx2 +import pytest +from synology_chat_notifier import SynologyChatNotifier + +from ab_sdk.notify import NotificationMessage, NotifierSettings +from ab_sdk.testing import NotifierContract, create_plugin + +URL = "https://nas:5001/webapi/entry.cgi?api=SYNO.Chat.External&token=t" + + +def channel(response: httpx2.Response, seen: list | None = None): + plugin, _ = create_plugin(SynologyChatNotifier, {"webhook_url": URL}) + + def handler(request: httpx2.Request) -> httpx2.Response: + if seen is not None: + seen.append(request) + return response + + plugin.client_factory = lambda: httpx2.AsyncClient( # type: ignore[method-assign] + transport=httpx2.MockTransport(handler) + ) + return plugin.channel()(NotifierSettings()) + + +class TestChannel(NotifierContract): + def create(self): + return channel(httpx2.Response(200, json={"success": True})) + + def create_failing(self): + # Chat 拒绝请求时仍返回 HTTP 200 + return channel(httpx2.Response(200, json={"success": False, "error": {}})) + + +def test_send_posts_form_encoded_payload_json(): + seen: list[httpx2.Request] = [] + ch = channel(httpx2.Response(200, json={"success": True}), seen) + message = NotificationMessage( + kind="episode", title="新番更新", body="第 5 集", poster_url="https://p/1.jpg" + ) + assert asyncio.run(ch.send(message)) is True + [request] = seen + assert str(request.url) == URL + form = parse_qs(request.content.decode()) + assert json.loads(form["payload"][0]) == { + "text": "新番更新\n第 5 集", + "file_url": "https://p/1.jpg", + } + + +@pytest.mark.parametrize( + ("response", "ok"), + [ + (httpx2.Response(200, text="ok"), True), + (httpx2.Response(500, json={"success": True}), False), + ], +) +def test_send_judges_status_and_optional_json_body(response, ok): + message = NotificationMessage(kind="event", title="t", body="b") + assert asyncio.run(channel(response).send(message)) is ok From f1dd439fbe008973b092a73bc3a1554e6a8084af Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Wed, 21 Oct 2026 09:09:43 +0200 Subject: [PATCH 3/6] =?UTF-8?q?feat(notification):=20Bark=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E9=93=83=E5=A3=B0=E3=80=81=E5=88=86=E7=BB=84=E3=80=81?= =?UTF-8?q?=E4=B8=AD=E6=96=AD=E7=BA=A7=E5=88=AB=E3=80=81=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E4=B8=8E=E7=82=B9=E5=87=BB=E5=9C=B0=E5=9D=80=EF=BC=9B=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E6=9D=83=E9=99=90=E6=96=B0=E5=A2=9E=E3=80=8C=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E5=A4=96=E9=83=A8=E5=91=BD=E4=BB=A4=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bark 渠道新增可选字段 sound、group、level、icon,并复用 url 作为点击打开的地址; 只发送已设置的项,旧配置的请求体不变;配置了 icon 时优先于海报地址(#792) - 测试接口 /notification/test-config 透传新字段;通知设置表单加上对应输入框 与 level 下拉框,并更新三语言的通知配置文档 - 插件权限标签新增 subprocess(运行外部命令),仅用于展示 --- backend/src/module/api/notification.py | 8 +++ backend/src/module/models/config.py | 14 ++++- .../src/module/notification/providers/bark.py | 43 ++++++++----- backend/src/test/test_notification.py | 61 +++++++++++++++++++ docs/config/notifier.md | 5 +- docs/en/config/notifier.md | 5 +- docs/ja/config/notifier.md | 3 +- webui/src/api/notification.ts | 4 ++ .../setting/config-notification.vue | 48 ++++++++++++++- webui/src/i18n/en.json | 10 ++- webui/src/i18n/zh-CN.json | 10 ++- webui/types/config.ts | 5 ++ 12 files changed, 187 insertions(+), 29 deletions(-) diff --git a/backend/src/module/api/notification.py b/backend/src/module/api/notification.py index a5963228..3b58e562 100644 --- a/backend/src/module/api/notification.py +++ b/backend/src/module/api/notification.py @@ -48,6 +48,10 @@ class TestProviderConfigRequest(BaseModel): api_token: Optional[str] = Field(default=None, description="API token") template: Optional[str] = Field(default=None, description="Custom template") url: Optional[str] = Field(default=None, description="URL for generic webhook") + sound: Optional[str] = Field(default=None, description="Bark sound") + group: Optional[str] = Field(default=None, description="Bark group") + level: Optional[str] = Field(default=None, description="Bark level") + icon: Optional[str] = Field(default=None, description="Bark icon URL") class TestResponse(BaseModel): @@ -108,6 +112,10 @@ async def test_provider_config(request: TestProviderConfigRequest): api_token=request.api_token or "", template=request.template, url=request.url or "", + sound=request.sound, + group=request.group, + level=request.level, + icon=request.icon, ) success, message = await NotificationManager.test_provider_config(config) diff --git a/backend/src/module/models/config.py b/backend/src/module/models/config.py index ec9ee49f..2465b26a 100644 --- a/backend/src/module/models/config.py +++ b/backend/src/module/models/config.py @@ -181,7 +181,19 @@ class NotificationProvider(BaseModel): ), ) url_: Optional[str] = Field( - default=None, alias="url", description="URL for generic webhook provider" + default=None, + alias="url", + description="URL for generic webhook provider; tap-to-open URL for bark", + ) + # Bark 的可选推送参数,留空则不发送 + sound: Optional[str] = Field(default=None, description="Bark sound name") + group: Optional[str] = Field(default=None, description="Bark message group") + level: Optional[str] = Field( + default=None, + description="Bark interruption level: active/timeSensitive/passive/critical", + ) + icon: Optional[str] = Field( + default=None, description="Bark icon URL; defaults to the poster URL" ) @property diff --git a/backend/src/module/notification/providers/bark.py b/backend/src/module/notification/providers/bark.py index d2fe5a48..70e0512e 100644 --- a/backend/src/module/notification/providers/bark.py +++ b/backend/src/module/notification/providers/bark.py @@ -22,30 +22,42 @@ class BarkProvider(NotificationProvider): self.device_key = config.device_key server_url = config.server_url or self.DEFAULT_SERVER self.notification_url = f"{server_url.rstrip('/')}/push" + # 可选推送参数:只发送已设置的项,旧配置的请求体不变 + self.options = { + "sound": config.sound, + "group": config.group, + "level": config.level, + "url": config.url, + } + self.icon = config.icon + + def _payload(self, title: str, body: str, icon: str | None = None) -> dict: + data: dict[str, str | None] = { + "title": title, + "body": body, + "device_key": self.device_key, + } + data.update({k: v for k, v in self.options.items() if v}) + # 配置的图标优先,其次是调用方给的(单集通知的海报) + if self.icon or icon: + data["icon"] = self.icon or icon + return data async def send(self, notification: Notification) -> bool: """Send notification via Bark.""" text = self._format_message(notification) - data = { - "title": notification.official_title, - "body": text, - "device_key": self.device_key, - } - poster_url = self._poster_url(notification) - if poster_url: - data["icon"] = poster_url - + data = self._payload( + notification.official_title, text, self._poster_url(notification) + ) resp = await self._post_json(self.notification_url, data) logger.debug("Bark notification: %s", resp.status_code) return resp.status_code == 200 async def test(self) -> tuple[bool, str]: """Test Bark configuration by sending a test notification.""" - data = { - "title": "AutoBangumi", - "body": "通知测试成功!\nNotification test successful!", - "device_key": self.device_key, - } + data = self._payload( + "AutoBangumi", "通知测试成功!\nNotification test successful!" + ) try: resp = await self._post_json(self.notification_url, data) if resp.status_code == 200: @@ -57,6 +69,5 @@ class BarkProvider(NotificationProvider): async def _deliver_text(self, title: str, body: str) -> bool: """Deliver a system event via Bark.""" - data = {"title": title, "body": body, "device_key": self.device_key} - resp = await self._post_json(self.notification_url, data) + resp = await self._post_json(self.notification_url, self._payload(title, body)) return resp.status_code == 200 diff --git a/backend/src/test/test_notification.py b/backend/src/test/test_notification.py index 3065e5e0..09275b1f 100644 --- a/backend/src/test/test_notification.py +++ b/backend/src/test/test_notification.py @@ -354,6 +354,67 @@ class TestBarkProvider: call_args = mock_post.call_args[0] assert "device_key" in call_args[1] + @pytest.mark.parametrize( + ("options", "expected"), + [ + ({}, {}), + ({"sound": "", "level": ""}, {}), + ( + { + "sound": "minuet", + "group": "AB", + "level": "timeSensitive", + "url": "https://ab.local", + }, + { + "sound": "minuet", + "group": "AB", + "level": "timeSensitive", + "url": "https://ab.local", + }, + ), + ], + ) + async def test_deliver_text_sends_only_set_options(self, options, expected): + """#792:sound/group/level/url 只在设置时发送,旧配置的请求体不变。""" + config = ProviderConfig(type="bark", device_key="k", **options) + provider = BarkProvider(config) + + with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post: + mock_post.return_value = MagicMock(status_code=200) + assert await provider._deliver_text("T", "B") is True + + assert mock_post.call_args[0][1] == { + "title": "T", + "body": "B", + "device_key": "k", + **expected, + } + + @pytest.mark.parametrize( + ("icon", "poster", "expected"), + [ + (None, "https://ab/p.jpg", "https://ab/p.jpg"), + ("https://ab/i.png", "https://ab/p.jpg", "https://ab/i.png"), + (None, None, None), + ], + ) + async def test_send_icon_prefers_configured_icon_over_poster( + self, icon, poster, expected + ): + """配置的 icon 优先;未配置时沿用海报地址;都没有则不发 icon。""" + provider = BarkProvider(ProviderConfig(type="bark", device_key="k", icon=icon)) + notify = Notification(official_title="Test Anime", season=1, episode=5) + + with ( + patch.object(provider, "_poster_url", return_value=poster), + patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post, + ): + mock_post.return_value = MagicMock(status_code=200) + await provider.send(notify) + + assert mock_post.call_args[0][1].get("icon") == expected + class TestWebhookProvider: @pytest.fixture diff --git a/docs/config/notifier.md b/docs/config/notifier.md index 7d0078a6..08d0f822 100644 --- a/docs/config/notifier.md +++ b/docs/config/notifier.md @@ -24,7 +24,7 @@ - Telegram:`Bot Token`、`Chat ID` - Discord:Webhook URL - WeCom:Webhook URL、Key -- Bark:Device Key,可选 Server URL +- Bark:Device Key,可选 Server URL、铃声、分组、中断级别、图标地址、点击打开的地址 - Server Chan:SendKey - Gotify:Server URL、App Token - Pushover:User Key、API Token @@ -53,9 +53,10 @@ Webhook 模板可以使用 `{{title}}`、`{{season}}`、`{{episode}}`、`{{poste | `token` | Telegram(Bot Token)、Server Chan(SendKey)、WeCom(Key)、Gotify(App Token)使用 | | `chat_id` | Telegram 使用 | | `webhook_url` | Discord、WeCom 使用 | -| `url` | Webhook 使用 | +| `url` | Webhook 使用;Bark 中为点击通知后打开的地址(可选) | | `server_url` | Bark(可选)、Gotify 使用 | | `device_key` | Bark 使用 | +| `sound` / `group` / `level` / `icon` | Bark 可选:铃声、分组、中断级别(`active`、`timeSensitive`、`passive`、`critical`)、图标地址。留空则不发送;`icon` 留空时单集通知用海报地址 | | `user_key` / `api_token` | Pushover 使用 | | `template` | 自定义通知模板 | diff --git a/docs/en/config/notifier.md b/docs/en/config/notifier.md index 37a23587..0a167cf5 100644 --- a/docs/en/config/notifier.md +++ b/docs/en/config/notifier.md @@ -24,7 +24,7 @@ Provider-specific fields include: - Telegram: `Bot Token`, `Chat ID` - Discord: Webhook URL - WeCom: Webhook URL and Key -- Bark: Device Key and optional Server URL +- Bark: Device Key. Optional: Server URL, sound, group, interruption level, icon URL, and the URL to open on tap - Server Chan: SendKey - Gotify: Server URL and App Token - Pushover: User Key and API Token @@ -53,9 +53,10 @@ Each object in `providers` usually contains: | `token` | Used by Telegram (Bot Token), Server Chan (SendKey), WeCom (Key) and Gotify (App Token) | | `chat_id` | Used by Telegram | | `webhook_url` | Used by Discord and WeCom | -| `url` | Used by Webhook | +| `url` | Used by Webhook. For Bark, the URL to open on tap (optional) | | `server_url` | Used by Bark (optional) and Gotify | | `device_key` | Used by Bark | +| `sound` / `group` / `level` / `icon` | Optional for Bark: sound, group, interruption level (`active`, `timeSensitive`, `passive`, `critical`), and icon URL. AB does not send an empty field. When `icon` is empty, episode notifications use the poster URL | | `user_key` / `api_token` | Used by Pushover | | `template` | Custom message template | diff --git a/docs/ja/config/notifier.md b/docs/ja/config/notifier.md index 7ca46ae6..f0f8ed42 100644 --- a/docs/ja/config/notifier.md +++ b/docs/ja/config/notifier.md @@ -42,9 +42,10 @@ Webhookテンプレートでは `{{title}}`、`{{season}}`、`{{episode}}`、`{{ | `token` | Telegram(Bot Token)、Server Chan(SendKey)、WeCom(Key)、Gotify(App Token)で使用 | | `chat_id` | Telegram で使用 | | `webhook_url` | Discord、WeCom で使用 | -| `url` | Webhook で使用 | +| `url` | Webhook で使用。Bark ではタップで開く URL(任意) | | `server_url` | Bark(任意)、Gotify で使用 | | `device_key` | Bark で使用 | +| `sound` / `group` / `level` / `icon` | Bark(任意):通知音、グループ、割り込みレベル(`active`、`timeSensitive`、`passive`、`critical`)、アイコン URL。空の項目は送信しません。`icon` が空のとき、エピソード通知はポスターの URL を使います | | `user_key` / `api_token` | Pushover で使用 | | `template` | カスタム通知テンプレート | diff --git a/webui/src/api/notification.ts b/webui/src/api/notification.ts index 8ddf1a70..4dd0d617 100644 --- a/webui/src/api/notification.ts +++ b/webui/src/api/notification.ts @@ -17,6 +17,10 @@ export interface TestProviderConfigRequest { api_token?: string; template?: string; url?: string; + sound?: string; + group?: string; + level?: string; + icon?: string; } export interface TestResponse { diff --git a/webui/src/components/setting/config-notification.vue b/webui/src/components/setting/config-notification.vue index b6464bc5..daa24852 100644 --- a/webui/src/components/setting/config-notification.vue +++ b/webui/src/components/setting/config-notification.vue @@ -42,8 +42,10 @@ const providerFields: Record< string, { key: keyof NotificationProviderConfig; - label: string; + label: string | (() => string); placeholder: string; + // 有选项时渲染为可清空的下拉框 + options?: string[]; }[] > = { telegram: [ @@ -64,6 +66,32 @@ const providerFields: Record< label: 'Server URL (optional)', placeholder: 'https://api.day.app', }, + { + key: 'sound', + label: () => t('config.notification_set.bark_sound'), + placeholder: 'minuet', + }, + { + key: 'group', + label: () => t('config.notification_set.bark_group'), + placeholder: 'AutoBangumi', + }, + { + key: 'level', + label: () => t('config.notification_set.bark_level'), + placeholder: 'active', + options: ['active', 'timeSensitive', 'passive', 'critical'], + }, + { + key: 'icon', + label: () => t('config.notification_set.bark_icon'), + placeholder: 'https://example.com/icon.png', + }, + { + key: 'url', + label: () => t('config.notification_set.bark_url'), + placeholder: 'https://example.com', + }, ], 'server-chan': [{ key: 'token', label: 'SendKey', placeholder: 'sendkey' }], wecom: [ @@ -385,8 +413,15 @@ function getFieldsForType(type: string) { :key="field.key" :label="field.label" > + @@ -457,8 +492,15 @@ function getFieldsForType(type: string) { :key="field.key" :label="field.label" > + diff --git a/webui/src/i18n/en.json b/webui/src/i18n/en.json index 1771eae5..40255217 100644 --- a/webui/src/i18n/en.json +++ b/webui/src/i18n/en.json @@ -119,7 +119,12 @@ "test_success": "Test successful", "test_failed": "Test failed", "plugin_label": "{id} (plugin)", - "plugin_hint": "This channel comes from a plugin and has no fields here. Enter its credentials in the plugin's options in the Plugins section." + "plugin_hint": "This channel comes from a plugin and has no fields here. Enter its credentials in the plugin's options in the Plugins section.", + "bark_sound": "Sound (optional)", + "bark_group": "Group (optional)", + "bark_level": "Interruption level (optional)", + "bark_icon": "Icon URL (optional, defaults to the poster)", + "bark_url": "URL to open on tap (optional)" }, "parser_set": { "enable": "Enable", @@ -245,7 +250,8 @@ "permission_labels": { "fs_write": "Write files", "network": "Network access", - "filesystem": "File system access" + "filesystem": "File system access", + "subprocess": "Run external commands" }, "disable": "Disable", "disable_confirm_title": "Disable {name}?", diff --git a/webui/src/i18n/zh-CN.json b/webui/src/i18n/zh-CN.json index 9a69a721..294e9970 100644 --- a/webui/src/i18n/zh-CN.json +++ b/webui/src/i18n/zh-CN.json @@ -119,7 +119,12 @@ "test_success": "测试成功", "test_failed": "测试失败", "plugin_label": "{id}(插件)", - "plugin_hint": "此渠道由插件提供,这里没有可填字段。请在插件设置中该插件的配置里填写凭据。" + "plugin_hint": "此渠道由插件提供,这里没有可填字段。请在插件设置中该插件的配置里填写凭据。", + "bark_sound": "铃声(可选)", + "bark_group": "分组(可选)", + "bark_level": "中断级别(可选)", + "bark_icon": "图标地址(可选,默认用海报)", + "bark_url": "点击打开的地址(可选)" }, "parser_set": { "enable": "启用", @@ -245,7 +250,8 @@ "permission_labels": { "fs_write": "写入文件", "network": "访问网络", - "filesystem": "访问文件系统" + "filesystem": "访问文件系统", + "subprocess": "运行外部命令" }, "disable": "停用", "disable_confirm_title": "停用 {name}?", diff --git a/webui/types/config.ts b/webui/types/config.ts index f1ab7766..c4f874f5 100644 --- a/webui/types/config.ts +++ b/webui/types/config.ts @@ -95,6 +95,11 @@ export interface NotificationProviderConfig { api_token?: string; template?: string; url?: string; + // Bark 的可选推送参数 + sound?: string; + group?: string; + level?: string; + icon?: string; } export interface Notification { From 81bbf9c71d645a0e7c705db038908aa4adbb356b Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Wed, 21 Oct 2026 11:05:59 +0200 Subject: [PATCH 4/6] =?UTF-8?q?fix(plugins):=20=E5=AE=A1=E6=9F=A5=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E2=80=94=E2=80=94=E7=A4=BA=E4=BE=8B=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E4=B8=8E=E9=80=9A=E7=9F=A5=E6=B8=A0=E9=81=93=E7=9A=84=E8=8B=A5?= =?UTF-8?q?=E5=B9=B2=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - run-command:等待进程退出而不是等 stderr 关闭(后台子进程不再导致误判超时), stderr 写临时文件取末尾;只替换整项为 {字段} 的参数,{files} 展开为每个文件 一个参数,夹带的占位符原样保留;不把宿主自己的 AB_* 环境变量传给命令;README 说明脚本从 AB_* 环境变量读取事件数据 - strm-writer:路径规范化后为媒体根目录本身(. / ..)时跳过,不再写出 STRM 目录之外的文件 - slack-notifier / onebot-notifier:带海报发送失败时去掉图片只发文字重试一次 - wpush-notifier / onebot-notifier:非 2xx 时报告 HTTP 状态,响应不是 JSON 时不再报解析异常 - 插件通知渠道的 extra 包含已填写的已声明字段(group、icon 等),不只是 model_extra - 新增通知渠道时切换类型会清掉上一类型的字段,避免 Bark 带上残留的 Webhook URL --- backend/src/module/notification/resolve.py | 11 ++- backend/src/test/test_plugin_extensions.py | 17 ++++ examples/plugins/onebot-notifier/README.md | 2 +- .../onebot_notifier/__init__.py | 37 +++++--- .../tests/test_onebot_notifier.py | 47 ++++++++++- examples/plugins/run-command/README.md | 12 ++- .../run-command/run_command/__init__.py | 84 +++++++++++++------ .../run-command/tests/test_run_command.py | 33 +++++++- examples/plugins/slack-notifier/README.md | 2 +- .../slack-notifier/slack_notifier/__init__.py | 17 +++- .../tests/test_slack_notifier.py | 34 +++++++- .../strm-writer/strm_writer/__init__.py | 10 ++- .../strm-writer/tests/test_strm_writer.py | 11 ++- .../tests/test_wpush_notifier.py | 15 +++- .../wpush-notifier/wpush_notifier/__init__.py | 12 ++- .../__tests__/config-notification.test.ts | 21 ++++- .../setting/config-notification.vue | 9 +- 17 files changed, 310 insertions(+), 64 deletions(-) diff --git a/backend/src/module/notification/resolve.py b/backend/src/module/notification/resolve.py index 4638eb71..ff37475d 100644 --- a/backend/src/module/notification/resolve.py +++ b/backend/src/module/notification/resolve.py @@ -56,7 +56,14 @@ def build_provider(config: "ProviderConfig") -> NotificationProvider | None: if entry.plugin_id == CORE: # 内置渠道:impl 是 NotificationProvider 子类,直接接收整条配置 return impl(config) - settings = NotifierSettings( - template=config.template, extra=dict(config.model_extra or {}) + # extra = 未知字段 + 已填写的已声明字段(group、icon 等同时是 Bark 的字段, + # 声明后不在 model_extra 里);type/enabled/template 由宿主处理,不放进去 + unknown = config.model_extra or {} + declared = config.model_dump( + by_alias=True, + exclude_none=True, + exclude={"type", "enabled", "template", *unknown}, ) + extra = {k: v for k, v in declared.items() if v != ""} | unknown + settings = NotifierSettings(template=config.template, extra=extra) return PluginNotifier(config, impl(settings)) diff --git a/backend/src/test/test_plugin_extensions.py b/backend/src/test/test_plugin_extensions.py index d0b01ec3..7413c550 100644 --- a/backend/src/test/test_plugin_extensions.py +++ b/backend/src/test/test_plugin_extensions.py @@ -170,6 +170,23 @@ class TestNotifier: provider = build_provider(ProviderConfig(type="MyChannel")) assert isinstance(provider, PluginNotifier) + def test_build_provider_plugin_extra_includes_set_declared_fields(self, registry): + from module.notification.resolve import build_provider + + created: list[RecordingNotifier] = [] + + def factory(conf: NotifierSettings) -> RecordingNotifier: + created.append(RecordingNotifier(conf)) + return created[-1] + + add_plugin_provider(registry, points.NOTIFIER, "chat", factory) + # group/icon 是 Bark 声明的字段,不在 model_extra 里;未填写的 sound 不传 + cfg = ProviderConfig.model_validate( + {"type": "chat", "room": "42", "group": "g", "icon": "i", "sound": ""} + ) + build_provider(cfg) + assert created[0].conf.extra == {"room": "42", "group": "g", "icon": "i"} + def test_extra_fields_survive_config_roundtrip(self): cfg = ProviderConfig.model_validate({"type": "chat", "room": "42"}) assert cfg.model_dump(by_alias=True)["room"] == "42" diff --git a/examples/plugins/onebot-notifier/README.md b/examples/plugins/onebot-notifier/README.md index 1dfb2844..4f4f26ed 100644 --- a/examples/plugins/onebot-notifier/README.md +++ b/examples/plugins/onebot-notifier/README.md @@ -19,7 +19,7 @@ ## 海报 -插件只能拿到宿主给出的公开海报地址,拿不到本地海报文件。因此要发送海报,需要在「设置 → 通知」中配置 `base_url`,并保证 OneBot 实现能访问这个地址;它会自行下载图片。未配置时只发送文字。 +插件只能拿到宿主给出的公开海报地址,拿不到本地海报文件。因此要发送海报,需要在「设置 → 通知」中配置 `base_url`,并保证 OneBot 实现能访问这个地址;它会自行下载图片。未配置时只发送文字。带图片发送失败时,插件去掉图片只发文字再试一次。 ## 安装 diff --git a/examples/plugins/onebot-notifier/onebot_notifier/__init__.py b/examples/plugins/onebot-notifier/onebot_notifier/__init__.py index c93b8919..8ea94691 100644 --- a/examples/plugins/onebot-notifier/onebot_notifier/__init__.py +++ b/examples/plugins/onebot-notifier/onebot_notifier/__init__.py @@ -2,7 +2,8 @@ POST JSON 到 ``{url}/send_private_msg`` 或 ``{url}/send_group_msg``,消息为消息段 数组:文字,以及(开启海报且宿主给出海报地址时)一张图片。HTTP 2xx 且返回 -``status == "ok"`` 或 ``retcode == 0`` 才算成功。 +``status == "ok"`` 或 ``retcode == 0`` 才算成功。带图片发送失败时(OneBot 实现取不到 +海报会拒绝整条消息),去掉图片只发文字再试一次。 协议文档:https://github.com/botuniverse/onebot-11 """ @@ -63,20 +64,33 @@ class Channel: opts = self.options if not opts.url or not opts.target: return False, "未填写 API 地址或目标" + segments = self._segments(message) + try: + async with self.client_factory() as client: + ok, detail = await self._send(client, segments) + if not ok and segments[0]["type"] == "image": + ok, detail = await self._send(client, segments[1:]) + except httpx2.HTTPError as e: + return False, f"请求失败:{e}" + return ok, detail + + async def _send( + self, client: httpx2.AsyncClient, segments: list[dict[str, Any]] + ) -> tuple[bool, str]: + opts = self.options id_key = "group_id" if opts.message_type == "group" else "user_id" headers = {} if opts.access_token: headers["Authorization"] = f"Bearer {opts.access_token}" + resp = await client.post( + f"{opts.url.rstrip('/')}/send_{opts.message_type}_msg", + json={id_key: opts.target, "message": segments}, + headers=headers, + ) try: - async with self.client_factory() as client: - resp = await client.post( - f"{opts.url.rstrip('/')}/send_{opts.message_type}_msg", - json={id_key: opts.target, "message": self._segments(message)}, - headers=headers, - ) body = resp.json() - except (httpx2.HTTPError, ValueError) as e: - return False, f"请求失败:{e}" + except ValueError: + body = None if ( resp.is_success and isinstance(body, dict) @@ -86,7 +100,10 @@ class Channel: detail = ( (body.get("wording") or body.get("msg")) if isinstance(body, dict) else None ) - return False, f"OneBot 返回错误:{detail or resp.status_code}" + if not resp.is_success: + # 反向代理的错误页不是 JSON,先报 HTTP 状态 + detail = f"HTTP {resp.status_code}" + (f",{detail}" if detail else "") + return False, f"OneBot 返回错误:{detail or '响应不是预期的 JSON'}" async def send(self, message: NotificationMessage) -> bool: ok, _ = await self._post(message) diff --git a/examples/plugins/onebot-notifier/tests/test_onebot_notifier.py b/examples/plugins/onebot-notifier/tests/test_onebot_notifier.py index a48a54ff..45aad4c5 100644 --- a/examples/plugins/onebot-notifier/tests/test_onebot_notifier.py +++ b/examples/plugins/onebot-notifier/tests/test_onebot_notifier.py @@ -1,5 +1,6 @@ import asyncio import json +from collections.abc import Callable import httpx2 import pytest @@ -12,10 +13,19 @@ BASE = {"url": "http://bot:3000/", "target": 123} POSTER = "https://ab.example.com/posters/a.jpg" -def channel(status: int, body: dict, options: dict | None = None, seen=None): +def channel( + status: int, + body: dict | str | Callable[[dict], httpx2.Response], + options: dict | None = None, + seen=None, +): def handler(request: httpx2.Request) -> httpx2.Response: if seen is not None: seen.append(request) + if callable(body): + return body(json.loads(request.content)) + if isinstance(body, str): + return httpx2.Response(status, text=body) return httpx2.Response(status, json=body) plugin, _ = create_plugin(OneBotNotifier, BASE if options is None else options) @@ -91,3 +101,38 @@ def test_send_without_target_returns_false_without_request(): ch = channel(200, {"status": "ok"}, {"url": "http://bot:3000"}, seen) assert asyncio.run(ch.send(episode())) is False assert seen == [] + + +# OneBot 实现取不到海报时拒绝整条消息 +def reject_image(body: dict) -> httpx2.Response: + if body["message"][0]["type"] == "image": + return httpx2.Response(200, json={"status": "failed", "retcode": 1200}) + return httpx2.Response(200, json={"status": "ok", "retcode": 0}) + + +def test_send_rejected_image_retries_once_with_text_only(): + seen: list[httpx2.Request] = [] + ch = channel(200, reject_image, seen=seen) + assert asyncio.run(ch.send(episode(POSTER))) is True + first, retry = seen + assert json.loads(first.content)["message"][0]["type"] == "image" + assert json.loads(retry.content)["message"] == [ + {"type": "text", "data": {"text": "番剧 第1集"}} + ] + + +def test_send_text_only_failure_is_not_retried(): + seen: list[httpx2.Request] = [] + ch = channel(200, {"status": "failed", "retcode": 100}, seen=seen) + assert asyncio.run(ch.send(episode())) is False + assert len(seen) == 1 + + +@pytest.mark.parametrize( + ("status", "body", "expected"), + [(502, "Bad Gateway", "HTTP 502"), (200, "", "不是预期的 JSON")], +) +def test_test_non_json_response_reports_status(status, body, expected): + ok, detail = asyncio.run(channel(status, body).test()) + assert ok is False + assert expected in detail diff --git a/examples/plugins/run-command/README.md b/examples/plugins/run-command/README.md index 48794538..cac3858e 100644 --- a/examples/plugins/run-command/README.md +++ b/examples/plugins/run-command/README.md @@ -16,7 +16,15 @@ ## 传给命令的字段 -每个字段既是环境变量,也可以在参数里写成占位符(环境变量名去掉 `AB_` 后的小写,如 `{torrent_hash}`)。未知的占位符原样保留。 +每个字段都是环境变量。参数列表里某一项**整项**写成占位符(环境变量名去掉 `AB_` 后的小写,如 `{torrent_hash}`)时,这一项替换为字段值;`{files}` 展开为每个文件一个参数。只替换整项:`--hash={torrent_hash}`、`sh -c "echo {new_path}"` 这类夹在参数里的占位符不替换,原样传给程序;未知的占位符也原样保留。 + +文件名来自 RSS,可能含有 `;`、`$()`、引号。不要把占位符写进 `sh -c` 的脚本文本,脚本里从 `AB_*` 环境变量读取事件数据,并给变量加双引号: + +```sh +#!/bin/sh +# /config/scripts/on-rename.sh,命令填 ["/config/scripts/on-rename.sh"] +ln -f "$AB_NEW_PATH" "/media/links/$(basename "$AB_NEW_PATH")" +``` | 环境变量 | 事件 | 内容 | | --- | --- | --- | @@ -28,7 +36,7 @@ | `AB_OLD_PATH` / `AB_NEW_PATH` | `file.renamed` | 重命名前后的路径 | | `AB_FILE_KIND` | `file.renamed` | `media` 或 `subtitle` | -事件里没有种子的保存目录,需要时从 `AB_FILES` 取。路径是**下载器视角**,AutoBangumi 与下载器的挂载不同时,脚本要自己换算。命令按事件顺序逐个运行,一个命令运行时后面的事件排队。 +命令继承 AutoBangumi 的环境变量,但 AutoBangumi 自己的 `AB_*`(如下载器密码)不传给命令。事件里没有种子的保存目录,需要时从 `AB_FILES` 取。路径是**下载器视角**,AutoBangumi 与下载器的挂载不同时,脚本要自己换算。命令按事件顺序逐个运行,一个命令运行时后面的事件排队。 `torrent.organized` 的投递是「至少一次」:每次 AutoBangumi 重启,已整理的种子会再发布一次。脚本必须幂等。 diff --git a/examples/plugins/run-command/run_command/__init__.py b/examples/plugins/run-command/run_command/__init__.py index f415f339..a52388b7 100644 --- a/examples/plugins/run-command/run_command/__init__.py +++ b/examples/plugins/run-command/run_command/__init__.py @@ -4,7 +4,9 @@ ``file.renamed`` 运行 ``command``。命令是参数列表,直接 ``exec``,不经过 shell, 所以参数里的空格、``$``、``;`` 都按字面传给程序。 -事件字段以 ``AB_*`` 环境变量传给命令,参数里也可以写 ``{字段}`` 占位符: +事件字段以 ``AB_*`` 环境变量传给命令。整个参数恰好是 ``{字段}`` 时替换为字段值 +(``{files}`` 展开为每个文件一个参数);参数里夹带的占位符不替换,以免文件名被拼进 +``sh -c`` 之类的脚本文本: - 两个事件都有:``AB_EVENT``、``AB_BANGUMI_ID``(旧种子可能为空)、``AB_DOWNLOADER_ID`` - ``torrent.organized``:``AB_TORRENT_HASH``、``AB_FILES``(正片与字幕的路径,每行一个) @@ -17,7 +19,8 @@ import asyncio import os import re -from subprocess import DEVNULL, PIPE +import tempfile +from subprocess import DEVNULL from pydantic import BaseModel, Field @@ -32,7 +35,10 @@ class Options(BaseModel): command: list[str] = Field( default_factory=list, title="命令", - description="程序及其参数,每项一个,如 /config/scripts/link.sh、{files};不经过 shell", + description=( + "程序及其参数,每项一个,如 /config/scripts/link.sh、{files};不经过 shell," + "只替换整项为占位符的参数" + ), ) events: list[str] = Field( default_factory=lambda: [TorrentOrganized.kind], @@ -70,13 +76,29 @@ def event_fields(event: Event) -> dict[str, str] | None: def build_argv(command: list[str], fields: dict[str, str]) -> list[str]: - """替换参数里的 ``{字段}``;未知的占位符原样保留。""" - return [_PLACEHOLDER.sub(lambda m: fields.get(m[1], m[0]), arg) for arg in command] + """整项为 ``{字段}`` 的参数替换为字段值,``{files}`` 展开为每个文件一个参数。 + + 部分包含占位符的参数与未知的占位符原样保留。 + """ + argv: list[str] = [] + for arg in command: + m = _PLACEHOLDER.fullmatch(arg) + if m is None or m[1] not in fields: + argv.append(arg) + elif m[1] == "files": + argv.extend(fields["files"].splitlines()) + else: + argv.append(fields[m[1]]) + return argv def build_env(fields: dict[str, str]) -> dict[str, str]: - """继承宿主环境(否则子进程没有 PATH),再加上 ``AB_*``。""" - return {**os.environ, **{f"AB_{k.upper()}": v for k, v in fields.items()}} + """继承宿主环境(否则子进程没有 PATH),再加上 ``AB_*``。 + + 宿主自己的 ``AB_*``(如 ``AB_DOWNLOADER_PASSWORD``)不传给命令。 + """ + env = {k: v for k, v in os.environ.items() if not k.startswith("AB_")} + return {**env, **{f"AB_{k.upper()}": v for k, v in fields.items()}} class RunCommand(Plugin[Options]): @@ -91,26 +113,34 @@ class RunCommand(Plugin[Options]): if fields is None: return argv = build_argv(cfg.command, fields) - try: - proc = await asyncio.create_subprocess_exec( - *argv, stdin=DEVNULL, stdout=DEVNULL, stderr=PIPE, env=build_env(fields) - ) - except OSError as e: - self.ctx.log.warning("无法启动命令 %s:%s", argv[0], e) - return - try: - async with asyncio.timeout(cfg.timeout): - _, stderr = await proc.communicate() - except TimeoutError: - self.ctx.log.warning("命令 %s 超过 %s 秒,已终止", argv[0], cfg.timeout) - return - finally: - # 超时或宿主取消处理时,不留下孤儿进程 - # ponytail: 只杀直接子进程;命令自己再派生的进程需由脚本负责 - if proc.returncode is None: - proc.kill() - await proc.wait() - tail = stderr[-2000:].decode(errors="replace").strip() + # stderr 写进临时文件而不是管道:命令留在后台的子进程继承 stderr 时, + # 读管道要等到它们退出,命令本身早已结束也会被当成超时 + with tempfile.TemporaryFile() as err: + try: + proc = await asyncio.create_subprocess_exec( + *argv, + stdin=DEVNULL, + stdout=DEVNULL, + stderr=err, + env=build_env(fields), + ) + except OSError as e: + self.ctx.log.warning("无法启动命令 %s:%s", argv[0], e) + return + try: + async with asyncio.timeout(cfg.timeout): + await proc.wait() + except TimeoutError: + self.ctx.log.warning("命令 %s 超过 %s 秒,已终止", argv[0], cfg.timeout) + return + finally: + # 超时或宿主取消处理时,不留下孤儿进程 + # ponytail: 只杀直接子进程;命令自己再派生的进程需由脚本负责 + if proc.returncode is None: + proc.kill() + await proc.wait() + err.seek(max(0, err.seek(0, os.SEEK_END) - 2000)) + tail = err.read().decode(errors="replace").strip() if proc.returncode: self.ctx.log.warning( "命令 %s 退出码 %s:%s", argv[0], proc.returncode, tail diff --git a/examples/plugins/run-command/tests/test_run_command.py b/examples/plugins/run-command/tests/test_run_command.py index 771e5068..39501d70 100644 --- a/examples/plugins/run-command/tests/test_run_command.py +++ b/examples/plugins/run-command/tests/test_run_command.py @@ -36,13 +36,27 @@ def run(options: dict, event=ORGANIZED) -> None: asyncio.run(plugin.on_event(event)) -def test_on_event_organized_passes_env_and_placeholders_without_shell(tmp_path): +def test_on_event_organized_passes_env_and_placeholders_without_shell( + tmp_path, monkeypatch +): + # 宿主自己的 AB_* 凭据不传给命令 + monkeypatch.setenv("AB_DOWNLOADER_PASSWORD", "s3cret") out = tmp_path / "out.json" - command = [sys.executable, "-c", DUMP, str(out), "{torrent_hash}", "$HOME; {x}"] + command = [ + *(sys.executable, "-c", DUMP, str(out)), + *("{torrent_hash}", "$HOME; {x}", "pre-{torrent_hash}", "{files}"), + ] run({"command": command}) got = json.loads(out.read_text()) - # $HOME 与 ; 按字面传入:没有经过 shell;未知占位符原样保留 - assert got["argv"] == ["abc", "$HOME; {x}"] + # $HOME 与 ; 按字面传入:没有经过 shell;未知占位符与夹在参数里的占位符原样保留; + # {files} 展开为每个文件一个参数 + assert got["argv"] == [ + "abc", + "$HOME; {x}", + "pre-{torrent_hash}", + "/dl/Show/Season 1/Show S01E01.mkv", + "/dl/Show/Season 1/Show S01E01.ass", + ] assert got["env"] == { "AB_EVENT": "torrent.organized", "AB_BANGUMI_ID": "", @@ -85,6 +99,17 @@ def test_on_event_timeout_kills_command(caplog): assert "已终止" in caplog.text +def test_on_event_background_child_does_not_delay_success(caplog): + # 后台进程继承了 stderr;命令本身已退出,不应等到后台进程结束 + command = ["sh", "-c", "sleep 5 & exit 0"] + plugin, _ = create_plugin(RunCommand, {"command": command, "timeout": 2}) + start = time.monotonic() + with caplog.at_level(logging.INFO): + asyncio.run(plugin.on_event(ORGANIZED)) + assert time.monotonic() - start < 2 + assert "已完成" in caplog.text and "已终止" not in caplog.text + + def test_on_event_nonzero_exit_logs_code_and_stderr(caplog): script = "import sys; sys.stderr.write('boom'); sys.exit(3)" with caplog.at_level(logging.WARNING): diff --git a/examples/plugins/slack-notifier/README.md b/examples/plugins/slack-notifier/README.md index cfd630ea..2f5b1ce5 100644 --- a/examples/plugins/slack-notifier/README.md +++ b/examples/plugins/slack-notifier/README.md @@ -9,7 +9,7 @@ ## 消息格式 -请求体为 `{"text": "*标题*\n正文"}`(Slack 的 mrkdwn,`&`、`<`、`>` 已转义)。配置了 `notification.base_url` 时单集通知带海报:再加 `blocks`(一段文字和一张图片),`text` 作为手机通知的摘要。海报地址必须能被 Slack 访问。 +请求体为 `{"text": "*标题*\n正文"}`(Slack 的 mrkdwn,`&`、`<`、`>` 已转义)。配置了 `notification.base_url` 时单集通知带海报:再加 `blocks`(一段文字和一张图片),`text` 作为手机通知的摘要。海报地址必须能被 Slack 访问;Slack 取不到图片时会拒绝整条消息,插件会去掉 `blocks` 只发文字再试一次。 ## 安装 diff --git a/examples/plugins/slack-notifier/slack_notifier/__init__.py b/examples/plugins/slack-notifier/slack_notifier/__init__.py index 5ea09723..27c94d4e 100644 --- a/examples/plugins/slack-notifier/slack_notifier/__init__.py +++ b/examples/plugins/slack-notifier/slack_notifier/__init__.py @@ -3,6 +3,8 @@ 向 Incoming Webhook POST ``{"text": "*标题*\\n正文"}``(Slack 的 mrkdwn)。带海报时 再加 ``blocks``:一段文字加一张图片,``text`` 留作手机通知里的摘要。Slack 成功时 返回 HTTP 200 与响应体 ``ok``,失败时返回 4xx 与错误码(如 ``invalid_token``)。 +Slack 取不到海报图片时会拒绝整条消息(400 ``invalid_blocks``),此时去掉 ``blocks`` +只发文字再试一次。 """ from collections.abc import Callable @@ -42,6 +44,10 @@ def slack_body(message: NotificationMessage) -> dict[str, Any]: return body +def _ok(resp: httpx2.Response) -> bool: + return resp.is_success and resp.text == "ok" + + class Channel: def __init__( self, @@ -56,14 +62,17 @@ class Channel: async def send(self, message: NotificationMessage) -> bool: if not self.options.webhook_url: return False + body = slack_body(message) try: async with self.client_factory() as client: - resp = await client.post( - self.options.webhook_url, json=slack_body(message) - ) + resp = await client.post(self.options.webhook_url, json=body) + if not _ok(resp) and "blocks" in body: + resp = await client.post( + self.options.webhook_url, json={"text": body["text"]} + ) except httpx2.HTTPError: return False - return resp.is_success and resp.text == "ok" + return _ok(resp) async def test(self) -> tuple[bool, str]: ok = await self.send( diff --git a/examples/plugins/slack-notifier/tests/test_slack_notifier.py b/examples/plugins/slack-notifier/tests/test_slack_notifier.py index 0f1b1f74..8d683a4d 100644 --- a/examples/plugins/slack-notifier/tests/test_slack_notifier.py +++ b/examples/plugins/slack-notifier/tests/test_slack_notifier.py @@ -1,5 +1,6 @@ import asyncio import json +from collections.abc import Callable import httpx2 import pytest @@ -11,12 +12,17 @@ from ab_sdk.testing import NotifierContract, create_plugin URL = "https://hooks.slack.com/services/T/B/x" -def channel(response: httpx2.Response, seen: list | None = None): +def channel( + response: httpx2.Response | Callable[[dict], httpx2.Response], + seen: list | None = None, +): plugin, _ = create_plugin(SlackNotifier, {"webhook_url": URL}) def handler(request: httpx2.Request) -> httpx2.Response: if seen is not None: seen.append(request) + if callable(response): + return response(json.loads(request.content)) return response plugin.client_factory = lambda: httpx2.AsyncClient( # type: ignore[method-assign] @@ -61,3 +67,29 @@ def test_send_with_poster_adds_image_block(): def test_send_2xx_without_ok_body_is_failure(text): message = NotificationMessage(kind="event", title="t", body="b") assert asyncio.run(channel(httpx2.Response(200, text=text)).send(message)) is False + + +# Slack 取不到海报时拒绝带 blocks 的整条消息 +def reject_blocks(body: dict) -> httpx2.Response: + if "blocks" in body: + return httpx2.Response(400, text="invalid_blocks") + return httpx2.Response(200, text="ok") + + +def test_send_rejected_blocks_retries_once_with_text_only(): + seen: list[httpx2.Request] = [] + message = NotificationMessage( + kind="episode", title="T", body="b", poster_url="https://p/1.jpg" + ) + assert asyncio.run(channel(reject_blocks, seen).send(message)) is True + first, retry = seen + assert "blocks" in json.loads(first.content) + assert json.loads(retry.content) == {"text": "*T*\nb"} + + +def test_send_text_only_failure_is_not_retried(): + seen: list[httpx2.Request] = [] + message = NotificationMessage(kind="event", title="t", body="b") + ch = channel(httpx2.Response(400, text="invalid_payload"), seen) + assert asyncio.run(ch.send(message)) is False + assert len(seen) == 1 diff --git a/examples/plugins/strm-writer/strm_writer/__init__.py b/examples/plugins/strm-writer/strm_writer/__init__.py index 0cbe34c4..4f9c9720 100644 --- a/examples/plugins/strm-writer/strm_writer/__init__.py +++ b/examples/plugins/strm-writer/strm_writer/__init__.py @@ -39,11 +39,15 @@ class Options(BaseModel): def relative(path: str, media_root: str) -> str | None: - """``path`` 不在 ``media_root`` 下时返回 None。""" + """``path`` 不在 ``media_root`` 下(或就是 ``media_root`` 本身)时返回 None。""" root = media_root.rstrip("/") if not root or not path.startswith(root + "/"): return None - return posixpath.normpath(path[len(root) + 1 :]) + rel = posixpath.normpath(path[len(root) + 1 :]) + # "." 会写出 .strm,落在 strm_root 之外 + if rel in (".", "..") or rel.startswith("../"): + return None + return rel def strm_target(rel: str, options: Options) -> tuple[Path, str]: @@ -74,7 +78,7 @@ class StrmWriter(Plugin[Options]): if file.kind != "media": continue rel = relative(file.path, cfg.media_root) - if rel is None or rel.startswith("../"): + if rel is None: self.ctx.log.warning( "%s 不在媒体根目录 %s 下", file.path, cfg.media_root ) diff --git a/examples/plugins/strm-writer/tests/test_strm_writer.py b/examples/plugins/strm-writer/tests/test_strm_writer.py index 5cf7b64b..8cfbfd4d 100644 --- a/examples/plugins/strm-writer/tests/test_strm_writer.py +++ b/examples/plugins/strm-writer/tests/test_strm_writer.py @@ -54,10 +54,17 @@ def test_on_organized_prefix_change_rewrites_strm(tmp_path): @pytest.mark.parametrize( - "path", ["/other/A S01E01.mkv", "/downloads/Bangumi/../../etc/x.mkv"] + "path", + [ + "/other/A S01E01.mkv", + "/downloads/Bangumi/../../etc/x.mkv", + # 规范化后是媒体根目录本身,曾写出 STRM 目录之外的 STRM.strm + "/downloads/Bangumi/x/..", + "/downloads/Bangumi/", + ], ) def test_on_organized_path_outside_media_root_is_skipped(tmp_path, caplog, path): with caplog.at_level(logging.WARNING): run(tmp_path, "http://nas/", OrganizedFile(path, "media")) - assert not (tmp_path / "STRM").exists() + assert list(tmp_path.iterdir()) == [] assert "不在媒体根目录" in caplog.text diff --git a/examples/plugins/wpush-notifier/tests/test_wpush_notifier.py b/examples/plugins/wpush-notifier/tests/test_wpush_notifier.py index aecff6d8..3f94755d 100644 --- a/examples/plugins/wpush-notifier/tests/test_wpush_notifier.py +++ b/examples/plugins/wpush-notifier/tests/test_wpush_notifier.py @@ -2,16 +2,19 @@ import asyncio import json import httpx2 +import pytest from wpush_notifier import API_URL, WPushNotifier from ab_sdk.notify import NotificationMessage, NotifierSettings from ab_sdk.testing import NotifierContract, create_plugin -def channel(status: int, body: dict, options: dict | None = None, seen=None): +def channel(status: int, body: dict | str, options: dict | None = None, seen=None): def handler(request: httpx2.Request) -> httpx2.Response: if seen is not None: seen.append(request) + if isinstance(body, str): + return httpx2.Response(status, text=body) return httpx2.Response(status, json=body) plugin, _ = create_plugin( @@ -63,3 +66,13 @@ def test_send_without_apikey_returns_false_without_request(): ch = channel(200, {"code": 0}, {}, seen) assert asyncio.run(ch.test()) == (False, "未填写 apikey") assert seen == [] + + +@pytest.mark.parametrize( + ("status", "body", "expected"), + [(502, "Bad Gateway", "HTTP 502"), (200, "", "不是预期的 JSON")], +) +def test_test_non_json_response_reports_status(status, body, expected): + ok, detail = asyncio.run(channel(status, body).test()) + assert ok is False + assert expected in detail diff --git a/examples/plugins/wpush-notifier/wpush_notifier/__init__.py b/examples/plugins/wpush-notifier/wpush_notifier/__init__.py index e2a3f55a..204d5493 100644 --- a/examples/plugins/wpush-notifier/wpush_notifier/__init__.py +++ b/examples/plugins/wpush-notifier/wpush_notifier/__init__.py @@ -51,13 +51,19 @@ class Channel: try: async with self.client_factory() as client: resp = await client.post(API_URL, json=payload) - body = resp.json() - except (httpx2.HTTPError, ValueError) as e: + except httpx2.HTTPError as e: return False, f"请求失败:{e}" + try: + body = resp.json() + except ValueError: + body = None if resp.is_success and isinstance(body, dict) and body.get("code") == 0: return True, "ok" detail = body.get("message") if isinstance(body, dict) else None - return False, f"WPUSH 返回错误:{detail or resp.status_code}" + if not resp.is_success: + # 网关的错误页不是 JSON,先报 HTTP 状态 + detail = f"HTTP {resp.status_code}" + (f",{detail}" if detail else "") + return False, f"WPUSH 返回错误:{detail or '响应不是预期的 JSON'}" async def send(self, message: NotificationMessage) -> bool: ok, _ = await self._post(message) diff --git a/webui/src/components/setting/__tests__/config-notification.test.ts b/webui/src/components/setting/__tests__/config-notification.test.ts index d3c709fc..447a41c9 100644 --- a/webui/src/components/setting/__tests__/config-notification.test.ts +++ b/webui/src/components/setting/__tests__/config-notification.test.ts @@ -1,4 +1,4 @@ -import { flushPromises, mount } from '@vue/test-utils'; +import { type VueWrapper, flushPromises, mount } from '@vue/test-utils'; import { ref } from 'vue'; import ConfigNotification from '../config-notification.vue'; import { apiNotification } from '@/api/notification'; @@ -52,6 +52,7 @@ function mountPage() { }, NSelect: { props: ['value'], + emits: ['update:value'], template: '