Skip to content

Commit e8cb946

Browse files
authored
feat: add web notification channel test (ZhuLinsen#1200) (ZhuLinsen#1218)
1 parent 4066ef6 commit e8cb946

29 files changed

Lines changed: 1424 additions & 65 deletions

api/v1/endpoints/system_config.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
SystemConfigValidationErrorResponse,
2323
TestLLMChannelRequest,
2424
TestLLMChannelResponse,
25+
TestNotificationChannelRequest,
26+
TestNotificationChannelResponse,
2527
UpdateSystemConfigRequest,
2628
UpdateSystemConfigResponse,
2729
ValidateSystemConfigRequest,
@@ -346,6 +348,50 @@ def test_llm_channel(
346348
)
347349

348350

351+
@router.post(
352+
"/config/notification/test-channel",
353+
response_model=TestNotificationChannelResponse,
354+
responses={
355+
200: {"description": "Notification channel test completed"},
356+
500: {"description": "Internal server error", "model": ErrorResponse},
357+
},
358+
summary="Test one notification channel",
359+
description="Send a short test notification using unsaved or saved notification configuration.",
360+
)
361+
def test_notification_channel(
362+
request: TestNotificationChannelRequest,
363+
service: SystemConfigService = Depends(get_system_config_service),
364+
) -> TestNotificationChannelResponse:
365+
"""Validate and test one notification channel without writing `.env`."""
366+
try:
367+
payload = service.test_notification_channel(
368+
channel=request.channel,
369+
items=[item.model_dump() for item in request.items],
370+
mask_token=request.mask_token,
371+
title=request.title,
372+
content=request.content,
373+
timeout_seconds=request.timeout_seconds,
374+
)
375+
return TestNotificationChannelResponse.model_validate(payload)
376+
except (ValueError, TypeError) as exc:
377+
raise HTTPException(
378+
status_code=422,
379+
detail={
380+
"error": "validation_error",
381+
"message": str(exc),
382+
},
383+
)
384+
except Exception as exc:
385+
logger.error("Failed to test notification channel: %s", exc, exc_info=True)
386+
raise HTTPException(
387+
status_code=500,
388+
detail={
389+
"error": "internal_error",
390+
"message": "Failed to test notification channel",
391+
},
392+
)
393+
394+
349395
@router.post(
350396
"/config/llm/discover-models",
351397
response_model=DiscoverLLMChannelModelsResponse,

api/v1/schemas/system_config.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,19 @@
88
from pydantic import BaseModel, ConfigDict, Field
99

1010
LLMCapabilityCheck = Literal["json", "tools", "vision", "stream"]
11+
NotificationTestChannel = Literal[
12+
"wechat",
13+
"feishu",
14+
"telegram",
15+
"email",
16+
"pushover",
17+
"pushplus",
18+
"serverchan3",
19+
"custom",
20+
"discord",
21+
"slack",
22+
"astrbot",
23+
]
1124

1225

1326
class SystemConfigOption(BaseModel):
@@ -204,6 +217,43 @@ class TestLLMChannelResponse(BaseModel):
204217
capability_results: Dict[str, LLMCapabilityCheckResult] = Field(default_factory=dict)
205218

206219

220+
class NotificationTestAttempt(BaseModel):
221+
"""One notification delivery attempt result."""
222+
223+
channel: NotificationTestChannel
224+
success: bool
225+
message: str
226+
target: Optional[str] = None
227+
error_code: Optional[str] = None
228+
stage: str = "notification_send"
229+
retryable: bool = False
230+
latency_ms: Optional[int] = None
231+
http_status: Optional[int] = None
232+
233+
234+
class TestNotificationChannelRequest(BaseModel):
235+
"""Request payload for testing one notification channel."""
236+
237+
channel: NotificationTestChannel
238+
items: List[SystemConfigUpdateItem] = Field(default_factory=list)
239+
mask_token: str = "******"
240+
title: str = Field(default="DSA 通知测试", min_length=1, max_length=80)
241+
content: str = Field(default="这是一条来自 DSA Web 设置页的通知测试消息。", min_length=1, max_length=1000)
242+
timeout_seconds: float = Field(default=20.0, ge=1.0, le=120.0)
243+
244+
245+
class TestNotificationChannelResponse(BaseModel):
246+
"""Response payload for one notification channel connectivity test."""
247+
248+
success: bool
249+
message: str
250+
error_code: Optional[str] = None
251+
stage: Optional[str] = None
252+
retryable: bool = False
253+
latency_ms: Optional[int] = None
254+
attempts: List[NotificationTestAttempt] = Field(default_factory=list)
255+
256+
207257
class DiscoverLLMChannelModelsRequest(BaseModel):
208258
"""Request payload for discovering models from one LLM channel."""
209259

apps/dsa-web/src/api/__tests__/systemConfig.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,54 @@ describe('systemConfigApi', () => {
6161
expect.objectContaining({ capability_checks: ['json', 'stream'] }),
6262
);
6363
});
64+
65+
it('sends notification channel test payloads with snake_case fields', async () => {
66+
post.mockResolvedValueOnce({
67+
data: {
68+
success: true,
69+
message: 'ok',
70+
error_code: null,
71+
stage: 'notification_send',
72+
retryable: false,
73+
latency_ms: 15,
74+
attempts: [
75+
{
76+
channel: 'custom',
77+
success: true,
78+
message: 'sent',
79+
target: 'https://example.com/hook?token=***',
80+
error_code: null,
81+
stage: 'notification_send',
82+
retryable: false,
83+
latency_ms: 15,
84+
http_status: 200,
85+
},
86+
],
87+
},
88+
});
89+
90+
const result = await systemConfigApi.testNotificationChannel({
91+
channel: 'custom',
92+
items: [{ key: 'CUSTOM_WEBHOOK_URLS', value: 'https://example.com/hook?token=secret' }],
93+
maskToken: '******',
94+
title: 'hello',
95+
content: 'world',
96+
timeoutSeconds: 7,
97+
});
98+
99+
expect(post).toHaveBeenCalledWith(
100+
'/api/v1/system/config/notification/test-channel',
101+
{
102+
channel: 'custom',
103+
items: [{ key: 'CUSTOM_WEBHOOK_URLS', value: 'https://example.com/hook?token=secret' }],
104+
mask_token: '******',
105+
title: 'hello',
106+
content: 'world',
107+
timeout_seconds: 7,
108+
},
109+
);
110+
expect(result.latencyMs).toBe(15);
111+
expect(result.attempts[0].errorCode).toBeNull();
112+
expect(result.attempts[0].httpStatus).toBe(200);
113+
});
64114
});

