diff --git a/config.example.toml b/config.example.toml index c76479a..5c16c03 100644 --- a/config.example.toml +++ b/config.example.toml @@ -70,3 +70,8 @@ embed_color = "ffb800" [logs] show_log_on_edit = true show_log_on_delete = true + +[updates] +enabled = true +check_interval_hours = 6 +notify_channel_id = 14043597305635899 diff --git a/crates/rustmail/src/api/handler/bot/config.rs b/crates/rustmail/src/api/handler/bot/config.rs index 2364386..91ff7d9 100644 --- a/crates/rustmail/src/api/handler/bot/config.rs +++ b/crates/rustmail/src/api/handler/bot/config.rs @@ -42,6 +42,7 @@ pub async fn handle_get_config( notifications: config.notifications.clone(), reminders: config.reminders.clone(), logs: config.logs.clone(), + updates: config.updates.clone(), }; Ok(Json(response)) @@ -83,6 +84,7 @@ pub async fn handle_update_config( notifications: update.notifications, reminders: update.reminders, logs: update.logs, + updates: update.updates, db_pool: None, error_handler: None, thread_locks: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), diff --git a/crates/rustmail/src/api/handler/bot/mod.rs b/crates/rustmail/src/api/handler/bot/mod.rs index 9f9a718..d45b019 100644 --- a/crates/rustmail/src/api/handler/bot/mod.rs +++ b/crates/rustmail/src/api/handler/bot/mod.rs @@ -6,6 +6,7 @@ pub mod statistics; pub mod status; pub mod stop; pub mod tickets; +pub mod version; pub use config::*; pub use profile::*; @@ -15,3 +16,4 @@ pub use statistics::*; pub use status::*; pub use stop::*; pub use tickets::*; +pub use version::*; diff --git a/crates/rustmail/src/api/handler/bot/version.rs b/crates/rustmail/src/api/handler/bot/version.rs new file mode 100644 index 0000000..4fc73d8 --- /dev/null +++ b/crates/rustmail/src/api/handler/bot/version.rs @@ -0,0 +1,59 @@ +use crate::modules::update_checker::{ + CURRENT_VERSION, LAST_UPDATE_CHECK_KEY, LATEST_KNOWN_VERSION_KEY, LATEST_RELEASE_URL_KEY, + is_newer, +}; +use crate::prelude::db::*; +use crate::prelude::types::*; +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use rustmail_types::VersionInfo; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub async fn handle_get_version( + State(bot_state): State>>, +) -> Result, StatusCode> { + let (pool, check_enabled) = { + let state = bot_state.lock().await; + + let check_enabled = state + .config + .as_ref() + .map(|c| c.updates.enabled) + .unwrap_or(false); + + (state.db_pool.clone(), check_enabled) + }; + + let Some(pool) = pool else { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + }; + + let latest = get_system_metadata(LATEST_KNOWN_VERSION_KEY, &pool) + .await + .ok() + .flatten(); + let release_url = get_system_metadata(LATEST_RELEASE_URL_KEY, &pool) + .await + .ok() + .flatten(); + let last_checked = get_system_metadata(LAST_UPDATE_CHECK_KEY, &pool) + .await + .ok() + .flatten(); + + let update_available = latest + .as_deref() + .map(|latest| is_newer(latest, CURRENT_VERSION)) + .unwrap_or(false); + + Ok(Json(VersionInfo { + current: CURRENT_VERSION.to_string(), + latest, + update_available, + release_url, + check_enabled, + last_checked, + })) +} diff --git a/crates/rustmail/src/api/routes/bot.rs b/crates/rustmail/src/api/routes/bot.rs index 281393c..c383df4 100644 --- a/crates/rustmail/src/api/routes/bot.rs +++ b/crates/rustmail/src/api/routes/bot.rs @@ -35,6 +35,7 @@ pub fn create_bot_router(bot_state: Arc>) -> Router = Pin + Send + 'a>>; diff --git a/crates/rustmail/src/commands/version/common.rs b/crates/rustmail/src/commands/version/common.rs new file mode 100644 index 0000000..3c75206 --- /dev/null +++ b/crates/rustmail/src/commands/version/common.rs @@ -0,0 +1,20 @@ +use crate::prelude::config::*; +use crate::prelude::i18n::*; +use crate::prelude::modules::*; +use std::collections::HashMap; + +pub async fn build_version_content(config: &Config) -> String { + let mut params = HashMap::new(); + params.insert("version".to_string(), CURRENT_VERSION.to_string()); + params.insert("repository".to_string(), REPOSITORY_URL.to_string()); + + get_translated_message( + config, + "version_command.current", + Some(¶ms), + None, + None, + None, + ) + .await +} diff --git a/crates/rustmail/src/commands/version/mod.rs b/crates/rustmail/src/commands/version/mod.rs new file mode 100644 index 0000000..e08ac47 --- /dev/null +++ b/crates/rustmail/src/commands/version/mod.rs @@ -0,0 +1,7 @@ +pub mod common; +pub mod slash_command; +pub mod text_command; + +pub use common::*; +pub use slash_command::*; +pub use text_command::*; diff --git a/crates/rustmail/src/commands/version/slash_command/mod.rs b/crates/rustmail/src/commands/version/slash_command/mod.rs new file mode 100644 index 0000000..9d363c5 --- /dev/null +++ b/crates/rustmail/src/commands/version/slash_command/mod.rs @@ -0,0 +1,3 @@ +pub mod version; + +pub use version::*; diff --git a/crates/rustmail/src/commands/version/slash_command/version.rs b/crates/rustmail/src/commands/version/slash_command/version.rs new file mode 100644 index 0000000..d6dc542 --- /dev/null +++ b/crates/rustmail/src/commands/version/slash_command/version.rs @@ -0,0 +1,75 @@ +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; +use serenity::FutureExt; +use serenity::all::{CommandInteraction, CommandType, Context, CreateCommand, ResolvedOption}; +use std::sync::Arc; + +pub struct VersionCommand; + +#[async_trait::async_trait] +impl RegistrableCommand for VersionCommand { + fn as_community(&self) -> Option<&dyn CommunityRegistrable> { + None + } + + fn name(&self) -> &'static str { + "version" + } + + fn doc<'a>(&self, config: &'a Config) -> BoxFuture<'a, String> { + async move { get_translated_message(config, "help.version", None, None, None, None).await } + .boxed() + } + + fn register(&self, config: &Config) -> BoxFuture<'_, Vec> { + let config = config.clone(); + + Box::pin(async move { + let cmd_desc = get_translated_message( + &config, + "slash_command.version_command_desc", + None, + None, + None, + None, + ) + .await; + + vec![ + CreateCommand::new(self.name()).description(cmd_desc), + CreateCommand::new(self.name()).kind(CommandType::User), + ] + }) + } + + fn run( + &self, + ctx: &Context, + command: &CommandInteraction, + _options: &[ResolvedOption<'_>], + config: &Config, + _handler: Arc, + ) -> BoxFuture<'_, ModmailResult<()>> { + let ctx = ctx.clone(); + let command = command.clone(); + let config = config.clone(); + + Box::pin(async move { + defer_response(&ctx, &command).await?; + + let content = build_version_content(&config).await; + + let _ = MessageBuilder::system_message(&ctx, &config) + .content(content) + .to_channel(command.channel_id) + .send_interaction_followup(&command, false) + .await; + + Ok(()) + }) + } +} diff --git a/crates/rustmail/src/commands/version/text_command/mod.rs b/crates/rustmail/src/commands/version/text_command/mod.rs new file mode 100644 index 0000000..9d363c5 --- /dev/null +++ b/crates/rustmail/src/commands/version/text_command/mod.rs @@ -0,0 +1,3 @@ +pub mod version; + +pub use version::*; diff --git a/crates/rustmail/src/commands/version/text_command/version.rs b/crates/rustmail/src/commands/version/text_command/version.rs new file mode 100644 index 0000000..6e926b6 --- /dev/null +++ b/crates/rustmail/src/commands/version/text_command/version.rs @@ -0,0 +1,24 @@ +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::utils::*; +use serenity::all::{Context, Message}; +use std::sync::Arc; + +pub async fn version( + ctx: Context, + msg: Message, + config: &Config, + _handler: Arc, +) -> ModmailResult<()> { + let content = build_version_content(config).await; + + let _ = MessageBuilder::system_message(&ctx, config) + .content(content) + .to_channel(msg.channel_id) + .send(false) + .await; + + Ok(()) +} diff --git a/crates/rustmail/src/config.rs b/crates/rustmail/src/config.rs index aee35c5..1bcfdff 100644 --- a/crates/rustmail/src/config.rs +++ b/crates/rustmail/src/config.rs @@ -18,6 +18,7 @@ pub struct Config { pub notifications: NotificationsConfig, pub reminders: ReminderConfig, pub logs: LogsConfig, + pub updates: UpdateConfig, pub db_pool: Option, pub error_handler: Option>, @@ -147,6 +148,7 @@ pub fn load_config(path: &str) -> Option { notifications: config_response.notifications, reminders: config_response.reminders, logs: config_response.logs, + updates: config_response.updates, db_pool: None, error_handler: Some(error_handler), thread_locks: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), @@ -169,6 +171,10 @@ pub fn validate_config(config: &Config) -> Result<(), String> { config.bot.validate_logs_config()?; config.bot.validate_features_config()?; + if config.updates.check_interval_hours == 0 { + return Err("Update check interval must be at least 1 hour".to_string()); + } + if !config .language .is_language_supported(config.language.get_default_language()) @@ -197,6 +203,7 @@ pub async fn save_config_with_backup(config: &Config, path: &str) -> Result<(), notifications: config.notifications.clone(), reminders: config.reminders.clone(), logs: config.logs.clone(), + updates: config.updates.clone(), }; let toml_content = toml::to_string_pretty(&config_response) @@ -274,3 +281,96 @@ impl Config { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + const CONFIG_WITHOUT_UPDATES: &str = r#" +[bot] +token = "token" +status = "status" +welcome_message = "welcome" +close_message = "close" +typing_proxy_from_user = true +typing_proxy_from_staff = true +enable_rustmail_logs = false +enable_discord_logs = false +enable_features = false +enable_panel = true +client_id = 1 +client_secret = "secret" +redirect_url = "http://localhost/api/auth/callback" + +[bot.mode] +type = "single" +guild_id = 1 + +[command] +prefix = "!" + +[thread] +inbox_category_id = 1 +embedded_message = true +user_message_color = "5865F2" +staff_message_color = "ED4245" +system_message_color = "FEE75C" +block_quote = true +time_to_close_thread = 0 +create_ticket_by_create_channel = false +close_on_leave = true +auto_archive_duration = 1440 + +[language] +default_language = "en" +fallback_language = "en" +supported_languages = ["en"] + +[error_handling] +show_detailed_errors = true +log_errors = true +send_error_embeds = true +auto_delete_error_messages = false +display_errors = true + +[notifications] +show_success_on_edit = true +show_partial_success_on_edit = true +show_failure_on_edit = true +show_success_on_reply = true +show_success_on_delete = true +show_success = true +show_error = true + +[reminders] +embed_color = "ffcc00" + +[logs] +show_log_on_edit = true +show_log_on_delete = true +"#; + + #[test] + fn config_without_updates_section_uses_defaults() { + let config: ConfigResponse = + toml::from_str(CONFIG_WITHOUT_UPDATES).expect("config without [updates] must parse"); + + assert_eq!(config.updates, UpdateConfig::default()); + assert!(config.updates.enabled); + assert_eq!(config.updates.check_interval_hours, 6); + assert_eq!(config.updates.notify_channel_id, None); + } + + #[test] + fn updates_section_round_trips_through_toml() { + let mut config: ConfigResponse = toml::from_str(CONFIG_WITHOUT_UPDATES).unwrap(); + config.updates.enabled = false; + config.updates.check_interval_hours = 24; + config.updates.notify_channel_id = Some(42); + + let serialized = toml::to_string_pretty(&config).expect("config must serialize"); + let reparsed: ConfigResponse = toml::from_str(&serialized).expect("config must reparse"); + + assert_eq!(reparsed.updates, config.updates); + } +} diff --git a/crates/rustmail/src/handlers/guild_messages_handler.rs b/crates/rustmail/src/handlers/guild_messages_handler.rs index cb8c26b..f6bacd8 100644 --- a/crates/rustmail/src/handlers/guild_messages_handler.rs +++ b/crates/rustmail/src/handlers/guild_messages_handler.rs @@ -87,6 +87,7 @@ impl GuildMessagesHandler { wrap_command!(lock, "category", category_command); wrap_command!(lock, ["rename", "rn"], rename_ticket); wrap_command!(lock, ["baninfo", "bi"], baninfo); + wrap_command!(lock, ["version", "v"], version); drop(lock); h diff --git a/crates/rustmail/src/handlers/ready_handler.rs b/crates/rustmail/src/handlers/ready_handler.rs index 624aa14..01e2ce7 100644 --- a/crates/rustmail/src/handlers/ready_handler.rs +++ b/crates/rustmail/src/handlers/ready_handler.rs @@ -24,6 +24,7 @@ pub struct ReadyHandler { pub shutdown: Arc>, pub bot_state: Arc>, backfill_started: Arc, + update_checker_started: Arc, } impl ReadyHandler { @@ -39,6 +40,7 @@ impl ReadyHandler { shutdown: Arc::new(shutdown), bot_state, backfill_started: Arc::new(AtomicBool::new(false)), + update_checker_started: Arc::new(AtomicBool::new(false)), } } } @@ -95,6 +97,23 @@ impl EventHandler for ReadyHandler { }); } + if self.config.updates.enabled + && self + .update_checker_started + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + tokio::spawn({ + let ctx = ctx.clone(); + let config = config.clone(); + let shutdown = (*self.shutdown).clone(); + + async move { + run_update_checker(ctx, config, shutdown).await; + } + }); + } + load_reminders(&ctx, &self.config, &pool.clone(), self.shutdown.clone()).await; update_threads_status(&ctx, &pool.clone()); diff --git a/crates/rustmail/src/i18n/language/en.rs b/crates/rustmail/src/i18n/language/en.rs index 653fa2c..41bb809 100644 --- a/crates/rustmail/src/i18n/language/en.rs +++ b/crates/rustmail/src/i18n/language/en.rs @@ -1068,6 +1068,12 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { "help.ping".to_string(), DictionaryMessage::new("Shows the actual latency of the bot."), ); + dict.messages.insert( + "help.version".to_string(), + DictionaryMessage::new( + "Shows the Rustmail version currently running. Use `!version` or `!v`.", + ), + ); dict.messages.insert( "add_reminder.helper".to_string(), DictionaryMessage::new( @@ -1219,6 +1225,20 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { "slash_command.ping_command_desc".to_string(), DictionaryMessage::new("Check the Discord bot latency."), ); + dict.messages.insert( + "slash_command.version_command_desc".to_string(), + DictionaryMessage::new("Show the Rustmail version currently running."), + ); + dict.messages.insert( + "version_command.current".to_string(), + DictionaryMessage::new( + "## Rustmail\n\nRunning version: **v{version}**\nSource code: {repository}", + ), + ); + dict.messages.insert( + "update.available".to_string(), + DictionaryMessage::new("-# A new Rustmail version is available: **{latest}** (currently running **v{current}**) - {url}"), + ); dict.messages.insert( "slash_command.ping_command".to_string(), DictionaryMessage::new("## Latency\n\nGateway latency: **{gateway_latency}** ms\nMinimal REST latency (GET /gateway): **{api_latency}** ms\nREST latency (message send): **{message_latency}** ms"), diff --git a/crates/rustmail/src/i18n/language/fr.rs b/crates/rustmail/src/i18n/language/fr.rs index e74f783..58b0b5d 100644 --- a/crates/rustmail/src/i18n/language/fr.rs +++ b/crates/rustmail/src/i18n/language/fr.rs @@ -1084,6 +1084,12 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { "help.ping".to_string(), DictionaryMessage::new("Permet d'afficher la latence actuelle du bot."), ); + dict.messages.insert( + "help.version".to_string(), + DictionaryMessage::new( + "Affiche la version de Rustmail actuellement en cours d'exécution. Utilisez `!version` ou `!v`.", + ), + ); dict.messages.insert( "add_reminder.helper".to_string(), DictionaryMessage::new("Format incorrect. Utilisation : `{prefix}remind ou {prefix}rem [contenu du rappel]`"), @@ -1228,6 +1234,20 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { "slash_command.ping_command_desc".to_string(), DictionaryMessage::new("Afficher la latence actuelle du bot."), ); + dict.messages.insert( + "slash_command.version_command_desc".to_string(), + DictionaryMessage::new( + "Afficher la version de Rustmail actuellement en cours d'exécution.", + ), + ); + dict.messages.insert( + "version_command.current".to_string(), + DictionaryMessage::new("## Rustmail\n\nVersion en cours d'exécution : **v{version}**\nCode source : {repository}"), + ); + dict.messages.insert( + "update.available".to_string(), + DictionaryMessage::new("-# Une nouvelle version de Rustmail est disponible : **{latest}** (version actuelle **v{current}**) - {url}"), + ); dict.messages.insert( "slash_command.ping_command".to_string(), DictionaryMessage::new("## Latence\n\nLatence Gateway : **{gateway_latency}** ms.\nLatence REST minimale (GET /gateway) : **{api_latency}** ms.\nLatence REST (envoi d'un message) : **{message_latency}** ms."), diff --git a/crates/rustmail/src/main.rs b/crates/rustmail/src/main.rs index 05aad13..317ecbe 100644 --- a/crates/rustmail/src/main.rs +++ b/crates/rustmail/src/main.rs @@ -15,7 +15,7 @@ use std::{env, process}; use tokio::signal; use tower_http::compression::CompressionLayer; -const VERSION: &str = env!("CARGO_PKG_VERSION"); +use crate::modules::update_checker::CURRENT_VERSION as VERSION; mod api; mod bot; diff --git a/crates/rustmail/src/modules/mod.rs b/crates/rustmail/src/modules/mod.rs index 689f5f1..b1f5744 100644 --- a/crates/rustmail/src/modules/mod.rs +++ b/crates/rustmail/src/modules/mod.rs @@ -5,6 +5,7 @@ pub mod reminders; pub mod scheduled_closures; pub mod threads; pub mod threads_status; +pub mod update_checker; pub use categories::*; pub use commands::*; @@ -13,3 +14,4 @@ pub use reminders::*; pub use scheduled_closures::*; pub use threads::*; pub use threads_status::*; +pub use update_checker::*; diff --git a/crates/rustmail/src/modules/update_checker.rs b/crates/rustmail/src/modules/update_checker.rs new file mode 100644 index 0000000..46e716c --- /dev/null +++ b/crates/rustmail/src/modules/update_checker.rs @@ -0,0 +1,191 @@ +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::utils::*; +use serde::Deserialize; +use serenity::all::{ChannelId, Context}; +use std::collections::HashMap; +use std::time::Duration; +use tokio::sync::watch::Receiver; +use tokio::time::interval; + +pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub const REPOSITORY_URL: &str = "https://github.com/Rustmail/rustmail"; + +const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/Rustmail/rustmail/releases/latest"; + +pub const LATEST_KNOWN_VERSION_KEY: &str = "latest_known_version"; +pub const LATEST_RELEASE_URL_KEY: &str = "latest_release_url"; +pub const LAST_UPDATE_CHECK_KEY: &str = "last_update_check"; +const ANNOUNCED_UPDATE_VERSION_KEY: &str = "announced_update_version"; + +#[derive(Debug, Deserialize)] +struct GithubRelease { + tag_name: String, + html_url: String, +} + +fn parse_version(version: &str) -> Option<(u64, u64, u64)> { + let version = version.trim(); + let version = version.strip_prefix('v').unwrap_or(version); + let version = version.split(['-', '+']).next()?; + + let mut parts = version.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next().unwrap_or("0").parse().ok()?; + let patch = parts.next().unwrap_or("0").parse().ok()?; + + if parts.next().is_some() { + return None; + } + + Some((major, minor, patch)) +} + +pub fn is_newer(latest: &str, current: &str) -> bool { + match (parse_version(latest), parse_version(current)) { + (Some(latest), Some(current)) => latest > current, + _ => false, + } +} + +async fn fetch_latest_release() -> Option { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|e| eprintln!("Update check: failed to build HTTP client: {}", e)) + .ok()?; + + let response = client + .get(LATEST_RELEASE_URL) + .header("User-Agent", format!("rustmail/{}", CURRENT_VERSION)) + .header("Accept", "application/vnd.github+json") + .send() + .await + .map_err(|e| eprintln!("Update check: request to GitHub failed: {}", e)) + .ok()?; + + if !response.status().is_success() { + eprintln!( + "Update check: GitHub returned status {}", + response.status().as_u16() + ); + return None; + } + + response + .json::() + .await + .map_err(|e| eprintln!("Update check: invalid response from GitHub: {}", e)) + .ok() +} + +async fn announce_update(ctx: &Context, config: &Config, latest: &str, release_url: &str) { + let channel_id = config + .updates + .notify_channel_id + .or(config.bot.logs_channel_id); + + let Some(channel_id) = channel_id else { + println!( + "A new Rustmail version is available: {} (current: {}) - {}", + latest, CURRENT_VERSION, release_url + ); + return; + }; + + let mut params = HashMap::new(); + params.insert("current".to_string(), CURRENT_VERSION.to_string()); + params.insert("latest".to_string(), latest.to_string()); + params.insert("url".to_string(), release_url.to_string()); + + if let Err(e) = MessageBuilder::system_message(ctx, config) + .translated_content("update.available", Some(¶ms), None, None) + .await + .to_channel(ChannelId::new(channel_id)) + .send(false) + .await + { + eprintln!("Update check: failed to announce new version: {}", e); + } +} + +async fn check_once(ctx: &Context, config: &Config) { + let Some(pool) = config.db_pool.as_ref() else { + eprintln!("Update check: database pool is not set, skipping."); + return; + }; + + let Some(release) = fetch_latest_release().await else { + return; + }; + + let _ = set_system_metadata(LATEST_KNOWN_VERSION_KEY, &release.tag_name, pool).await; + let _ = set_system_metadata(LATEST_RELEASE_URL_KEY, &release.html_url, pool).await; + let _ = set_system_metadata( + LAST_UPDATE_CHECK_KEY, + &chrono::Utc::now().to_rfc3339(), + pool, + ) + .await; + + if !is_newer(&release.tag_name, CURRENT_VERSION) { + return; + } + + let already_announced = get_system_metadata(ANNOUNCED_UPDATE_VERSION_KEY, pool) + .await + .ok() + .flatten(); + + if already_announced.as_deref() == Some(release.tag_name.as_str()) { + return; + } + + announce_update(ctx, config, &release.tag_name, &release.html_url).await; + + let _ = set_system_metadata(ANNOUNCED_UPDATE_VERSION_KEY, &release.tag_name, pool).await; +} + +pub async fn run_update_checker(ctx: Context, config: Config, mut shutdown: Receiver) { + let period = Duration::from_secs(config.updates.check_interval_hours.max(1) * 3600); + let mut ticker = interval(period); + + loop { + tokio::select! { + _ = shutdown.changed() => { + if *shutdown.borrow() { + break; + } + } + _ = ticker.tick() => { + check_once(&ctx, &config).await; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_version() { + assert_eq!(parse_version("1.1.0"), Some((1, 1, 0))); + assert_eq!(parse_version("v1.1.0"), Some((1, 1, 0))); + assert_eq!(parse_version("v1.2"), Some((1, 2, 0))); + assert_eq!(parse_version("1.2.3-rc.1"), Some((1, 2, 3))); + assert_eq!(parse_version("nightly"), None); + assert_eq!(parse_version("1.2.3.4"), None); + } + + #[test] + fn test_is_newer() { + assert!(is_newer("v1.2.0", "1.1.0")); + assert!(is_newer("1.10.0", "1.9.0")); + assert!(is_newer("2.0.0", "1.99.99")); + assert!(!is_newer("v1.1.0", "1.1.0")); + assert!(!is_newer("1.0.32", "1.1.0")); + assert!(!is_newer("not-a-version", "1.1.0")); + } +} diff --git a/crates/rustmail/src/setup/handlers/save.rs b/crates/rustmail/src/setup/handlers/save.rs index 5884cc9..44d9979 100644 --- a/crates/rustmail/src/setup/handlers/save.rs +++ b/crates/rustmail/src/setup/handlers/save.rs @@ -6,7 +6,7 @@ use axum::http::StatusCode; use axum::response::IntoResponse; use rustmail_types::{ BotConfig, CommandConfig, ErrorHandlingConfig, LanguageConfig, LogsConfig, NotificationsConfig, - ReminderConfig, ServerMode, ThreadConfig, + ReminderConfig, ServerMode, ThreadConfig, UpdateConfig, }; use serde::Deserialize; use std::sync::Arc; @@ -49,6 +49,12 @@ pub struct SaveConfigRequest { pub default_language: String, pub fallback_language: String, pub timezone: String, + #[serde(default = "default_check_updates")] + pub check_updates: bool, +} + +fn default_check_updates() -> bool { + true } pub async fn handle_setup_save( @@ -166,6 +172,10 @@ pub async fn handle_setup_save( notifications: NotificationsConfig::default(), reminders: ReminderConfig::default(), logs: LogsConfig::default(), + updates: UpdateConfig { + enabled: payload.check_updates, + ..UpdateConfig::default() + }, db_pool: None, error_handler: None, thread_locks: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), diff --git a/crates/rustmail_panel/src/components/configuration.rs b/crates/rustmail_panel/src/components/configuration.rs index fa7d909..506465f 100644 --- a/crates/rustmail_panel/src/components/configuration.rs +++ b/crates/rustmail_panel/src/components/configuration.rs @@ -21,7 +21,7 @@ pub fn configuration_page() -> Html { let save_message = use_state(|| None::<(bool, String)>); let expanded_sections = - use_state(|| vec![true, false, false, false, false, false, false, false]); + use_state(|| vec![true, false, false, false, false, false, false, false, false]); let permissions = use_state(|| None::>); { @@ -532,6 +532,17 @@ fn config_form(props: &ConfigFormProps) -> Html { > + + + + {if has_changes && !*is_saving { @@ -1644,6 +1655,76 @@ fn logs_reminder_section(props: &LogsReminderSectionProps) -> Html { } } +#[derive(Properties, PartialEq)] +struct UpdatesSectionProps { + config: UseStateHandle, +} + +#[function_component(UpdatesSection)] +fn updates_section(props: &UpdatesSectionProps) -> Html { + let (i18n, _set_language) = use_translation(); + let config = props.config.clone(); + + html! { +
+ + + () + && hours > 0 + { + let mut cfg = (*config).clone(); + cfg.updates.check_interval_hours = hours; + config.set(cfg); + } + }) + }} + /> + + ().ok().map(Some) + }; + + if let Some(channel_id) = parsed { + let mut cfg = (*config).clone(); + cfg.updates.notify_channel_id = channel_id; + config.set(cfg); + } + }) + }} + /> +
+ } +} + #[derive(Properties, PartialEq)] struct DiscordProfileInlineSectionProps { bot_status: String, diff --git a/crates/rustmail_panel/src/components/mod.rs b/crates/rustmail_panel/src/components/mod.rs index f9391d3..3892f7c 100644 --- a/crates/rustmail_panel/src/components/mod.rs +++ b/crates/rustmail_panel/src/components/mod.rs @@ -10,4 +10,5 @@ pub mod navbar; pub mod setup_detector; pub mod statistics; pub mod ticket; +pub mod update_banner; pub mod wizard; diff --git a/crates/rustmail_panel/src/components/update_banner.rs b/crates/rustmail_panel/src/components/update_banner.rs new file mode 100644 index 0000000..69d8f50 --- /dev/null +++ b/crates/rustmail_panel/src/components/update_banner.rs @@ -0,0 +1,108 @@ +use crate::i18n::yew::use_translation; +use gloo_net::http::Request; +use rustmail_types::VersionInfo; +use wasm_bindgen_futures::spawn_local; +use yew::prelude::*; + +const DISMISSED_KEY: &str = "rustmail_dismissed_update"; + +fn dismissed_version() -> Option { + web_sys::window()? + .local_storage() + .ok() + .flatten()? + .get_item(DISMISSED_KEY) + .ok() + .flatten() +} + +fn dismiss_version(version: &str) { + if let Some(Ok(Some(storage))) = web_sys::window().map(|w| w.local_storage()) { + let _ = storage.set_item(DISMISSED_KEY, version); + } +} + +#[function_component(UpdateBanner)] +pub fn update_banner() -> Html { + let (i18n, _set_language) = use_translation(); + + let version_info = use_state(|| None::); + let dismissed = use_state(dismissed_version); + + { + let version_info = version_info.clone(); + use_effect_with((), move |_| { + spawn_local(async move { + if let Ok(resp) = Request::get("/api/bot/version").send().await + && resp.ok() + && let Ok(info) = resp.json::().await + { + version_info.set(Some(info)); + } + }); + || () + }); + } + + let Some(info) = (*version_info).clone() else { + return html! {}; + }; + + if !info.check_enabled || !info.update_available { + return html! {}; + } + + let Some(latest) = info.latest.clone() else { + return html! {}; + }; + + if (*dismissed).as_deref() == Some(latest.as_str()) { + return html! {}; + } + + let on_dismiss = { + let dismissed = dismissed.clone(); + let latest = latest.clone(); + Callback::from(move |_| { + dismiss_version(&latest); + dismissed.set(Some(latest.clone())); + }) + }; + + let release_url = info + .release_url + .clone() + .unwrap_or_else(|| "https://github.com/Rustmail/rustmail/releases".to_string()); + + html! { +
+
+

