refactor: purge Node.js, Rust is the one true backend
- Moved boosapi-rs/* to repo root - Deleted all Node.js source (src/server/, src/web/, scripts/) - Deleted node_modules, package.json, tsconfig, vite.config - Updated .gitignore and AGENTS.md for Rust-only stack - Fixed static file path for frontend dist/web/ - Dockerfile stays the same (already Rust-only) - Kept dist/web/ for Rust static serving
This commit is contained in:
@@ -0,0 +1,544 @@
|
||||
use crate::error::AppResult;
|
||||
use chrono::{FixedOffset, SecondsFormat, TimeZone, Utc};
|
||||
use lettre::{
|
||||
message::Mailbox, transport::smtp::authentication::Credentials, Message, SmtpTransport,
|
||||
Transport,
|
||||
};
|
||||
use reqwest::Url;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sqlx::SqlitePool;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct NotificationService {
|
||||
pub db: Arc<SqlitePool>,
|
||||
pub client: reqwest::Client,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NotificationLevel {
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
Critical,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Notification {
|
||||
pub title: String,
|
||||
pub message: String,
|
||||
pub level: NotificationLevel,
|
||||
pub channel: Option<String>,
|
||||
pub signature: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct NotificationConfig {
|
||||
telegram_bot_token: Option<String>,
|
||||
telegram_chat_id: Option<String>,
|
||||
telegram_thread_id: Option<i64>,
|
||||
wecom_webhook: Option<String>,
|
||||
feishu_webhook: Option<String>,
|
||||
webhook_url: Option<String>,
|
||||
bark_url: Option<String>,
|
||||
serverchan_key: Option<String>,
|
||||
smtp: SmtpConfig,
|
||||
cooldown_sec: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct SmtpConfig {
|
||||
host: Option<String>,
|
||||
port: Option<u16>,
|
||||
user: Option<String>,
|
||||
pass: Option<String>,
|
||||
from: Option<String>,
|
||||
to: Option<String>,
|
||||
}
|
||||
|
||||
impl NotificationService {
|
||||
pub fn new(db: Arc<SqlitePool>) -> Self {
|
||||
Self {
|
||||
db,
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_client(db: Arc<SqlitePool>, client: reqwest::Client) -> Self {
|
||||
Self { db, client }
|
||||
}
|
||||
|
||||
pub async fn send(&self, notif: Notification) -> AppResult<()> {
|
||||
Self::ensure_schema(&self.db).await?;
|
||||
let config = self.load_config().await?;
|
||||
|
||||
if self.should_throttle(¬if, config.cooldown_sec).await? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut channels = self.enabled_channels(&config);
|
||||
if let Some(channel) = notif.channel.as_deref() {
|
||||
channels.retain(|candidate| candidate == &normalize_channel(channel));
|
||||
}
|
||||
|
||||
if channels.is_empty() {
|
||||
tracing::debug!(
|
||||
channel = notif.channel.as_deref().unwrap_or("all"),
|
||||
"notification skipped because no matching channel is configured"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for channel in channels {
|
||||
if let Err(err) = self.send_channel(&channel, ¬if, &config).await {
|
||||
tracing::warn!(%err, channel, "notification channel failed");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_config(&self) -> AppResult<NotificationConfig> {
|
||||
let rows: Vec<(String, Option<String>)> =
|
||||
sqlx::query_as("SELECT key, value FROM settings WHERE key LIKE 'notify_%'")
|
||||
.fetch_all(&*self.db)
|
||||
.await?;
|
||||
let map: HashMap<String, String> = rows
|
||||
.into_iter()
|
||||
.filter_map(|(key, value)| value.map(|value| (key, value)))
|
||||
.filter(|(_, value)| !value.trim().is_empty())
|
||||
.collect();
|
||||
|
||||
Ok(NotificationConfig {
|
||||
telegram_bot_token: setting(&map, "notify_telegram_bot_token"),
|
||||
telegram_chat_id: setting(&map, "notify_telegram_chat_id"),
|
||||
telegram_thread_id: setting(&map, "notify_telegram_thread_id")
|
||||
.and_then(|value| value.parse().ok()),
|
||||
wecom_webhook: setting(&map, "notify_wecom_webhook"),
|
||||
feishu_webhook: setting(&map, "notify_feishu_webhook"),
|
||||
webhook_url: setting(&map, "notify_webhook_url"),
|
||||
bark_url: setting(&map, "notify_bark_url"),
|
||||
serverchan_key: setting(&map, "notify_serverchan_key"),
|
||||
smtp: SmtpConfig {
|
||||
host: setting(&map, "notify_smtp_host"),
|
||||
port: setting(&map, "notify_smtp_port").and_then(|value| value.parse().ok()),
|
||||
user: setting(&map, "notify_smtp_user"),
|
||||
pass: setting(&map, "notify_smtp_pass"),
|
||||
from: setting(&map, "notify_smtp_from"),
|
||||
to: setting(&map, "notify_smtp_to"),
|
||||
},
|
||||
cooldown_sec: setting(&map, "notify_cooldown_sec")
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(60),
|
||||
})
|
||||
}
|
||||
|
||||
async fn should_throttle(&self, notif: &Notification, cooldown_sec: i64) -> AppResult<bool> {
|
||||
let Some(signature) = notif.signature.as_deref().map(str::trim) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if signature.is_empty() || cooldown_sec <= 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let updated = sqlx::query(
|
||||
"UPDATE notification_throttle
|
||||
SET count = count + 1,
|
||||
title = ?,
|
||||
message = ?,
|
||||
level = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE signature = ?
|
||||
AND updated_at >= datetime('now', ?)",
|
||||
)
|
||||
.bind(¬if.title)
|
||||
.bind(¬if.message)
|
||||
.bind(level_name(¬if.level))
|
||||
.bind(signature)
|
||||
.bind(format!("-{} seconds", cooldown_sec))
|
||||
.execute(&*self.db)
|
||||
.await?;
|
||||
|
||||
if updated.rows_affected() > 0 {
|
||||
tracing::info!(signature, "notification merged into cooldown throttle");
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO notification_throttle
|
||||
(signature, title, message, level, count, updated_at)
|
||||
VALUES (?, ?, ?, ?, 1, datetime('now'))
|
||||
ON CONFLICT(signature) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
message = excluded.message,
|
||||
level = excluded.level,
|
||||
count = 1,
|
||||
updated_at = datetime('now')",
|
||||
)
|
||||
.bind(signature)
|
||||
.bind(¬if.title)
|
||||
.bind(¬if.message)
|
||||
.bind(level_name(¬if.level))
|
||||
.execute(&*self.db)
|
||||
.await?;
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn enabled_channels(&self, config: &NotificationConfig) -> Vec<String> {
|
||||
let mut channels = Vec::new();
|
||||
if config.telegram_bot_token.is_some() && config.telegram_chat_id.is_some() {
|
||||
channels.push("telegram".to_string());
|
||||
}
|
||||
if config.wecom_webhook.is_some() {
|
||||
channels.push("wecom".to_string());
|
||||
}
|
||||
if config.feishu_webhook.is_some() {
|
||||
channels.push("feishu".to_string());
|
||||
}
|
||||
if config.webhook_url.is_some() {
|
||||
channels.push("webhook".to_string());
|
||||
}
|
||||
if config.bark_url.is_some() {
|
||||
channels.push("bark".to_string());
|
||||
}
|
||||
if config.serverchan_key.is_some() {
|
||||
channels.push("serverchan".to_string());
|
||||
}
|
||||
if config.smtp.is_configured() {
|
||||
channels.push("smtp".to_string());
|
||||
}
|
||||
channels
|
||||
}
|
||||
|
||||
async fn send_channel(
|
||||
&self,
|
||||
channel: &str,
|
||||
notif: &Notification,
|
||||
config: &NotificationConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
match channel {
|
||||
"telegram" => self.send_telegram(notif, config).await,
|
||||
"wecom" => self.send_wecom(notif, config).await,
|
||||
"feishu" => self.send_feishu(notif, config).await,
|
||||
"webhook" => self.send_webhook(notif, config).await,
|
||||
"bark" => self.send_bark(notif, config).await,
|
||||
"serverchan" => self.send_serverchan(notif, config).await,
|
||||
"smtp" => self.send_smtp(notif, config).await,
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_telegram(
|
||||
&self,
|
||||
notif: &Notification,
|
||||
config: &NotificationConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let token = required(config.telegram_bot_token.as_deref(), "telegram token")?;
|
||||
let chat_id = required(config.telegram_chat_id.as_deref(), "telegram chat id")?;
|
||||
let mut body = json!({
|
||||
"chat_id": chat_id,
|
||||
"text": truncate_chars(&format_notification_text(notif), 3900),
|
||||
});
|
||||
if let Some(thread_id) = config.telegram_thread_id {
|
||||
body["message_thread_id"] = json!(thread_id);
|
||||
}
|
||||
self.client
|
||||
.post(format!("https://api.telegram.org/bot{token}/sendMessage"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_wecom(
|
||||
&self,
|
||||
notif: &Notification,
|
||||
config: &NotificationConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let url = required(config.wecom_webhook.as_deref(), "wecom webhook")?;
|
||||
self.client
|
||||
.post(url)
|
||||
.json(&json!({
|
||||
"msgtype": "text",
|
||||
"text": { "content": format_notification_text(notif) },
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_feishu(
|
||||
&self,
|
||||
notif: &Notification,
|
||||
config: &NotificationConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let url = required(config.feishu_webhook.as_deref(), "feishu webhook")?;
|
||||
self.client
|
||||
.post(url)
|
||||
.json(&json!({
|
||||
"msg_type": "text",
|
||||
"content": { "text": format_notification_text(notif) },
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_webhook(
|
||||
&self,
|
||||
notif: &Notification,
|
||||
config: &NotificationConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let url = required(config.webhook_url.as_deref(), "webhook url")?;
|
||||
self.client
|
||||
.post(url)
|
||||
.json(&json!({
|
||||
"title": ¬if.title,
|
||||
"message": ¬if.message,
|
||||
"level": level_name(¬if.level),
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_bark(
|
||||
&self,
|
||||
notif: &Notification,
|
||||
config: &NotificationConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let base = required(config.bark_url.as_deref(), "bark url")?;
|
||||
let mut url = Url::parse(base)?;
|
||||
{
|
||||
let mut segments = url
|
||||
.path_segments_mut()
|
||||
.map_err(|_| anyhow::anyhow!("bark url cannot be a base url"))?;
|
||||
segments.pop_if_empty();
|
||||
segments.push(¬if.title);
|
||||
segments.push(¬if.message);
|
||||
}
|
||||
self.client.get(url).send().await?.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_serverchan(
|
||||
&self,
|
||||
notif: &Notification,
|
||||
config: &NotificationConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let key = required(config.serverchan_key.as_deref(), "serverchan key")?;
|
||||
self.client
|
||||
.post(format!("https://sctapi.ftqq.com/{key}.send"))
|
||||
.form(&[
|
||||
("title", notif.title.as_str()),
|
||||
("desp", notif.message.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_smtp(
|
||||
&self,
|
||||
notif: &Notification,
|
||||
config: &NotificationConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let smtp = config.smtp.clone();
|
||||
let notif = notif.clone();
|
||||
tokio::task::spawn_blocking(move || smtp.send(¬if)).await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_schema(db: &SqlitePool) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS notification_throttle (
|
||||
signature TEXT PRIMARY KEY NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl SmtpConfig {
|
||||
fn is_configured(&self) -> bool {
|
||||
self.host.is_some()
|
||||
&& self.port.is_some()
|
||||
&& self.user.is_some()
|
||||
&& self.pass.is_some()
|
||||
&& self.from.is_some()
|
||||
}
|
||||
|
||||
fn send(self, notif: &Notification) -> anyhow::Result<()> {
|
||||
let host = required(self.host.as_deref(), "smtp host")?;
|
||||
let port = self.port.unwrap_or(587);
|
||||
let user = required(self.user.as_deref(), "smtp user")?;
|
||||
let pass = required(self.pass.as_deref(), "smtp pass")?;
|
||||
let from = required(self.from.as_deref(), "smtp from")?;
|
||||
let to = self.to.as_deref().unwrap_or(from);
|
||||
|
||||
let email = Message::builder()
|
||||
.from(from.parse::<Mailbox>()?)
|
||||
.to(to.parse::<Mailbox>()?)
|
||||
.subject(format!(
|
||||
"[BoosAPI][{}] {}",
|
||||
level_name(¬if.level),
|
||||
notif.title
|
||||
))
|
||||
.body(format_notification_text(notif))?;
|
||||
|
||||
let mailer = SmtpTransport::relay(host)?
|
||||
.port(port)
|
||||
.credentials(Credentials::new(user.to_string(), pass.to_string()))
|
||||
.build();
|
||||
mailer.send(&email)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn setting(map: &HashMap<String, String>, key: &str) -> Option<String> {
|
||||
map.get(key).map(|value| value.trim().to_string())
|
||||
}
|
||||
|
||||
fn required<'a>(value: Option<&'a str>, name: &str) -> anyhow::Result<&'a str> {
|
||||
value.ok_or_else(|| anyhow::anyhow!("{name} is not configured"))
|
||||
}
|
||||
|
||||
fn normalize_channel(channel: &str) -> String {
|
||||
channel.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn level_name(level: &NotificationLevel) -> &'static str {
|
||||
match level {
|
||||
NotificationLevel::Info => "info",
|
||||
NotificationLevel::Warning => "warning",
|
||||
NotificationLevel::Error => "error",
|
||||
NotificationLevel::Critical => "critical",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_notification_text(notif: &Notification) -> String {
|
||||
let utc = Utc::now();
|
||||
let shanghai = FixedOffset::east_opt(8 * 3600)
|
||||
.expect("valid Shanghai offset")
|
||||
.from_utc_datetime(&utc.naive_utc());
|
||||
format!(
|
||||
"[BoosAPI][{}] {}\n\n{}\n\nLevel: {}\nLocal Time: {} (Asia/Shanghai)\nUTC Time: {}",
|
||||
level_name(¬if.level).to_ascii_uppercase(),
|
||||
notif.title,
|
||||
notif.message,
|
||||
level_name(¬if.level),
|
||||
shanghai.format("%Y-%m-%d %H:%M:%S"),
|
||||
utc.to_rfc3339_opts(SecondsFormat::Millis, true)
|
||||
)
|
||||
}
|
||||
|
||||
fn truncate_chars(value: &str, max_chars: usize) -> String {
|
||||
if value.chars().count() <= max_chars {
|
||||
return value.to_string();
|
||||
}
|
||||
value.chars().take(max_chars).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
async fn pool() -> SqlitePool {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("CREATE TABLE settings (key TEXT PRIMARY KEY NOT NULL, value TEXT)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
pool
|
||||
}
|
||||
|
||||
async fn set(pool: &SqlitePool, key: &str, value: &str) {
|
||||
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||
.bind(key)
|
||||
.bind(value)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skips_when_no_channel_is_configured() {
|
||||
let pool = pool().await;
|
||||
let service = NotificationService::new(Arc::new(pool));
|
||||
service
|
||||
.send(Notification {
|
||||
title: "Disk".to_string(),
|
||||
message: "Almost full".to_string(),
|
||||
level: NotificationLevel::Warning,
|
||||
channel: Some("webhook".to_string()),
|
||||
signature: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn throttles_duplicate_signature() {
|
||||
let pool = pool().await;
|
||||
set(&pool, "notify_cooldown_sec", "60").await;
|
||||
|
||||
let service = NotificationService::new(Arc::new(pool));
|
||||
let notification = Notification {
|
||||
title: "Route down".to_string(),
|
||||
message: "primary failed".to_string(),
|
||||
level: NotificationLevel::Error,
|
||||
channel: Some("webhook".to_string()),
|
||||
signature: Some("route:1".to_string()),
|
||||
};
|
||||
service.send(notification.clone()).await.unwrap();
|
||||
service.send(notification).await.unwrap();
|
||||
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT count FROM notification_throttle WHERE signature = 'route:1'",
|
||||
)
|
||||
.fetch_one(&*service.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_telegram_compatible_text() {
|
||||
let text = format_notification_text(&Notification {
|
||||
title: "Title".to_string(),
|
||||
message: "Message".to_string(),
|
||||
level: NotificationLevel::Critical,
|
||||
channel: None,
|
||||
signature: None,
|
||||
});
|
||||
|
||||
assert!(text.starts_with("[BoosAPI][CRITICAL] Title\n\nMessage"));
|
||||
assert!(text.contains("Local Time:"));
|
||||
assert!(text.contains("UTC Time:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_by_char_count() {
|
||||
assert_eq!(truncate_chars("abcdef", 3), "abc");
|
||||
assert_eq!(truncate_chars("短消息", 2), "短消");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user