feat: 支持自定义 webhook 模板,支持发送测试信息 (#551)
This commit is contained in:
@@ -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<Arc<BiliClient>>,
|
||||
Json(mut notifier): Json<Notifier>,
|
||||
) -> Result<ApiResponse<()>, 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(()))
|
||||
}
|
||||
|
||||
@@ -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<VersionedCache<handlebars::Handlebars<'static>>> =
|
||||
LazyLock::new(|| VersionedCache::new(create_template).expect("Failed to create handlebars template"));
|
||||
@@ -17,6 +18,13 @@ fn create_template(config: &Config) -> Result<handlebars::Handlebars<'static>> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
#[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<String>) -> &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(())
|
||||
|
||||
@@ -229,6 +229,10 @@ class ApiClient {
|
||||
return this.get<string>(`/video-sources/${type}/default-path`, { name });
|
||||
}
|
||||
|
||||
async testNotifier(notifier: Notifier): Promise<ApiResponse<boolean>> {
|
||||
return this.post<boolean>('/config/notifiers/ping', notifier);
|
||||
}
|
||||
|
||||
async getConfig(): Promise<ApiResponse<Config>> {
|
||||
return this.get<Config>('/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(),
|
||||
|
||||
@@ -282,6 +282,7 @@ export interface TelegramNotifier {
|
||||
export interface WebhookNotifier {
|
||||
type: 'webhook';
|
||||
url: string;
|
||||
template?: string | null;
|
||||
}
|
||||
|
||||
export type Notifier = TelegramNotifier | WebhookNotifier;
|
||||
|
||||
@@ -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 @@
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onclick={() => testNotifier(notifier)}>
|
||||
测试
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onclick={() => deleteNotifier(index)}>
|
||||
删除
|
||||
</Button>
|
||||
|
||||
@@ -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}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="webhook-template">模板(可选)</Label>
|
||||
<textarea
|
||||
id="webhook-template"
|
||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[120px] w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder={'{"text": "{{{message}}}"}'}
|
||||
bind:value={webhookTemplate}
|
||||
></textarea>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
用于渲染 Webhook 的 Handlebars 模板。如果不填写,将使用默认模板。<br />
|
||||
可用变量:<code class="text-xs">message</code>(通知内容)
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<Button variant="outline" onclick={onCancel}>取消</Button>
|
||||
<Button onclick={handleSave}>保存</Button>
|
||||
<Button onclick={handleSave}>确认</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user