mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-10-29 03:36:15 +08:00
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:
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
## 海报
|
||||
|
||||
插件只能拿到宿主给出的公开海报地址,拿不到本地海报文件。因此要发送海报,需要在「设置 → 通知」中配置 `base_url`,并保证 OneBot 实现能访问这个地址;它会自行下载图片。未配置时只发送文字。
|
||||
插件只能拿到宿主给出的公开海报地址,拿不到本地海报文件。因此要发送海报,需要在「设置 → 通知」中配置 `base_url`,并保证 OneBot 实现能访问这个地址;它会自行下载图片。未配置时只发送文字。带图片发送失败时,插件去掉图片只发文字再试一次。
|
||||
|
||||
## 安装
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 重启,已整理的种子会再发布一次。脚本必须幂等。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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` 只发文字再试一次。
|
||||
|
||||
## 安装
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user