feat(notification): Bark 支持铃声、分组、中断级别、图标与点击地址;插件权限新增「运行外部命令」

- Bark 渠道新增可选字段 sound、group、level、icon,并复用 url 作为点击打开的地址;
  只发送已设置的项,旧配置的请求体不变;配置了 icon 时优先于海报地址(#792)
- 测试接口 /notification/test-config 透传新字段;通知设置表单加上对应输入框
  与 level 下拉框,并更新三语言的通知配置文档
- 插件权限标签新增 subprocess(运行外部命令),仅用于展示
This commit is contained in:
Estrella Pan
2026-10-21 09:09:43 +02:00
parent 8312f47769
commit f1dd439fbe
12 changed files with 187 additions and 29 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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` | 自定义通知模板 |

View File

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

View File

@@ -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` | カスタム通知テンプレート |

View File

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

View File

@@ -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"
>
<NSelect
v-if="field.options"
v-model:value="(newProvider as any)[field.key]"
:options="field.options.map((value) => ({ label: value, value }))"
:placeholder="field.placeholder"
clearable
/>
<ab-input
v-if="field.key !== 'template'"
v-else-if="field.key !== 'template'"
v-model="(newProvider as any)[field.key]"
:placeholder="field.placeholder"
/>
@@ -457,8 +492,15 @@ function getFieldsForType(type: string) {
:key="field.key"
:label="field.label"
>
<NSelect
v-if="field.options"
v-model:value="(newProvider as any)[field.key]"
:options="field.options.map((value) => ({ label: value, value }))"
:placeholder="field.placeholder"
clearable
/>
<ab-input
v-if="field.key !== 'template'"
v-else-if="field.key !== 'template'"
v-model="(newProvider as any)[field.key]"
:placeholder="field.placeholder"
/>

View File

@@ -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}?",

View File

@@ -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}?",

View File

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