Compare commits

...

3 Commits

Author SHA1 Message Date
amtoaer
90d451cfa0 feat: telegram 通知渠道支持仅发送文字 2026-04-08 00:17:11 +08:00
ᴀᴍᴛᴏᴀᴇʀ
744bb536b3 feat: 视频源页显示最新视频时间 (#700) 2026-04-07 18:38:02 +08:00
ᴀᴍᴛᴏᴀᴇʀ
91ab64a068 feat: 支持自定义 webhook 请求的 headers,更新说明内容 (#693) 2026-03-31 01:49:32 +08:00
7 changed files with 133 additions and 27 deletions

View File

@@ -1,5 +1,6 @@
use bili_sync_entity::rule::Rule; use bili_sync_entity::rule::Rule;
use bili_sync_entity::*; use bili_sync_entity::*;
use sea_orm::prelude::DateTime;
use sea_orm::{DerivePartialModel, FromQueryResult}; use sea_orm::{DerivePartialModel, FromQueryResult};
use serde::Serialize; use serde::Serialize;
@@ -218,6 +219,7 @@ pub struct VideoSourceDetail {
#[serde(default)] #[serde(default)]
pub use_dynamic_api: Option<bool>, pub use_dynamic_api: Option<bool>,
pub enabled: bool, pub enabled: bool,
pub latest_row_at: Option<DateTime>,
} }
#[derive(Serialize)] #[derive(Serialize)]

View File

@@ -9,7 +9,7 @@ use sea_orm::DatabaseConnection;
use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson}; use crate::api::wrapper::{ApiError, ApiResponse, ValidatedJson};
use crate::bilibili::BiliClient; use crate::bilibili::BiliClient;
use crate::config::{Config, VersionedConfig}; use crate::config::{Config, VersionedConfig};
use crate::notifier::Notifier; use crate::notifier::{Message, Notifier};
pub(super) fn router() -> Router { pub(super) fn router() -> Router {
Router::new() Router::new()
@@ -41,7 +41,10 @@ pub async fn ping_notifiers(
*ignore_cache = Some(()); *ignore_cache = Some(());
} }
notifier notifier
.notify(bili_client.inner_client(), "This is a test notification from BiliSync.") .notify(bili_client.inner_client(), Message{
message: "This is a test notification from BiliSync.".into(),
image_url: Some("https://socialify.git.ci/amtoaer/bili-sync/image?description=1&font=KoHo&issues=1&language=1&logo=https%3A%2F%2Fs2.loli.net%2F2023%2F12%2F02%2F9EwT2yInOu1d3zm.png&name=1&owner=1&pattern=Signal&pulls=1&stargazers=1&theme=Light".to_owned()),
})
.await?; .await?;
Ok(ApiResponse::ok(())) Ok(ApiResponse::ok(()))
} }

View File