apps/dsa-web/src/api/systemConfig.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import type {
1212
SystemConfigValidationErrorResponse,
1313
TestLLMChannelRequest,
1414
TestLLMChannelResponse,
15+
TestNotificationChannelRequest,
16+
TestNotificationChannelResponse,
1517
UpdateSystemConfigRequest,
1618
UpdateSystemConfigResponse,
1719
ValidateSystemConfigRequest,
@@ -99,6 +101,20 @@ function toSnakeTestChannelPayload(payload: TestLLMChannelRequest): Record<strin
99101
return request;
100102
}
101103

104+
function toSnakeNotificationTestPayload(payload: TestNotificationChannelRequest): Record<string, unknown> {
105+
return {
106+
channel: payload.channel,
107+
items: (payload.items || []).map((item) => ({
108+
key: item.key,
109+
value: item.value,
110+
})),
111+
mask_token: payload.maskToken ?? '******',
112+
title: payload.title ?? 'DSA 通知测试',
113+
content: payload.content ?? '这是一条来自 DSA Web 设置页的通知测试消息。',
114+
timeout_seconds: payload.timeoutSeconds ?? 20,
115+
};
116+
}
117+
102118
function toSnakeDiscoverModelsPayload(payload: DiscoverLLMChannelModelsRequest): Record<string, unknown> {
103119
return {
104120
name: payload.name,
@@ -152,6 +168,14 @@ export const systemConfigApi = {
152168
return toCamelCase<TestLLMChannelResponse>(response.data);
153169
},
154170

171+
async testNotificationChannel(payload: TestNotificationChannelRequest): Promise<TestNotificationChannelResponse> {
172+
const response = await apiClient.post<Record<string, unknown>>(
173+
'/api/v1/system/config/notification/test-channel',
174+
toSnakeNotificationTestPayload(payload),
175+
);
176+
return toCamelCase<TestNotificationChannelResponse>(response.data);
177+
},
178+
155179
async discoverLLMChannelModels(
156180
payload: DiscoverLLMChannelModelsRequest,
157181
): Promise<DiscoverLLMChannelModelsResponse> {

0 commit comments

Comments
 (0)