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: '