fix(plugins): 审查修复——示例插件与通知渠道的若干问题

- 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
This commit is contained in:
Estrella Pan
2026-10-21 11:05:59 +02:00
parent 4b739bcfc3
commit 81bbf9c71d
17 changed files with 310 additions and 64 deletions

View File

@@ -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))

View File

@@ -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"

View File

@@ -19,7 +19,7 @@
## 海报
插件只能拿到宿主给出的公开海报地址,拿不到本地海报文件。因此要发送海报,需要在「设置 → 通知」中配置 `base_url`,并保证 OneBot 实现能访问这个地址;它会自行下载图片。未配置时只发送文字。
插件只能拿到宿主给出的公开海报地址,拿不到本地海报文件。因此要发送海报,需要在「设置 → 通知」中配置 `base_url`,并保证 OneBot 实现能访问这个地址;它会自行下载图片。未配置时只发送文字。带图片发送失败时,插件去掉图片只发文字再试一次。
## 安装

View File

@@ -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)

View File

@@ -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, "<html>Bad Gateway</html>", "HTTP 502"), (200, "<html>", "不是预期的 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

View File

@@ -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 重启,已整理的种子会再发布一次。脚本必须幂等。

View File

@@ -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

View File

@@ -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):

View File

@@ -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` 只发文字再试一次。
## 安装

View File

@@ -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(

View File

@@ -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

View File

@@ -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_root>.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
)

View File

@@ -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

View File

@@ -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, "<html>Bad Gateway</html>", "HTTP 502"), (200, "<html>", "不是预期的 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

View File

@@ -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)

View File

@@ -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: '<select class="type" />',
},
},
@@ -122,6 +123,24 @@ describe('config-notification', () => {
expect(w.find('.test-result').exists()).toBe(false);
});
// Bark 也读 url(点击跳转):切换类型后不能带着上一类型的 Webhook URL
it('should reset type-specific fields when the type changes in the add dialog', async () => {
const w = mountPage();
const add = w
.findAll('button')
.find((b) => b.text() === 'config.notification_set.add_provider')!;
await add.trigger('click');
const vm = w.vm as any;
vm.newProvider.type = 'webhook';
vm.newProvider.url = 'https://hook.example.com';
vm.newProvider.template = '{"t": 1}';
await flushPromises();
const select = w.findComponent('.provider-type-select') as VueWrapper;
select.vm.$emit('update:value', 'bark');
await flushPromises();
expect(vm.newProvider).toEqual({ type: 'bark', enabled: true });
});
it('should show a credentials hint and no Test button for plugin types', async () => {
const w = mountPage();
const add = w

View File

@@ -210,6 +210,12 @@ function openAddDialog() {
showAddDialog.value = true;
}
// 新增时切换类型:清掉上一类型的字段。Bark 也读 url(点击跳转),
// 残留的 Webhook URL 会被一起发出去
function changeType(type: NotificationProviderConfig['type']) {
newProvider.value = { type, enabled: newProvider.value.enabled };
}
function openEditDialog(index: number) {
editingIndex.value = index;
newProvider.value = { ...providers.value[index] };
@@ -410,9 +416,10 @@ function getFieldsForType(type: string) {
<div class="space-y-16">
<ab-field :label="$t('config.notification_set.type')">
<NSelect
v-model:value="newProvider.type"
:value="newProvider.type"
:options="providerTypes"
class="provider-type-select"
@update:value="changeType"
/>
</ab-field>