@@ -104,7 +104,8 @@ pub async fn get_video_sources_details(
collection::Column::Name, collection::Column::Name,
collection::Column::Path, collection::Column::Path,
collection::Column::Rule, collection::Column::Rule,
collection::Column::Enabled collection::Column::Enabled,
collection::Column::LatestRowAt
]) ])
.into_model::<VideoSourceDetail>() .into_model::<VideoSourceDetail>()
.all(&db), .all(&db),
@@ -115,7 +116,8 @@ pub async fn get_video_sources_details(
favorite::Column::Name, favorite::Column::Name,
favorite::Column::Path, favorite::Column::Path,
favorite::Column::Rule, favorite::Column::Rule,
favorite::Column::Enabled favorite::Column::Enabled,
favorite::Column::LatestRowAt
]) ])
.into_model::<VideoSourceDetail>() .into_model::<VideoSourceDetail>()
.all(&db), .all(&db),
@@ -127,7 +129,8 @@ pub async fn get_video_sources_details(
submission::Column::Path, submission::Column::Path,
submission::Column::Enabled, submission::Column::Enabled,
submission::Column::Rule, submission::Column::Rule,
submission::Column::UseDynamicApi submission::Column::UseDynamicApi,
submission::Column::LatestRowAt
]) ])
.into_model::<VideoSourceDetail>() .into_model::<VideoSourceDetail>()
.all(&db), .all(&db),
@@ -138,7 +141,8 @@ pub async fn get_video_sources_details(
watch_later::Column::Id, watch_later::Column::Id,
watch_later::Column::Path, watch_later::Column::Path,
watch_later::Column::Enabled, watch_later::Column::Enabled,
watch_later::Column::Rule watch_later::Column::Rule,
watch_later::Column::LatestRowAt
]) ])
.into_model::<VideoSourceDetail>() .into_model::<VideoSourceDetail>()
.all(&db) .all(&db)
@@ -152,6 +156,7 @@ pub async fn get_video_sources_details(
rule_display: None, rule_display: None,
use_dynamic_api: None, use_dynamic_api: None,
enabled: false, enabled: false,
latest_row_at: None,
}) })
} }
for sources in [&mut collections, &mut favorites, &mut submissions, &mut watch_later] { for sources in [&mut collections, &mut favorites, &mut submissions, &mut watch_later] {
@@ -159,6 +164,7 @@ pub async fn get_video_sources_details(
if let Some(rule) = &item.rule { if let Some(rule) = &item.rule {
item.rule_display = Some(rule.to_string()); item.rule_display = Some(rule.to_string());
} }
item.latest_row_at = item.latest_row_at.filter(|dt| dt.and_utc().timestamp() != 0);
}); });
} }
Ok(ApiResponse::ok(VideoSourcesDetailsResponse { Ok(ApiResponse::ok(VideoSourcesDetailsResponse {

View File

@@ -1,6 +1,8 @@
mod info; mod info;
mod message; mod message;
use std::collections::HashMap;
use anyhow::Result; use anyhow::Result;
use futures::future; use futures::future;
pub use info::DownloadNotifyInfo; pub use info::DownloadNotifyInfo;
@@ -16,10 +18,14 @@ pub enum Notifier {
Telegram { Telegram {
bot_token: String, bot_token: String,
chat_id: String, chat_id: String,
#[serde(default)]
skip_image: bool,
}, },
Webhook { Webhook {
url: String, url: String,
template: Option<String>, template: Option<String>,
#[serde(default)]
headers: Option<HashMap<String, String>>,
#[serde(skip)] #[serde(skip)]
// 一个内部辅助字段,用于决定是否强制渲染当前模板,在测试时使用 // 一个内部辅助字段,用于决定是否强制渲染当前模板,在测试时使用
ignore_cache: Option<()>, ignore_cache: Option<()>,
@@ -56,8 +62,14 @@ impl Notifier {
async fn notify_internal<'a>(&self, client: &reqwest::Client, message: &Message<'a>) -> Result<()> { async fn notify_internal<'a>(&self, client: &reqwest::Client, message: &Message<'a>) -> Result<()> {
match self { match self {
Notifier::Telegram { bot_token, chat_id } => { Notifier::Telegram {
if let Some(img_url) = &message.image_url { bot_token,
chat_id,
skip_image,
} => {
if let Some(img_url) = &message.image_url
&& !*skip_image
{
let url = format!("https://api.telegram.org/bot{}/sendPhoto", bot_token); let url = format!("https://api.telegram.org/bot{}/sendPhoto", bot_token);
let params = [ let params = [
("chat_id", chat_id.as_str()), ("chat_id", chat_id.as_str()),
@@ -74,6 +86,7 @@ impl Notifier {
Notifier::Webhook { Notifier::Webhook {
url, url,
template, template,
headers,
ignore_cache, ignore_cache,
} => { } => {
let key = webhook_template_key(url); let key = webhook_template_key(url);
@@ -82,12 +95,20 @@ impl Notifier {
Some(_) => handlebar.render_template(webhook_template_content(template), &message)?, Some(_) => handlebar.render_template(webhook_template_content(template), &message)?,
None => handlebar.render(&key, &message)?, None => handlebar.render(&key, &message)?,
}; };
client let mut headers_map = header::HeaderMap::new();
.post(url) headers_map.insert(header::CONTENT_TYPE, "application/json".try_into()?);
.header(header::CONTENT_TYPE, "application/json")
.body(payload) if let Some(custom_headers) = headers {
.send() for (key, value) in custom_headers {
.await?; if let (Ok(key), Ok(value)) =
(header::HeaderName::try_from(key), header::HeaderValue::try_from(value))
{
headers_map.insert(key, value);
}
}
}
client.post(url).headers(headers_map).body(payload).send().await?;
} }
} }
Ok(()) Ok(())

View File

@@ -224,6 +224,7 @@ export interface VideoSourceDetail {
ruleDisplay: string | null; ruleDisplay: string | null;
useDynamicApi: boolean | null; useDynamicApi: boolean | null;
enabled: boolean; enabled: boolean;
latestRowAt: string | null;
} }
export interface VideoSourcesDetailsResponse { export interface VideoSourcesDetailsResponse {
@@ -305,12 +306,14 @@ export interface TelegramNotifier {
type: 'telegram'; type: 'telegram';
bot_token: string; bot_token: string;
chat_id: string; chat_id: string;
skip_image: boolean;
} }
export interface WebhookNotifier { export interface WebhookNotifier {
type: 'webhook'; type: 'webhook';
url: string; url: string;
template?: string | null; template?: string | null;
headers?: Record<string, string> | null;
} }
export type Notifier = TelegramNotifier | WebhookNotifier; export type Notifier = TelegramNotifier | WebhookNotifier;

View File

@@ -1,12 +1,11 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { Input } from '$lib/components/ui/input/index.js'; import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js'; import { Label } from '$lib/components/ui/label/index.js';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import type { Notifier } from '$lib/types'; import type { Notifier } from '$lib/types';
const jsonExample = '{"text": "您的消息内容"}';
export let notifier: Notifier | null = null; export let notifier: Notifier | null = null;
export let onSave: (notifier: Notifier) => void; export let onSave: (notifier: Notifier) => void;
export let onCancel: () => void; export let onCancel: () => void;
@@ -14,8 +13,10 @@
let type: 'telegram' | 'webhook' = 'telegram'; let type: 'telegram' | 'webhook' = 'telegram';
let botToken = ''; let botToken = '';
let chatId = ''; let chatId = '';
let skipImage = false;
let webhookUrl = ''; let webhookUrl = '';
let webhookTemplate = ''; let webhookTemplate = '';
let webhookHeaders: { key: string; value: string }[] = [];
// 初始化表单 // 初始化表单
$: { $: {
@@ -24,22 +25,29 @@
type = 'telegram'; type = 'telegram';
botToken = notifier.bot_token; botToken = notifier.bot_token;
chatId = notifier.chat_id; chatId = notifier.chat_id;
skipImage = notifier.skip_image;
} else { } else {
type = 'webhook'; type = 'webhook';
webhookUrl = notifier.url; webhookUrl = notifier.url;
webhookTemplate = notifier.template || ''; webhookTemplate = notifier.template || '';
if (notifier.headers) {
webhookHeaders = Object.entries(notifier.headers).map(([key, value]) => ({ key, value }));
} else {
webhookHeaders = [];
}
} }
} else { } else {
type = 'telegram'; type = 'telegram';
botToken = ''; botToken = '';
chatId = ''; chatId = '';
skipImage = false;
webhookUrl = ''; webhookUrl = '';
webhookTemplate = ''; webhookTemplate = '';
webhookHeaders = [];
} }
} }
function handleSave() { function handleSave() {
// 验证表单
if (type === 'telegram') { if (type === 'telegram') {
if (!botToken.trim()) { if (!botToken.trim()) {
toast.error('请输入 Bot Token'); toast.error('请输入 Bot Token');
@@ -53,7 +61,8 @@
const newNotifier: Notifier = { const newNotifier: Notifier = {
type: 'telegram', type: 'telegram',
bot_token: botToken.trim(), bot_token: botToken.trim(),
chat_id: chatId.trim() chat_id: chatId.trim(),
skip_image: skipImage
}; };
onSave(newNotifier); onSave(newNotifier);
} else { } else {
@@ -62,7 +71,6 @@
return; return;
} }
// 简单的 URL 验证
try { try {
new URL(webhookUrl.trim()); new URL(webhookUrl.trim());
} catch { } catch {
@@ -70,10 +78,20 @@
return; return;
} }
const headers: Record<string, string> = {};
for (const { key, value } of webhookHeaders) {
const trimmedKey = key.trim();
const trimmedValue = value.trim();
if (trimmedKey && trimmedValue) {
headers[trimmedKey] = trimmedValue;
}
}
const newNotifier: Notifier = { const newNotifier: Notifier = {
type: 'webhook', type: 'webhook',
url: webhookUrl.trim(), url: webhookUrl.trim(),
template: webhookTemplate.trim() || null template: webhookTemplate.trim() || null,
headers: Object.keys(headers).length > 0 ? headers : null
}; };
onSave(newNotifier); onSave(newNotifier);
} }
@@ -108,14 +126,14 @@
<Input id="chat-id" placeholder="-1001234567890" bind:value={chatId} /> <Input id="chat-id" placeholder="-1001234567890" bind:value={chatId} />
<p class="text-muted-foreground text-xs">目标聊天室的 ID个人用户、群组或频道</p> <p class="text-muted-foreground text-xs">目标聊天室的 ID个人用户、群组或频道</p>
</div> </div>
<div class="flex items-center gap-2">
<Checkbox id="skip-image" bind:checked={skipImage} />
<Label for="skip-image" class="text-sm font-normal">仅发送文字</Label>
</div>
{:else if type === 'webhook'} {:else if type === 'webhook'}
<div class="space-y-2"> <div class="space-y-2">
<Label for="webhook-url">Webhook URL</Label> <Label for="webhook-url">Webhook URL</Label>
<Input id="webhook-url" placeholder="https://example.com/webhook" bind:value={webhookUrl} /> <Input id="webhook-url" placeholder="请输入 Webhook 地址" bind:value={webhookUrl} />
<p class="text-muted-foreground text-xs">
接收通知的 Webhook 地址<br />
格式示例:{jsonExample}
</p>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="webhook-template">模板(可选)</Label> <Label for="webhook-template">模板(可选)</Label>
@@ -127,7 +145,48 @@
></textarea> ></textarea>
<p class="text-muted-foreground text-xs"> <p class="text-muted-foreground text-xs">
用于渲染 Webhook 的 Handlebars 模板。如果不填写,将使用默认模板。<br /> 用于渲染 Webhook 的 Handlebars 模板。如果不填写,将使用默认模板。<br />
可用变量:<code class="text-xs">message</code>(通知内容) 可用变量:<code class="text-xs">message</code>(通知内容)<code class="text-xs"
>image_url</code
>(封面图片地址,无图时为 null
</p>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>自定义请求头(可选)</Label>
<Button
variant="ghost"
size="sm"
onclick={() => (webhookHeaders = [...webhookHeaders, { key: '', value: '' }])}
>
+ 添加请求头
</Button>
</div>
{#each webhookHeaders as header, index (index)}
<div class="flex items-center gap-2">
<Input
placeholder="Header 名称(例如 Authorization"
bind:value={header.key}
class="flex-1"
/>
<Input
placeholder="Header 值"
bind:value={header.value}
class="flex-1"
type={header.key.toLowerCase() === 'authorization' ? 'password' : 'text'}
/>
<Button
variant="ghost"
size="sm"
onclick={() => (webhookHeaders = webhookHeaders.filter((_, i) => i !== index))}
class="h-10 px-2"
>
×
</Button>
</div>
{/each}
<p class="text-muted-foreground text-xs">
添加自定义请求头例如Authorization: Bearer your_token
</p> </p>
</div> </div>
{/if} {/if}

View File

@@ -366,8 +366,9 @@
<Table.Row> <Table.Row>
<Table.Head class="w-[20%]">名称</Table.Head> <Table.Head class="w-[20%]">名称</Table.Head>
<Table.Head class="w-[30%]">下载路径</Table.Head> <Table.Head class="w-[30%]">下载路径</Table.Head>
<Table.Head class="w-[15%]">最新视频时间</Table.Head>
<Table.Head class="w-[15%]">过滤规则</Table.Head> <Table.Head class="w-[15%]">过滤规则</Table.Head>
<Table.Head class="w-[15%]">启用状态</Table.Head> <Table.Head class="w-[10%]">启用状态</Table.Head>
<Table.Head class="w-[10%] text-right">操作</Table.Head> <Table.Head class="w-[10%] text-right">操作</Table.Head>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
@@ -387,6 +388,17 @@
</span> </span>
</div> </div>
</Table.Cell> </Table.Cell>
<Table.Cell>
{#if source.latestRowAt}
<Badge variant="secondary" class="flex w-fit items-center gap-1.5">
{new Date(source.latestRowAt).toLocaleString('zh-CN')}
</Badge>
{:else}
<Badge variant="secondary" class="flex w-fit items-center gap-1.5">
-
</Badge>
{/if}
</Table.Cell>
<Table.Cell> <Table.Cell>
{#if source.rule && source.rule.length > 0} {#if source.rule && source.rule.length > 0}
<Tooltip.Root disableHoverableContent={true}> <Tooltip.Root disableHoverableContent={true}>