diff --git a/README.md b/README.md index 1012f9df..22a10446 100644 --- a/README.md +++ b/README.md @@ -341,3 +341,7 @@ MoviePilot环境变量添加本项目地址,具体参见 https://github.com/jx ![](images/2024-12-28-01-21-56.png) ![](images/2024-12-28-01-22-59.png) ![](images/2024-12-28-01-23-07.png) + +### 30. [Webhook消息推送](plugins.v2/webhooknotify/README.md) + +- 接收 Webhook 消息并推送到通知客户端 diff --git a/package.v2.json b/package.v2.json index 22553f08..f4baffaf 100644 --- a/package.v2.json +++ b/package.v2.json @@ -290,6 +290,19 @@ }, "release": true }, + "WebhookNotify": { + "name": "Webhook消息推送", + "description": "接收 Webhook 消息并推送到通知客户端。", + "labels": "通知,工具", + "version": "1.0", + "icon": "https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/main/icons/customplugin.png", + "author": "InfinityPacer", + "level": 1, + "history": { + "v1.0": "新增通用 Webhook 通知入口,支持 GET、POST 和消息类型配置。" + }, + "release": true + }, "AutoDiagnosis": { "name": "自动诊断", "description": "自动发起系统健康检查、网络连通性测试以及硬链接检查。", diff --git a/plugins.v2/webhooknotify/README.md b/plugins.v2/webhooknotify/README.md new file mode 100644 index 00000000..f7a83f3f --- /dev/null +++ b/plugins.v2/webhooknotify/README.md @@ -0,0 +1,100 @@ +# Webhook消息推送 + +接收 Webhook 消息并推送到通知客户端。 + +> 兼容性:MoviePilot v2.x +> 适用场景:路由、服务器、服务进程或其他外部监控系统的告警通知 + +## 版本更新日志 + +- v1.0 + - 新增通用 Webhook 通知入口,支持 GET、POST 和消息类型配置。 + +## 功能概览 + +- 提供受 MoviePilot 公共 `API_TOKEN` 保护的 `GET` 和 `POST` Webhook。 +- 接收外部请求中的 `title` 和 `body`,任意一项非空即可转发为所配置类型的消息。 +- 支持选择 MoviePilot 消息类型,默认为“插件”,用于匹配通知渠道的接收设置。 +- 复用 MoviePilot 通知历史和已配置的 WebPush、Telegram、微信等通知渠道。 +- 不负责健康检查、故障判断、重试或告警去重,调用方只负责在需要通知时发送请求。 + +## API + +| 方法 | 地址 | 说明 | +| --- | --- | --- | +| `POST` | `/api/v1/plugin/WebhookNotify/webhook?token=API_TOKEN` | 从 JSON 请求体接收入站通知 | +| `GET` | `/api/v1/plugin/WebhookNotify/webhook?token=API_TOKEN&title=...&body=...` | 从查询参数接收入站通知 | + +`POST` 请求头使用 `Content-Type: application/json`,请求体格式如下: + +```json +{ + "title": "路由故障", + "body": "主线路连续 3 次探测失败" +} +``` + +`title` 和 `body` 均为可选字段,但至少需要提供一项非空内容;`title` 最长 200 个字符,`body` 最长 10000 个字符。`GET` 请求使用同名查询参数。接口成功接收后返回: + +```json +{ + "success": true, + "message": "通知已提交", + "data": {} +} +``` + +`POST` 示例: + +```bash +curl -X POST \ + "https://moviepilot.example.com/api/v1/plugin/WebhookNotify/webhook?token=API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"title":"路由故障","body":"主线路连续 3 次探测失败"}' +``` + +仅发送正文的 `GET` 示例: + +```bash +curl --get \ + "https://moviepilot.example.com/api/v1/plugin/WebhookNotify/webhook" \ + --data-urlencode "token=API_TOKEN" \ + --data-urlencode "body=主线路连续 3 次探测失败" +``` + +## 配置说明 + +| 配置项 | 标识 | 类型 | 默认值 | 说明 | 备注 | +| --- | --- | --- | --- | --- | --- | +| 启用插件 | `enabled` | bool | `false` | 是否接收入站 Webhook | 使用主程序公共 `API_TOKEN`,无需重复配置 | +| [消息类型](#cfg-notify_type) | `notify_type` | enum | `Plugin`(插件) | 设置通知分类和渠道过滤类型 | 调用方不能通过 Webhook 覆盖 | + +## 深入说明 + + +#### 消息类型(`notify_type`) + +MoviePilot 的通知渠道可分别选择接收哪些消息类型。Webhook 消息会使用这里选择的类型进入通知链,只有允许该类型的渠道才会向客户端推送;该配置不改变标题或正文内容。 + +可选值为:`Download`(资源下载)、`Organize`(整理入库)、`Subscribe`(订阅)、`SiteMessage`(站点)、`MediaServer`(媒体服务器)、`Manual`(手动处理)、`Plugin`(插件)、`Agent`(智能体)和 `Other`(其它)。默认使用 `Plugin`(插件);配置缺失或不是有效枚举值时同样按“插件”处理。 + +## 使用步骤 + +1. 在插件市场安装并启用 Webhook消息推送。 +2. 选择消息类型,并确认 MoviePilot 中至少有一个已启用通知渠道允许接收该类型。 +3. 将调用方的 Webhook 地址配置为插件 API 地址,将 `API_TOKEN` 放在 `token` 查询参数中。 +4. 使用 `GET` 查询参数或 `POST` JSON 发送通知,`title` 和 `body` 至少提供一项,确认客户端收到通知。 + +## 注意事项 / 已知风险 + +- `API_TOKEN` 具备 MoviePilot 公共集成权限,请只在受信任的监控系统中使用,并通过 HTTPS 传输。 +- 插件只负责提交通知,不会为重复请求做去重;健康监测的失败阈值、恢复判定和重试策略由调用方负责。 +- 消息是否实际发送仍受 MoviePilot 通知渠道配置和所选消息类型开关影响。 + +## 故障排查 + +- 插件请求日志位于 `/config/logs/plugins/webhooknotify.log`,记录请求方式、消息类型以及标题、正文是否存在,不记录 `API_TOKEN` 或消息内容。 +- 返回 `401`:检查 URL 中的 `token` 是否为当前 MoviePilot 公共 `API_TOKEN`。 +- 返回 `422`:检查参数长度、JSON 格式,并确认 `title`、`body` 至少有一项为非空字符串。 +- 返回 `503`:插件在 WebUI 中处于停用状态。 +- 接口返回成功但客户端无消息:检查通知渠道是否启用,以及是否允许接收插件配置的消息类型。 diff --git a/plugins.v2/webhooknotify/__init__.py b/plugins.v2/webhooknotify/__init__.py new file mode 100644 index 00000000..8959e7d1 --- /dev/null +++ b/plugins.v2/webhooknotify/__init__.py @@ -0,0 +1,226 @@ +from typing import Annotated, Any, Dict, List, Optional, Tuple + +from fastapi import Depends, HTTPException, Query, status +from pydantic import BaseModel, Field, model_validator + +from app import schemas +from app.core.security import verify_apitoken +from app.log import logger +from app.plugins import _PluginBase +from app.schemas import NotificationType + + +class WebhookNotifyPayload(BaseModel): + """外部 Webhook 通知的消息载荷。""" + + # 标题和正文均可单独发送,避免调用方为缺失字段构造无意义占位文本。 + title: Optional[str] = Field(default=None, max_length=200) + body: Optional[str] = Field(default=None, max_length=10000) + + @model_validator(mode="after") + def validate_message(self) -> "WebhookNotifyPayload": + """把纯空白字段视为缺失,并保证通知至少包含一项可见内容。""" + if self.title is not None and not self.title.strip(): + self.title = None + if self.body is not None and not self.body.strip(): + self.body = None + if self.title is None and self.body is None: + raise ValueError("title 和 body 至少提供一项") + return self + + +class WebhookNotify(_PluginBase): + """接收入站 Webhook,并转发到 MoviePilot 已配置的通知渠道。""" + + plugin_name = "Webhook消息推送" + plugin_desc = "接收 Webhook 消息并推送到通知客户端。" + plugin_icon = "https://raw.githubusercontent.com/InfinityPacer/MoviePilot-Plugins/main/icons/customplugin.png" + plugin_version = "1.0" + plugin_author = "InfinityPacer" + author_url = "https://github.com/InfinityPacer" + plugin_config_prefix = "webhooknotify_" + plugin_order = 999 + auth_level = 1 + + _enabled = False + # MoviePilot 通知渠道按消息类型过滤,默认使用专用的插件分类。 + _notify_type = NotificationType.Plugin + + def init_plugin(self, config: dict = None): + """加载插件开关和消息类型;公共 API_TOKEN 不在插件配置中重复保存。""" + config = config or {} + self._enabled = bool(config.get("enabled", False)) + notify_type = config.get("notify_type", NotificationType.Plugin.name) + self._notify_type = ( + NotificationType.__members__.get(notify_type, NotificationType.Plugin) + if isinstance(notify_type, str) + else NotificationType.Plugin + ) + + def get_state(self) -> bool: + """返回 Webhook 接收能力是否启用。""" + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """Webhook 通知没有需要注册到聊天客户端的命令。""" + return [] + + def get_api(self) -> List[Dict[str, Any]]: + """注册使用公共 API_TOKEN 认证的入站 Webhook。""" + return [ + { + "path": "/webhook", + "endpoint": self.receive_webhook, + "methods": ["POST"], + # 插件注册器的默认 apikey 只读取 X-API-KEY/apikey;认证依赖由 + # receive_webhook 的参数显式声明,以保持 MoviePilot 原生 ?token= 约定。 + "allow_anonymous": True, + "response_model": schemas.Response, + "summary": "接收 Webhook JSON 通知", + "description": "使用 MoviePilot 公共 API_TOKEN 接收 JSON;title 和 body 至少提供一项。", + }, + { + "path": "/webhook", + "endpoint": self.receive_webhook_get, + "methods": ["GET"], + "allow_anonymous": True, + "response_model": schemas.Response, + "summary": "接收 Webhook 查询通知", + "description": "使用 MoviePilot 公共 API_TOKEN 接收查询参数;title 和 body 至少提供一项。", + }, + ] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + """展示启用开关、消息类型和入站请求约定。""" + return [ + { + "component": "VForm", + "content": [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "enabled", + "label": "启用插件", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSelect", + "props": { + "model": "notify_type", + "label": "消息类型", + "items": [ + {"title": item.value, "value": item.name} + for item in NotificationType + ], + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "GET/POST /api/v1/plugin/WebhookNotify/webhook?token=API_TOKEN,title 和 body 至少提供一项。", + }, + } + ], + } + ], + }, + ], + } + ], { + "enabled": False, + "notify_type": NotificationType.Plugin.name, + } + + def get_page(self) -> Optional[List[dict]]: + """不提供插件数据页。""" + pass + + def stop_service(self): + """Webhook消息推送没有后台服务需要停止。""" + pass + + def receive_webhook( + self, + payload: WebhookNotifyPayload, + _: Annotated[str, Depends(verify_apitoken)], + ) -> schemas.Response: + """接收 POST JSON,并把外部消息交给 MoviePilot 通知链。""" + return self._post_notification(payload, request_method="POST") + + def receive_webhook_get( + self, + _: Annotated[str, Depends(verify_apitoken)], + title: Annotated[Optional[str], Query(max_length=200)] = None, + body: Annotated[Optional[str], Query(max_length=10000)] = None, + ) -> schemas.Response: + """接收 GET 查询参数,并把外部消息交给 MoviePilot 通知链。""" + if not ((title and title.strip()) or (body and body.strip())): + raise HTTPException( + status_code=422, + detail="title 和 body 至少提供一项", + ) + return self._post_notification( + WebhookNotifyPayload(title=title, body=body), + request_method="GET", + ) + + def _post_notification( + self, + payload: WebhookNotifyPayload, + request_method: str, + ) -> schemas.Response: + """执行统一的启用状态检查和通知提交。""" + if not self._enabled: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Webhook消息推送插件未启用", + ) + + logger.info( + "接收 %s Webhook 消息,消息类型:%s,包含标题:%s,包含正文:%s", + request_method, + self._notify_type.value, + bool(payload.title), + bool(payload.body), + ) + self.chain.post_message( + schemas.Notification( + mtype=self._notify_type, + title=payload.title, + text=payload.body, + ) + ) + logger.info("%s Webhook 消息已提交到 MoviePilot 通知链", request_method) + return schemas.Response(success=True, message="通知已提交") diff --git a/pytest.ini b/pytest.ini index c98d7993..ad0f2c38 100644 --- a/pytest.ini +++ b/pytest.ini @@ -15,3 +15,4 @@ filterwarnings = ignore:datetime.datetime.utcfromtimestamp\(\) is deprecated:DeprecationWarning ignore:'crypt' is deprecated:DeprecationWarning ignore:'audioop' is deprecated:DeprecationWarning + ignore:Using `httpx` with `starlette\.testclient` is deprecated; install `httpx2` instead\.:starlette.exceptions.StarletteDeprecationWarning diff --git a/tests/v2/webhooknotify/test_webhooknotify.py b/tests/v2/webhooknotify/test_webhooknotify.py new file mode 100644 index 00000000..363966c1 --- /dev/null +++ b/tests/v2/webhooknotify/test_webhooknotify.py @@ -0,0 +1,263 @@ +from typing import Optional +from unittest.mock import MagicMock + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient +from pydantic import ValidationError + +from app.core.config import settings +from app.schemas.types import NotificationType +from app.utils.object import ObjectUtils + +import webhooknotify +from webhooknotify import WebhookNotify, WebhookNotifyPayload + + +def _build_test_app(plugin: WebhookNotify) -> FastAPI: + """按插件 API 元数据注册最小 FastAPI 应用,覆盖真实依赖注入契约。""" + test_app = FastAPI() + for api_definition in plugin.get_api(): + api = api_definition.copy() + path = api.pop("path") + api.pop("allow_anonymous") + test_app.add_api_route(path, **api) + return test_app + + +def _mock_notification_chain(plugin: WebhookNotify) -> MagicMock: + """替换通知链提交方法,便于检查最终 Notification 契约。""" + post_message = MagicMock() + plugin.chain.post_message = post_message + return post_message + + +def _assert_notification( + post_message: MagicMock, + *, + mtype: NotificationType, + title: Optional[str] = None, + text: Optional[str] = None, +) -> None: + """确认消息不绑定渠道配置,也不附加默认插件详情链接。""" + post_message.assert_called_once() + notification = post_message.call_args.args[0] + assert notification.mtype == mtype + assert notification.title == title + assert notification.text == text + assert notification.source is None + assert notification.link is None + + +class TestWebhookNotify: + """Webhook 请求校验、公共 API_TOKEN 认证和通知转发契约。""" + + def test_payload_accepts_either_field_and_rejects_empty_message(self): + assert WebhookNotifyPayload(title="告警").body is None + assert WebhookNotifyPayload(body="故障").title is None + assert WebhookNotifyPayload(title="告警", body="故障").body == "故障" + + with pytest.raises(ValidationError, match="title 和 body 至少提供一项"): + WebhookNotifyPayload() + with pytest.raises(ValidationError, match="title 和 body 至少提供一项"): + WebhookNotifyPayload(title=" ", body="\n") + + def test_api_registers_get_and_post_with_public_token_dependency(self): + plugin = WebhookNotify() + api_definitions = plugin.get_api() + + assert len(api_definitions) == 2 + assert {tuple(api["methods"]) for api in api_definitions} == {("GET",), ("POST",)} + assert all(api["path"] == "/webhook" for api in api_definitions) + assert all(api["allow_anonymous"] is True for api in api_definitions) + assert all(api["response_model"] for api in api_definitions) + + def test_data_page_is_not_exposed(self): + assert WebhookNotify().get_page() is None + assert ObjectUtils.check_method(WebhookNotify.get_page) is False + + def test_form_defaults_to_disabled_and_exposes_all_notification_types(self): + plugin = WebhookNotify() + form, defaults = plugin.get_form() + enabled_row = form[0]["content"][0] + notify_type_row = form[0]["content"][1] + enabled_field = enabled_row["content"][0]["content"][0] + notify_type_field = notify_type_row["content"][0]["content"][0] + + assert defaults == { + "enabled": False, + "notify_type": NotificationType.Plugin.name, + } + assert enabled_row["component"] == "VRow" + assert notify_type_row["component"] == "VRow" + assert notify_type_row["content"][0]["props"] == {"cols": 12, "md": 6} + assert "hint" not in enabled_field["props"] + assert "persistent-hint" not in enabled_field["props"] + assert notify_type_field["props"]["label"] == "消息类型" + assert "hint" not in notify_type_field["props"] + assert "persistent-hint" not in notify_type_field["props"] + assert notify_type_field["props"]["items"] == [ + {"title": item.value, "value": item.name} + for item in NotificationType + ] + info_row = form[0]["content"][2] + assert info_row["component"] == "VRow" + assert info_row["content"][0]["component"] == "VCol" + assert info_row["content"][0]["props"] == {"cols": 12} + assert info_row["content"][0]["content"][0]["component"] == "VAlert" + + def test_missing_token_is_rejected_and_valid_token_forwards_message(self, monkeypatch): + plugin = WebhookNotify() + plugin.init_plugin({"enabled": True}) + post_message = _mock_notification_chain(plugin) + monkeypatch.setattr(settings, "API_TOKEN", "unit-test-token") + client = TestClient(_build_test_app(plugin)) + payload = {"title": "路由故障", "body": "主线路不可达"} + + unauthorized = client.post("/webhook", json=payload) + authorized = client.post("/webhook?token=unit-test-token", json=payload) + + assert unauthorized.status_code == 401 + assert authorized.status_code == 200 + assert authorized.json()["success"] is True + _assert_notification( + post_message, + mtype=NotificationType.Plugin, + title="路由故障", + text="主线路不可达", + ) + + @pytest.mark.parametrize( + ("payload", "expected_title", "expected_text"), + [ + ({"title": "只有标题"}, "只有标题", None), + ({"body": "只有正文"}, None, "只有正文"), + ], + ) + def test_post_accepts_title_or_body(self, monkeypatch, payload, expected_title, expected_text): + plugin = WebhookNotify() + plugin.init_plugin({"enabled": True}) + post_message = _mock_notification_chain(plugin) + monkeypatch.setattr(settings, "API_TOKEN", "unit-test-token") + client = TestClient(_build_test_app(plugin)) + + response = client.post("/webhook?token=unit-test-token", json=payload) + + assert response.status_code == 200 + _assert_notification( + post_message, + mtype=NotificationType.Plugin, + title=expected_title, + text=expected_text, + ) + + @pytest.mark.parametrize( + ("params", "expected_title", "expected_text"), + [ + ({"title": "只有标题"}, "只有标题", None), + ({"body": "只有正文"}, None, "只有正文"), + ], + ) + def test_get_accepts_title_or_body(self, monkeypatch, params, expected_title, expected_text): + plugin = WebhookNotify() + plugin.init_plugin({"enabled": True}) + post_message = _mock_notification_chain(plugin) + monkeypatch.setattr(settings, "API_TOKEN", "unit-test-token") + client = TestClient(_build_test_app(plugin)) + params["token"] = "unit-test-token" + + response = client.get("/webhook", params=params) + + assert response.status_code == 200 + _assert_notification( + post_message, + mtype=NotificationType.Plugin, + title=expected_title, + text=expected_text, + ) + + def test_submission_logs_metadata_without_message_content(self, monkeypatch): + plugin = WebhookNotify() + plugin.init_plugin({"enabled": True}) + _mock_notification_chain(plugin) + log_info = MagicMock() + monkeypatch.setattr(webhooknotify.logger, "info", log_info) + + plugin.receive_webhook( + WebhookNotifyPayload(title="敏感标题", body="敏感正文"), + "unit-test-token", + ) + + assert log_info.call_count == 2 + logged_values = " ".join( + str(value) + for call in log_info.call_args_list + for value in (*call.args, *call.kwargs.values()) + ) + assert "POST" in logged_values + assert "敏感标题" not in logged_values + assert "敏感正文" not in logged_values + assert "unit-test-token" not in logged_values + + def test_get_and_post_reject_missing_content(self, monkeypatch): + plugin = WebhookNotify() + plugin.init_plugin({"enabled": True}) + post_message = _mock_notification_chain(plugin) + monkeypatch.setattr(settings, "API_TOKEN", "unit-test-token") + client = TestClient(_build_test_app(plugin)) + + post_response = client.post("/webhook?token=unit-test-token", json={}) + get_response = client.get("/webhook?token=unit-test-token") + + assert post_response.status_code == 422 + assert get_response.status_code == 422 + post_message.assert_not_called() + + def test_plugin_defaults_to_disabled_and_returns_service_unavailable(self): + plugin = WebhookNotify() + post_message = _mock_notification_chain(plugin) + + assert plugin.get_state() is False + + with pytest.raises(HTTPException) as exc_info: + plugin.receive_webhook(WebhookNotifyPayload(title="标题"), "unit-test-token") + + assert exc_info.value.status_code == 503 + post_message.assert_not_called() + + def test_missing_enabled_config_keeps_plugin_disabled(self): + plugin = WebhookNotify() + + plugin.init_plugin({"notify_type": NotificationType.Manual.name}) + + assert plugin.get_state() is False + + @pytest.mark.parametrize( + ("notify_type", "expected_type"), + [ + ("Manual", NotificationType.Manual), + ("invalid", NotificationType.Plugin), + (None, NotificationType.Plugin), + ], + ) + def test_configured_notification_type_is_forwarded_or_defaults_to_plugin( + self, + notify_type, + expected_type, + ): + plugin = WebhookNotify() + plugin.init_plugin({"enabled": True, "notify_type": notify_type}) + post_message = _mock_notification_chain(plugin) + + response = plugin.receive_webhook( + WebhookNotifyPayload(title="标题"), + "unit-test-token", + ) + + assert response.success is True + _assert_notification( + post_message, + mtype=expected_type, + title="标题", + text=None, + )