Skip to content

Commit 48bf570

Browse files
EstrellaXDclaude
andcommitted
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>
1 parent 5eb21bf commit 48bf570

File tree

25 files changed

+1863
-188
lines changed

25 files changed

+1863
-188
lines changed

backend/src/module/api/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .rss import router as rss_router
1111
from .search import router as search_router
1212
from .setup import router as setup_router
13+
from .notification import router as notification_router
1314

1415
__all__ = "v1"
1516

@@ -25,3 +26,4 @@
2526
v1.include_router(rss_router)
2627
v1.include_router(search_router)
2728
v1.include_router(setup_router)
29+
v1.include_router(notification_router)
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Notification API endpoints."""
2+
3+
import logging
4+
from typing import Optional
5+
6+
from fastapi import APIRouter
7+
from pydantic import BaseModel, Field
8+
9+
from module.notification import NotificationManager
10+
from module.models.config import NotificationProvider as ProviderConfig
11+
12+
logger = logging.getLogger(__name__)
13+
router = APIRouter(prefix="/notification", tags=["notification"])
14+
15+
16+
class TestProviderRequest(BaseModel):
17+
"""Request body for testing a saved provider by index."""
18+
19+
provider_index: int = Field(..., description="Index of the provider to test")
20+
21+
22+
class TestProviderConfigRequest(BaseModel):
23+
"""Request body for testing an unsaved provider configuration."""
24+
25+
type: str = Field(..., description="Provider type")
26+
enabled: bool = Field(True, description="Whether provider is enabled")
27+
token: Optional[str] = Field(None, description="Auth token")
28+
chat_id: Optional[str] = Field(None, description="Chat/channel ID")
29+
webhook_url: Optional[str] = Field(None, description="Webhook URL")
30+
server_url: Optional[str] = Field(None, description="Server URL")
31+
device_key: Optional[str] = Field(None, description="Device key")
32+
user_key: Optional[str] = Field(None, description="User key")
33+
api_token: Optional[str] = Field(None, description="API token")
34+
template: Optional[str] = Field(None, description="Custom template")
35+
url: Optional[str] = Field(None, description="URL for generic webhook")
36+
37+
38+
class TestResponse(BaseModel):
39+
"""Response for test notification endpoints."""
40+
41+
success: bool
42+
message: str
43+
message_zh: str = ""
44+
message_en: str = ""
45+
46+
47+
@router.post("/test", response_model=TestResponse)
48+
async def test_provider(request: TestProviderRequest):
49+
"""Test a configured notification provider by its index.
50+
51+
Sends a test notification using the provider at the specified index
52+
in the current configuration.
53+
"""
54+
try:
55+
manager = NotificationManager()
56+
if request.provider_index >= len(manager):
57+
return TestResponse(
58+
success=False,
59+
message=f"Invalid provider index: {request.provider_index}",
60+
message_zh=f"无效的提供者索引: {request.provider_index}",
61+
message_en=f"Invalid provider index: {request.provider_index}",
62+
)
63+
64+
success, message = await manager.test_provider(request.provider_index)
65+
return TestResponse(
66+
success=success,
67+
message=message,
68+
message_zh="测试成功" if success else f"测试失败: {message}",
69+
message_en="Test successful" if success else f"Test failed: {message}",
70+
)
71+
except Exception as e:
72+
logger.error(f"Failed to test provider: {e}")
73+
return TestResponse(
74+
success=False,
75+
message=str(e),
76+
message_zh=f"测试失败: {e}",
77+
message_en=f"Test failed: {e}",
78+
)
79+
80+
81+
@router.post("/test-config", response_model=TestResponse)
82+
async def test_provider_config(request: TestProviderConfigRequest):
83+
"""Test an unsaved notification provider configuration.
84+
85+
Useful for testing a provider before saving it to the configuration.
86+
"""
87+
try:
88+
# Convert request to ProviderConfig
89+
config = ProviderConfig(
90+
type=request.type,
91+
enabled=request.enabled,
92+
token=request.token or "",
93+
chat_id=request.chat_id or "",
94+
webhook_url=request.webhook_url or "",
95+
server_url=request.server_url or "",
96+
device_key=request.device_key or "",
97+
user_key=request.user_key or "",
98+
api_token=request.api_token or "",
99+
template=request.template,
100+
url=request.url or "",
101+
)
102+
103+
success, message = await NotificationManager.test_provider_config(config)
104+
return TestResponse(
105+
success=success,
106+
message=message,
107+
message_zh="测试成功" if success else f"测试失败: {message}",
108+
message_en="Test successful" if success else f"Test failed: {message}",
109+
)
110+
except Exception as e:
111+
logger.error(f"Failed to test provider config: {e}")
112+
return TestResponse(
113+
success=False,
114+
message=str(e),
115+
message_zh=f"测试失败: {e}",
116+
message_en=f"Test failed: {e}",
117+
)

backend/src/module/api/setup.py

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
from module.conf import VERSION, settings
99
from module.models import Config, ResponseModel
1010
from module.network import RequestContent
11-
from module.notification.notification import getClient
11+
from module.notification import PROVIDER_REGISTRY
12+
from module.models.config import NotificationProvider as ProviderConfig
1213
from module.security.jwt import get_password_hash
1314

1415
logger = logging.getLogger(__name__)
@@ -202,39 +203,36 @@ async def test_notification(req: TestNotificationRequest):
202203
"""Send a test notification."""
203204
_require_setup_needed()
204205

205-
NotifierClass = getClient(req.type)
206-
if NotifierClass is None:
206+
provider_cls = PROVIDER_REGISTRY.get(req.type.lower())
207+
if provider_cls is None:
207208
return TestResultResponse(
208209
success=False,
209210
message_en=f"Unknown notification type: {req.type}",
210211
message_zh=f"未知的通知类型:{req.type}",
211212
)
212213

213214
try:
214-
notifier = NotifierClass(token=req.token, chat_id=req.chat_id)
215-
async with notifier:
216-
# Send a simple test message
217-
data = {"chat_id": req.chat_id, "text": "AutoBangumi 通知测试成功!"}
218-
if req.type.lower() == "telegram":
219-
resp = await notifier.post_data(notifier.message_url, data)
220-
if resp.status_code == 200:
221-
return TestResultResponse(
222-
success=True,
223-
message_en="Test notification sent successfully.",
224-
message_zh="测试通知发送成功。",
225-
)
226-
else:
227-
return TestResultResponse(
228-
success=False,
229-
message_en="Failed to send test notification.",
230-
message_zh="测试通知发送失败。",
231-
)
232-
else:
233-
# For other providers, just verify the notifier can be created
215+
# Create provider config
216+
config = ProviderConfig(
217+
type=req.type,
218+
enabled=True,
219+
token=req.token,
220+
chat_id=req.chat_id,
221+
)
222+
provider = provider_cls(config)
223+
async with provider:
224+
success, message = await provider.test()
225+
if success:
234226
return TestResultResponse(
235227
success=True,
236-
message_en="Notification configuration is valid.",
237-
message_zh="通知配置有效。",
228+
message_en="Test notification sent successfully.",
229+
message_zh="测试通知发送成功。",
230+
)
231+
else:
232+
return TestResultResponse(
233+
success=False,
234+
message_en=f"Failed to send test notification: {message}",
235+
message_zh=f"测试通知发送失败:{message}",
238236
)
239237
except Exception as e:
240238
logger.error(f"[Setup] Notification test failed: {e}")
@@ -275,9 +273,14 @@ async def complete_setup(req: SetupCompleteRequest):
275273
if req.notification_enable:
276274
config_dict["notification"] = {
277275
"enable": True,
278-
"type": req.notification_type,
279-
"token": req.notification_token,
280-
"chat_id": req.notification_chat_id,
276+
"providers": [
277+
{
278+
"type": req.notification_type,
279+
"enabled": True,
280+
"token": req.notification_token,
281+
"chat_id": req.notification_chat_id,
282+
}
283+
],
281284
}
282285

283286
settings.save(config_dict)

backend/src/module/conf/const.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"username": "",
3737
"password": "",
3838
},
39-
"notification": {"enable": False, "type": "telegram", "token": "", "chat_id": ""},
39+
"notification": {"enable": False, "providers": []},
4040
"experimental_openai": {
4141
"enable": False,
4242
"api_key": "",

backend/src/module/core/sub_thread.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from module.conf import settings
55
from module.downloader import DownloadClient
66
from module.manager import Renamer, TorrentManager, eps_complete
7-
from module.notification import PostNotification
7+
from module.notification import NotificationManager
88
from module.rss import RSSAnalyser, RSSEngine
99

1010
from .offset_scanner import OffsetScanner
@@ -66,10 +66,9 @@ async def rename_loop(self):
6666
async with Renamer() as renamer:
6767
renamed_info = await renamer.rename()
6868
if settings.notification.enable and renamed_info:
69-
async with PostNotification() as notifier:
70-
await asyncio.gather(
71-
*[notifier.send_msg(info) for info in renamed_info]
72-
)
69+
manager = NotificationManager()
70+
for info in renamed_info:
71+
await manager.send_all(info)
7372
try:
7473
await asyncio.wait_for(
7574
self.stop_event.wait(),

backend/src/module/models/config.py

Lines changed: 97 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from os.path import expandvars
2-
from typing import Literal
2+
from typing import Literal, Optional
33

4-
from pydantic import BaseModel, Field, field_validator
4+
from pydantic import BaseModel, Field, field_validator, model_validator
55

66

77
class Program(BaseModel):
@@ -68,19 +68,106 @@ def password(self):
6868
return expandvars(self.password_)
6969

7070

71+
class NotificationProvider(BaseModel):
72+
"""Configuration for a single notification provider."""
73+
74+
type: str = Field(..., description="Provider type (telegram, discord, bark, etc.)")
75+
enabled: bool = Field(True, description="Whether this provider is enabled")
76+
77+
# Common fields (with env var expansion)
78+
token_: Optional[str] = Field(None, alias="token", description="Auth token")
79+
chat_id_: Optional[str] = Field(None, alias="chat_id", description="Chat/channel ID")
80+
81+
# Provider-specific fields
82+
webhook_url_: Optional[str] = Field(
83+
None, alias="webhook_url", description="Webhook URL for discord/wecom"
84+
)
85+
server_url_: Optional[str] = Field(
86+
None, alias="server_url", description="Server URL for gotify/bark"
87+
)
88+
device_key_: Optional[str] = Field(
89+
None, alias="device_key", description="Device key for bark"
90+
)
91+
user_key_: Optional[str] = Field(
92+
None, alias="user_key", description="User key for pushover"
93+
)
94+
api_token_: Optional[str] = Field(
95+
None, alias="api_token", description="API token for pushover"
96+
)
97+
template: Optional[str] = Field(
98+
None, description="Custom template for webhook provider"
99+
)
100+
url_: Optional[str] = Field(
101+
None, alias="url", description="URL for generic webhook provider"
102+
)
103+
104+
@property
105+
def token(self) -> str:
106+
return expandvars(self.token_) if self.token_ else ""
107+
108+
@property
109+
def chat_id(self) -> str:
110+
return expandvars(self.chat_id_) if self.chat_id_ else ""
111+
112+
@property
113+
def webhook_url(self) -> str:
114+
return expandvars(self.webhook_url_) if self.webhook_url_ else ""
115+
116+
@property
117+
def server_url(self) -> str:
118+
return expandvars(self.server_url_) if self.server_url_ else ""
119+
120+
@property
121+
def device_key(self) -> str:
122+
return expandvars(self.device_key_) if self.device_key_ else ""
123+
124+
@property
125+
def user_key(self) -> str:
126+
return expandvars(self.user_key_) if self.user_key_ else ""
127+
128+
@property
129+
def api_token(self) -> str:
130+
return expandvars(self.api_token_) if self.api_token_ else ""
131+
132+
@property
133+
def url(self) -> str:
134+
return expandvars(self.url_) if self.url_ else ""
135+
136+
71137
class Notification(BaseModel):
72-
enable: bool = Field(False, description="Enable notification")
73-
type: str = Field("telegram", description="Notification type")
74-
token_: str = Field("", alias="token", description="Notification token")
75-
chat_id_: str = Field("", alias="chat_id", description="Notification chat id")
138+
"""Notification configuration supporting multiple providers."""
139+
140+
enable: bool = Field(False, description="Enable notification system")
141+
providers: list[NotificationProvider] = Field(
142+
default_factory=list, description="List of notification providers"
143+
)
144+
145+
# Legacy fields for backward compatibility (deprecated)
146+
type: Optional[str] = Field(None, description="[Deprecated] Use providers instead")
147+
token_: Optional[str] = Field(None, alias="token", description="[Deprecated]")
148+
chat_id_: Optional[str] = Field(None, alias="chat_id", description="[Deprecated]")
76149

77150
@property
78-
def token(self):
79-
return expandvars(self.token_)
151+
def token(self) -> str:
152+
return expandvars(self.token_) if self.token_ else ""
80153

81154
@property
82-
def chat_id(self):
83-
return expandvars(self.chat_id_)
155+
def chat_id(self) -> str:
156+
return expandvars(self.chat_id_) if self.chat_id_ else ""
157+
158+
@model_validator(mode="after")
159+
def migrate_legacy_config(self) -> "Notification":
160+
"""Auto-migrate old single-provider config to new format."""
161+
if self.type and not self.providers:
162+
# Old format detected, migrate to new format
163+
legacy_provider = NotificationProvider(
164+
type=self.type,
165+
enabled=True,
166+
token=self.token_ or "",
167+
chat_id=self.chat_id_ or "",
168+
)
169+
self.providers = [legacy_provider]
170+
return self
84171

85172

86173
class ExperimentalOpenAI(BaseModel):
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,11 @@
11
from .notification import PostNotification
2+
from .manager import NotificationManager
3+
from .base import NotificationProvider
4+
from .providers import PROVIDER_REGISTRY
5+
6+
__all__ = [
7+
"PostNotification",
8+
"NotificationManager",
9+
"NotificationProvider",
10+
"PROVIDER_REGISTRY",
11+
]

0 commit comments

Comments
 (0)