diff --git a/crates/bili_sync/src/api/routes/config/mod.rs b/crates/bili_sync/src/api/routes/config/mod.rs
index d301a52..154d594 100644
--- a/crates/bili_sync/src/api/routes/config/mod.rs
+++ b/crates/bili_sync/src/api/routes/config/mod.rs
@@ -1,16 +1,20 @@
use std::sync::Arc;
use anyhow::Result;
-use axum::Router;
use axum::extract::Extension;
-use axum::routing::get;
+use axum::routing::{get, post};
+use axum::{Json, Router};
use sea_orm::DatabaseConnection;
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
+use crate::bilibili::BiliClient;
use crate::config::{Config, VersionedConfig};
+use crate::notifier::Notifier;
pub(super) fn router() -> Router {
- Router::new().route("/config", get(get_config).put(update_config))
+ Router::new()
+ .route("/config", get(get_config).put(update_config))
+ .route("/config/notifiers/ping", post(ping_notifiers))
}
/// 获取全局配置
@@ -27,3 +31,17 @@ pub async fn update_config(
let new_config = VersionedConfig::get().update(config, &db).await?;
Ok(ApiResponse::ok(new_config))
}
+
+pub async fn ping_notifiers(
+ Extension(bili_client): Extension>,
+ Json(mut notifier): Json,
+) -> Result, ApiError> {
+ // 对于 webhook 类型的通知器测试,设置上 ignore_cache tag 以强制实时渲染
+ if let Notifier::Webhook { ignore_cache, .. } = &mut notifier {
+ *ignore_cache = Some(());
+ }
+ notifier
+ .notify(bili_client.inner_client(), "This is a test notification from BiliSync.")
+ .await?;
+ Ok(ApiResponse::ok(()))
+}
diff --git a/crates/bili_sync/src/config/handlebar.rs b/crates/bili_sync/src/config/handlebar.rs
index ec42db7..79490d7 100644
--- a/crates/bili_sync/src/config/handlebar.rs
+++ b/crates/bili_sync/src/config/handlebar.rs
@@ -5,6 +5,7 @@ use handlebars::handlebars_helper;
use crate::config::versioned_cache::VersionedCache;
use crate::config::{Config, PathSafeTemplate};
+use crate::notifier::{Notifier, webhook_template_content, webhook_template_key};
pub static TEMPLATE: LazyLock>> =
LazyLock::new(|| VersionedCache::new(create_template).expect("Failed to create handlebars template"));
@@ -17,6 +18,13 @@ fn create_template(config: &Config) -> Result> {
handlebars.path_safe_register("favorite_default_path", config.favorite_default_path.clone())?;
handlebars.path_safe_register("collection_default_path", config.collection_default_path.clone())?;
handlebars.path_safe_register("submission_default_path", config.submission_default_path.clone())?;
+ if let Some(notifiers) = &config.notifiers {
+ for notifier in notifiers.iter() {
+ if let Notifier::Webhook { url, template, .. } = notifier {
+ handlebars.register_template_string(&webhook_template_key(url), webhook_template_content(template))?;
+ }
+ }
+ }
Ok(handlebars)
}
diff --git a/crates/bili_sync/src/notifier/mod.rs b/crates/bili_sync/src/notifier/mod.rs
index 8e58ee7..411246d 100644
--- a/crates/bili_sync/src/notifier/mod.rs
+++ b/crates/bili_sync/src/notifier/mod.rs
@@ -1,17 +1,35 @@
use anyhow::Result;
use futures::future;
+use reqwest::header;
use serde::{Deserialize, Serialize};
+use crate::config::TEMPLATE;
+
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum Notifier {
- Telegram { bot_token: String, chat_id: String },
- Webhook { url: String },
+ Telegram {
+ bot_token: String,
+ chat_id: String,
+ },
+ Webhook {
+ url: String,
+ template: Option,
+ #[serde(skip)]
+ // 一个内部辅助字段,用于决定是否强制渲染当前模板,在测试时使用
+ ignore_cache: Option<()>,
+ },
}
-#[derive(Serialize)]
-struct WebhookPayload<'a> {
- text: &'a str,
+pub fn webhook_template_key(url: &str) -> String {
+ format!("payload_{}", url)
+}
+
+pub fn webhook_template_content(template: &Option) -> &str {
+ template
+ .as_deref()
+ .filter(|t| !t.trim().is_empty())
+ .unwrap_or(r#"{"text": "{{{message}}}"}"#)
}
pub trait NotifierAllExt {
@@ -33,9 +51,28 @@ impl Notifier {
let params = [("chat_id", chat_id.as_str()), ("text", message)];
client.post(&url).form(¶ms).send().await?;
}
- Notifier::Webhook { url } => {
- let payload = WebhookPayload { text: message };
- client.post(url).json(&payload).send().await?;
+ Notifier::Webhook {
+ url,
+ template,
+ ignore_cache,
+ } => {
+ let key = webhook_template_key(url);
+ let data = serde_json::json!(
+ {
+ "message": message,
+ }
+ );
+ let handlebar = TEMPLATE.read();
+ let payload = match ignore_cache {
+ Some(_) => handlebar.render_template(webhook_template_content(template), &data)?,
+ None => handlebar.render(&key, &data)?,
+ };
+ client
+ .post(url)
+ .header(header::CONTENT_TYPE, "application/json")
+ .body(payload)
+ .send()
+ .await?;
}
}
Ok(())
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 23aa459..af4101b 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -229,6 +229,10 @@ class ApiClient {
return this.get(`/video-sources/${type}/default-path`, { name });
}
+ async testNotifier(notifier: Notifier): Promise> {
+ return this.post('/config/notifiers/ping', notifier);
+ }
+
async getConfig(): Promise> {
return this.get('/config');
}
@@ -283,6 +287,7 @@ const api = {
evaluateVideoSourceRules: (type: string, id: number) =>
apiClient.evaluateVideoSourceRules(type, id),
getDefaultPath: (type: string, name: string) => apiClient.getDefaultPath(type, name),
+ testNotifier: (notifier: Notifier) => apiClient.testNotifier(notifier),
getConfig: () => apiClient.getConfig(),
updateConfig: (config: Config) => apiClient.updateConfig(config),
getDashboard: () => apiClient.getDashboard(),
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index 6bf04cd..3e2f124 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -282,6 +282,7 @@ export interface TelegramNotifier {
export interface WebhookNotifier {
type: 'webhook';
url: string;
+ template?: string | null;
}
export type Notifier = TelegramNotifier | WebhookNotifier;
diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte
index a5b1f1e..feca71f 100644
--- a/web/src/routes/settings/+page.svelte
+++ b/web/src/routes/settings/+page.svelte
@@ -74,6 +74,18 @@
formData.notifiers = formData.notifiers.filter((_, i) => i !== index);
}
+ async function testNotifier(notifier: Notifier) {
+ try {
+ await api.testNotifier(notifier);
+ toast.success('测试通知发送成功');
+ } catch (error) {
+ console.error('测试通知失败:', error);
+ toast.error('测试通知失败', {
+ description: (error as ApiError).message
+ });
+ }
+ }
+
async function loadConfig() {
loading = true;
try {
@@ -772,6 +784,9 @@
>
编辑
+
diff --git a/web/src/routes/settings/NotifierDialog.svelte b/web/src/routes/settings/NotifierDialog.svelte
index 11b7a9a..22ea5d9 100644
--- a/web/src/routes/settings/NotifierDialog.svelte
+++ b/web/src/routes/settings/NotifierDialog.svelte
@@ -15,6 +15,7 @@
let botToken = '';
let chatId = '';
let webhookUrl = '';
+ let webhookTemplate = '';
// 初始化表单
$: {
@@ -26,12 +27,14 @@
} else {
type = 'webhook';
webhookUrl = notifier.url;
+ webhookTemplate = notifier.template || '';
}
} else {
type = 'telegram';
botToken = '';
chatId = '';
webhookUrl = '';
+ webhookTemplate = '';
}
}
@@ -69,7 +72,8 @@
const newNotifier: Notifier = {
type: 'webhook',
- url: webhookUrl.trim()
+ url: webhookUrl.trim(),
+ template: webhookTemplate.trim() || null
};
onSave(newNotifier);
}
@@ -113,10 +117,23 @@
格式示例:{jsonExample}
+
+
+
+
+ 用于渲染 Webhook 的 Handlebars 模板。如果不填写,将使用默认模板。
+ 可用变量:message(通知内容)
+
+
{/if}
-
+