+ { i18n.t("panel.update.available") } + + { format!("v{} → {}", info.current, latest) } + +

+ + +
+
+ } +} diff --git a/crates/rustmail_panel/src/components/wizard/step5_language.rs b/crates/rustmail_panel/src/components/wizard/step5_language.rs index 5429740..a25ee2c 100644 --- a/crates/rustmail_panel/src/components/wizard/step5_language.rs +++ b/crates/rustmail_panel/src/components/wizard/step5_language.rs @@ -18,6 +18,7 @@ pub fn step5_language(props: &Step5Props) -> Html { let status = use_state(|| props.data.status.clone()); let direct_message = use_state(|| props.data.direct_message.clone()); let close_message = use_state(|| props.data.close_message.clone()); + let check_updates = use_state(|| props.data.check_updates); let is_valid = !(*status).trim().is_empty() && !(*direct_message).trim().is_empty() @@ -33,6 +34,7 @@ pub fn step5_language(props: &Step5Props) -> Html { let status = status.clone(); let direct_message = direct_message.clone(); let close_message = close_message.clone(); + let check_updates = check_updates.clone(); Callback::from(move |_| { let mut new_data = data.clone(); @@ -41,6 +43,7 @@ pub fn step5_language(props: &Step5Props) -> Html { new_data.status = (*status).clone(); new_data.direct_message = (*direct_message).clone(); new_data.close_message = (*close_message).clone(); + new_data.check_updates = *check_updates; props_on_update.emit(new_data); props_on_next.emit(()); @@ -156,6 +159,25 @@ pub fn step5_language(props: &Step5Props) -> Html { +
+ +

{ i18n.t("wizard.steps.step5.check_updates_help") }

+
+