mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
Merge branch 'fawney19:main' into main
This commit is contained in:
173
apps/aether-gateway/src/bark_push.rs
Normal file
173
apps/aether-gateway/src/bark_push.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
use crate::handlers::shared::{
|
||||
decrypt_catalog_secret_with_fallbacks, system_config_bool, system_config_string,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub(crate) const BARK_PUSH_ENABLED_KEY: &str = "module.bark_push.enabled";
|
||||
pub(crate) const BARK_PUSH_DEVICE_KEY_KEY: &str = "module.bark_push.device_key";
|
||||
pub(crate) const BARK_PUSH_SERVER_URL_KEY: &str = "module.bark_push.server_url";
|
||||
pub(crate) const BARK_PUSH_TEMPLATE_KEY: &str = "module.bark_push.template";
|
||||
|
||||
const DEFAULT_BARK_API_BASE: &str = "https://api.day.app";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct BarkPushConfig {
|
||||
pub(crate) enabled: bool,
|
||||
pub(crate) device_key: Option<String>,
|
||||
pub(crate) server_url: String,
|
||||
pub(crate) template: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn bark_push_module_enabled(state: &AppState) -> Result<bool, GatewayError> {
|
||||
let value = state
|
||||
.read_system_config_json_value(BARK_PUSH_ENABLED_KEY)
|
||||
.await?;
|
||||
Ok(system_config_bool(value.as_ref(), false))
|
||||
}
|
||||
|
||||
pub(crate) async fn bark_push_configured(state: &AppState) -> Result<bool, GatewayError> {
|
||||
let config = read_bark_push_config(state).await?;
|
||||
Ok(config.device_key.is_some() && !config.server_url.trim().is_empty())
|
||||
}
|
||||
|
||||
pub(crate) async fn read_bark_push_config(
|
||||
state: &AppState,
|
||||
) -> Result<BarkPushConfig, GatewayError> {
|
||||
let enabled = bark_push_module_enabled(state).await?;
|
||||
let device_key = state
|
||||
.read_system_config_json_value(BARK_PUSH_DEVICE_KEY_KEY)
|
||||
.await?
|
||||
.and_then(|value| system_config_string(Some(&value)))
|
||||
.map(|value| {
|
||||
decrypt_catalog_secret_with_fallbacks(state.encryption_key(), &value).unwrap_or(value)
|
||||
});
|
||||
let server_url = state
|
||||
.read_system_config_json_value(BARK_PUSH_SERVER_URL_KEY)
|
||||
.await?
|
||||
.and_then(|value| system_config_string(Some(&value)))
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_BARK_API_BASE.to_string());
|
||||
let template = state
|
||||
.read_system_config_json_value(BARK_PUSH_TEMPLATE_KEY)
|
||||
.await?
|
||||
.and_then(|value| system_config_string(Some(&value)));
|
||||
|
||||
Ok(BarkPushConfig {
|
||||
enabled,
|
||||
device_key,
|
||||
server_url,
|
||||
template,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn send_bark_push(
|
||||
state: &AppState,
|
||||
config: &BarkPushConfig,
|
||||
title: &str,
|
||||
markdown_body: &str,
|
||||
) -> Result<(), GatewayError> {
|
||||
let Some(device_key) = config.device_key.as_deref() else {
|
||||
return Err(GatewayError::Internal("未配置 Bark Device Key".to_string()));
|
||||
};
|
||||
let device_key = device_key.trim();
|
||||
if device_key.is_empty() {
|
||||
return Err(GatewayError::Internal(
|
||||
"Bark Device Key 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
let server_url = normalized_bark_server_url(&config.server_url)?;
|
||||
let body = render_bark_body(config.template.as_deref(), title, markdown_body);
|
||||
let response = state
|
||||
.client
|
||||
.post(format!("{server_url}/push"))
|
||||
.json(&json!({
|
||||
"device_key": device_key,
|
||||
"title": title,
|
||||
"body": body,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
if !status.is_success() {
|
||||
return Err(GatewayError::Internal(format!(
|
||||
"Bark 返回 HTTP {status}: {text}"
|
||||
)));
|
||||
}
|
||||
if let Ok(payload) = serde_json::from_str::<Value>(&text) {
|
||||
let code_is_ok = payload
|
||||
.get("code")
|
||||
.and_then(|value| {
|
||||
value
|
||||
.as_i64()
|
||||
.map(|code| matches!(code, 0 | 200))
|
||||
.or_else(|| {
|
||||
value
|
||||
.as_str()
|
||||
.map(|code| matches!(code.trim(), "0" | "200"))
|
||||
})
|
||||
})
|
||||
.unwrap_or(true);
|
||||
if !code_is_ok {
|
||||
return Err(GatewayError::Internal(format!("Bark 返回失败: {payload}")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalized_bark_server_url(server_url: &str) -> Result<String, GatewayError> {
|
||||
let server_url = server_url.trim().trim_end_matches('/');
|
||||
if server_url.is_empty() {
|
||||
return Err(GatewayError::Internal(
|
||||
"Bark 服务器地址不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
if !server_url.starts_with("https://") && !server_url.starts_with("http://") {
|
||||
return Err(GatewayError::Internal(
|
||||
"Bark 服务器地址必须以 http:// 或 https:// 开头".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(server_url.to_string())
|
||||
}
|
||||
|
||||
fn render_bark_body(template: Option<&str>, title: &str, markdown_body: &str) -> String {
|
||||
match template {
|
||||
Some(template) if !template.trim().is_empty() => template
|
||||
.replace("{title}", title)
|
||||
.replace("{body}", markdown_body),
|
||||
_ => markdown_body.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{normalized_bark_server_url, render_bark_body};
|
||||
|
||||
#[test]
|
||||
fn bark_body_uses_template_when_provided() {
|
||||
let rendered = render_bark_body(Some("{title}\n\n{body}"), "告警", "原始正文");
|
||||
assert_eq!(rendered, "告警\n\n原始正文");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bark_body_falls_back_to_markdown_body_for_empty_template() {
|
||||
assert_eq!(render_bark_body(None, "告警", "原始正文"), "原始正文");
|
||||
assert_eq!(
|
||||
render_bark_body(Some(" "), "告警", "原始正文"),
|
||||
"原始正文"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bark_server_url_trims_trailing_slashes() {
|
||||
assert_eq!(
|
||||
normalized_bark_server_url(" https://api.day.app/ ").expect("url should parse"),
|
||||
"https://api.day.app"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::bark_push::bark_push_configured;
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::handlers::shared::{module_available_from_env, system_config_bool};
|
||||
use crate::important_notification::{
|
||||
@@ -96,6 +97,18 @@ pub(crate) const ADMIN_MODULE_DEFINITIONS: &[AdminModuleDefinition] = &[
|
||||
admin_menu_group: Some("system"),
|
||||
admin_menu_order: 59,
|
||||
},
|
||||
AdminModuleDefinition {
|
||||
name: "bark_push",
|
||||
display_name: "Bark 推送",
|
||||
description: "第三方推送服务,配置 Bark Device Key 并测试 iOS 推送",
|
||||
category: "integration",
|
||||
env_key: "BARK_PUSH_AVAILABLE",
|
||||
default_available: true,
|
||||
admin_route: Some("/admin/modules/bark"),
|
||||
admin_menu_icon: Some("Send"),
|
||||
admin_menu_group: Some("system"),
|
||||
admin_menu_order: 59,
|
||||
},
|
||||
AdminModuleDefinition {
|
||||
name: "model_directives",
|
||||
display_name: "模型后缀参数",
|
||||
@@ -169,6 +182,7 @@ pub(crate) struct AdminModuleRuntimeState {
|
||||
gemini_files_has_capable_key: bool,
|
||||
important_notification_configured: bool,
|
||||
server_chan_push_configured: bool,
|
||||
bark_push_configured: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn admin_module_by_name(name: &str) -> Option<&'static AdminModuleDefinition> {
|
||||
@@ -257,6 +271,7 @@ pub(crate) async fn build_admin_module_runtime_state(
|
||||
|
||||
let notification_configured = important_notification_configured(state.app()).await?;
|
||||
let server_chan_configured = server_chan_push_configured(state.app()).await?;
|
||||
let bark_configured = bark_push_configured(state.app()).await?;
|
||||
|
||||
Ok(AdminModuleRuntimeState {
|
||||
oauth_providers,
|
||||
@@ -264,6 +279,7 @@ pub(crate) async fn build_admin_module_runtime_state(
|
||||
gemini_files_has_capable_key,
|
||||
important_notification_configured: notification_configured,
|
||||
server_chan_push_configured: server_chan_configured,
|
||||
bark_push_configured: bark_configured,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -278,6 +294,7 @@ pub(crate) fn build_admin_module_validation_result(
|
||||
runtime.gemini_files_has_capable_key,
|
||||
runtime.important_notification_configured,
|
||||
runtime.server_chan_push_configured,
|
||||
runtime.bark_push_configured,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::admin_api::AdminAppState;
|
||||
use crate::bark_push::{read_bark_push_config, send_bark_push, BarkPushConfig};
|
||||
use crate::email_delivery::{
|
||||
read_smtp_delivery_config, send_smtp_email, ComposedEmail, SmtpDeliveryConfig,
|
||||
};
|
||||
@@ -35,6 +36,7 @@ pub(crate) enum ImportantNotificationChannelFilter {
|
||||
All,
|
||||
Email,
|
||||
ServerChan,
|
||||
Bark,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -45,6 +47,7 @@ struct ImportantNotificationConfig {
|
||||
default_channel: ImportantNotificationChannelFilter,
|
||||
items: Vec<ImportantNotificationItemConfig>,
|
||||
server_chan: ServerChanPushConfig,
|
||||
bark: BarkPushConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -63,6 +66,7 @@ struct ImportantNotificationItemConfig {
|
||||
struct NotificationChannelReadiness {
|
||||
email: bool,
|
||||
server_chan: bool,
|
||||
bark: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -226,6 +230,7 @@ async fn read_notification_channel_readiness(
|
||||
Ok(NotificationChannelReadiness {
|
||||
email: config.email_enabled && !config.email_recipients.is_empty() && smtp_config.is_some(),
|
||||
server_chan: config.server_chan.enabled && config.server_chan.send_key.is_some(),
|
||||
bark: config.bark.enabled && config.bark.device_key.is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -234,9 +239,12 @@ fn channel_filter_has_ready_channel(
|
||||
readiness: NotificationChannelReadiness,
|
||||
) -> bool {
|
||||
match filter {
|
||||
ImportantNotificationChannelFilter::All => readiness.email || readiness.server_chan,
|
||||
ImportantNotificationChannelFilter::All => {
|
||||
readiness.email || readiness.server_chan || readiness.bark
|
||||
}
|
||||
ImportantNotificationChannelFilter::Email => readiness.email,
|
||||
ImportantNotificationChannelFilter::ServerChan => readiness.server_chan,
|
||||
ImportantNotificationChannelFilter::Bark => readiness.bark,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +300,20 @@ async fn dispatch_important_notification(
|
||||
.await;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
channel_filter,
|
||||
ImportantNotificationChannelFilter::All | ImportantNotificationChannelFilter::Bark
|
||||
) {
|
||||
maybe_send_bark_notification(
|
||||
state,
|
||||
&config,
|
||||
¬ification,
|
||||
bypass_enable_checks,
|
||||
&mut reports,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if reports.is_empty() {
|
||||
reports.push(ImportantNotificationChannelReport {
|
||||
channel: "none",
|
||||
@@ -385,6 +407,7 @@ async fn read_important_notification_config(
|
||||
.unwrap_or(ImportantNotificationChannelFilter::All),
|
||||
items: parse_notification_items(items.as_ref()),
|
||||
server_chan: read_server_chan_push_config(state).await?,
|
||||
bark: read_bark_push_config(state).await?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -395,6 +418,7 @@ fn parse_channel_filter(raw: &str) -> Option<ImportantNotificationChannelFilter>
|
||||
"server_chan" | "serverchan" | "serve_chan" => {
|
||||
Some(ImportantNotificationChannelFilter::ServerChan)
|
||||
}
|
||||
"bark" => Some(ImportantNotificationChannelFilter::Bark),
|
||||
"global" | "" => None,
|
||||
_ => None,
|
||||
}
|
||||
@@ -680,6 +704,48 @@ async fn maybe_send_server_chan_notification(
|
||||
}
|
||||
}
|
||||
|
||||
async fn maybe_send_bark_notification(
|
||||
state: &AppState,
|
||||
config: &ImportantNotificationConfig,
|
||||
notification: &ImportantNotification,
|
||||
bypass_channel_toggle: bool,
|
||||
reports: &mut Vec<ImportantNotificationChannelReport>,
|
||||
) {
|
||||
if !bypass_channel_toggle && !config.bark.enabled {
|
||||
return;
|
||||
}
|
||||
if config.bark.device_key.is_none() {
|
||||
reports.push(ImportantNotificationChannelReport {
|
||||
channel: "bark",
|
||||
success: false,
|
||||
message: "未配置 Bark Device Key".to_string(),
|
||||
});
|
||||
return;
|
||||
};
|
||||
match send_bark_push(
|
||||
state,
|
||||
&config.bark,
|
||||
¬ification.title,
|
||||
¬ification.markdown_body,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => reports.push(ImportantNotificationChannelReport {
|
||||
channel: "bark",
|
||||
success: true,
|
||||
message: "Bark 通知已发送".to_string(),
|
||||
}),
|
||||
Err(err) => {
|
||||
warn!(error = ?err, "failed to send bark important notification");
|
||||
reports.push(ImportantNotificationChannelReport {
|
||||
channel: "bark",
|
||||
success: false,
|
||||
message: format!("Bark 通知发送失败: {err:?}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn single_report(
|
||||
channel: &'static str,
|
||||
success: bool,
|
||||
@@ -743,8 +809,8 @@ fn escape_html(value: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
apply_notification_item_template, parse_notification_items, parse_recipient_list,
|
||||
ImportantNotification, ImportantNotificationChannelFilter,
|
||||
apply_notification_item_template, parse_channel_filter, parse_notification_items,
|
||||
parse_recipient_list, ImportantNotification, ImportantNotificationChannelFilter,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -785,6 +851,14 @@ mod tests {
|
||||
assert!(items[0].user_email_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_channel_filter_accepts_bark() {
|
||||
assert_eq!(
|
||||
parse_channel_filter("bark"),
|
||||
Some(ImportantNotificationChannelFilter::Bark)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_template_renders_fallback_and_variables() {
|
||||
let items = parse_notification_items(Some(&json!([
|
||||
|
||||
@@ -30,6 +30,7 @@ mod api;
|
||||
mod async_task;
|
||||
mod audit;
|
||||
mod auth;
|
||||
mod bark_push;
|
||||
mod cache;
|
||||
mod client_session_affinity;
|
||||
mod clock;
|
||||
|
||||
@@ -816,6 +816,8 @@ async fn gateway_handles_admin_modules_status_locally_with_trusted_admin_princip
|
||||
payload["server_chan_push"]["admin_route"],
|
||||
"/admin/modules/server-chan"
|
||||
);
|
||||
assert_eq!(payload["bark_push"]["display_name"], "Bark 推送");
|
||||
assert_eq!(payload["bark_push"]["admin_route"], "/admin/modules/bark");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
|
||||
Reference in New Issue
Block a user