feat(notification): redesign system to support multiple providers

- Add NotificationProvider base class with send() and test() methods
- Add NotificationManager for handling multiple providers simultaneously
- Add new providers: Discord, Gotify, Pushover, generic Webhook
- Migrate existing providers (Telegram, Bark, Server Chan, WeChat Work) to new architecture
- Add API endpoints for testing providers (/notification/test, /notification/test-config)
- Auto-migrate legacy single-provider configs to new multi-provider format
- Update WebUI with card-based multi-provider settings UI
- Add test button for each provider in settings
- Generic webhook supports template variables: {{title}}, {{season}}, {{episode}}, {{poster_url}}

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Estrella Pan
2026-01-28 20:58:42 +01:00
parent 5eb21bfcfa
commit 48bf570697
25 changed files with 1865 additions and 190 deletions

View File

@@ -10,6 +10,7 @@ from .program import router as program_router
from .rss import router as rss_router
from .search import router as search_router
from .setup import router as setup_router
from .notification import router as notification_router
__all__ = "v1"
@@ -25,3 +26,4 @@ v1.include_router(downloader_router)
v1.include_router(rss_router)
v1.include_router(search_router)
v1.include_router(setup_router)
v1.include_router(notification_router)

View File

@@ -0,0 +1,117 @@
"""Notification API endpoints."""
import logging
from typing import Optional
from fastapi import APIRouter
from pydantic import BaseModel, Field
from module.notification import NotificationManager
from module.models.config import NotificationProvider as ProviderConfig
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/notification", tags=["notification"])
class TestProviderRequest(BaseModel):
"""Request body for testing a saved provider by index."""
provider_index: int = Field(..., description="Index of the provider to test")
class TestProviderConfigRequest(BaseModel):
"""Request body for testing an unsaved provider configuration."""
type: str = Field(..., description="Provider type")
enabled: bool = Field(True, description="Whether provider is enabled")
token: Optional[str] = Field(None, description="Auth token")
chat_id: Optional[str] = Field(None, description="Chat/channel ID")
webhook_url: Optional[str] = Field(None, description="Webhook URL")
server_url: Optional[str] = Field(None, description="Server URL")
device_key: Optional[str] = Field(None, description="Device key")
user_key: Optional[str] = Field(None, description="User key")
api_token: Optional[str] = Field(None, description="API token")
template: Optional[str] = Field(None, description="Custom template")
url: Optional[str] = Field(None, description="URL for generic webhook")
class TestResponse(BaseModel):
"""Response for test notification endpoints."""
success: bool
message: str
message_zh: str = ""
message_en: str = ""
@router.post("/test", response_model=TestResponse)
async def test_provider(request: TestProviderRequest):
"""Test a configured notification provider by its index.
Sends a test notification using the provider at the specified index
in the current configuration.
"""
try:
manager = NotificationManager()
if request.provider_index >= len(manager):
return TestResponse(
success=False,
message=f"Invalid provider index: {request.provider_index}",
message_zh=f"无效的提供者索引: {request.provider_index}",
message_en=f"Invalid provider index: {request.provider_index}",
)
success, message = await manager.test_provider(request.provider_index)
return TestResponse(
success=success,
message=message,
message_zh="测试成功" if success else f"测试失败: {message}",
message_en="Test successful" if success else f"Test failed: {message}",
)
except Exception as e:
logger.error(f"Failed to test provider: {e}")
return TestResponse(
success=False,
message=str(e),
message_zh=f"测试失败: {e}",
message_en=f"Test failed: {e}",
)
@router.post("/test-config", response_model=TestResponse)
async def test_provider_config(request: TestProviderConfigRequest):
"""Test an unsaved notification provider configuration.
Useful for testing a provider before saving it to the configuration.
"""
try:
# Convert request to ProviderConfig
config = ProviderConfig(
type=request.type,
enabled=request.enabled,
token=request.token or "",
chat_id=request.chat_id or "",
webhook_url=request.webhook_url or "",
server_url=request.server_url or "",
device_key=request.device_key or "",
user_key=request.user_key or "",
api_token=request.api_token or "",
template=request.template,
url=request.url or "",
)
success, message = await NotificationManager.test_provider_config(config)
return TestResponse(
success=success,
message=message,
message_zh="测试成功" if success else f"测试失败: {message}",
message_en="Test successful" if success else f"Test failed: {message}",
)
except Exception as e:
logger.error(f"Failed to test provider config: {e}")
return TestResponse(
success=False,
message=str(e),
message_zh=f"测试失败: {e}",
message_en=f"Test failed: {e}",
)

View File

@@ -8,7 +8,8 @@ from pydantic import BaseModel, Field
from module.conf import VERSION, settings
from module.models import Config, ResponseModel
from module.network import RequestContent
from module.notification.notification import getClient
from module.notification import PROVIDER_REGISTRY
from module.models.config import NotificationProvider as ProviderConfig
from module.security.jwt import get_password_hash
logger = logging.getLogger(__name__)
@@ -202,8 +203,8 @@ async def test_notification(req: TestNotificationRequest):
"""Send a test notification."""
_require_setup_needed()
NotifierClass = getClient(req.type)
if NotifierClass is None:
provider_cls = PROVIDER_REGISTRY.get(req.type.lower())
if provider_cls is None:
return TestResultResponse(
success=False,
message_en=f"Unknown notification type: {req.type}",
@@ -211,30 +212,27 @@ async def test_notification(req: TestNotificationRequest):
)
try:
notifier = NotifierClass(token=req.token, chat_id=req.chat_id)
async with notifier:
# Send a simple test message
data = {"chat_id": req.chat_id, "text": "AutoBangumi 通知测试成功!"}
if req.type.lower() == "telegram":
resp = await notifier.post_data(notifier.message_url, data)
if resp.status_code == 200:
return TestResultResponse(
success=True,
message_en="Test notification sent successfully.",
message_zh="测试通知发送成功。",
)
else:
return TestResultResponse(
success=False,
message_en="Failed to send test notification.",
message_zh="测试通知发送失败。",
)
else:
# For other providers, just verify the notifier can be created
# Create provider config
config = ProviderConfig(
type=req.type,
enabled=True,
token=req.token,
chat_id=req.chat_id,
)
provider = provider_cls(config)
async with provider:
success, message = await provider.test()
if success:
return TestResultResponse(
success=True,
message_en="Notification configuration is valid.",
message_zh="通知配置有效",
message_en="Test notification sent successfully.",
message_zh="测试通知发送成功",
)
else:
return TestResultResponse(
success=False,
message_en=f"Failed to send test notification: {message}",
message_zh=f"测试通知发送失败:{message}",
)
except Exception as e:
logger.error(f"[Setup] Notification test failed: {e}")
@@ -275,9 +273,14 @@ async def complete_setup(req: SetupCompleteRequest):
if req.notification_enable:
config_dict["notification"] = {
"enable": True,
"type": req.notification_type,
"token": req.notification_token,
"chat_id": req.notification_chat_id,
"providers": [
{
"type": req.notification_type,
"enabled": True,
"token": req.notification_token,
"chat_id": req.notification_chat_id,
}
],
}
settings.save(config_dict)