From 7ef38a38ed08ac670dff1e4d785a8a9d2713ab07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=B4=80=E1=B4=8D=E1=B4=9B=E1=B4=8F=E1=B4=80=E1=B4=87?= =?UTF-8?q?=CA=80?= Date: Fri, 5 Dec 2025 00:21:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=20webhook=20=E6=A8=A1=E6=9D=BF=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=8F=91=E9=80=81=E6=B5=8B=E8=AF=95=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=20(#551)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/bili_sync/src/api/routes/config/mod.rs | 24 +++++++-- crates/bili_sync/src/config/handlebar.rs | 8 +++ crates/bili_sync/src/notifier/mod.rs | 53 ++++++++++++++++--- web/src/lib/api.ts | 5 ++ web/src/lib/types.ts | 1 + web/src/routes/settings/+page.svelte | 15 ++++++ web/src/routes/settings/NotifierDialog.svelte | 21 +++++++- 7 files changed, 114 insertions(+), 13 deletions(-) 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}
- +