diff --git a/rustmail/src/commands/close/common.rs b/rustmail/src/commands/close/common.rs index 5a346bd3..cc5eb2e9 100644 --- a/rustmail/src/commands/close/common.rs +++ b/rustmail/src/commands/close/common.rs @@ -1,12 +1,26 @@ use std::time::Duration; +pub fn format_duration(seconds: u64) -> String { + if seconds < 60 { + format!("{}s", seconds) + } else if seconds < 3600 { + format!("{}m", seconds / 60) + } else if seconds < 86400 { + format!("{}h{}m", seconds / 3600, (seconds % 3600) / 60) + } else { + format!("{}d{}h", seconds / 86400, (seconds % 86400) / 3600) + } +} + pub fn parse_duration_spec(spec: &str) -> Option { if spec.is_empty() { return None; } + let mut total: u64 = 0; let mut num: u64 = 0; let mut has_unit_segment = false; + for ch in spec.chars() { if ch.is_ascii_digit() { let digit = ch.to_digit(10)? as u64; @@ -24,13 +38,15 @@ pub fn parse_duration_spec(spec: &str) -> Option { has_unit_segment = true; } } + if num > 0 { if has_unit_segment { total = total.saturating_add(num); } else { - total = total.saturating_add(num * 60); + total = total.saturating_add(num); } } + if total == 0 { None } else { diff --git a/rustmail/src/commands/close/slash_command/close.rs b/rustmail/src/commands/close/slash_command/close.rs index 1e78bf23..11fd098c 100644 --- a/rustmail/src/commands/close/slash_command/close.rs +++ b/rustmail/src/commands/close/slash_command/close.rs @@ -4,17 +4,17 @@ use crate::prelude::db::*; use crate::prelude::errors::*; use crate::prelude::handlers::*; use crate::prelude::i18n::*; +use crate::prelude::modules::*; use crate::prelude::utils::*; use chrono::Utc; use serenity::FutureExt; use serenity::all::{ - CommandDataOptionValue, CommandInteraction, CommandOptionType, Context, CreateCommand, - CreateCommandOption, GuildId, ResolvedOption, UserId, + Channel, CommandDataOptionValue, CommandInteraction, CommandOptionType, Context, CreateCommand, + CreateCommandOption, GuildId, PermissionOverwriteType, ResolvedOption, RoleId, UserId, }; use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; -use tokio::time::sleep; pub struct CloseCommand; @@ -191,16 +191,32 @@ impl RegistrableCommand for CloseCommand { } if let Some(delay) = duration { + if let Ok(Some(existing)) = get_scheduled_closure(&thread.id, db_pool).await { + let remaining = existing.close_at - Utc::now().timestamp(); + if remaining > 0 { + let old_human = format_duration(remaining as u64); + + let mut warn_params = HashMap::new(); + warn_params.insert("old_time".to_string(), old_human); + + let response = MessageBuilder::system_message(&ctx, &config) + .translated_content( + "close.replacing_existing_closure", + Some(&warn_params), + Some(command.user.id), + command.guild_id.map(|g| g.get()), + ) + .await + .to_channel(command.channel_id) + .build_interaction_message_followup() + .await; + + let _ = command.create_followup(&ctx.http, response).await; + } + } + let delay_secs = delay.as_secs(); - let human = if delay_secs < 60 { - format!("{}s", delay_secs) - } else if delay_secs < 3600 { - format!("{}m", delay_secs / 60) - } else if delay_secs < 86400 { - format!("{}h{}m", delay_secs / 3600, (delay_secs % 3600) / 60) - } else { - format!("{}d{}h", delay_secs / 86400, (delay_secs % 86400) / 3600) - }; + let human = format_duration(delay_secs); let mut params = HashMap::new(); params.insert("time".to_string(), human); @@ -217,7 +233,7 @@ impl RegistrableCommand for CloseCommand { .build_interaction_message_followup() .await; - let _ = command.create_followup(&ctx.http, response).await; + command.create_followup(&ctx.http, response).await } else { let response = MessageBuilder::system_message(&ctx, &config) .translated_content( @@ -231,18 +247,58 @@ impl RegistrableCommand for CloseCommand { .build_interaction_message_followup() .await; - let _ = command.create_followup(&ctx.http, response).await; + command.create_followup(&ctx.http, response).await }; + let closed_by = command.user.id.to_string(); + + let (category_id, category_name, required_permissions) = + match command.channel_id.to_channel(&ctx.http).await { + Ok(Channel::Guild(guild_channel)) => { + let guild_id = guild_channel.guild_id; + let parent_id = guild_channel.parent_id; + + let category_id = + parent_id.map(|id| id.to_string()).unwrap_or_default(); + + let category_name = if let Some(parent_id) = parent_id { + guild_id + .channels(&ctx.http) + .await + .ok() + .and_then(|channels| { + channels.get(&parent_id).map(|c| c.name.clone()) + }) + .unwrap_or_default() + } else { + String::new() + }; + + let guild = guild_id.to_partial_guild(&ctx.http).await.ok(); + let everyone_role_id = RoleId::new(guild_id.get()); + + let mut perms = guild + .and_then(|g| { + g.roles.get(&everyone_role_id).map(|r| r.permissions.bits()) + }) + .unwrap_or(0u64); + + for overwrite in &guild_channel.permission_overwrites { + if let PermissionOverwriteType::Role(_) = overwrite.kind { + let allow = overwrite.allow.bits(); + let deny = overwrite.deny.bits(); + perms = (perms & !deny) | allow; + } + } + + (category_id, category_name, perms) + } + _ => (String::new(), String::new(), 0u64), + }; + let thread_id = thread.id.clone(); let close_at = Utc::now().timestamp() + delay.as_secs() as i64; - let closed_by = command.user.id.to_string(); - let category_id = get_category_id_from_command(&ctx, &command).await; - let category_name = get_category_name_from_command(&ctx, &command).await; - let required_permissions = - get_required_permissions_channel_from_command(&ctx, &command).await; - if let Err(e) = upsert_scheduled_closure( &thread_id, close_at, @@ -257,103 +313,8 @@ impl RegistrableCommand for CloseCommand { { eprintln!("Failed to persist scheduled closure: {e:?}"); } - let channel_id = command.channel_id; - let config_clone = config.clone(); - let ctx_clone = ctx.clone(); - let user_id_clone = user_id; - let thread_id_for_task = thread_id.clone(); - - tokio::spawn(async move { - sleep(delay).await; - if let Some(pool) = config_clone.db_pool.as_ref() { - if let Ok(Some(record)) = - get_scheduled_closure(&thread_id_for_task, pool).await - { - if record.close_at <= Utc::now().timestamp() { - let _ = close_thread( - &thread_id_for_task, - &record.closed_by, - &record.category_id, - &record.category_name, - record.required_permissions.parse::().unwrap_or(0), - pool, - ) - .await; - let _ = delete_scheduled_closure(&thread_id_for_task, pool).await; - - let community_guild_id = - GuildId::new(config_clone.bot.get_community_guild_id()); - - let user_still_member = community_guild_id - .member(&ctx_clone.http, user_id_clone) - .await - .is_ok(); - - if !record.silent && user_still_member { - let _ = - MessageBuilder::system_message(&ctx_clone, &config_clone) - .content(&config_clone.bot.close_message) - .to_user(user_id_clone) - .send(true) - .await; - } - let _ = channel_id.delete(&ctx_clone.http).await; - } else { - let delay2 = - (record.close_at - Utc::now().timestamp()).max(1) as u64; - let config_clone2 = config_clone.clone(); - let ctx_clone2 = ctx_clone.clone(); - let thread_id_again = thread_id_for_task.clone(); - - tokio::spawn(async move { - sleep(Duration::from_secs(delay2)).await; - if let Some(pool2) = config_clone2.db_pool.as_ref() { - if let Ok(Some(r2)) = - get_scheduled_closure(&thread_id_again, pool2).await - { - if r2.close_at <= Utc::now().timestamp() { - let _ = close_thread( - &thread_id_again, - &r2.closed_by, - &r2.category_id, - &r2.category_name, - r2.required_permissions - .parse::() - .unwrap_or(0), - pool2, - ) - .await; - let _ = delete_scheduled_closure( - &thread_id_again, - pool2, - ) - .await; - let community_guild_id = GuildId::new( - config_clone2.bot.get_community_guild_id(), - ); - let user_still_member = community_guild_id - .member(&ctx_clone2.http, user_id_clone) - .await - .is_ok(); - if !r2.silent && user_still_member { - let _ = MessageBuilder::system_message( - &ctx_clone2, - &config_clone2, - ) - .content(&config_clone2.bot.close_message) - .to_user(user_id_clone) - .send(true) - .await; - } - let _ = channel_id.delete(&ctx_clone2.http).await; - } - } - } - }); - } - } - } - }); + + schedule_one(&ctx, &config, thread_id, close_at); return Ok(()); } diff --git a/rustmail/src/commands/close/text_command/close.rs b/rustmail/src/commands/close/text_command/close.rs index 7cd515f4..fefd9206 100644 --- a/rustmail/src/commands/close/text_command/close.rs +++ b/rustmail/src/commands/close/text_command/close.rs @@ -3,13 +3,13 @@ use crate::prelude::config::*; use crate::prelude::db::*; use crate::prelude::errors::*; use crate::prelude::handlers::*; +use crate::prelude::modules::*; use crate::prelude::utils::*; use chrono::Utc; use serenity::all::{Channel, Context, GuildId, Message, PermissionOverwriteType, RoleId, UserId}; use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; -use tokio::time::sleep; pub async fn close( ctx: Context, @@ -127,21 +127,35 @@ pub async fn close( } if let Some(delay) = duration { + if let Ok(Some(existing)) = get_scheduled_closure(&thread.id, db_pool).await { + let remaining = existing.close_at - Utc::now().timestamp(); + if remaining > 0 { + let old_human = format_duration(remaining as u64); + + let mut warn_params = HashMap::new(); + warn_params.insert("old_time".to_string(), old_human); + + let _ = MessageBuilder::system_message(&ctx, config) + .translated_content( + "close.replacing_existing_closure", + Some(&warn_params), + Some(msg.author.id), + msg.guild_id.map(|g| g.get()), + ) + .await + .to_channel(msg.channel_id) + .send(true) + .await; + } + } + let delay_secs = delay.as_secs(); - let human = if delay_secs < 60 { - format!("{}s", delay_secs) - } else if delay_secs < 3600 { - format!("{}m", delay_secs / 60) - } else if delay_secs < 86400 { - format!("{}h{}m", delay_secs / 3600, (delay_secs % 3600) / 60) - } else { - format!("{}d{}h", delay_secs / 86400, (delay_secs % 86400) / 3600) - }; + let human = format_duration(delay_secs); let mut params = HashMap::new(); params.insert("time".to_string(), human); let _ = if silent { - let _ = MessageBuilder::system_message(&ctx, config) + MessageBuilder::system_message(&ctx, config) .translated_content( "close.silent_closing", Some(¶ms), @@ -151,9 +165,9 @@ pub async fn close( .await .to_channel(msg.channel_id) .send(true) - .await; + .await } else { - let _ = MessageBuilder::system_message(&ctx, config) + MessageBuilder::system_message(&ctx, config) .translated_content( "close.closing", Some(¶ms), @@ -163,51 +177,53 @@ pub async fn close( .await .to_channel(msg.channel_id) .send(true) - .await; + .await }; let closed_by = msg.author.id.to_string(); - let category_id = match msg.channel_id.to_channel(&ctx.http).await { - Ok(channel) => match channel.category() { - Some(category) => category.id.to_string(), - None => String::new(), - }, - _ => String::new(), - }; - let category_name = match msg.channel_id.to_channel(&ctx.http).await { - Ok(channel) => match channel.category() { - Some(category) => category.name.clone(), - None => String::new(), - }, - _ => String::new(), - }; - let required_permissions = match msg.channel_id.to_channel(&ctx.http).await { - Ok(Channel::Guild(guild_channel)) => { - let guild_id = guild_channel.guild_id; - let guild = guild_id.to_partial_guild(&ctx.http).await.ok(); + let (category_id, category_name, required_permissions) = + match msg.channel_id.to_channel(&ctx.http).await { + Ok(Channel::Guild(guild_channel)) => { + let guild_id = guild_channel.guild_id; + let parent_id = guild_channel.parent_id; + + let category_id = parent_id.map(|id| id.to_string()).unwrap_or_default(); + + let category_name = if let Some(parent_id) = parent_id { + guild_id + .channels(&ctx.http) + .await + .ok() + .and_then(|channels| channels.get(&parent_id).map(|c| c.name.clone())) + .unwrap_or_default() + } else { + String::new() + }; - let everyone_role_id = RoleId::new(guild_id.get()); + let guild = guild_id.to_partial_guild(&ctx.http).await.ok(); + let everyone_role_id = RoleId::new(guild_id.get()); - let mut perms = guild - .and_then(|g| g.roles.get(&everyone_role_id).map(|r| r.permissions.bits())) - .unwrap_or(0u64); + let mut perms = guild + .and_then(|g| g.roles.get(&everyone_role_id).map(|r| r.permissions.bits())) + .unwrap_or(0u64); - for overwrite in &guild_channel.permission_overwrites { - if let PermissionOverwriteType::Role(_) = overwrite.kind { - let allow = overwrite.allow.bits(); - let deny = overwrite.deny.bits(); - perms = (perms & !deny) | allow; + for overwrite in &guild_channel.permission_overwrites { + if let PermissionOverwriteType::Role(_) = overwrite.kind { + let allow = overwrite.allow.bits(); + let deny = overwrite.deny.bits(); + perms = (perms & !deny) | allow; + } } - } - perms - } - _ => 0u64, - }; + (category_id, category_name, perms) + } + _ => (String::new(), String::new(), 0u64), + }; let thread_id = thread.id.clone(); let close_at = Utc::now().timestamp() + delay.as_secs() as i64; + if let Err(e) = upsert_scheduled_closure( &thread_id, close_at, @@ -222,94 +238,8 @@ pub async fn close( { eprintln!("Failed to persist scheduled closure: {e:?}"); } - let channel_id = msg.channel_id; - let config_clone = config.clone(); - let ctx_clone = ctx.clone(); - let user_id_clone = user_id; - let thread_id_for_task = thread_id.clone(); - - tokio::spawn(async move { - sleep(delay).await; - if let Some(pool) = config_clone.db_pool.as_ref() { - if let Ok(Some(record)) = get_scheduled_closure(&thread_id_for_task, pool).await { - if record.close_at <= Utc::now().timestamp() { - let _ = close_thread( - &thread_id_for_task, - &record.closed_by, - &record.category_id, - &category_name, - record.required_permissions.parse::().unwrap_or(0), - pool, - ) - .await; - let _ = delete_scheduled_closure(&thread_id_for_task, pool).await; - - let community_guild_id = - GuildId::new(config_clone.bot.get_community_guild_id()); - let user_still_member = community_guild_id - .member(&ctx_clone.http, user_id_clone) - .await - .is_ok(); - - if !record.silent && user_still_member { - let _ = MessageBuilder::system_message(&ctx_clone, &config_clone) - .content(&config_clone.bot.close_message) - .to_user(user_id_clone) - .send(true) - .await; - } - let _ = channel_id.delete(&ctx_clone.http).await; - } else { - let delay2 = (record.close_at - Utc::now().timestamp()).max(1) as u64; - let config_clone2 = config_clone.clone(); - let ctx_clone2 = ctx_clone.clone(); - let thread_id_again = thread_id_for_task.clone(); - - tokio::spawn(async move { - sleep(Duration::from_secs(delay2)).await; - if let Some(pool2) = config_clone2.db_pool.as_ref() { - if let Ok(Some(r2)) = - get_scheduled_closure(&thread_id_again, pool2).await - { - if r2.close_at <= Utc::now().timestamp() { - let _ = close_thread( - &thread_id_again, - &r2.closed_by, - &r2.category_id, - &r2.category_id, - r2.required_permissions.parse::().unwrap_or(0), - pool2, - ) - .await; - let _ = - delete_scheduled_closure(&thread_id_again, pool2).await; - let community_guild_id = GuildId::new( - config_clone2.bot.get_community_guild_id(), - ); - let user_still_member = community_guild_id - .member(&ctx_clone2.http, user_id_clone) - .await - .is_ok(); - if !r2.silent && user_still_member { - let _ = MessageBuilder::system_message( - &ctx_clone2, - &config_clone2, - ) - .content(&config_clone2.bot.close_message) - .to_user(user_id_clone) - .send(true) - .await; - } - let _ = channel_id.delete(&ctx_clone2.http).await; - } - } - } - }); - } - } - } - }); + schedule_one(&ctx, config, thread_id, close_at); return Ok(()); } diff --git a/rustmail/src/handlers/guild_messages_handler.rs b/rustmail/src/handlers/guild_messages_handler.rs index 55ddf01a..6ca38d68 100644 --- a/rustmail/src/handlers/guild_messages_handler.rs +++ b/rustmail/src/handlers/guild_messages_handler.rs @@ -149,6 +149,24 @@ async fn manage_incoming_message( .await; return Err(error); } + + if let Ok(thread) = fetch_thread(pool, &channel_id_str).await { + if let Ok(existed) = delete_scheduled_closure(&thread.id, pool).await { + if existed { + let _ = MessageBuilder::system_message(ctx, config) + .translated_content( + "close.auto_canceled_on_message", + None, + Some(msg.author.id), + None, + ) + .await + .to_channel(channel_id) + .send(true) + .await; + } + } + } } } else { create_channel(ctx, msg, config).await; diff --git a/rustmail/src/i18n/language/en.rs b/rustmail/src/i18n/language/en.rs index c915ccc1..749fd196 100644 --- a/rustmail/src/i18n/language/en.rs +++ b/rustmail/src/i18n/language/en.rs @@ -602,6 +602,14 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { "close.closure_canceled".to_string(), DictionaryMessage::new("Closure canceled."), ); + dict.messages.insert( + "close.auto_canceled_on_message".to_string(), + DictionaryMessage::new("Scheduled closure has been automatically canceled because a message was received."), + ); + dict.messages.insert( + "close.replacing_existing_closure".to_string(), + DictionaryMessage::new("⚠️ Warning: A closure was already scheduled in {old_time}. It will be replaced by the new one."), + ); dict.messages.insert( "close.no_scheduled_closures_to_cancel".to_string(), DictionaryMessage::new("No scheduled closures to cancel."), diff --git a/rustmail/src/i18n/language/fr.rs b/rustmail/src/i18n/language/fr.rs index 783d9894..fed8901d 100644 --- a/rustmail/src/i18n/language/fr.rs +++ b/rustmail/src/i18n/language/fr.rs @@ -630,6 +630,14 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { "close.closure_canceled".to_string(), DictionaryMessage::new("Fermeture programmée annulée."), ); + dict.messages.insert( + "close.auto_canceled_on_message".to_string(), + DictionaryMessage::new("La fermeture programmée a été automatiquement annulée car un message a été reçu."), + ); + dict.messages.insert( + "close.replacing_existing_closure".to_string(), + DictionaryMessage::new("⚠️ Attention : Une fermeture était déjà programmée dans {old_time}. Elle sera remplacée par la nouvelle."), + ); dict.messages.insert( "close.no_scheduled_closures_to_cancel".to_string(), DictionaryMessage::new("Aucune fermeture programmée à annuler."), diff --git a/rustmail/src/modules/scheduled_closures.rs b/rustmail/src/modules/scheduled_closures.rs index 07707260..27a7ac10 100644 --- a/rustmail/src/modules/scheduled_closures.rs +++ b/rustmail/src/modules/scheduled_closures.rs @@ -5,7 +5,7 @@ use chrono::Utc; use serenity::all::{ChannelId, Context, UserId}; use tokio::time::{Duration, sleep}; -fn schedule_one(ctx: &Context, config: &Config, thread_id: String, close_at: i64) { +pub fn schedule_one(ctx: &Context, config: &Config, thread_id: String, close_at: i64) { let now = Utc::now().timestamp(); let delay_secs = (close_at - now).max(0) as u64; let ctx_clone = ctx.clone(); @@ -61,6 +61,7 @@ pub async fn hydrate_scheduled_closures(ctx: &Context, config: &Config) { let Some(pool) = config.db_pool.as_ref() else { return; }; + let list = match get_all_scheduled_closures(pool).await { Ok(l) => l, Err(e) => { @@ -68,6 +69,7 @@ pub async fn hydrate_scheduled_closures(ctx: &Context, config: &Config) { return; } }; + for sc in list { if let Some(thread) = get_thread_by_id(&sc.thread_id, pool).await { if sc.close_at <= Utc::now().timestamp() {