Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 消息并推送到通知客户端
13 changes: 13 additions & 0 deletions package.v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "自动发起系统健康检查、网络连通性测试以及硬链接检查。",
Expand Down
100 changes: 100 additions & 0 deletions plugins.v2/webhooknotify/README.md
Original file line number Diff line number Diff line change
@@ -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 覆盖 |

## 深入说明

<a id="cfg-notify_type"></a>
#### 消息类型(`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 中处于停用状态。
- 接口返回成功但客户端无消息:检查通知渠道是否启用,以及是否允许接收插件配置的消息类型。
226 changes: 226 additions & 0 deletions plugins.v2/webhooknotify/__init__.py
Original file line number Diff line number Diff line change
@@ -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="通知已提交")
1 change: 1 addition & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading