From d1a36f3dc7d857dabb614059e2b736992db95029 Mon Sep 17 00:00:00 2001 From: LimeNade Date: Sun, 12 Oct 2025 16:05:16 +0200 Subject: [PATCH 1/4] Added misc commands Added /set-token /set-nightscout-url /get-nightscout-url /set-visibility commands Updated other commands contents. --- src/commands/get_nightscout_url.rs | 71 +++++++++ src/commands/help.rs | 26 +++- src/commands/mod.rs | 3 + src/commands/set_nightscout_url.rs | 230 +++++++++++++++++++++++++++++ src/commands/set_token.rs | 133 +++++++++++++++++ src/commands/set_visibility.rs | 146 ++++++++++++++++++ src/commands/update_message.rs | 5 +- src/main.rs | 26 +++- 8 files changed, 631 insertions(+), 9 deletions(-) create mode 100644 src/commands/get_nightscout_url.rs create mode 100644 src/commands/set_nightscout_url.rs create mode 100644 src/commands/set_token.rs diff --git a/src/commands/get_nightscout_url.rs b/src/commands/get_nightscout_url.rs new file mode 100644 index 0000000..d2c1db7 --- /dev/null +++ b/src/commands/get_nightscout_url.rs @@ -0,0 +1,71 @@ +use crate::Handler; +use serenity::all::{ + Colour, CommandInteraction, Context, CreateCommand, CreateEmbed, CreateInteractionResponse, + CreateInteractionResponseMessage, InteractionContext, +}; + +pub async fn run( + handler: &Handler, + context: &Context, + interaction: &CommandInteraction, +) -> anyhow::Result<()> { + if !handler + .database + .user_exists(interaction.user.id.get()) + .await? + { + let error_embed = CreateEmbed::new() + .color(Colour::RED) + .title("Not Set Up") + .description("You need to run `/setup` first to configure your Nightscout URL."); + + let msg = CreateInteractionResponseMessage::new() + .embed(error_embed) + .ephemeral(true); + let builder = CreateInteractionResponse::Message(msg); + interaction.create_response(&context.http, builder).await?; + return Ok(()); + } + + let user_info = handler + .database + .get_user_info(interaction.user.id.get()) + .await?; + + let url = user_info + .nightscout + .nightscout_url + .unwrap_or_else(|| "Not set".to_string()); + + let has_token = user_info.nightscout.nightscout_token.is_some(); + let token_status = if has_token { + "[SECURE] Token is configured" + } else { + "[OPEN] No token configured" + }; + + let embed = CreateEmbed::new() + .title("Your Nightscout Configuration") + .description(format!( + "**URL:** {}\n**Token Status:** {}", + url, token_status + )) + .color(Colour::BLURPLE); + + let msg = CreateInteractionResponseMessage::new() + .embed(embed) + .ephemeral(true); + let builder = CreateInteractionResponse::Message(msg); + interaction.create_response(&context.http, builder).await?; + + Ok(()) +} + +pub fn register() -> CreateCommand { + CreateCommand::new("get-nightscout-url") + .description("View your current Nightscout URL and token status") + .contexts(vec![ + InteractionContext::Guild, + InteractionContext::PrivateChannel, + ]) +} diff --git a/src/commands/help.rs b/src/commands/help.rs index 28b273c..c1a046d 100644 --- a/src/commands/help.rs +++ b/src/commands/help.rs @@ -75,13 +75,33 @@ fn create_help_page(page: u8) -> (CreateEmbed, Option) { false, ) .field( - "/token ", - "Set or update your Nightscout API token for authentication (optional but recommended).", + "/token", + "Set or update your Nightscout API token for authentication (optional but recommended). Opens a modal for secure input.", + false, + ) + .field( + "/set-token", + "Alternative command to set or update your Nightscout API token. Same as /token.", + false, + ) + .field( + "/set-nightscout-url", + "Update your Nightscout URL. Tests the connection before saving changes.", + false, + ) + .field( + "/get-nightscout-url", + "View your current Nightscout URL and token status (without revealing the token).", + false, + ) + .field( + "/set-visibility ", + "Set your profile visibility. Public = anyone can view, Private = only you and allowed users can view.", false, ) .field( "/allow @user [action]", - "Manage who can view your blood glucose data. Add or remove users from your allowed list.", + "Manage who can view your blood glucose data when your profile is private. Add or remove users from your allowed list.", false, ) .field( diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 75bd2e0..75361d7 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -4,10 +4,13 @@ pub mod analyze_units; pub mod bg; pub mod convert; pub mod error; +pub mod get_nightscout_url; pub mod graph; pub mod help; pub mod info; +pub mod set_nightscout_url; pub mod set_threshold; +pub mod set_token; pub mod set_visibility; pub mod setup; pub mod stickers; diff --git a/src/commands/set_nightscout_url.rs b/src/commands/set_nightscout_url.rs new file mode 100644 index 0000000..63d1ac2 --- /dev/null +++ b/src/commands/set_nightscout_url.rs @@ -0,0 +1,230 @@ +use crate::Handler; +use serenity::all::{ + Colour, CommandInteraction, Context, CreateCommand, CreateEmbed, CreateInteractionResponse, + CreateInteractionResponseMessage, CreateQuickModal, InteractionContext, +}; +use url::Url; + +pub async fn run( + handler: &Handler, + context: &Context, + interaction: &CommandInteraction, +) -> anyhow::Result<()> { + if !handler + .database + .user_exists(interaction.user.id.get()) + .await? + { + let error_embed = CreateEmbed::new() + .color(Colour::RED) + .title("Not Set Up") + .description("You need to run `/setup` first to configure your Nightscout settings."); + + let msg = CreateInteractionResponseMessage::new() + .embed(error_embed) + .ephemeral(true); + let builder = CreateInteractionResponse::Message(msg); + interaction.create_response(&context.http, builder).await?; + return Ok(()); + } + + let modal = CreateQuickModal::new("Update Nightscout URL") + .timeout(std::time::Duration::from_secs(300)) + .short_field("New Nightscout URL"); + + let response = interaction.quick_modal(context, modal).await?; + + if let Some(modal_response) = response { + let url_input = &modal_response.inputs[0]; + + let validated_url = match validate_and_fix_url(url_input) { + Ok(url) => url, + Err(e) => { + let error_embed = CreateEmbed::new() + .title("Invalid URL") + .description(format!("Please check your URL: {}", e)) + .color(Colour::RED); + + let error_response = CreateInteractionResponseMessage::new() + .embed(error_embed) + .ephemeral(true); + + modal_response + .interaction + .create_response(context, CreateInteractionResponse::Message(error_response)) + .await?; + return Ok(()); + } + }; + + // Test the connection + let current_user_info = handler + .database + .get_user_info(interaction.user.id.get()) + .await?; + + tracing::info!( + "[TEST] Testing Nightscout connection for URL: {}", + validated_url + ); + match handler + .nightscout_client + .get_entry( + &validated_url, + current_user_info.nightscout.nightscout_token.as_deref(), + ) + .await + { + Ok(_) => { + tracing::info!("[OK] Nightscout connection test successful"); + + // Update the URL + let updated_nightscout_info = crate::utils::database::NightscoutInfo { + nightscout_url: Some(validated_url.clone()), + nightscout_token: current_user_info.nightscout.nightscout_token, + is_private: current_user_info.nightscout.is_private, + allowed_people: current_user_info.nightscout.allowed_people, + microbolus_threshold: current_user_info.nightscout.microbolus_threshold, + display_microbolus: current_user_info.nightscout.display_microbolus, + }; + + let user_id = interaction.user.id.get(); + match handler + .database + .update_user(user_id, updated_nightscout_info) + .await + { + Ok(_) => { + let success_embed = CreateEmbed::new() + .title("URL Updated") + .description(format!( + "[OK] Your Nightscout URL has been updated successfully.\n\n**New URL:** {}", + validated_url + )) + .color(Colour::DARK_GREEN); + + let success_response = CreateInteractionResponseMessage::new() + .embed(success_embed) + .ephemeral(true); + + modal_response + .interaction + .create_response( + context, + CreateInteractionResponse::Message(success_response), + ) + .await?; + } + Err(e) => { + eprintln!("Failed to update user URL: {}", e); + let error_embed = CreateEmbed::new() + .title("Update Failed") + .description( + "[ERROR] Failed to update your URL. Please try again later.", + ) + .color(Colour::RED); + + let error_response = CreateInteractionResponseMessage::new() + .embed(error_embed) + .ephemeral(true); + + modal_response + .interaction + .create_response( + context, + CreateInteractionResponse::Message(error_response), + ) + .await?; + } + } + } + Err(e) => { + tracing::error!("[ERROR] Nightscout connection test failed: {}", e); + let error_embed = CreateEmbed::new() + .title("Connection Failed") + .description("Could not connect to your Nightscout site. Please verify:\n• The URL is correct\n• Your site is publicly accessible\n• Your site is online") + .color(Colour::RED); + + let error_response = CreateInteractionResponseMessage::new() + .embed(error_embed) + .ephemeral(true); + + modal_response + .interaction + .create_response(context, CreateInteractionResponse::Message(error_response)) + .await?; + } + } + } + + Ok(()) +} + +fn validate_and_fix_url(input: &str) -> Result { + let mut url = input.trim().to_string(); + + // Check for empty or whitespace-only input + if url.is_empty() { + return Err("URL cannot be empty".to_string()); + } + + // Check for obviously invalid patterns + if url.contains(' ') { + return Err("URL cannot contain spaces".to_string()); + } + + // Add https:// prefix if no scheme is present + if !url.starts_with("http://") && !url.starts_with("https://") { + url = format!("https://{}", url); + } + + // Ensure URL ends with '/' + if !url.ends_with('/') { + url.push('/'); + } + + // Parse and validate the URL + match Url::parse(&url) { + Ok(parsed) => { + // Check for required components + if parsed.host().is_none() { + return Err("URL must have a valid domain name".to_string()); + } + + // Ensure it's http or https + match parsed.scheme() { + "http" | "https" => {} + _ => return Err("URL must use http or https protocol".to_string()), + } + + // Additional validation: ensure domain has at least one dot (basic domain check) + if let Some(host) = parsed.host_str() + && !host.contains('.') + && host != "localhost" + { + return Err("Invalid domain name format".to_string()); + } + + Ok(url) + } + Err(e) => match e { + url::ParseError::RelativeUrlWithoutBase => { + Err("Invalid URL: cannot be a relative path".to_string()) + } + url::ParseError::InvalidDomainCharacter => { + Err("Invalid characters in domain name".to_string()) + } + url::ParseError::InvalidPort => Err("Invalid port number in URL".to_string()), + _ => Err(format!("Invalid URL format: {}", e)), + }, + } +} + +pub fn register() -> CreateCommand { + CreateCommand::new("set-nightscout-url") + .description("Update your Nightscout URL") + .contexts(vec![ + InteractionContext::Guild, + InteractionContext::PrivateChannel, + ]) +} diff --git a/src/commands/set_token.rs b/src/commands/set_token.rs new file mode 100644 index 0000000..452e92c --- /dev/null +++ b/src/commands/set_token.rs @@ -0,0 +1,133 @@ +use crate::Handler; +use serenity::all::{ + Colour, CommandInteraction, Context, CreateCommand, CreateEmbed, CreateInputText, + CreateInteractionResponse, CreateInteractionResponseMessage, CreateQuickModal, + InteractionContext, +}; + +pub async fn run( + handler: &Handler, + context: &Context, + interaction: &CommandInteraction, +) -> anyhow::Result<()> { + if !handler + .database + .user_exists(interaction.user.id.get()) + .await? + { + let error_embed = CreateEmbed::new() + .color(Colour::RED) + .title("Not Set Up") + .description("You need to run `/setup` first to configure your Nightscout URL before setting a token."); + + let msg = CreateInteractionResponseMessage::new() + .embed(error_embed) + .ephemeral(true); + let builder = CreateInteractionResponse::Message(msg); + interaction.create_response(&context.http, builder).await?; + return Ok(()); + } + + let modal = CreateQuickModal::new("Set Nightscout API Token") + .timeout(std::time::Duration::from_secs(600)) + .field( + CreateInputText::new( + serenity::all::InputTextStyle::Short, + "Nightscout Token (optional)", + "", + ) + .required(false) + .placeholder("Leave empty to remove token"), + ); + + let response = interaction.quick_modal(context, modal).await?; + let modal_response = response.unwrap(); + + let token_input = &modal_response.inputs[0]; + let token = if token_input.trim().is_empty() { + None + } else { + Some(token_input.trim().to_string()) + }; + + let current_user_info = handler + .database + .get_user_info(interaction.user.id.get()) + .await?; + + let updated_nightscout_info = crate::utils::database::NightscoutInfo { + nightscout_url: current_user_info.nightscout.nightscout_url, + nightscout_token: token.clone(), + is_private: current_user_info.nightscout.is_private, + allowed_people: current_user_info.nightscout.allowed_people, + microbolus_threshold: current_user_info.nightscout.microbolus_threshold, + display_microbolus: current_user_info.nightscout.display_microbolus, + }; + + let user_id = interaction.user.id.get(); + match handler + .database + .update_user(user_id, updated_nightscout_info) + .await + { + Ok(_) => { + let (title, description, color) = if token.is_some() { + ( + "Token Updated", + "[OK] Your Nightscout access token has been updated successfully.\n\nThis token will be used to authenticate requests to your Nightscout site using either API-SECRET header or Bearer authorization depending on the token format.", + Colour::DARK_GREEN, + ) + } else { + ( + "Token Removed", + "[OK] Your Nightscout access token has been removed.\n\nRequests will now be made without authentication.", + Colour::ORANGE, + ) + }; + + let success_embed = CreateEmbed::new() + .title(title) + .description(description) + .color(color); + + let success_response = CreateInteractionResponseMessage::new() + .embed(success_embed) + .ephemeral(true); + + modal_response + .interaction + .create_response( + context, + CreateInteractionResponse::Message(success_response), + ) + .await?; + } + Err(e) => { + eprintln!("Failed to update user token: {}", e); + let error_embed = CreateEmbed::new() + .title("Update Failed") + .description("[ERROR] Failed to update your token. Please try again later.") + .color(Colour::RED); + + let error_response = CreateInteractionResponseMessage::new() + .embed(error_embed) + .ephemeral(true); + + modal_response + .interaction + .create_response(context, CreateInteractionResponse::Message(error_response)) + .await?; + } + } + + Ok(()) +} + +pub fn register() -> CreateCommand { + CreateCommand::new("set-token") + .description("Set or update your Nightscout API token for authentication") + .contexts(vec![ + InteractionContext::Guild, + InteractionContext::PrivateChannel, + ]) +} diff --git a/src/commands/set_visibility.rs b/src/commands/set_visibility.rs index 8b13789..f0342c4 100644 --- a/src/commands/set_visibility.rs +++ b/src/commands/set_visibility.rs @@ -1 +1,147 @@ +use crate::Handler; +use serenity::all::{ + Colour, CommandInteraction, CommandOptionType, Context, CreateCommand, CreateCommandOption, + CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage, InteractionContext, + ResolvedOption, ResolvedValue, +}; +pub async fn run( + handler: &Handler, + context: &Context, + interaction: &CommandInteraction, +) -> anyhow::Result<()> { + if !handler + .database + .user_exists(interaction.user.id.get()) + .await? + { + crate::commands::error::run( + context, + interaction, + "You need to run `/setup` first to configure your Nightscout before changing visibility settings.", + ) + .await?; + return Ok(()); + } + + let mut visibility: Option<&str> = None; + + for option in &interaction.data.options() { + if let ResolvedOption { + name: "visibility", + value: ResolvedValue::String(vis), + .. + } = option + { + visibility = Some(vis); + } + } + + let visibility = visibility.ok_or_else(|| anyhow::anyhow!("Visibility parameter is required"))?; + + let is_private = match visibility { + "public" => false, + "private" => true, + _ => { + crate::commands::error::run( + context, + interaction, + "Invalid visibility option. Use 'public' or 'private'.", + ) + .await?; + return Ok(()); + } + }; + + let current_user_info = handler + .database + .get_user_info(interaction.user.id.get()) + .await?; + + // Check if the visibility is already set to the requested value + if current_user_info.nightscout.is_private == is_private { + let status = if is_private { "private" } else { "public" }; + crate::commands::error::run( + context, + interaction, + &format!("Your profile is already set to {}.", status), + ) + .await?; + return Ok(()); + } + + let updated_nightscout_info = crate::utils::database::NightscoutInfo { + nightscout_url: current_user_info.nightscout.nightscout_url, + nightscout_token: current_user_info.nightscout.nightscout_token, + is_private, + allowed_people: current_user_info.nightscout.allowed_people, + microbolus_threshold: current_user_info.nightscout.microbolus_threshold, + display_microbolus: current_user_info.nightscout.display_microbolus, + }; + + let user_id = interaction.user.id.get(); + match handler + .database + .update_user(user_id, updated_nightscout_info) + .await + { + Ok(_) => { + let (title, description, color) = if is_private { + ( + "Profile Set to Private", + "Your profile is now **private**.\n\nOnly you and users you've explicitly allowed with `/allow` can view your blood glucose data.", + Colour::from_rgb(59, 130, 246), // Blue + ) + } else { + ( + "Profile Set to Public", + "Your profile is now **public**.\n\nAnyone can view your blood glucose data. Users in your `/allow` list will still have access.", + Colour::from_rgb(34, 197, 94), // Green + ) + }; + + let embed = CreateEmbed::new() + .title(title) + .description(description) + .color(color); + + let response = CreateInteractionResponseMessage::new() + .embed(embed) + .ephemeral(true); + + interaction + .create_response(context, CreateInteractionResponse::Message(response)) + .await?; + } + Err(e) => { + eprintln!("Failed to update visibility: {}", e); + crate::commands::error::run( + context, + interaction, + "[ERROR] Failed to update your visibility settings. Please try again later.", + ) + .await?; + } + } + + Ok(()) +} + +pub fn register() -> CreateCommand { + CreateCommand::new("set-visibility") + .description("Set whether your profile is public or private") + .add_option( + CreateCommandOption::new( + CommandOptionType::String, + "visibility", + "Choose public or private visibility", + ) + .add_string_choice("Public - Anyone can view", "public") + .add_string_choice("Private - Only allowed users", "private") + .required(true), + ) + .contexts(vec![ + InteractionContext::Guild, + InteractionContext::PrivateChannel, + ]) +} diff --git a/src/commands/update_message.rs b/src/commands/update_message.rs index 5172236..05a461f 100644 --- a/src/commands/update_message.rs +++ b/src/commands/update_message.rs @@ -6,6 +6,9 @@ pub fn create_update_embed(version: &str) -> CreateEmbed { "**What's new:**", "• **Doubled** the graph resolution allowing for noticeably bigger and clearer resulting images", "• Added a warning in the `/bg` command if the data is older than 15 min", + "• Added contextual stickers. When adding a new sticker it will prompt you to categorize it. The sticker will now generate Depending your blood glucose value!", + "• Updated the `/stickers` commmand to work with contextual stickers", + "• Added `/set-token`, `/set-nightscout-url`, `/get-nightscout-url` and `/set-visibility` commands to avoid having to run `/setup` each time to change their values.", "", "**Fixes:**", "• Fixed issue where missing data on the edges of the graph would collapse the graph instead of showing the gap", @@ -18,7 +21,7 @@ pub fn create_update_embed(version: &str) -> CreateEmbed { }; CreateEmbed::new() - .title(format!("🎉 Beetroot has been updated to v{}", version)) + .title(format!("🎉 Beetroot has been updated to v{} | Enhancements Update", version)) .description("Here's what's new in this update:") .color(Colour::DARK_GREEN) .field("Changelog", changelog.join("\n"), false) diff --git a/src/main.rs b/src/main.rs index 6795de2..7ba64ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -124,16 +124,28 @@ impl EventHandler for Handler { "convert" => { commands::convert::run(self, &context, command).await } + "get-nightscout-url" => { + commands::get_nightscout_url::run(self, &context, command).await + } "graph" => commands::graph::run(self, &context, command).await, "help" => commands::help::run(self, &context, command).await, "info" => commands::info::run(self, &context, command).await, - "setup" => commands::setup::run(self, &context, command).await, - "stickers" => { - commands::stickers::run(self, &context, command).await + "set-nightscout-url" => { + commands::set_nightscout_url::run(self, &context, command).await } "set-threshold" => { commands::set_threshold::run(self, &context, command).await } + "set-token" => { + commands::set_token::run(self, &context, command).await + } + "set-visibility" => { + commands::set_visibility::run(self, &context, command).await + } + "setup" => commands::setup::run(self, &context, command).await, + "stickers" => { + commands::stickers::run(self, &context, command).await + } "token" => commands::token::run(self, &context, command).await, unknown_command => { eprintln!( @@ -143,7 +155,7 @@ impl EventHandler for Handler { commands::error::run( &context, command, - &format!("Unknown command: `{}`. Available commands are: `/allow`, `/bg`, `/convert`, `/graph`, `/help`, `/info`, `/setup`, `/set-threshold`, `/stickers`, `/token`", unknown_command) + &format!("Unknown command: `{}`. Available commands are: `/allow`, `/bg`, `/convert`, `/get-nightscout-url`, `/graph`, `/help`, `/info`, `/set-nightscout-url`, `/set-threshold`, `/set-token`, `/set-visibility`, `/setup`, `/stickers`, `/token`", unknown_command) ).await } } @@ -232,11 +244,15 @@ impl EventHandler for Handler { commands::allow::register(), commands::bg::register(), commands::convert::register(), + commands::get_nightscout_url::register(), commands::graph::register(), commands::help::register(), commands::info::register(), - commands::setup::register(), + commands::set_nightscout_url::register(), commands::set_threshold::register(), + commands::set_token::register(), + commands::set_visibility::register(), + commands::setup::register(), commands::stickers::register(), commands::token::register(), commands::add_sticker::register(), From cc73a9206eefe12419b75cb3c8f9e352d7d81d35 Mon Sep 17 00:00:00 2001 From: LimeNade Date: Sun, 12 Oct 2025 16:05:51 +0200 Subject: [PATCH 2/4] chore(fmt): cargo fmt --- src/commands/set_visibility.rs | 3 ++- src/main.rs | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/commands/set_visibility.rs b/src/commands/set_visibility.rs index f0342c4..84671fb 100644 --- a/src/commands/set_visibility.rs +++ b/src/commands/set_visibility.rs @@ -37,7 +37,8 @@ pub async fn run( } } - let visibility = visibility.ok_or_else(|| anyhow::anyhow!("Visibility parameter is required"))?; + let visibility = + visibility.ok_or_else(|| anyhow::anyhow!("Visibility parameter is required"))?; let is_private = match visibility { "public" => false, diff --git a/src/main.rs b/src/main.rs index 7ba64ac..7412bc4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -125,13 +125,15 @@ impl EventHandler for Handler { commands::convert::run(self, &context, command).await } "get-nightscout-url" => { - commands::get_nightscout_url::run(self, &context, command).await + commands::get_nightscout_url::run(self, &context, command) + .await } "graph" => commands::graph::run(self, &context, command).await, "help" => commands::help::run(self, &context, command).await, "info" => commands::info::run(self, &context, command).await, "set-nightscout-url" => { - commands::set_nightscout_url::run(self, &context, command).await + commands::set_nightscout_url::run(self, &context, command) + .await } "set-threshold" => { commands::set_threshold::run(self, &context, command).await From 8499db120fdd8ce3afe835a4b6fbd83fe5b8be90 Mon Sep 17 00:00:00 2001 From: LimeNade Date: Sun, 12 Oct 2025 16:31:57 +0200 Subject: [PATCH 3/4] feat(everything): Small update. --- src/bot/README.md | 164 +++++++++++++++++ src/bot/command_registry.rs | 26 +++ src/bot/component_router.rs | 45 +++++ src/bot/event_handler.rs | 98 ++++++++++ src/bot/handler.rs | 28 +++ src/bot/helpers/command_handler.rs | 83 +++++++++ src/bot/helpers/components.rs | 157 ++++++++++++++++ src/bot/helpers/mod.rs | 3 + src/bot/helpers/pagination.rs | 80 ++++++++ src/bot/init.rs | 22 +++ src/bot/mod.rs | 11 ++ src/bot/version_checker.rs | 53 ++++++ src/commands/add_sticker.rs | 2 +- src/commands/allow.rs | 2 +- src/commands/analyze_units.rs | 2 +- src/commands/bg.rs | 2 +- src/commands/convert.rs | 2 +- src/commands/get_nightscout_url.rs | 2 +- src/commands/graph.rs | 2 +- src/commands/help.rs | 40 +--- src/commands/info.rs | 2 +- src/commands/remove_sticker.rs | 2 +- src/commands/set_nightscout_url.rs | 2 +- src/commands/set_threshold.rs | 2 +- src/commands/set_token.rs | 2 +- src/commands/set_visibility.rs | 2 +- src/commands/setup.rs | 3 +- src/commands/stickers.rs | 2 +- src/commands/token.rs | 2 +- src/main.rs | 285 +---------------------------- src/utils/graph/drawing.rs | 2 +- src/utils/graph/mod.rs | 2 +- src/utils/graph/stickers.rs | 2 +- 33 files changed, 799 insertions(+), 335 deletions(-) create mode 100644 src/bot/README.md create mode 100644 src/bot/command_registry.rs create mode 100644 src/bot/component_router.rs create mode 100644 src/bot/event_handler.rs create mode 100644 src/bot/handler.rs create mode 100644 src/bot/helpers/command_handler.rs create mode 100644 src/bot/helpers/components.rs create mode 100644 src/bot/helpers/mod.rs create mode 100644 src/bot/helpers/pagination.rs create mode 100644 src/bot/init.rs create mode 100644 src/bot/mod.rs create mode 100644 src/bot/version_checker.rs diff --git a/src/bot/README.md b/src/bot/README.md new file mode 100644 index 0000000..20fbf7c --- /dev/null +++ b/src/bot/README.md @@ -0,0 +1,164 @@ +# Bot Module Structure + +This directory contains all Discord bot-related code, organized for modularity and maintainability. + +## Directory Structure + +``` +src/bot/ +├── mod.rs # Module exports +├── handler.rs # Handler struct definition +├── event_handler.rs # EventHandler implementation +├── init.rs # Bot initialization +├── command_registry.rs # Command registration +├── component_router.rs # Component interaction routing +├── version_checker.rs # Version update notifications +└── helpers/ # Helper utilities + ├── mod.rs # Helper exports + ├── command_handler.rs # Command routing logic + ├── components.rs # Button/component helpers + └── pagination.rs # Pagination utilities +``` + +## Module Descriptions + +### Core Modules + +#### `handler.rs` +Defines the `Handler` struct that holds bot state: +- Nightscout client +- Database connection +- Font for graph rendering + +#### `event_handler.rs` +Implements `EventHandler` trait for Serenity: +- Routes all interactions (commands & components) +- Handles errors gracefully +- Registers commands on bot ready + +#### `init.rs` +Bot initialization and startup: +- Loads environment variables +- Creates Handler instance +- Starts Discord client + +#### `command_registry.rs` +Central location for command registration: +- Returns all slash commands +- Returns all context menu commands +- Easy to add new commands + +#### `component_router.rs` +Routes button/component interactions: +- Pattern-based routing +- Forwards to appropriate command handlers +- Handles unknown interactions gracefully + +#### `version_checker.rs` +Manages version update notifications: +- Checks user's last seen version +- Sends update notifications +- Updates database with new version + +### Helper Modules + +#### `helpers/command_handler.rs` +Command routing and validation: +- `handle_slash_command()` - Routes slash commands +- `handle_context_command()` - Routes context menu commands +- Checks user setup requirements +- Consistent error handling + +#### `helpers/components.rs` +Button and component utilities: +- `ButtonBuilder` - Fluent API for creating buttons +- `ComponentResponseBuilder` - Helper for responding to interactions +- `custom_id_matches()` - Pattern matching helper +- `extract_custom_id_value()` - Extract data from custom IDs + +#### `helpers/pagination.rs` +Pagination utilities for multi-page interfaces: +- `create_pagination_buttons()` - Generate prev/next buttons +- `extract_page_number()` - Parse page from custom_id + +## Usage Examples + +### Adding a New Command + +1. Create command file in `src/commands/your_command.rs` +2. Add to `src/commands/mod.rs` +3. Add registration to `command_registry.rs`: +```rust +commands::your_command::register(), +``` +4. Add routing in `helpers/command_handler.rs`: +```rust +"your-command" => commands::your_command::run(handler, context, command).await, +``` + +### Using Pagination Helper + +```rust +use crate::bot::helpers::pagination; + +fn create_page(page: u8, total: u8) -> (CreateEmbed, Option) { + let embed = CreateEmbed::new() + .title(format!("Page {}/{}", page, total)); + + let buttons = pagination::create_pagination_buttons("prefix_", page, total); + + (embed, buttons) +} + +// In button handler: +if let Some(page) = pagination::extract_page_number(custom_id, "prefix_") { + // Handle page change +} +``` + +### Using Button Builder + +```rust +use crate::bot::helpers::ButtonBuilder; + +let buttons = ButtonBuilder::new() + .success("confirm_action", "Confirm") + .danger("cancel_action", "Cancel") + .build(); +``` + +### Adding Component Interactions + +Add pattern to `component_router.rs`: +```rust +id if id.starts_with("your_prefix_") => { + commands::your_command::handle_button(handler, context, component).await +} +``` + +## Benefits of This Structure + +1. **Separation of Concerns**: Bot logic separated from business logic +2. **Modularity**: Easy to find and modify specific functionality +3. **Reusability**: Helpers can be used across multiple commands +4. **Maintainability**: Clear structure makes it easy to understand +5. **Testability**: Isolated modules are easier to test +6. **Scalability**: Easy to add new commands and features + +## Main Function + +The main function in `src/main.rs` is now minimal: +```rust +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Initialize logging + tracing_subscriber::fmt() + .with_env_filter(...) + .init(); + + // Start the bot + bot::init::start_bot().await +} +``` + +All bot-specific logic is contained within the `bot` module. diff --git a/src/bot/command_registry.rs b/src/bot/command_registry.rs new file mode 100644 index 0000000..6ff9cff --- /dev/null +++ b/src/bot/command_registry.rs @@ -0,0 +1,26 @@ +use crate::commands; +use serenity::all::CreateCommand; + +/// Get all slash commands and context menu commands to register +pub fn get_all_commands() -> Vec { + vec![ + // Slash commands + commands::allow::register(), + commands::bg::register(), + commands::convert::register(), + commands::get_nightscout_url::register(), + commands::graph::register(), + commands::help::register(), + commands::info::register(), + commands::set_nightscout_url::register(), + commands::set_threshold::register(), + commands::set_token::register(), + commands::set_visibility::register(), + commands::setup::register(), + commands::stickers::register(), + commands::token::register(), + // Context menu commands + commands::add_sticker::register(), + commands::analyze_units::register(), + ] +} diff --git a/src/bot/component_router.rs b/src/bot/component_router.rs new file mode 100644 index 0000000..31df0a2 --- /dev/null +++ b/src/bot/component_router.rs @@ -0,0 +1,45 @@ +use crate::bot::Handler; +use crate::commands; +use anyhow::Result; +use serenity::all::{ComponentInteraction, Context}; + +/// Route component interactions (button clicks) to their handlers +pub async fn route_component_interaction( + handler: &Handler, + context: &Context, + component: &ComponentInteraction, +) -> Result<()> { + let custom_id = component.data.custom_id.as_str(); + + match custom_id { + // Setup buttons + "setup_private" | "setup_public" => { + commands::setup::handle_button(handler, context, component).await + } + + // Help pagination buttons + id if id.starts_with("help_page_") => { + commands::help::handle_button(handler, context, component).await + } + + // Add sticker buttons + id if id.starts_with("add_sticker_") => { + commands::add_sticker::handle_button(handler, context, component).await + } + + // Stickers management buttons + id if id.starts_with("remove_sticker_") + || id == "clear_all_stickers" + || id.starts_with("clear_category_stickers_") + || id.starts_with("stickers_page_") => + { + commands::stickers::handle_button(handler, context, component).await + } + + // Unknown component interaction - ignore silently + _ => { + tracing::debug!("Unhandled component interaction: {}", custom_id); + Ok(()) + } + } +} diff --git a/src/bot/event_handler.rs b/src/bot/event_handler.rs new file mode 100644 index 0000000..8067690 --- /dev/null +++ b/src/bot/event_handler.rs @@ -0,0 +1,98 @@ +use crate::bot::{ + Handler, command_registry, component_router, helpers::command_handler, version_checker, +}; +use crate::commands; +use serenity::all::{ + Command, CreateInteractionResponse, CreateInteractionResponseMessage, Interaction, Ready, +}; +use serenity::prelude::*; + +#[serenity::async_trait] +impl EventHandler for Handler { + async fn interaction_create(&self, context: Context, interaction: Interaction) { + let result = match interaction { + Interaction::Command(ref command) => { + // Determine if it's a context menu or slash command + let command_result = + if command.data.kind == serenity::model::application::CommandType::Message { + command_handler::handle_context_command(self, &context, command).await + } else { + command_handler::handle_slash_command(self, &context, command).await + }; + + // Check for version updates after successful command execution + if command_result.is_ok() + && let Ok(exists) = self.database.user_exists(command.user.id.get()).await + && exists + { + let _ = + version_checker::check_and_notify_version_update(self, &context, command) + .await; + } + + command_result + } + + Interaction::Component(ref component) => { + component_router::route_component_interaction(self, &context, component).await + } + + _ => Ok(()), + }; + + // Handle errors + if let Err(e) = result { + let error_msg = format!("There was an error processing your interaction: {}", e); + eprintln!("ERROR: {}", error_msg); + + match &interaction { + Interaction::Command(command) => { + if let Err(send_err) = commands::error::run( + &context, + command, + "An unexpected error occurred. Please try again later.", + ) + .await + { + eprintln!("Failed to send error response to user: {}", send_err); + } + } + Interaction::Component(component) => { + let error_response = CreateInteractionResponseMessage::new() + .content("[ERROR] An unexpected error occurred. Please try again later.") + .ephemeral(true); + + if let Err(send_err) = component + .create_response( + &context.http, + CreateInteractionResponse::Message(error_response), + ) + .await + { + eprintln!( + "Failed to send component error response to user: {}", + send_err + ); + } + } + _ => { + eprintln!("Unhandled interaction type in error handler"); + } + } + } + } + + async fn ready(&self, context: Context, ready: Ready) { + tracing::info!("[BOT] {} is ready and connected!", ready.user.name); + + let commands_vec = command_registry::get_all_commands(); + let command_count = commands_vec.len(); + + let _commands = Command::set_global_commands(&context, commands_vec).await; + + tracing::info!( + "[CMD] Successfully registered {} global commands", + command_count + ); + } +} diff --git a/src/bot/handler.rs b/src/bot/handler.rs new file mode 100644 index 0000000..3ee0fc7 --- /dev/null +++ b/src/bot/handler.rs @@ -0,0 +1,28 @@ +use ab_glyph::FontArc; +use anyhow::anyhow; + +use crate::utils::database::Database; +use crate::utils::nightscout::Nightscout; + +#[allow(dead_code)] +pub struct Handler { + pub nightscout_client: Nightscout, + pub database: Database, + pub font: FontArc, +} + +impl Handler { + pub async fn new() -> Self { + let font_bytes = std::fs::read("assets/fonts/GeistMono-Regular.ttf") + .map_err(|e| anyhow!("Failed to read font: {}", e)) + .unwrap(); + + Handler { + nightscout_client: Nightscout::new(), + database: Database::new().await.unwrap(), + font: FontArc::try_from_vec(font_bytes) + .map_err(|_| anyhow!("Failed to parse font")) + .unwrap(), + } + } +} diff --git a/src/bot/helpers/command_handler.rs b/src/bot/helpers/command_handler.rs new file mode 100644 index 0000000..c51f697 --- /dev/null +++ b/src/bot/helpers/command_handler.rs @@ -0,0 +1,83 @@ +use crate::bot::Handler; +use crate::commands; +use anyhow::Result; +use serenity::all::{CommandInteraction, Context}; + +/// List of commands that don't require user setup +const UNRESTRICTED_COMMANDS: &[&str] = &["setup", "convert", "help"]; + +/// Route a slash command to its handler +pub async fn handle_slash_command( + handler: &Handler, + context: &Context, + command: &CommandInteraction, +) -> Result<()> { + // Check if user exists for restricted commands + let user_exists = handler.database.user_exists(command.user.id.get()).await?; + + if !user_exists && !UNRESTRICTED_COMMANDS.contains(&command.data.name.as_str()) { + return commands::error::run( + context, + command, + "You need to register your Nightscout URL first. Use `/setup` to get started.", + ) + .await; + } + + // Route to appropriate command handler + match command.data.name.as_str() { + "allow" => commands::allow::run(handler, context, command).await, + "bg" => commands::bg::run(handler, context, command).await, + "convert" => commands::convert::run(handler, context, command).await, + "get-nightscout-url" => commands::get_nightscout_url::run(handler, context, command).await, + "graph" => commands::graph::run(handler, context, command).await, + "help" => commands::help::run(handler, context, command).await, + "info" => commands::info::run(handler, context, command).await, + "set-nightscout-url" => commands::set_nightscout_url::run(handler, context, command).await, + "set-threshold" => commands::set_threshold::run(handler, context, command).await, + "set-token" => commands::set_token::run(handler, context, command).await, + "set-visibility" => commands::set_visibility::run(handler, context, command).await, + "setup" => commands::setup::run(handler, context, command).await, + "stickers" => commands::stickers::run(handler, context, command).await, + "token" => commands::token::run(handler, context, command).await, + unknown_command => { + eprintln!("Unknown slash command received: '{}'", unknown_command); + commands::error::run( + context, + command, + &format!( + "Unknown command: `{}`. Use `/help` to see all available commands.", + unknown_command + ), + ) + .await + } + } +} + +/// Route a context menu command to its handler +pub async fn handle_context_command( + handler: &Handler, + context: &Context, + command: &CommandInteraction, +) -> Result<()> { + match command.data.name.as_str() { + "Add Sticker" => commands::add_sticker::run(handler, context, command).await, + "Analyze Units" => commands::analyze_units::run(handler, context, command).await, + unknown_context_command => { + eprintln!( + "Unknown context menu command received: '{}'", + unknown_context_command + ); + commands::error::run( + context, + command, + &format!( + "Unknown context menu command: `{}`", + unknown_context_command + ), + ) + .await + } + } +} diff --git a/src/bot/helpers/components.rs b/src/bot/helpers/components.rs new file mode 100644 index 0000000..a2ecc74 --- /dev/null +++ b/src/bot/helpers/components.rs @@ -0,0 +1,157 @@ +use serenity::all::{ + ButtonStyle, ComponentInteraction, Context, CreateActionRow, CreateButton, + CreateInteractionResponse, CreateInteractionResponseMessage, +}; + +/// Helper for building buttons more easily +#[allow(dead_code)] +pub struct ButtonBuilder { + buttons: Vec, +} + +#[allow(dead_code)] +impl ButtonBuilder { + pub fn new() -> Self { + Self { + buttons: Vec::new(), + } + } + + /// Add a primary button + pub fn primary(mut self, custom_id: impl Into, label: impl Into) -> Self { + self.buttons.push( + CreateButton::new(custom_id.into()) + .label(label.into()) + .style(ButtonStyle::Primary), + ); + self + } + + /// Add a secondary button + pub fn secondary(mut self, custom_id: impl Into, label: impl Into) -> Self { + self.buttons.push( + CreateButton::new(custom_id.into()) + .label(label.into()) + .style(ButtonStyle::Secondary), + ); + self + } + + /// Add a success button (green) + pub fn success(mut self, custom_id: impl Into, label: impl Into) -> Self { + self.buttons.push( + CreateButton::new(custom_id.into()) + .label(label.into()) + .style(ButtonStyle::Success), + ); + self + } + + /// Add a danger button (red) + pub fn danger(mut self, custom_id: impl Into, label: impl Into) -> Self { + self.buttons.push( + CreateButton::new(custom_id.into()) + .label(label.into()) + .style(ButtonStyle::Danger), + ); + self + } + + /// Add a custom styled button + pub fn add_button(mut self, button: CreateButton) -> Self { + self.buttons.push(button); + self + } + + /// Build the action row + pub fn build(self) -> CreateActionRow { + CreateActionRow::Buttons(self.buttons) + } + + /// Build if there are buttons, otherwise return None + pub fn build_optional(self) -> Option { + if self.buttons.is_empty() { + None + } else { + Some(CreateActionRow::Buttons(self.buttons)) + } + } +} + +impl Default for ButtonBuilder { + fn default() -> Self { + Self::new() + } +} + +/// Helper for responding to component interactions +#[allow(dead_code)] +pub struct ComponentResponseBuilder; + +#[allow(dead_code)] +impl ComponentResponseBuilder { + /// Send an ephemeral error message in response to a component interaction + pub async fn error( + context: &Context, + interaction: &ComponentInteraction, + message: impl Into, + ) -> anyhow::Result<()> { + let response = CreateInteractionResponseMessage::new() + .content(format!("[ERROR] {}", message.into())) + .ephemeral(true); + + interaction + .create_response(context, CreateInteractionResponse::Message(response)) + .await?; + + Ok(()) + } + + /// Send an ephemeral success message in response to a component interaction + pub async fn success( + context: &Context, + interaction: &ComponentInteraction, + message: impl Into, + ) -> anyhow::Result<()> { + let response = CreateInteractionResponseMessage::new() + .content(format!("[OK] {}", message.into())) + .ephemeral(true); + + interaction + .create_response(context, CreateInteractionResponse::Message(response)) + .await?; + + Ok(()) + } + + /// Update the message with new content + pub async fn update_message( + context: &Context, + interaction: &ComponentInteraction, + response: CreateInteractionResponseMessage, + ) -> anyhow::Result<()> { + interaction + .create_response(context, CreateInteractionResponse::UpdateMessage(response)) + .await?; + + Ok(()) + } +} + +/// Check if a custom_id matches a pattern +#[allow(dead_code)] +pub fn custom_id_matches(custom_id: &str, pattern: &str) -> bool { + custom_id.starts_with(pattern) +} + +/// Extract a value from a custom_id with a prefix +/// +/// # Example +/// ``` +/// let value = extract_custom_id_value("remove_sticker_123", "remove_sticker_"); +/// assert_eq!(value, Some("123")); +/// ``` +#[allow(dead_code)] +pub fn extract_custom_id_value<'a>(custom_id: &'a str, prefix: &str) -> Option<&'a str> { + custom_id.strip_prefix(prefix) +} diff --git a/src/bot/helpers/mod.rs b/src/bot/helpers/mod.rs new file mode 100644 index 0000000..e16795c --- /dev/null +++ b/src/bot/helpers/mod.rs @@ -0,0 +1,3 @@ +pub mod command_handler; +pub mod components; +pub mod pagination; diff --git a/src/bot/helpers/pagination.rs b/src/bot/helpers/pagination.rs new file mode 100644 index 0000000..e6a38cf --- /dev/null +++ b/src/bot/helpers/pagination.rs @@ -0,0 +1,80 @@ +use serenity::all::{ButtonStyle, CreateActionRow, CreateButton}; + +/// Create pagination buttons for multi-page interfaces +/// +/// # Arguments +/// * `prefix` - The prefix for button custom IDs (e.g., "help_page_") +/// * `current_page` - The current page number (1-indexed) +/// * `total_pages` - Total number of pages +/// +/// # Returns +/// Optional CreateActionRow with Previous/Next buttons, or None if only 1 page +pub fn create_pagination_buttons( + prefix: &str, + current_page: u8, + total_pages: u8, +) -> Option { + if total_pages <= 1 { + return None; + } + + let mut buttons = Vec::new(); + + if current_page > 1 { + buttons.push( + CreateButton::new(format!("{}{}", prefix, current_page - 1)) + .label("◀ Previous") + .style(ButtonStyle::Secondary), + ); + } + + if current_page < total_pages { + buttons.push( + CreateButton::new(format!("{}{}", prefix, current_page + 1)) + .label("Next ▶") + .style(ButtonStyle::Secondary), + ); + } + + if buttons.is_empty() { + None + } else { + Some(CreateActionRow::Buttons(buttons)) + } +} + +/// Extract page number from a custom_id with a given prefix +/// +/// # Arguments +/// * `custom_id` - The button custom ID (e.g., "help_page_2") +/// * `prefix` - The prefix to strip (e.g., "help_page_") +/// +/// # Returns +/// The page number, or None if parsing fails +pub fn extract_page_number(custom_id: &str, prefix: &str) -> Option { + custom_id.strip_prefix(prefix)?.parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pagination_buttons_single_page() { + let result = create_pagination_buttons("test_", 1, 1); + assert!(result.is_none()); + } + + #[test] + fn test_pagination_buttons_first_page() { + let result = create_pagination_buttons("test_", 1, 3); + assert!(result.is_some()); + } + + #[test] + fn test_extract_page_number() { + assert_eq!(extract_page_number("help_page_2", "help_page_"), Some(2)); + assert_eq!(extract_page_number("help_page_10", "help_page_"), Some(10)); + assert_eq!(extract_page_number("invalid", "help_page_"), None); + } +} diff --git a/src/bot/init.rs b/src/bot/init.rs new file mode 100644 index 0000000..43798e1 --- /dev/null +++ b/src/bot/init.rs @@ -0,0 +1,22 @@ +use crate::bot::Handler; +use anyhow::Result; +use serenity::prelude::*; + +/// Initialize and start the Discord bot +pub async fn start_bot() -> Result<()> { + tracing::info!("[INIT] Starting Beetroot Discord Bot"); + + let token = dotenvy::var("DISCORD_TOKEN").expect("Expected a token in the environment"); + let handler = Handler::new().await; + + let mut client = Client::builder(token, GatewayIntents::empty()) + .event_handler(handler) + .await + .expect("Error creating client"); + + if let Err(why) = client.start().await { + tracing::error!("[ERROR] Discord client error: {why:?}"); + } + + Ok(()) +} diff --git a/src/bot/mod.rs b/src/bot/mod.rs new file mode 100644 index 0000000..e1895b8 --- /dev/null +++ b/src/bot/mod.rs @@ -0,0 +1,11 @@ +mod command_registry; +mod component_router; +mod event_handler; +mod handler; +mod version_checker; + +pub mod helpers; +pub mod init; + +// Re-export Handler for convenience +pub use handler::Handler; diff --git a/src/bot/version_checker.rs b/src/bot/version_checker.rs new file mode 100644 index 0000000..d5178c4 --- /dev/null +++ b/src/bot/version_checker.rs @@ -0,0 +1,53 @@ +use crate::bot::Handler; +use crate::commands; +use anyhow::Result; +use serenity::all::{CommandInteraction, Context, CreateInteractionResponseFollowup}; + +/// Check if user needs to be notified of version update and send notification +pub async fn check_and_notify_version_update( + handler: &Handler, + context: &Context, + command: &CommandInteraction, +) -> Result<()> { + let current_version = dotenvy::var("BOT_VERSION").unwrap_or_else(|_| "0.1.1".to_string()); + let user_id = command.user.id.get(); + + match handler.database.get_user_last_seen_version(user_id).await { + Ok(last_seen_version) => { + if last_seen_version != current_version { + let embed = commands::update_message::create_update_embed(¤t_version); + let response = CreateInteractionResponseFollowup::new() + .embed(embed) + .ephemeral(true); + + if let Err(e) = command.create_followup(&context.http, response).await { + tracing::warn!("[VERSION] Failed to send update notification: {}", e); + } + + if let Err(e) = handler + .database + .update_user_last_seen_version(user_id, ¤t_version) + .await + { + tracing::error!("[VERSION] Failed to update last seen version: {}", e); + } else { + tracing::info!( + "[VERSION] User {} notified of update from {} to {}", + user_id, + last_seen_version, + current_version + ); + } + } + } + Err(e) => { + tracing::debug!( + "[VERSION] Could not get last seen version for user {}: {}", + user_id, + e + ); + } + } + + Ok(()) +} diff --git a/src/commands/add_sticker.rs b/src/commands/add_sticker.rs index e7187fa..ce78f0b 100644 --- a/src/commands/add_sticker.rs +++ b/src/commands/add_sticker.rs @@ -1,4 +1,4 @@ -use crate::Handler; +use crate::bot::Handler; use crate::utils::database::StickerCategory; use serenity::all::{ ButtonStyle, Colour, CommandInteraction, ComponentInteraction, Context, CreateActionRow, diff --git a/src/commands/allow.rs b/src/commands/allow.rs index e68456a..99f1429 100644 --- a/src/commands/allow.rs +++ b/src/commands/allow.rs @@ -1,4 +1,4 @@ -use crate::Handler; +use crate::bot::Handler; use serenity::all::{ Colour, CommandInteraction, CommandOptionType, Context, CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage, InteractionContext, ResolvedOption, ResolvedValue, User, diff --git a/src/commands/analyze_units.rs b/src/commands/analyze_units.rs index be14501..bae0127 100644 --- a/src/commands/analyze_units.rs +++ b/src/commands/analyze_units.rs @@ -1,4 +1,4 @@ -use crate::Handler; +use crate::bot::Handler; use regex::Regex; use serenity::all::{ Colour, CommandInteraction, Context, CreateEmbed, CreateInteractionResponse, diff --git a/src/commands/bg.rs b/src/commands/bg.rs index 5e5e904..3c72b98 100644 --- a/src/commands/bg.rs +++ b/src/commands/bg.rs @@ -1,4 +1,4 @@ -use crate::Handler; +use crate::bot::Handler; use anyhow::Context as AnyhowContext; use serenity::all::{ Colour, CommandInteraction, CommandOptionType, Context, CreateAttachment, CreateEmbed, diff --git a/src/commands/convert.rs b/src/commands/convert.rs index 6446b9c..399d50a 100644 --- a/src/commands/convert.rs +++ b/src/commands/convert.rs @@ -1,4 +1,4 @@ -use crate::Handler; +use crate::bot::Handler; use serenity::all::{ Colour, CommandInteraction, CommandOptionType, Context, CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage, InteractionContext, ResolvedOption, ResolvedValue, diff --git a/src/commands/get_nightscout_url.rs b/src/commands/get_nightscout_url.rs index d2c1db7..0cd04b9 100644 --- a/src/commands/get_nightscout_url.rs +++ b/src/commands/get_nightscout_url.rs @@ -1,4 +1,4 @@ -use crate::Handler; +use crate::bot::Handler; use serenity::all::{ Colour, CommandInteraction, Context, CreateCommand, CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage, InteractionContext, diff --git a/src/commands/graph.rs b/src/commands/graph.rs index d4a92e3..6daa5b0 100644 --- a/src/commands/graph.rs +++ b/src/commands/graph.rs @@ -1,4 +1,4 @@ -use crate::Handler; +use crate::bot::Handler; use crate::utils::graph::draw_graph; use serenity::all::{ CommandInteraction, CommandOptionType, Context, CreateInteractionResponse, diff --git a/src/commands/help.rs b/src/commands/help.rs index c1a046d..278b1ab 100644 --- a/src/commands/help.rs +++ b/src/commands/help.rs @@ -1,8 +1,9 @@ -use crate::Handler; +use crate::bot::Handler; +use crate::bot::helpers::pagination; use serenity::all::{ - ButtonStyle, Colour, CommandInteraction, CommandOptionType, ComponentInteraction, Context, - CreateActionRow, CreateButton, CreateEmbed, CreateInteractionResponse, - CreateInteractionResponseMessage, InteractionContext, ResolvedOption, ResolvedValue, + Colour, CommandInteraction, CommandOptionType, ComponentInteraction, Context, CreateActionRow, + CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage, InteractionContext, + ResolvedOption, ResolvedValue, }; use serenity::builder::{CreateCommand, CreateCommandOption}; @@ -145,33 +146,7 @@ fn create_help_page(page: u8) -> (CreateEmbed, Option) { "Use /info for bot information and GitHub repository", )); - let components = if total_pages > 1 { - let mut buttons = Vec::new(); - - if page > 1 { - buttons.push( - CreateButton::new(format!("help_page_{}", page - 1)) - .label("◀ Previous") - .style(ButtonStyle::Secondary), - ); - } - - if page < total_pages { - buttons.push( - CreateButton::new(format!("help_page_{}", page + 1)) - .label("Next ▶") - .style(ButtonStyle::Secondary), - ); - } - - if !buttons.is_empty() { - Some(CreateActionRow::Buttons(buttons)) - } else { - None - } - } else { - None - }; + let components = pagination::create_pagination_buttons("help_page_", page, total_pages); (embed, components) } @@ -183,8 +158,7 @@ pub async fn handle_button( ) -> anyhow::Result<()> { let custom_id = &interaction.data.custom_id; - if let Some(page_str) = custom_id.strip_prefix("help_page_") { - let page: u8 = page_str.parse().unwrap_or(1); + if let Some(page) = pagination::extract_page_number(custom_id, "help_page_") { let (embed, components) = create_help_page(page); let mut response = CreateInteractionResponseMessage::new().embed(embed); diff --git a/src/commands/info.rs b/src/commands/info.rs index 5c92c95..d556434 100644 --- a/src/commands/info.rs +++ b/src/commands/info.rs @@ -1,4 +1,4 @@ -use crate::Handler; +use crate::bot::Handler; use serenity::all::{ Colour, CommandInteraction, Context, CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage, InteractionContext, diff --git a/src/commands/remove_sticker.rs b/src/commands/remove_sticker.rs index 27d6749..f246989 100644 --- a/src/commands/remove_sticker.rs +++ b/src/commands/remove_sticker.rs @@ -1,4 +1,4 @@ -use crate::Handler; +use crate::bot::Handler; use serenity::all::{ CommandInteraction, CommandOptionType, Context, CreateInteractionResponse, CreateInteractionResponseMessage, InteractionContext, ResolvedOption, ResolvedValue, diff --git a/src/commands/set_nightscout_url.rs b/src/commands/set_nightscout_url.rs index 63d1ac2..df779c1 100644 --- a/src/commands/set_nightscout_url.rs +++ b/src/commands/set_nightscout_url.rs @@ -1,4 +1,4 @@ -use crate::Handler; +use crate::bot::Handler; use serenity::all::{ Colour, CommandInteraction, Context, CreateCommand, CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage, CreateQuickModal, InteractionContext, diff --git a/src/commands/set_threshold.rs b/src/commands/set_threshold.rs index 3eb0ee7..6725785 100644 --- a/src/commands/set_threshold.rs +++ b/src/commands/set_threshold.rs @@ -1,4 +1,4 @@ -use crate::Handler; +use crate::bot::Handler; use serenity::all::{ Colour, CommandInteraction, CommandOptionType, Context, CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage, InteractionContext, ResolvedOption, ResolvedValue, diff --git a/src/commands/set_token.rs b/src/commands/set_token.rs index 452e92c..8dcb483 100644 --- a/src/commands/set_token.rs +++ b/src/commands/set_token.rs @@ -1,4 +1,4 @@ -use crate::Handler; +use crate::bot::Handler; use serenity::all::{ Colour, CommandInteraction, Context, CreateCommand, CreateEmbed, CreateInputText, CreateInteractionResponse, CreateInteractionResponseMessage, CreateQuickModal, diff --git a/src/commands/set_visibility.rs b/src/commands/set_visibility.rs index 84671fb..4101320 100644 --- a/src/commands/set_visibility.rs +++ b/src/commands/set_visibility.rs @@ -1,4 +1,4 @@ -use crate::Handler; +use crate::bot::Handler; use serenity::all::{ Colour, CommandInteraction, CommandOptionType, Context, CreateCommand, CreateCommandOption, CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage, InteractionContext, diff --git a/src/commands/setup.rs b/src/commands/setup.rs index ef61bc0..f3195bb 100644 --- a/src/commands/setup.rs +++ b/src/commands/setup.rs @@ -1,4 +1,5 @@ -use crate::{Handler, utils::database::NightscoutInfo}; +use crate::bot::Handler; +use crate::utils::database::NightscoutInfo; use serenity::all::{ ButtonStyle, Colour, CommandInteraction, ComponentInteraction, Context, CreateActionRow, CreateButton, CreateCommand, CreateEmbed, CreateInputText, CreateInteractionResponse, diff --git a/src/commands/stickers.rs b/src/commands/stickers.rs index 1d8e4fe..b89ef4c 100644 --- a/src/commands/stickers.rs +++ b/src/commands/stickers.rs @@ -1,4 +1,4 @@ -use crate::Handler; +use crate::bot::Handler; use crate::utils::database::StickerCategory; use serenity::all::{ ButtonStyle, Colour, CommandInteraction, CommandOptionType, ComponentInteraction, Context, diff --git a/src/commands/token.rs b/src/commands/token.rs index 6848686..f09f59c 100644 --- a/src/commands/token.rs +++ b/src/commands/token.rs @@ -1,4 +1,4 @@ -use crate::Handler; +use crate::bot::Handler; use serenity::all::{ Colour, CommandInteraction, Context, CreateCommand, CreateEmbed, CreateInputText, CreateInteractionResponse, CreateInteractionResponseMessage, CreateQuickModal, diff --git a/src/main.rs b/src/main.rs index 7412bc4..b478132 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,275 +1,7 @@ +mod bot; mod commands; mod utils; -use ab_glyph::FontArc; -use anyhow::anyhow; -use serenity::all::{ - Command, CreateInteractionResponse, CreateInteractionResponseFollowup, - CreateInteractionResponseMessage, Interaction, Ready, -}; -use serenity::prelude::*; - -use crate::utils::database::Database; -use crate::utils::nightscout::Nightscout; - -#[allow(dead_code)] -pub struct Handler { - nightscout_client: Nightscout, - database: Database, - font: FontArc, -} - -impl Handler { - async fn new() -> Self { - let font_bytes = std::fs::read("assets/fonts/GeistMono-Regular.ttf") - .map_err(|e| anyhow!("Failed to read font: {}", e)) - .unwrap(); - Handler { - nightscout_client: Nightscout::new(), - database: Database::new().await.unwrap(), - font: FontArc::try_from_vec(font_bytes) - .map_err(|_| anyhow!("Failed to parse font")) - .unwrap(), - } - } - - async fn check_and_notify_version_update( - &self, - context: &Context, - command: &serenity::all::CommandInteraction, - ) -> anyhow::Result<()> { - let current_version = dotenvy::var("BOT_VERSION").unwrap_or_else(|_| "0.1.1".to_string()); - let user_id = command.user.id.get(); - - match self.database.get_user_last_seen_version(user_id).await { - Ok(last_seen_version) => { - if last_seen_version != current_version { - let embed = commands::update_message::create_update_embed(¤t_version); - let response = CreateInteractionResponseFollowup::new() - .embed(embed) - .ephemeral(true); - - if let Err(e) = command.create_followup(&context.http, response).await { - tracing::warn!("[VERSION] Failed to send update notification: {}", e); - } - - if let Err(e) = self - .database - .update_user_last_seen_version(user_id, ¤t_version) - .await - { - tracing::error!("[VERSION] Failed to update last seen version: {}", e); - } else { - tracing::info!( - "[VERSION] User {} notified of update from {} to {}", - user_id, - last_seen_version, - current_version - ); - } - } - } - Err(e) => { - tracing::debug!( - "[VERSION] Could not get last seen version for user {}: {}", - user_id, - e - ); - } - } - - Ok(()) - } -} - -#[serenity::async_trait] -impl EventHandler for Handler { - async fn interaction_create(&self, context: Context, interaction: Interaction) { - let result = match interaction { - Interaction::Command(ref command) => { - if command.data.kind == serenity::model::application::CommandType::Message { - match command.data.name.as_str() { - "Add Sticker" => commands::add_sticker::run(self, &context, command).await, - "Analyze Units" => { - commands::analyze_units::run(self, &context, command).await - } - unknown_context_command => { - eprintln!( - "Unknown context menu command received: '{}'", - unknown_context_command - ); - commands::error::run( - &context, - command, - &format!( - "Unknown context menu command: `{}`", - unknown_context_command - ), - ) - .await - } - } - } else { - let cmd_result = match self.database.user_exists(command.user.id.get()).await { - Ok(exists) => { - if !exists - && !["setup", "convert", "help"] - .contains(&command.data.name.as_str()) - { - commands::error::run(&context, command, "You need to register your Nightscout URL first. Use `/setup` to get started.").await - } else { - match command.data.name.as_str() { - "allow" => commands::allow::run(self, &context, command).await, - "bg" => commands::bg::run(self, &context, command).await, - "convert" => { - commands::convert::run(self, &context, command).await - } - "get-nightscout-url" => { - commands::get_nightscout_url::run(self, &context, command) - .await - } - "graph" => commands::graph::run(self, &context, command).await, - "help" => commands::help::run(self, &context, command).await, - "info" => commands::info::run(self, &context, command).await, - "set-nightscout-url" => { - commands::set_nightscout_url::run(self, &context, command) - .await - } - "set-threshold" => { - commands::set_threshold::run(self, &context, command).await - } - "set-token" => { - commands::set_token::run(self, &context, command).await - } - "set-visibility" => { - commands::set_visibility::run(self, &context, command).await - } - "setup" => commands::setup::run(self, &context, command).await, - "stickers" => { - commands::stickers::run(self, &context, command).await - } - "token" => commands::token::run(self, &context, command).await, - unknown_command => { - eprintln!( - "Unknown slash command received: '{}'", - unknown_command - ); - commands::error::run( - &context, - command, - &format!("Unknown command: `{}`. Available commands are: `/allow`, `/bg`, `/convert`, `/get-nightscout-url`, `/graph`, `/help`, `/info`, `/set-nightscout-url`, `/set-threshold`, `/set-token`, `/set-visibility`, `/setup`, `/stickers`, `/token`", unknown_command) - ).await - } - } - } - } - Err(db_error) => Err(anyhow::anyhow!("Database error: {}", db_error)), - }; - - if cmd_result.is_ok() - && let Ok(exists) = self.database.user_exists(command.user.id.get()).await - && exists - { - let _ = self - .check_and_notify_version_update(&context, command) - .await; - } - - cmd_result - } - } - Interaction::Component(ref component) => match component.data.custom_id.as_str() { - "setup_private" | "setup_public" => { - commands::setup::handle_button(self, &context, component).await - } - id if id.starts_with("help_page_") => { - commands::help::handle_button(self, &context, component).await - } - id if id.starts_with("add_sticker_") => { - commands::add_sticker::handle_button(self, &context, component).await - } - id if id.starts_with("remove_sticker_") - || id == "clear_all_stickers" - || id.starts_with("clear_category_stickers_") - || id.starts_with("stickers_page_") => - { - commands::stickers::handle_button(self, &context, component).await - } - _ => Ok(()), - }, - _ => Ok(()), - }; - - if let Err(e) = result { - let error_msg = format!("There was an error processing your interaction: {}", e); - eprintln!("ERROR: {}", error_msg); - - match &interaction { - Interaction::Command(command) => { - if let Err(send_err) = commands::error::run( - &context, - command, - "An unexpected error occurred. Please try again later.", - ) - .await - { - eprintln!("Failed to send error response to user: {}", send_err); - } - } - Interaction::Component(component) => { - let error_response = CreateInteractionResponseMessage::new() - .content("[ERROR] An unexpected error occurred. Please try again later.") - .ephemeral(true); - if let Err(send_err) = component - .create_response( - &context.http, - CreateInteractionResponse::Message(error_response), - ) - .await - { - eprintln!( - "Failed to send component error response to user: {}", - send_err - ); - } - } - _ => { - eprintln!("Unhandled interaction type in error handler"); - } - } - } - } - - async fn ready(&self, context: Context, ready: Ready) { - tracing::info!("[BOT] {} is ready and connected!", ready.user.name); - let commands_vec = vec![ - commands::allow::register(), - commands::bg::register(), - commands::convert::register(), - commands::get_nightscout_url::register(), - commands::graph::register(), - commands::help::register(), - commands::info::register(), - commands::set_nightscout_url::register(), - commands::set_threshold::register(), - commands::set_token::register(), - commands::set_visibility::register(), - commands::setup::register(), - commands::stickers::register(), - commands::token::register(), - commands::add_sticker::register(), - commands::analyze_units::register(), - ]; - let command_count = commands_vec.len(); - let _commands = Command::set_global_commands(&context, commands_vec).await; - tracing::info!( - "[CMD] Successfully registered {} global slash commands", - command_count - ); - // tracing::debug!("Registered commands: {:#?}", commands); - } -} - #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() @@ -283,18 +15,5 @@ async fn main() -> anyhow::Result<()> { .with_line_number(true) .init(); - tracing::info!("[INIT] Starting Beetroot Discord Bot"); - - let token = dotenvy::var("DISCORD_TOKEN").expect("Expected a token in the environment"); - let handler = Handler::new().await; - let mut client = Client::builder(token, GatewayIntents::empty()) - .event_handler(handler) - .await - .expect("Error creating client"); - - if let Err(why) = client.start().await { - tracing::error!("[ERROR] Discord client error: {why:?}"); - } - - Ok(()) + bot::init::start_bot().await } diff --git a/src/utils/graph/drawing.rs b/src/utils/graph/drawing.rs index 8f42760..eafdf7e 100644 --- a/src/utils/graph/drawing.rs +++ b/src/utils/graph/drawing.rs @@ -4,7 +4,7 @@ use imageproc::drawing::{draw_filled_circle_mut, draw_polygon_mut, draw_text_mut use imageproc::point::Point; use super::types::PrefUnit; -use crate::Handler; +use crate::bot::Handler; use crate::utils::nightscout::Entry; /// Draw insulin treatment (triangle) diff --git a/src/utils/graph/mod.rs b/src/utils/graph/mod.rs index bc8cf28..ad13007 100644 --- a/src/utils/graph/mod.rs +++ b/src/utils/graph/mod.rs @@ -16,7 +16,7 @@ use types::PrefUnit; use super::database::{NightscoutInfo, Sticker}; use super::nightscout::{Entry, Profile, Treatment}; -use crate::Handler; +use crate::bot::Handler; use ab_glyph::PxScale; use anyhow::{Result, anyhow}; use chrono::Utc; diff --git a/src/utils/graph/stickers.rs b/src/utils/graph/stickers.rs index e20419d..b2d0da7 100644 --- a/src/utils/graph/stickers.rs +++ b/src/utils/graph/stickers.rs @@ -5,7 +5,7 @@ use imageproc::drawing::draw_text_mut; use super::helpers::download_sticker_image; use super::types::GlucoseStatus; -use crate::Handler; +use crate::bot::Handler; use crate::utils::database::{Sticker, StickerCategory}; use crate::utils::nightscout::Entry; From b77455b74f1a73cff098dc46b7b50c8be5f26abe Mon Sep 17 00:00:00 2001 From: LimeNade Date: Sun, 12 Oct 2025 16:34:12 +0200 Subject: [PATCH 4/4] feat(readme): delete helper readme Was made to not lose myself in this refactor and forgot to delete it... --- src/bot/README.md | 164 ---------------------------------------------- 1 file changed, 164 deletions(-) delete mode 100644 src/bot/README.md diff --git a/src/bot/README.md b/src/bot/README.md deleted file mode 100644 index 20fbf7c..0000000 --- a/src/bot/README.md +++ /dev/null @@ -1,164 +0,0 @@ -# Bot Module Structure - -This directory contains all Discord bot-related code, organized for modularity and maintainability. - -## Directory Structure - -``` -src/bot/ -├── mod.rs # Module exports -├── handler.rs # Handler struct definition -├── event_handler.rs # EventHandler implementation -├── init.rs # Bot initialization -├── command_registry.rs # Command registration -├── component_router.rs # Component interaction routing -├── version_checker.rs # Version update notifications -└── helpers/ # Helper utilities - ├── mod.rs # Helper exports - ├── command_handler.rs # Command routing logic - ├── components.rs # Button/component helpers - └── pagination.rs # Pagination utilities -``` - -## Module Descriptions - -### Core Modules - -#### `handler.rs` -Defines the `Handler` struct that holds bot state: -- Nightscout client -- Database connection -- Font for graph rendering - -#### `event_handler.rs` -Implements `EventHandler` trait for Serenity: -- Routes all interactions (commands & components) -- Handles errors gracefully -- Registers commands on bot ready - -#### `init.rs` -Bot initialization and startup: -- Loads environment variables -- Creates Handler instance -- Starts Discord client - -#### `command_registry.rs` -Central location for command registration: -- Returns all slash commands -- Returns all context menu commands -- Easy to add new commands - -#### `component_router.rs` -Routes button/component interactions: -- Pattern-based routing -- Forwards to appropriate command handlers -- Handles unknown interactions gracefully - -#### `version_checker.rs` -Manages version update notifications: -- Checks user's last seen version -- Sends update notifications -- Updates database with new version - -### Helper Modules - -#### `helpers/command_handler.rs` -Command routing and validation: -- `handle_slash_command()` - Routes slash commands -- `handle_context_command()` - Routes context menu commands -- Checks user setup requirements -- Consistent error handling - -#### `helpers/components.rs` -Button and component utilities: -- `ButtonBuilder` - Fluent API for creating buttons -- `ComponentResponseBuilder` - Helper for responding to interactions -- `custom_id_matches()` - Pattern matching helper -- `extract_custom_id_value()` - Extract data from custom IDs - -#### `helpers/pagination.rs` -Pagination utilities for multi-page interfaces: -- `create_pagination_buttons()` - Generate prev/next buttons -- `extract_page_number()` - Parse page from custom_id - -## Usage Examples - -### Adding a New Command - -1. Create command file in `src/commands/your_command.rs` -2. Add to `src/commands/mod.rs` -3. Add registration to `command_registry.rs`: -```rust -commands::your_command::register(), -``` -4. Add routing in `helpers/command_handler.rs`: -```rust -"your-command" => commands::your_command::run(handler, context, command).await, -``` - -### Using Pagination Helper - -```rust -use crate::bot::helpers::pagination; - -fn create_page(page: u8, total: u8) -> (CreateEmbed, Option) { - let embed = CreateEmbed::new() - .title(format!("Page {}/{}", page, total)); - - let buttons = pagination::create_pagination_buttons("prefix_", page, total); - - (embed, buttons) -} - -// In button handler: -if let Some(page) = pagination::extract_page_number(custom_id, "prefix_") { - // Handle page change -} -``` - -### Using Button Builder - -```rust -use crate::bot::helpers::ButtonBuilder; - -let buttons = ButtonBuilder::new() - .success("confirm_action", "Confirm") - .danger("cancel_action", "Cancel") - .build(); -``` - -### Adding Component Interactions - -Add pattern to `component_router.rs`: -```rust -id if id.starts_with("your_prefix_") => { - commands::your_command::handle_button(handler, context, component).await -} -``` - -## Benefits of This Structure - -1. **Separation of Concerns**: Bot logic separated from business logic -2. **Modularity**: Easy to find and modify specific functionality -3. **Reusability**: Helpers can be used across multiple commands -4. **Maintainability**: Clear structure makes it easy to understand -5. **Testability**: Isolated modules are easier to test -6. **Scalability**: Easy to add new commands and features - -## Main Function - -The main function in `src/main.rs` is now minimal: -```rust -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // Initialize logging - tracing_subscriber::fmt() - .with_env_filter(...) - .init(); - - // Start the bot - bot::init::start_bot().await -} -``` - -All bot-specific logic is contained within the `bot` module.