diff --git a/Cargo.toml b/Cargo.toml index 697f91f..25cecde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "beetroot" -version = "0.1.1" +version = "0.2.0" edition = "2024" [dependencies] 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..31d6010 --- /dev/null +++ b/src/bot/version_checker.rs @@ -0,0 +1,116 @@ +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(); + + tracing::info!( + "[VERSION] Checking version for user {}. Current version: {}", + user_id, + current_version + ); + + match handler.database.get_user_last_seen_version(user_id).await { + Ok(last_seen_version) => { + tracing::info!( + "[VERSION] User {} last seen version: {}", + user_id, + last_seen_version + ); + + if last_seen_version != current_version { + tracing::info!( + "[VERSION] Version mismatch. Sending update notification to user {}", + user_id + ); + + 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 to user {}: {}", + user_id, + e + ); + } else { + tracing::info!( + "[VERSION] Successfully sent update notification to user {}", + user_id + ); + } + + 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 {} version updated from {} to {}", + user_id, + last_seen_version, + current_version + ); + } + } else { + tracing::debug!( + "[VERSION] User {} already on current version {}", + user_id, + current_version + ); + } + } + Err(_) => { + tracing::info!( + "[VERSION] No last seen version for user {}. Sending initial notification", + user_id + ); + + 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 initial version notification to user {}: {}", + user_id, + e + ); + } else { + tracing::info!( + "[VERSION] Successfully sent initial notification to user {}", + user_id + ); + } + + if let Err(e) = handler + .database + .update_user_last_seen_version(user_id, ¤t_version) + .await + { + tracing::error!("[VERSION] Failed to set initial version: {}", e); + } else { + tracing::info!( + "[VERSION] User {} initial version set to {}", + user_id, + current_version + ); + } + } + } + + Ok(()) +} diff --git a/src/commands/add_sticker.rs b/src/commands/add_sticker.rs index 6ab77f2..ce78f0b 100644 --- a/src/commands/add_sticker.rs +++ b/src/commands/add_sticker.rs @@ -1,7 +1,9 @@ -use crate::Handler; +use crate::bot::Handler; +use crate::utils::database::StickerCategory; use serenity::all::{ - Colour, CommandInteraction, Context, CreateEmbed, CreateInteractionResponse, - CreateInteractionResponseMessage, InteractionContext, + ButtonStyle, Colour, CommandInteraction, ComponentInteraction, Context, CreateActionRow, + CreateButton, CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage, + InteractionContext, }; use serenity::builder::CreateCommand; use serenity::model::application::CommandType; @@ -11,7 +13,6 @@ pub async fn run( context: &Context, interaction: &CommandInteraction, ) -> anyhow::Result<()> { - // Get target message from context menu let resolved = &interaction.data.resolved; let target_message = if let Some(message) = resolved.messages.values().next() { message @@ -27,7 +28,6 @@ pub async fn run( let user_id = interaction.user.id.get(); - // Check if user exists in database if !handler.database.user_exists(user_id).await? { crate::commands::error::run( context, @@ -38,27 +38,12 @@ pub async fn run( return Ok(()); } - // Check sticker count limit (max 3) - let sticker_count = handler.database.get_user_sticker_count(user_id).await?; - if sticker_count >= 3 { - crate::commands::error::run( - context, - interaction, - "You already have the maximum number of stickers (3). Use `/remove-sticker` to remove one first.", - ) - .await?; - return Ok(()); - } - - // Extract sticker from Discord message let sticker_info = if let Some(sticker) = target_message.sticker_items.first() { - // Discord sticker found ( sticker.name.clone(), format!("https://media.discordapp.net/stickers/{}.png", sticker.id), ) } else if let Some(content) = extract_sticker_name(&target_message.content) { - // Fallback to content-based extraction (content.clone(), format!("images/stickers/{}.png", content)) } else { crate::commands::error::run( @@ -72,50 +57,92 @@ pub async fn run( let (sticker_name, sticker_url) = sticker_info; - // Generate random position and rotation - let x_position: f32 = rand::random::() * 0.6 + 0.2; // 0.2 to 0.8 - let y_position: f32 = rand::random::() * 0.6 + 0.2; // 0.2 to 0.8 - let rotation: f32 = rand::random::() * 60.0 - 30.0; // -30.0 to 30.0 - - // Use the sticker URL (for Discord stickers) or path (for local stickers) - let sticker_path = sticker_url; - - // Insert sticker into database - match handler - .database - .insert_sticker( - user_id, - &sticker_path, - &sticker_name, - x_position, - y_position, - rotation, - ) - .await - { - Ok(_) => { - let embed = CreateEmbed::new() - .title("Sticker Added") - .description(format!( - "Successfully added **{}** to your graph!\n\nIt will appear on your next `/graph` command.", - sticker_name - )) - .color(Colour::DARK_GREEN); + let buttons = vec![ + CreateButton::new(format!("add_sticker_low:{}:{}", sticker_name, sticker_url)) + .label("Low (3 max)") + .style(ButtonStyle::Danger), + CreateButton::new(format!( + "add_sticker_inrange:{}:{}", + sticker_name, sticker_url + )) + .label("In Range (3 max)") + .style(ButtonStyle::Success), + CreateButton::new(format!("add_sticker_high:{}:{}", sticker_name, sticker_url)) + .label("High (3 max)") + .style(ButtonStyle::Primary), + CreateButton::new(format!("add_sticker_any:{}:{}", sticker_name, sticker_url)) + .label("Any (5 max)") + .style(ButtonStyle::Secondary), + ]; + + let action_row = CreateActionRow::Buttons(buttons); + + let embed = CreateEmbed::new() + .title("Select Sticker Category") + .description(format!( + "Choose a category for **{}**:\n\n\ + • **Low**: Shows when blood glucose is low (<70 mg/dL)\n\ + • **In Range**: Shows when blood glucose is in range (70-180 mg/dL)\n\ + • **High**: Shows when blood glucose is high (>180 mg/dL)\n\ + • **Any**: Shows randomly regardless of blood glucose", + sticker_name + )) + .color(Colour::BLUE); + + let response = CreateInteractionResponseMessage::new() + .embed(embed) + .components(vec![action_row]) + .ephemeral(true); + + interaction + .create_response(&context.http, CreateInteractionResponse::Message(response)) + .await?; - let response = CreateInteractionResponseMessage::new() - .embed(embed) - .ephemeral(true); + Ok(()) +} - interaction - .create_response(&context.http, CreateInteractionResponse::Message(response)) - .await?; +pub async fn handle_button( + handler: &Handler, + context: &Context, + interaction: &ComponentInteraction, +) -> anyhow::Result<()> { + let custom_id = &interaction.data.custom_id; + + if let Some(data) = custom_id.strip_prefix("add_sticker_") { + let parts: Vec<&str> = data.splitn(3, ':').collect(); + if parts.len() != 3 { + return Ok(()); } - Err(e) => { - tracing::error!("[STICKER] Failed to add sticker: {}", e); + let category_str = parts[0]; + let sticker_name = parts[1]; + let sticker_url = parts[2]; + + let category = match category_str { + "low" => StickerCategory::Low, + "inrange" => StickerCategory::InRange, + "high" => StickerCategory::High, + "any" => StickerCategory::Any, + _ => return Ok(()), + }; + + let user_id = interaction.user.id.get(); + + let sticker_count = handler + .database + .get_user_sticker_count_by_category(user_id, category) + .await?; + + if sticker_count >= category.max_count() { let embed = CreateEmbed::new() - .title("Error") - .description("Failed to add sticker. Please try again.") + .title("Category Full") + .description(format!( + "You already have the maximum number of **{}** stickers ({}).\n\ + Use `/stickers category:{}` to remove one first.", + category.display_name(), + category.max_count(), + category.display_name() + )) .color(Colour::RED); let response = CreateInteractionResponseMessage::new() @@ -125,6 +152,55 @@ pub async fn run( interaction .create_response(&context.http, CreateInteractionResponse::Message(response)) .await?; + return Ok(()); + } + + match handler + .database + .insert_sticker(user_id, sticker_url, sticker_name, category) + .await + { + Ok(_) => { + let embed = CreateEmbed::new() + .title("Sticker Added") + .description(format!( + "Successfully added **{}** to your **{}** stickers!\n\n\ + It will appear on your next `/graph` command when your blood glucose is {}.", + sticker_name, + category.display_name(), + match category { + StickerCategory::Low => "low (<70 mg/dL)", + StickerCategory::InRange => "in range (70-180 mg/dL)", + StickerCategory::High => "high (>180 mg/dL)", + StickerCategory::Any => "in any state", + } + )) + .color(Colour::DARK_GREEN); + + let response = CreateInteractionResponseMessage::new() + .embed(embed) + .ephemeral(true); + + interaction + .create_response(&context.http, CreateInteractionResponse::Message(response)) + .await?; + } + Err(e) => { + tracing::error!("[STICKER] Failed to add sticker: {}", e); + + let embed = CreateEmbed::new() + .title("Error") + .description("Failed to add sticker. Please try again.") + .color(Colour::RED); + + let response = CreateInteractionResponseMessage::new() + .embed(embed) + .ephemeral(true); + + interaction + .create_response(&context.http, CreateInteractionResponse::Message(response)) + .await?; + } } } @@ -132,11 +208,8 @@ pub async fn run( } fn extract_sticker_name(content: &str) -> Option { - // Simple extraction - look for common sticker patterns - // This is a basic implementation - you might want to make this more sophisticated let words: Vec<&str> = content.split_whitespace().collect(); - // Look for words that might be sticker names (alphanumeric, underscore) for word in &words { let clean_word = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '_'); if clean_word.len() >= 3 && clean_word.chars().all(|c| c.is_alphanumeric() || c == '_') { @@ -144,7 +217,6 @@ fn extract_sticker_name(content: &str) -> Option { } } - // If no good word found, use first word if let Some(first_word) = words.first() { let clean_word = first_word.trim_matches(|c: char| !c.is_alphanumeric() && c != '_'); if !clean_word.is_empty() { 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 eb4fcb2..9015f19 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, @@ -102,6 +102,12 @@ pub async fn run( } }; + let status = handler + .nightscout_client + .get_status(base_url, token) + .await + .ok(); + let profile = match handler.nightscout_client.get_profile(base_url, token).await { Ok(profile) => profile, Err(e) => { @@ -120,13 +126,38 @@ pub async fn run( .ok() .flatten(); + let now_utc = chrono::Utc::now(); + let thirty_min_ago = now_utc - chrono::Duration::minutes(30); + let start_time = thirty_min_ago.to_rfc3339(); + let end_time = now_utc.to_rfc3339(); + + let recent_entries = handler + .nightscout_client + .get_entries_for_hours(base_url, 1, token) + .await + .unwrap_or_default(); + + let recent_treatments = handler + .nightscout_client + .fetch_treatments_between(base_url, &start_time, &end_time, token) + .await + .unwrap_or_default(); + let default_profile_name = &profile.default_profile; let profile_store = profile .store .get(default_profile_name) .context("Default profile not found")?; + let thresholds = status + .as_ref() + .and_then(|s| s.settings.as_ref()) + .and_then(|settings| settings.thresholds.as_ref()); + let user_timezone = &profile_store.timezone; + let target_low_mg = profile_store.get_target_low_mg(thresholds); + let target_high_mg = profile_store.get_target_high_mg(thresholds); + let entry_time = entry.millis_to_user_timezone(user_timezone); let now = chrono::Utc::now() .with_timezone(&chrono_tz::Tz::from_str(user_timezone).unwrap_or(chrono_tz::UTC)); @@ -140,9 +171,9 @@ pub async fn run( format!("{} days ago", duration.num_days()) }; - let color = if entry.sgv > 180.0 { + let color = if entry.sgv > target_high_mg { Colour::from_rgb(227, 177, 11) - } else if entry.sgv < 70.0 { + } else if entry.sgv < target_low_mg { Colour::from_rgb(235, 47, 47) } else { Colour::from_rgb(87, 189, 79) @@ -154,13 +185,23 @@ pub async fn run( .and_then(|u| u.avatar_url()) .unwrap_or_default(); - let title = format!( - "{}'s Nightscout data", - target_user - .as_ref() - .map(|u| u.display_name()) - .unwrap_or_else(|| "User") - ); + let custom_title = status + .as_ref() + .and_then(|s| s.settings.as_ref()) + .and_then(|settings| settings.custom_title.as_deref()) + .filter(|title| *title != "Nightscout"); + + let title = if let Some(custom) = custom_title { + custom.to_string() + } else { + format!( + "{}'s Nightscout data", + target_user + .as_ref() + .map(|u| u.display_name()) + .unwrap_or_else(|| "User") + ) + }; let icon_bytes = std::fs::read("assets/images/nightscout_icon.png")?; let icon_attachment = CreateAttachment::bytes(icon_bytes, "nightscout_icon.png"); @@ -168,21 +209,41 @@ pub async fn run( let mut embed = CreateEmbed::new() .thumbnail(thumbnail_url) .title(title) - .color(color) - .field( - "mg/dL", - format!("{} ({})", entry.sgv, delta.as_signed_str()), - true, + .color(color); + + let is_data_old = duration.num_minutes() > 15; + + if is_data_old { + embed = embed.field( + "⚠️ Warning ⚠️", + format!("Data is {}min old!", duration.num_minutes()), + false, + ); + } + + let (mgdl_value, mmol_value) = if is_data_old { + ( + format!("~~{} ({})~~", entry.sgv, delta.as_signed_str()), + format!( + "~~{} ({})~~", + entry.svg_as_mmol(), + delta.as_mmol().as_signed_str() + ), ) - .field( - "mmol/L", + } else { + ( + format!("{} ({})", entry.sgv, delta.as_signed_str()), format!( "{} ({})", entry.svg_as_mmol(), delta.as_mmol().as_signed_str() ), - true, ) + }; + + embed = embed + .field("mg/dL", mgdl_value, true) + .field("mmol/L", mmol_value, true) .field("Trend", entry.trend().as_arrow(), true); if let Some(pebble) = pebble_data { @@ -199,6 +260,80 @@ pub async fn run( } } + let mut fingerprick_value: Option<(f32, u64)> = None; + let thirty_min_ago_millis = thirty_min_ago.timestamp_millis() as u64; + + for entry in recent_entries.iter() { + if entry.has_mbg() + && let Some(entry_time_millis) = entry.date + && entry_time_millis >= thirty_min_ago_millis + && let Some(mbg) = entry.mbg + { + fingerprick_value = Some((mbg, entry_time_millis)); + break; + } + } + + if fingerprick_value.is_none() { + for treatment in recent_treatments.iter() { + if treatment.event_type.as_deref() == Some("BG Check") + && let Some(glucose_str) = &treatment.glucose + && let Ok(glucose) = glucose_str.parse::() + { + let treatment_time_millis = if let Some(time) = treatment.date.or(treatment.mills) { + time + } else if let Some(created_at) = &treatment.created_at { + if let Ok(parsed_time) = chrono::DateTime::parse_from_rfc3339(created_at) { + parsed_time.timestamp_millis() as u64 + } else { + continue; + } + } else { + continue; + }; + + if treatment_time_millis >= thirty_min_ago_millis { + fingerprick_value = Some((glucose, treatment_time_millis)); + break; + } + } + } + } + + if let Some((fp_value, fp_timestamp)) = fingerprick_value { + let fp_mmol = fp_value / 18.0; + + let now_millis = now_utc.timestamp_millis() as u64; + + tracing::info!("[BG] Fingerprick timestamp: {}", fp_timestamp); + tracing::info!("[BG] Now timestamp: {}", now_millis); + + let timestamp_millis = if fp_timestamp < 10000000000 { + tracing::info!("[BG] Converting seconds to milliseconds"); + fp_timestamp * 1000 + } else { + tracing::info!("[BG] Timestamp already in milliseconds"); + fp_timestamp + }; + + tracing::info!("[BG] Normalized timestamp: {}", timestamp_millis); + + let diff_millis = now_millis.saturating_sub(timestamp_millis); + tracing::info!("[BG] Difference in milliseconds: {}", diff_millis); + + let fp_age_minutes = diff_millis / 1000 / 60; + tracing::info!("[BG] Age in minutes: {}", fp_age_minutes); + + embed = embed.field( + "Fingerprick", + format!( + "{:.0} mg/dL ({:.1} mmol/L)\n-# {} min ago", + fp_value, fp_mmol, fp_age_minutes + ), + false, + ); + } + embed = embed.footer( CreateEmbedFooter::new(format!("measured • {time_ago}")) .icon_url("attachment://nightscout_icon.png"), 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 new file mode 100644 index 0000000..0cd04b9 --- /dev/null +++ b/src/commands/get_nightscout_url.rs @@ -0,0 +1,71 @@ +use crate::bot::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/graph.rs b/src/commands/graph.rs index d4a92e3..1976842 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, @@ -115,6 +115,12 @@ pub async fn run( } }; + let status = handler + .nightscout_client + .get_status(base_url, token) + .await + .ok(); + let now = chrono::Utc::now(); let hours_ago = now - chrono::Duration::hours(hours); let start_time = hours_ago.to_rfc3339(); @@ -132,6 +138,11 @@ pub async fn run( } }; + let thresholds = status + .as_ref() + .and_then(|s| s.settings.as_ref()) + .and_then(|settings| settings.thresholds.as_ref()); + let buffer = draw_graph( &entries, &treatments, @@ -141,6 +152,7 @@ pub async fn run( handler, hours as u16, None, + thresholds, ) .await?; diff --git a/src/commands/help.rs b/src/commands/help.rs index 28b273c..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}; @@ -75,13 +76,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( @@ -125,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) } @@ -163,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/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/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 new file mode 100644 index 0000000..df779c1 --- /dev/null +++ b/src/commands/set_nightscout_url.rs @@ -0,0 +1,230 @@ +use crate::bot::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_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 new file mode 100644 index 0000000..8dcb483 --- /dev/null +++ b/src/commands/set_token.rs @@ -0,0 +1,133 @@ +use crate::bot::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..4101320 100644 --- a/src/commands/set_visibility.rs +++ b/src/commands/set_visibility.rs @@ -1 +1,148 @@ +use crate::bot::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/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 721c83a..b89ef4c 100644 --- a/src/commands/stickers.rs +++ b/src/commands/stickers.rs @@ -1,10 +1,11 @@ -use crate::Handler; +use crate::bot::Handler; +use crate::utils::database::StickerCategory; use serenity::all::{ - ButtonStyle, Colour, CommandInteraction, ComponentInteraction, Context, CreateActionRow, - CreateButton, CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage, - InteractionContext, + ButtonStyle, Colour, CommandInteraction, CommandOptionType, ComponentInteraction, Context, + CreateActionRow, CreateButton, CreateCommand, CreateCommandOption, CreateEmbed, + CreateInteractionResponse, CreateInteractionResponseMessage, InteractionContext, + ResolvedOption, ResolvedValue, }; -use serenity::builder::CreateCommand; pub async fn run( handler: &Handler, @@ -23,12 +24,53 @@ pub async fn run( return Ok(()); } - let stickers = handler.database.get_user_stickers(user_id).await?; + let options = interaction.data.options(); + let category_filter = if let Some(ResolvedOption { + value: ResolvedValue::String(cat_str), + .. + }) = options.first() + { + cat_str + } else { + "All" + }; + + if category_filter.to_lowercase() == "all" { + show_all_stickers_paginated(handler, context, interaction, 0).await?; + } else if let Some(category) = StickerCategory::from_str(category_filter) { + show_category_stickers(handler, context, interaction, category).await?; + } else { + crate::commands::error::run( + context, + interaction, + "Invalid category. Please choose: Low, In Range, High, Any, or All.", + ) + .await?; + } + + Ok(()) +} + +async fn show_category_stickers( + handler: &Handler, + context: &Context, + interaction: &CommandInteraction, + category: StickerCategory, +) -> anyhow::Result<()> { + let user_id = interaction.user.id.get(); + let stickers = handler + .database + .get_user_stickers_by_category(user_id, category) + .await?; if stickers.is_empty() { let embed = CreateEmbed::new() - .title("Your Stickers") - .description("You don't have any stickers yet!\n\nUse the **\"Add Sticker\"** context menu on a message with a sticker to add one.") + .title(format!("{} Stickers", category.display_name())) + .description(format!( + "You don't have any **{}** stickers yet!\n\n\ + Use the **\"Add Sticker\"** context menu on a message with a sticker to add one.", + category.display_name() + )) .color(Colour::ORANGE); let response = CreateInteractionResponseMessage::new() @@ -42,7 +84,7 @@ pub async fn run( } let mut buttons = Vec::new(); - for (_index, sticker) in stickers.iter().enumerate().take(3) { + for sticker in stickers.iter().take(3) { let button = CreateButton::new(format!("remove_sticker_{}", sticker.id)) .label(format!("Remove {}", sticker.display_name)) .style(ButtonStyle::Danger); @@ -50,10 +92,11 @@ pub async fn run( } if !stickers.is_empty() { - let clear_all_button = CreateButton::new("clear_all_stickers") - .label("Clear All") - .style(ButtonStyle::Secondary); - buttons.push(clear_all_button); + let clear_button = + CreateButton::new(format!("clear_category_stickers_{}", category.to_str())) + .label(format!("Clear All {}", category.display_name())) + .style(ButtonStyle::Secondary); + buttons.push(clear_button); } let action_row = CreateActionRow::Buttons(buttons); @@ -65,13 +108,13 @@ pub async fn run( .join("\n"); let embed = CreateEmbed::new() - .title("Your Stickers") + .title(format!("{} Stickers", category.display_name())) .description(format!( - "**{}/3 stickers:**\n{}", + "**{}/{} stickers:**\n{}", stickers.len(), + category.max_count(), sticker_list )) - .field("Info", "To add a sticker to your graph, use the context menu command `Applications > Add Sticker` when right clicking a sticker sent in chat.", true) .color(Colour::BLUE) .footer(serenity::all::CreateEmbedFooter::new( "Click a button below to remove a sticker", @@ -89,6 +132,136 @@ pub async fn run( Ok(()) } +async fn show_all_stickers_paginated( + handler: &Handler, + context: &Context, + interaction: &CommandInteraction, + page: usize, +) -> anyhow::Result<()> { + let user_id = interaction.user.id.get(); + let all_stickers = handler.database.get_user_stickers(user_id).await?; + + if all_stickers.is_empty() { + let embed = CreateEmbed::new() + .title("Your Stickers") + .description( + "You don't have any stickers yet!\n\n\ + Use the **\"Add Sticker\"** context menu on a message with a sticker to add one.", + ) + .color(Colour::ORANGE); + + let response = CreateInteractionResponseMessage::new() + .embed(embed) + .ephemeral(true); + + interaction + .create_response(&context.http, CreateInteractionResponse::Message(response)) + .await?; + return Ok(()); + } + + let mut categorized: std::collections::HashMap> = + std::collections::HashMap::new(); + for sticker in &all_stickers { + categorized + .entry(sticker.category) + .or_insert_with(Vec::new) + .push(sticker); + } + + let stickers_per_page = 3; + let total_pages = all_stickers.len().div_ceil(stickers_per_page); + let page = page.min(total_pages.saturating_sub(1)); + + let start_idx = page * stickers_per_page; + let end_idx = (start_idx + stickers_per_page).min(all_stickers.len()); + let page_stickers = &all_stickers[start_idx..end_idx]; + + let mut buttons = Vec::new(); + for sticker in page_stickers { + let button = CreateButton::new(format!("remove_sticker_{}", sticker.id)) + .label(format!("Remove {}", sticker.display_name)) + .style(ButtonStyle::Danger); + buttons.push(button); + } + + let mut action_rows = vec![CreateActionRow::Buttons(buttons)]; + + if total_pages > 1 { + let mut nav_buttons = Vec::new(); + + if page > 0 { + nav_buttons.push( + CreateButton::new(format!("stickers_page_{}", page - 1)) + .label("◀ Previous") + .style(ButtonStyle::Primary), + ); + } + + nav_buttons.push( + CreateButton::new("stickers_page_info") + .label(format!("Page {}/{}", page + 1, total_pages)) + .style(ButtonStyle::Secondary) + .disabled(true), + ); + + if page < total_pages - 1 { + nav_buttons.push( + CreateButton::new(format!("stickers_page_{}", page + 1)) + .label("Next ▶") + .style(ButtonStyle::Primary), + ); + } + + action_rows.push(CreateActionRow::Buttons(nav_buttons)); + } + + let mut description = String::from("**Your stickers by category:**\n\n"); + + for category in &[ + StickerCategory::Low, + StickerCategory::InRange, + StickerCategory::High, + StickerCategory::Any, + ] { + let count = categorized.get(category).map_or(0, |v| v.len()); + description.push_str(&format!( + "**{}**: {}/{}\n", + category.display_name(), + count, + category.max_count() + )); + } + + description.push_str("\n**Stickers on this page:**\n"); + for sticker in page_stickers { + description.push_str(&format!( + "• {} ({})\n", + sticker.display_name, + sticker.category.display_name() + )); + } + + let embed = CreateEmbed::new() + .title("All Stickers") + .description(description) + .color(Colour::BLUE) + .footer(serenity::all::CreateEmbedFooter::new( + "Click a button to remove a sticker, or use navigation to see more", + )); + + let response = CreateInteractionResponseMessage::new() + .embed(embed) + .components(action_rows) + .ephemeral(true); + + interaction + .create_response(&context.http, CreateInteractionResponse::Message(response)) + .await?; + + Ok(()) +} + pub async fn handle_button( handler: &Handler, context: &Context, @@ -96,6 +269,146 @@ pub async fn handle_button( ) -> anyhow::Result<()> { let custom_id = &interaction.data.custom_id; + if let Some(page_str) = custom_id.strip_prefix("stickers_page_") + && let Ok(page) = page_str.parse::() + { + let user_id = interaction.user.id.get(); + let all_stickers = handler.database.get_user_stickers(user_id).await?; + + let stickers_per_page = 3; + let total_pages = all_stickers.len().div_ceil(stickers_per_page); + let page = page.min(total_pages.saturating_sub(1)); + + let start_idx = page * stickers_per_page; + let end_idx = (start_idx + stickers_per_page).min(all_stickers.len()); + let page_stickers = &all_stickers[start_idx..end_idx]; + + let mut buttons = Vec::new(); + for sticker in page_stickers { + let button = CreateButton::new(format!("remove_sticker_{}", sticker.id)) + .label(format!("Remove {}", sticker.display_name)) + .style(ButtonStyle::Danger); + buttons.push(button); + } + + let mut action_rows = vec![CreateActionRow::Buttons(buttons)]; + + if total_pages > 1 { + let mut nav_buttons = Vec::new(); + + if page > 0 { + nav_buttons.push( + CreateButton::new(format!("stickers_page_{}", page - 1)) + .label("◀ Previous") + .style(ButtonStyle::Primary), + ); + } + + nav_buttons.push( + CreateButton::new("stickers_page_info") + .label(format!("Page {}/{}", page + 1, total_pages)) + .style(ButtonStyle::Secondary) + .disabled(true), + ); + + if page < total_pages - 1 { + nav_buttons.push( + CreateButton::new(format!("stickers_page_{}", page + 1)) + .label("Next ▶") + .style(ButtonStyle::Primary), + ); + } + + action_rows.push(CreateActionRow::Buttons(nav_buttons)); + } + + let mut categorized: std::collections::HashMap> = + std::collections::HashMap::new(); + for sticker in &all_stickers { + categorized + .entry(sticker.category) + .or_insert_with(Vec::new) + .push(sticker); + } + + let mut description = String::from("**Your stickers by category:**\n\n"); + for category in &[ + StickerCategory::Low, + StickerCategory::InRange, + StickerCategory::High, + StickerCategory::Any, + ] { + let count = categorized.get(category).map_or(0, |v| v.len()); + description.push_str(&format!( + "**{}**: {}/{}\n", + category.display_name(), + count, + category.max_count() + )); + } + + description.push_str("\n**Stickers on this page:**\n"); + for sticker in page_stickers { + description.push_str(&format!( + "• {} ({})\n", + sticker.display_name, + sticker.category.display_name() + )); + } + + let embed = CreateEmbed::new() + .title("All Stickers") + .description(description) + .color(Colour::BLUE) + .footer(serenity::all::CreateEmbedFooter::new( + "Click a button to remove a sticker, or use navigation to see more", + )); + + let response = CreateInteractionResponseMessage::new() + .embed(embed) + .components(action_rows) + .ephemeral(true); + + interaction + .create_response( + &context.http, + CreateInteractionResponse::UpdateMessage(response), + ) + .await?; + return Ok(()); + } + + if let Some(category_str) = custom_id.strip_prefix("clear_category_stickers_") + && let Some(category) = StickerCategory::from_str(category_str) + { + let user_id = interaction.user.id.get(); + let stickers = handler + .database + .get_user_stickers_by_category(user_id, category) + .await?; + + for sticker in stickers { + handler.database.delete_sticker(sticker.id).await?; + } + + let embed = CreateEmbed::new() + .title("Stickers Cleared") + .description(format!( + "Successfully removed all **{}** stickers!", + category.display_name() + )) + .color(Colour::ORANGE); + + let response = CreateInteractionResponseMessage::new() + .embed(embed) + .ephemeral(true); + + interaction + .create_response(&context.http, CreateInteractionResponse::Message(response)) + .await?; + return Ok(()); + } + if custom_id == "clear_all_stickers" { let user_id = interaction.user.id.get(); @@ -188,6 +501,19 @@ pub async fn handle_button( pub fn register() -> CreateCommand { CreateCommand::new("stickers") .description("Manage your stickers - view and remove stickers from your graph.") + .add_option( + CreateCommandOption::new( + CommandOptionType::String, + "category", + "Filter by category (Low, In Range, High, Any, or All)", + ) + .required(true) + .add_string_choice("All", "All") + .add_string_choice("Low", "Low") + .add_string_choice("In Range", "In Range") + .add_string_choice("High", "High") + .add_string_choice("Any", "Any"), + ) .contexts(vec![ InteractionContext::Guild, InteractionContext::PrivateChannel, 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/commands/update_message.rs b/src/commands/update_message.rs index 6c47408..7d255d8 100644 --- a/src/commands/update_message.rs +++ b/src/commands/update_message.rs @@ -1,37 +1,61 @@ use serenity::all::{Colour, CreateEmbed, CreateEmbedFooter}; pub fn create_update_embed(version: &str) -> CreateEmbed { - let changelog = match version { - "0.1.2" => vec![ - "**⚠️WARNING⚠️**", - "We recommend closed loops users to remove the threshold (by setting it to 0) they set prior to this update (read changelog)", - "", - "**New Features:**", - "• Added IOB (Insulin On Board) & COB (Carbs On Board) display (appears when using `/bg`)", - "• Blood glucose unit conversion features (`/convert {value} {unit}`)", - "• Sticker customization improvements (`/stickers`)", - "• Analyzing blood glucose values in any mesages using the `Analyzing Units` context menu command", - "• Thanks to Gar, AAPS users will now benefit from SMB detection instead of using the `/threshold` command, which makes the bot - easier and more convenient to use by separating manual boluses from automatic ones.", - "", - "**Fixes:**", - "• Help command updates", - "• Various bug fixes and improvements", - ], - _ => vec![ - "**What's New:**", - "• Bug fixes and performance improvements", - "• Enhanced stability", - ], - }; + match version { + "0.2.0" => { + let whats_new = [ + "• **Doubled** graph resolution for bigger and clearer images", + "• Added warning in `/bg` if data is older than 15 min", + "• Added contextual stickers that generate based on your blood glucose value", + "• Updated `/stickers` command to work with contextual stickers", + "• Added `/set-token`, `/set-nightscout-url`, `/get-nightscout-url` and `/set-visibility` commands", + "• MBG (meter blood glucose) entries now displayed as fingerprick readings on graphs", + "• Target ranges now dynamically fetched from your Nightscout profile", + "• Added faint striped lines at target high/low ranges on graphs", + "• `/bg` now uses custom title from Nightscout status settings", + "• `/bg` displays fingerprick values from past 30 min in both mg/dL and mmol/L", + ]; - CreateEmbed::new() - .title(format!("🎉 Beetroot has been updated to v{}", version)) - .description("Here's what's new in this update:") - .color(Colour::DARK_GREEN) - .field("Changelog", changelog.join("\n"), false) - .field("For more info","For additional information, please check out the official repository: https://github.com/ItsLimeNade/Beetroot/releases", false) - .footer(CreateEmbedFooter::new( - "Thank you for using Beetroot! Use /help to see all available commands.", - )) + let fixes = [ + "• Fixed missing data on graph edges collapsing the graph", + "• Fixed MBG entries not being fetched from the API", + "• Fixed duplicate detection treating MBG and SGV entries the same", + ]; + + CreateEmbed::new() + .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("What's New", whats_new.join("\n"), false) + .field("Fixes", fixes.join("\n"), false) + .field( + "For more info", + "Check out: https://github.com/ItsLimeNade/Beetroot/releases", + false, + ) + .footer(CreateEmbedFooter::new( + "Thank you for using Beetroot! Use /help to see all available commands.", + )) + } + _ => CreateEmbed::new() + .title(format!("🎉 Beetroot has been updated to v{}", version)) + .description("Here's what's new in this update:") + .color(Colour::DARK_GREEN) + .field( + "What's New", + "• Bug fixes and performance improvements\n• Enhanced stability", + false, + ) + .field( + "For more info", + "Check out: https://github.com/ItsLimeNade/Beetroot/releases", + false, + ) + .footer(CreateEmbedFooter::new( + "Thank you for using Beetroot! Use /help to see all available commands.", + )), + } } diff --git a/src/main.rs b/src/main.rs index 5b61444..b478132 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,259 +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(); - - // Get user's last seen version - match self.database.get_user_last_seen_version(user_id).await { - Ok(last_seen_version) => { - // If versions differ, show update message - if last_seen_version != current_version { - let embed = commands::update_message::create_update_embed(¤t_version); - let response = CreateInteractionResponseFollowup::new() - .embed(embed) - .ephemeral(true); - - // Send as follow-up message - if let Err(e) = command.create_followup(&context.http, response).await { - tracing::warn!("[VERSION] Failed to send update notification: {}", e); - } - - // Update the user's last seen version - 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) => { - // Handle context menu commands - 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 { - // Handle regular slash commands - 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 - } - "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-threshold" => { - commands::set_threshold::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`, `/graph`, `/help`, `/info`, `/setup`, `/set-threshold`, `/stickers`, `/token`", unknown_command) - ).await - } - } - } - } - Err(db_error) => Err(anyhow::anyhow!("Database error: {}", db_error)), - }; - - // After command execution, check for version updates - 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("remove_sticker_") || id == "clear_all_stickers" => { - 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![ - // Slash commands - commands::allow::register(), - commands::bg::register(), - commands::convert::register(), - commands::graph::register(), - commands::help::register(), - commands::info::register(), - commands::setup::register(), - commands::set_threshold::register(), - commands::stickers::register(), - commands::token::register(), - // Context menu commands - 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() @@ -267,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/database.rs b/src/utils/database.rs index 73c538d..8e3d39d 100644 --- a/src/utils/database.rs +++ b/src/utils/database.rs @@ -84,17 +84,59 @@ pub struct NightscoutInfo { pub display_microbolus: bool, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum StickerCategory { + Low, + InRange, + High, + Any, +} + +impl StickerCategory { + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "low" => Some(Self::Low), + "inrange" | "in_range" | "in range" => Some(Self::InRange), + "high" => Some(Self::High), + "any" => Some(Self::Any), + _ => None, + } + } + + pub fn to_str(self) -> &'static str { + match self { + Self::Low => "low", + Self::InRange => "inrange", + Self::High => "high", + Self::Any => "any", + } + } + + pub fn display_name(&self) -> &'static str { + match self { + Self::Low => "Low", + Self::InRange => "In Range", + Self::High => "High", + Self::Any => "Any", + } + } + + pub fn max_count(&self) -> i64 { + match self { + Self::Low => 3, + Self::InRange => 3, + Self::High => 3, + Self::Any => 5, + } + } +} + #[derive(Clone, Debug)] pub struct Sticker { pub id: i32, pub file_name: String, pub display_name: String, - #[allow(dead_code)] - pub x_position: f32, - #[allow(dead_code)] - pub y_position: f32, - #[allow(dead_code)] - pub rotation: f32, + pub category: StickerCategory, } #[derive(Clone, Debug)] @@ -123,6 +165,7 @@ impl Database { migration.add_sticker_position_fields().await?; migration.add_sticker_display_name_field().await?; migration.add_last_seen_version_field().await?; + migration.add_sticker_category_field().await?; Ok(Database { pool }) } @@ -294,17 +337,13 @@ impl Database { discord_id: u64, file_name: &str, display_name: &str, - x_position: f32, - y_position: f32, - rotation: f32, + category: StickerCategory, ) -> Result<(), sqlx::Error> { - sqlx::query("INSERT INTO stickers (file_name, display_name, discord_id, x_position, y_position, rotation) VALUES (?, ?, ?, ?, ?, ?)") + sqlx::query("INSERT INTO stickers (file_name, display_name, discord_id, category) VALUES (?, ?, ?, ?)") .bind(file_name) .bind(display_name) .bind(discord_id as i64) - .bind(x_position) - .bind(y_position) - .bind(rotation) + .bind(category.to_str()) .execute(&self.pool) .await?; @@ -336,6 +375,7 @@ impl Database { Ok(()) } + #[allow(dead_code)] pub async fn get_user_sticker_count(&self, discord_id: u64) -> Result { let row = sqlx::query("SELECT COUNT(*) as count FROM stickers WHERE discord_id = ?") .bind(discord_id as i64) @@ -463,26 +503,74 @@ impl Database { } pub async fn get_user_stickers(&self, user_id: u64) -> Result, sqlx::Error> { - let rows = sqlx::query("SELECT id, file_name, display_name, x_position, y_position, rotation FROM stickers WHERE discord_id = ?") + let rows = sqlx::query( + "SELECT id, file_name, display_name, category FROM stickers WHERE discord_id = ?", + ) + .bind(user_id as i64) + .fetch_all(&self.pool) + .await?; + + let stickers: Vec = rows + .iter() + .map(|row| { + let category_str: String = row.get("category"); + Sticker { + id: row.get("id"), + file_name: row.get("file_name"), + display_name: row.get("display_name"), + category: StickerCategory::from_str(&category_str) + .unwrap_or(StickerCategory::Any), + } + }) + .collect(); + + Ok(stickers) + } + + pub async fn get_user_stickers_by_category( + &self, + user_id: u64, + category: StickerCategory, + ) -> Result, sqlx::Error> { + let rows = sqlx::query("SELECT id, file_name, display_name, category FROM stickers WHERE discord_id = ? AND category = ?") .bind(user_id as i64) + .bind(category.to_str()) .fetch_all(&self.pool) .await?; let stickers: Vec = rows .iter() - .map(|row| Sticker { - id: row.get("id"), - file_name: row.get("file_name"), - display_name: row.get("display_name"), - x_position: row.get("x_position"), - y_position: row.get("y_position"), - rotation: row.get("rotation"), + .map(|row| { + let category_str: String = row.get("category"); + Sticker { + id: row.get("id"), + file_name: row.get("file_name"), + display_name: row.get("display_name"), + category: StickerCategory::from_str(&category_str) + .unwrap_or(StickerCategory::Any), + } }) .collect(); Ok(stickers) } + pub async fn get_user_sticker_count_by_category( + &self, + user_id: u64, + category: StickerCategory, + ) -> Result { + let row = sqlx::query( + "SELECT COUNT(*) as count FROM stickers WHERE discord_id = ? AND category = ?", + ) + .bind(user_id as i64) + .bind(category.to_str()) + .fetch_one(&self.pool) + .await?; + + Ok(row.get("count")) + } + pub async fn update_microbolus_settings( &self, discord_id: u64, diff --git a/src/utils/graph/drawing.rs b/src/utils/graph/drawing.rs new file mode 100644 index 0000000..3c2ffd3 --- /dev/null +++ b/src/utils/graph/drawing.rs @@ -0,0 +1,241 @@ +use ab_glyph::PxScale; +use image::{Rgba, RgbaImage}; +use imageproc::drawing::{draw_filled_circle_mut, draw_polygon_mut, draw_text_mut}; +use imageproc::point::Point; + +use super::types::PrefUnit; +use crate::bot::Handler; +use crate::utils::nightscout::Entry; + +/// Draw insulin treatment (triangle) +#[allow(clippy::too_many_arguments)] +pub fn draw_insulin_treatment( + img: &mut RgbaImage, + insulin_amount: f32, + is_microbolus: bool, + microbolus_threshold: f32, + x: f32, + y: f32, + insulin_col: Rgba, + bg: Rgba, + bright: Rgba, + handler: &Handler, +) { + let triangle_size = if is_microbolus { + 8 + } else if insulin_amount <= microbolus_threshold + 1.0 { + 12 + } else if insulin_amount <= microbolus_threshold + 5.0 { + 18 + } else { + 30 + }; + + let triangle_y = y + 70.0; + + tracing::trace!( + "[GRAPH] Drawing insulin: {:.1}u at ({:.1}, {:.1}) - size: {}", + insulin_amount, + x, + triangle_y, + triangle_size + ); + + let triangle_points = vec![ + Point::new( + (x - triangle_size as f32) as i32, + (triangle_y - triangle_size as f32) as i32, + ), + Point::new( + (x + triangle_size as f32) as i32, + (triangle_y - triangle_size as f32) as i32, + ), + Point::new(x as i32, (triangle_y + triangle_size as f32) as i32), + ]; + + draw_polygon_mut(img, &triangle_points, insulin_col); + + if !is_microbolus { + let insulin_text = format!("{:.1}u", insulin_amount); + let text_width = insulin_text.len() as f32 * 18.0; + let text_x = (x - text_width / 2.0) as i32; + let text_y = (triangle_y + triangle_size as f32 + 16.0) as i32; + let scale = PxScale::from(36.0); + + for dx in [-1, 0, 1] { + for dy in [-1, 0, 1] { + if dx != 0 || dy != 0 { + draw_text_mut( + img, + bg, + text_x + dx, + text_y + dy, + scale, + &handler.font, + &insulin_text, + ); + } + } + } + + draw_text_mut( + img, + bright, + text_x, + text_y, + scale, + &handler.font, + &insulin_text, + ); + } +} + +/// Draw carbs treatment (circle) +pub fn draw_carbs_treatment( + img: &mut RgbaImage, + carbs_amount: f32, + x: f32, + y: f32, + carbs_col: Rgba, + bg: Rgba, + handler: &Handler, +) { + let circle_radius = if carbs_amount < 0.5 { + 8 + } else if carbs_amount <= 2.0 { + 14 + } else { + 24 + }; + + tracing::trace!( + "[GRAPH] Drawing carbs: {:.0}g at ({:.1}, {:.1})", + carbs_amount, + x, + y + ); + + let carbs_y = y - 70.0; + + draw_filled_circle_mut(img, (x as i32, carbs_y as i32), circle_radius, carbs_col); + + let carbs_text = format!("{}g", carbs_amount as i32); + let text_width = carbs_text.len() as f32 * 18.0; + let text_x = (x - text_width / 2.0) as i32; + let text_y = (carbs_y - circle_radius as f32 - 50.0) as i32; + let scale = PxScale::from(36.0); + + for dx in [-1, 0, 1] { + for dy in [-1, 0, 1] { + if dx != 0 || dy != 0 { + draw_text_mut( + img, + bg, + text_x + dx, + text_y + dy, + scale, + &handler.font, + &carbs_text, + ); + } + } + } + + draw_text_mut( + img, + carbs_col, + text_x, + text_y, + scale, + &handler.font, + &carbs_text, + ); +} + +/// Draw glucose reading treatment (dual circle) +#[allow(clippy::too_many_arguments)] +pub fn draw_glucose_reading( + img: &mut RgbaImage, + glucose_value: f32, + x: f32, + y: f32, + pref: PrefUnit, + bg: Rgba, + bright: Rgba, + handler: &Handler, +) { + tracing::trace!( + "[GRAPH] Drawing glucose reading: {:.1} at ({:.1}, {:.1})", + glucose_value, + x, + y + ); + + let bg_check_radius = 12; + let grey_outline = Rgba([128u8, 128u8, 128u8, 255u8]); + let red_inside = Rgba([220u8, 38u8, 27u8, 255u8]); + + draw_filled_circle_mut(img, (x as i32, y as i32), bg_check_radius, grey_outline); + draw_filled_circle_mut(img, (x as i32, y as i32), bg_check_radius - 4, red_inside); + + let glucose_text = match pref { + PrefUnit::MgDl => format!("{:.0}", glucose_value), + PrefUnit::Mmol => format!("{:.1}", glucose_value / 18.0), + }; + let text_width = glucose_text.len() as f32 * 16.0; + let text_x = (x - text_width / 2.0) as i32; + let text_y = (y - bg_check_radius as f32 - 40.0) as i32; + let scale = PxScale::from(32.0); + + for dx in [-1, 0, 1] { + for dy in [-1, 0, 1] { + if dx != 0 || dy != 0 { + draw_text_mut( + img, + bg, + text_x + dx, + text_y + dy, + scale, + &handler.font, + &glucose_text, + ); + } + } + } + + draw_text_mut( + img, + bright, + text_x, + text_y, + scale, + &handler.font, + &glucose_text, + ); +} + +/// Draw glucose data points on the graph +#[allow(clippy::too_many_arguments)] +pub fn draw_glucose_points( + img: &mut RgbaImage, + entries: &[Entry], + points_px: &[(f32, f32)], + svg_radius: i32, + high_col: Rgba, + low_col: Rgba, + axis_col: Rgba, + target_high: f32, + target_low: f32, +) { + for (i, e) in entries.iter().enumerate() { + let (x, y) = points_px[i]; + let color = if e.sgv > target_high { + high_col + } else if e.sgv < target_low { + low_col + } else { + axis_col + }; + draw_filled_circle_mut(img, (x.round() as i32, y.round() as i32), svg_radius, color); + } +} diff --git a/src/utils/graph/helpers.rs b/src/utils/graph/helpers.rs new file mode 100644 index 0000000..b8e298b --- /dev/null +++ b/src/utils/graph/helpers.rs @@ -0,0 +1,91 @@ +use anyhow::{Result, anyhow}; +use image::RgbaImage; + +/// Download a sticker image from a URL +pub async fn download_sticker_image(url: &str) -> Result { + tracing::debug!("[STICKER] Downloading sticker from: {}", url); + + let response = reqwest::get(url).await?; + + if !response.status().is_success() { + return Err(anyhow!( + "Failed to download sticker: HTTP {}", + response.status() + )); + } + + let bytes = response.bytes().await?; + let img = image::load_from_memory(&bytes)?; + + tracing::debug!( + "[STICKER] Successfully downloaded sticker ({} bytes)", + bytes.len() + ); + Ok(img) +} + +/// Draw a dashed vertical line on the image +pub fn draw_dashed_vertical_line( + img: &mut RgbaImage, + x: f32, + y_start: f32, + y_end: f32, + color: image::Rgba, + dash_length: i32, + gap_length: i32, +) { + let x = x.round() as i32; + let y_start = y_start.round() as i32; + let y_end = y_end.round() as i32; + + let mut y = y_start; + let mut drawing_dash = true; + + while y < y_end { + if drawing_dash { + let dash_end = (y + dash_length).min(y_end); + for py in y..dash_end { + if x >= 0 && x < img.width() as i32 && py >= 0 && py < img.height() as i32 { + img.put_pixel(x as u32, py as u32, color); + } + } + y += dash_length; + } else { + y += gap_length; + } + drawing_dash = !drawing_dash; + } +} + +/// Draw a dashed horizontal line on the image +pub fn draw_dashed_horizontal_line( + img: &mut RgbaImage, + y: f32, + x_start: f32, + x_end: f32, + color: image::Rgba, + dash_length: i32, + gap_length: i32, +) { + let y = y.round() as i32; + let x_start = x_start.round() as i32; + let x_end = x_end.round() as i32; + + let mut x = x_start; + let mut drawing_dash = true; + + while x < x_end { + if drawing_dash { + let dash_end = (x + dash_length).min(x_end); + for px in x..dash_end { + if px >= 0 && px < img.width() as i32 && y >= 0 && y < img.height() as i32 { + img.put_pixel(px as u32, y as u32, color); + } + } + x += dash_length; + } else { + x += gap_length; + } + drawing_dash = !drawing_dash; + } +} diff --git a/src/utils/graph.rs b/src/utils/graph/mod.rs similarity index 57% rename from src/utils/graph.rs rename to src/utils/graph/mod.rs index b4d4215..473a5dc 100644 --- a/src/utils/graph.rs +++ b/src/utils/graph/mod.rs @@ -1,78 +1,29 @@ +mod drawing; +mod helpers; +mod stickers; +mod types; + +use drawing::{ + draw_carbs_treatment, draw_glucose_points, draw_glucose_reading, draw_insulin_treatment, +}; +use helpers::{draw_dashed_horizontal_line, draw_dashed_vertical_line}; +use stickers::{ + StickerConfig, draw_sticker, filter_ranges_by_duration, find_sticker_position, + identify_status_ranges, select_stickers_to_place, +}; +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; use chrono_tz::Tz; use image::{DynamicImage, Rgba, RgbaImage}; -use imageproc::drawing::{ - draw_filled_circle_mut, draw_line_segment_mut, draw_polygon_mut, draw_text_mut, -}; -use imageproc::point::Point; +use imageproc::drawing::{draw_line_segment_mut, draw_text_mut}; use std::io::Cursor; -async fn download_sticker_image(url: &str) -> Result { - tracing::debug!("[STICKER] Downloading sticker from: {}", url); - - let response = reqwest::get(url).await?; - - if !response.status().is_success() { - return Err(anyhow!( - "Failed to download sticker: HTTP {}", - response.status() - )); - } - - let bytes = response.bytes().await?; - let img = image::load_from_memory(&bytes)?; - - tracing::debug!( - "[STICKER] Successfully downloaded sticker ({} bytes)", - bytes.len() - ); - Ok(img) -} - -fn draw_dashed_vertical_line( - img: &mut RgbaImage, - x: f32, - y_start: f32, - y_end: f32, - color: image::Rgba, - dash_length: i32, - gap_length: i32, -) { - let x = x.round() as i32; - let y_start = y_start.round() as i32; - let y_end = y_end.round() as i32; - - let mut y = y_start; - let mut drawing_dash = true; - - while y < y_end { - if drawing_dash { - let dash_end = (y + dash_length).min(y_end); - for py in y..dash_end { - if x >= 0 && x < img.width() as i32 && py >= 0 && py < img.height() as i32 { - img.put_pixel(x as u32, py as u32, color); - } - } - y += dash_length; - } else { - y += gap_length; - } - drawing_dash = !drawing_dash; - } -} - -#[derive(Clone, Copy, Debug)] -#[allow(dead_code)] -enum PrefUnit { - MgDl, - Mmol, -} - #[allow(dead_code)] #[allow(clippy::too_many_arguments)] pub async fn draw_graph( @@ -84,6 +35,7 @@ pub async fn draw_graph( handler: &Handler, hours: u16, save_path: Option<&str>, + status_thresholds: Option<&super::nightscout::StatusThresholds>, ) -> Result> { tracing::info!( "[GRAPH] Starting graph generation for {} hours of data", @@ -113,7 +65,14 @@ pub async fn draw_graph( let user_timezone = &profile_store.timezone; tracing::info!("[GRAPH] Using timezone: {}", user_timezone); - // Use the new filtering method from nightscout client + let target_low_mg = profile_store.get_target_low_mg(status_thresholds); + let target_high_mg = profile_store.get_target_high_mg(status_thresholds); + tracing::info!( + "[GRAPH] Using target ranges: {:.1} - {:.1} mg/dL", + target_low_mg, + target_high_mg + ); + let nightscout_client = crate::utils::nightscout::Nightscout::new(); let entries = match nightscout_client.filter_and_clean_entries(entries, hours, user_timezone) { Ok(filtered) => filtered, @@ -147,8 +106,8 @@ pub async fn draw_graph( let num_y_labels = 8; let approximation = false; - let width = 850u32; - let height = 550u32; + let width = 1700u32; + let height = 1100u32; let bg = Rgba([17u8, 24u8, 28u8, 255u8]); let grid_col = Rgba([30u8, 41u8, 47u8, 255u8]); @@ -162,10 +121,10 @@ pub async fn draw_graph( let carbs_col = Rgba([251u8, 191u8, 36u8, 255u8]); let _glucose_reading_col = Rgba([52u8, 211u8, 153u8, 255u8]); - let left_margin = 80.0_f32; - let right_margin = 40.0_f32; - let top_margin = 40.0_f32; - let bottom_margin = 80.0_f32; + let left_margin = 160.0_f32; + let right_margin = 80.0_f32; + let top_margin = 80.0_f32; + let bottom_margin = 160.0_f32; let plot_w = (width as f32) - left_margin - right_margin; let plot_h = (height as f32) - top_margin - bottom_margin; @@ -174,7 +133,7 @@ pub async fn draw_graph( let plot_right = plot_left + plot_w; let plot_bottom = plot_top + plot_h; - let plot_padding = 10.0; + let plot_padding = 20.0; let inner_plot_left = plot_left + plot_padding; let inner_plot_right = plot_right - plot_padding; @@ -183,14 +142,14 @@ pub async fn draw_graph( let inner_plot_w = inner_plot_right - inner_plot_left; let inner_plot_h = inner_plot_bottom - inner_plot_top; - let y_label_size_primary = 20.0_f32; - let y_label_size_secondary = 18.0_f32; - let x_label_size_primary = 20.0_f32; - let x_label_size_secondary = 18.0_f32; - let primary_legend_font_size: f32 = 20.0_f32; - let secondary_legend_font_size: f32 = 18.0_f32; + let y_label_size_primary = 40.0_f32; + let y_label_size_secondary = 36.0_f32; + let x_label_size_primary = 40.0_f32; + let x_label_size_secondary = 36.0_f32; + let primary_legend_font_size: f32 = 40.0_f32; + let secondary_legend_font_size: f32 = 36.0_f32; - let svg_radius: i32 = if entries.len() < 100 { 4 } else { 3 }; + let svg_radius: i32 = if entries.len() < 100 { 8 } else { 6 }; let (y_min, y_max) = match pref { PrefUnit::MgDl => { @@ -286,7 +245,7 @@ pub async fn draw_graph( ); } - let label_x = (plot_left - 68.0) as i32; + let label_x = (plot_left - 136.0) as i32; match pref { PrefUnit::MgDl => { @@ -294,7 +253,7 @@ pub async fn draw_graph( &mut img, bright, label_x, - (y_px - 8.0) as i32, + (y_px - 16.0) as i32, PxScale::from(y_label_size_primary), &handler.font, &format!("{}", (*y_val as i32)), @@ -310,7 +269,7 @@ pub async fn draw_graph( &mut img, dim, label_x, - (y_px + 6.0) as i32, + (y_px + 12.0) as i32, PxScale::from(y_label_size_secondary), &handler.font, &mmol_display, @@ -321,7 +280,7 @@ pub async fn draw_graph( &mut img, bright, label_x, - (y_px - 8.0) as i32, + (y_px - 16.0) as i32, PxScale::from(y_label_size_primary), &handler.font, &format!("{:.1}", y_val), @@ -337,7 +296,7 @@ pub async fn draw_graph( &mut img, dim, label_x, - (y_px + 6.0) as i32, + (y_px + 12.0) as i32, PxScale::from(y_label_size_secondary), &handler.font, &mg_display, @@ -365,17 +324,45 @@ pub async fn draw_graph( } } + let target_high_y = project_y(target_high_mg); + if target_high_y >= inner_plot_top && target_high_y <= inner_plot_bottom { + let faint_orange = Rgba([255u8, 159u8, 10u8, 80u8]); + draw_dashed_horizontal_line( + &mut img, + target_high_y, + inner_plot_left, + inner_plot_right, + faint_orange, + 10, + 5, + ); + } + + let target_low_y = project_y(target_low_mg); + if target_low_y >= inner_plot_top && target_low_y <= inner_plot_bottom { + let faint_red = Rgba([255u8, 69u8, 58u8, 80u8]); + draw_dashed_horizontal_line( + &mut img, + target_low_y, + inner_plot_left, + inner_plot_right, + faint_red, + 10, + 5, + ); + } + let user_tz: Tz = user_timezone.parse().unwrap_or(chrono_tz::UTC); let now = Utc::now().with_timezone(&user_tz); - let oldest_entry = entries.last().unwrap(); - let newest_entry = entries.first().unwrap(); - - let oldest_time = oldest_entry.millis_to_user_timezone(user_timezone); - let newest_time = newest_entry.millis_to_user_timezone(user_timezone); + let newest_time = now; + let oldest_time = now - chrono::Duration::hours(hours as i64); - let total_hours = (newest_time.timestamp() - oldest_time.timestamp()) as f32 / 3600.0; - tracing::info!("[GRAPH] Data spans {:.1} hours", total_hours); + let total_hours = hours as f32; + tracing::info!( + "[GRAPH] Displaying {} hours of data (as requested)", + total_hours + ); let max_x_labels = 6; let time_interval = if total_hours <= 3.0 { @@ -388,21 +375,17 @@ pub async fn draw_graph( 3.0 }; - // Calculate time-based positioning to preserve gaps in data let time_range_seconds = (newest_time.timestamp() - oldest_time.timestamp()) as f32; - // Helper function to calculate x position based on timestamp let calculate_x_position = |entry_time: chrono::DateTime| -> f32 { let time_from_oldest = (entry_time.timestamp() - oldest_time.timestamp()) as f32; let time_ratio = time_from_oldest / time_range_seconds; inner_plot_left + (time_ratio * inner_plot_w) }; - // Generate time-based labels let mut label_entries = Vec::new(); let mut last_labeled_time = oldest_time; - // Sample entries for labels based on time intervals, not array indices for entry in entries.iter().rev() { let entry_time = entry.millis_to_user_timezone(user_timezone); let hours_since_last = @@ -414,7 +397,6 @@ pub async fn draw_graph( } } - // Always include the newest entry if not already included if let Some(newest_entry) = entries.first() { let newest_entry_time = newest_entry.millis_to_user_timezone(user_timezone); if label_entries.is_empty() @@ -426,7 +408,6 @@ pub async fn draw_graph( } } - // Limit the number of labels and ensure minimum spacing if label_entries.len() > max_x_labels { let step = label_entries.len() / max_x_labels; let mut filtered = vec![label_entries[0]]; @@ -439,7 +420,7 @@ pub async fn draw_graph( label_entries = filtered; } - let min_label_distance = 80.0; + let min_label_distance = 160.0; let mut final_label_entries = Vec::new(); for (i, &entry) in label_entries.iter().enumerate() { @@ -468,7 +449,6 @@ pub async fn draw_graph( } } - // Draw day change indicators by checking all entries, not just labeled ones let mut drawn_day_changes: std::collections::HashSet = std::collections::HashSet::new(); let mut prev_date: Option = None; @@ -490,18 +470,18 @@ pub async fn draw_graph( inner_plot_top, inner_plot_bottom, darker_dim, - 3, 6, + 12, ); let date_text = entry_time.format("%m/%d").to_string(); - let text_width = (date_text.len() as f32) * 7.0; + let text_width = (date_text.len() as f32) * 14.0; draw_text_mut( &mut img, dim, (x_center - text_width / 2.0) as i32, - (plot_top - 15.) as i32, - PxScale::from(14.0), + (plot_top - 30.) as i32, + PxScale::from(28.0), &handler.font, &date_text, ); @@ -509,7 +489,6 @@ pub async fn draw_graph( prev_date = Some(current_date); } - // Draw time labels for entry in final_label_entries.iter() { let entry_time = entry.millis_to_user_timezone(user_timezone); let x_center = calculate_x_position(entry_time); @@ -523,7 +502,7 @@ pub async fn draw_graph( &mut img, bright, x_text, - (plot_bottom + 8.0) as i32, + (plot_bottom + 16.0) as i32, PxScale::from(x_label_size_primary), &handler.font, &time_label, @@ -552,14 +531,13 @@ pub async fn draw_graph( &mut img, dim, x_text2, - (plot_bottom + 28.0) as i32, + (plot_bottom + 56.0) as i32, PxScale::from(x_label_size_secondary), &handler.font, &rel, ); } - // Calculate points with time-based positioning let mut points_px: Vec<(f32, f32)> = Vec::with_capacity(entries.len()); for entry in &entries { let entry_time = entry.millis_to_user_timezone(user_timezone); @@ -577,6 +555,100 @@ pub async fn draw_graph( points_px.push((x, y)); } + tracing::info!("[GRAPH] Drawing contextual stickers"); + + let status_ranges = + identify_status_ranges(&entries, user_timezone, target_low_mg, target_high_mg); + let status_ranges = filter_ranges_by_duration(status_ranges, &entries, user_timezone); + + let mut treatment_positions: Vec<(f32, f32)> = Vec::new(); + + for treatment in treatments { + let treatment_time = if let Some(created_at) = &treatment.created_at { + match chrono::DateTime::parse_from_rfc3339(created_at) { + Ok(dt) => dt.with_timezone(&user_tz), + Err(_) => continue, + } + } else if let Some(ts) = treatment.date.or(treatment.mills) { + chrono::DateTime::from_timestamp_millis(ts as i64) + .map(|dt| dt.with_timezone(&user_tz)) + .unwrap_or(now) + } else { + continue; + }; + + let treatment_x = calculate_x_position(treatment_time); + let mut closest_y = inner_plot_bottom - inner_plot_h / 2.0; + + for (i, entry) in entries.iter().enumerate() { + let entry_time = entry.millis_to_user_timezone(user_timezone); + let time_diff = (treatment_time.timestamp() - entry_time.timestamp()).abs(); + + if time_diff < i64::MAX { + closest_y = points_px[i].1; + break; + } + } + + treatment_positions.push((treatment_x, closest_y)); + } + + for (i, entry) in entries.iter().enumerate() { + if entry.has_mbg() { + let (x, _) = points_px[i]; + let mbg_y = project_y(entry.mbg.unwrap_or(0.0)); + treatment_positions.push((x, mbg_y)); + } + } + + let stickers_to_place = select_stickers_to_place(stickers, &status_ranges); + + let config = StickerConfig::default(); + let mut occupied_areas: Vec<(f32, f32, f32)> = Vec::new(); + + for (sticker, range) in stickers_to_place { + if let Some((x, y)) = find_sticker_position( + range, + &entries, + &points_px, + &occupied_areas, + &treatment_positions, + inner_plot_left, + inner_plot_right, + inner_plot_top, + inner_plot_bottom, + &config, + ) { + let abs_x = inner_plot_left + x * (inner_plot_right - inner_plot_left); + let abs_y = inner_plot_top + y * (inner_plot_bottom - inner_plot_top); + occupied_areas.push((abs_x, abs_y, config.sticker_radius)); + + if let Err(e) = draw_sticker( + &mut img, + sticker, + x, + y, + inner_plot_left, + inner_plot_right, + inner_plot_top, + inner_plot_bottom, + handler, + ) + .await + { + tracing::warn!( + "[GRAPH] Failed to draw sticker {}: {}", + sticker.display_name, + e + ); + } + } else { + tracing::info!( + "[GRAPH] Skipping sticker {} due to no available space", + sticker.display_name + ); + } + } tracing::debug!("[GRAPH] Drawing {} treatments", treatments.len()); for treatment in treatments { tracing::debug!( @@ -606,12 +678,10 @@ pub async fn draw_graph( continue; }; - // Position treatment at its actual timestamp, not closest entry let treatment_x = calculate_x_position(treatment_time); let mut closest_y = inner_plot_bottom - inner_plot_h / 2.0; let mut min_time_diff = i64::MAX; - // Find closest entry for Y positioning only for (i, entry) in entries.iter().enumerate() { let entry_time = entry.millis_to_user_timezone(user_timezone); let time_diff = (treatment_time.timestamp() - entry_time.timestamp()).abs(); @@ -633,91 +703,30 @@ pub async fn draw_graph( continue; } - let triangle_size = if is_microbolus { - 4 - } else if insulin_amount <= user_settings.microbolus_threshold + 1.0 { - 6 - } else if insulin_amount <= user_settings.microbolus_threshold + 5.0 { - 9 - } else { - 15 - }; - - let triangle_y = closest_y + 35.0; - - tracing::trace!( - "[GRAPH] Drawing insulin: {:.1}u at ({:.1}, {:.1}) - size: {}", + draw_insulin_treatment( + &mut img, insulin_amount, + is_microbolus, + user_settings.microbolus_threshold, closest_x, - triangle_y, - triangle_size + closest_y, + insulin_col, + bg, + bright, + handler, ); - - let triangle_points = vec![ - Point::new( - (closest_x - triangle_size as f32) as i32, - (triangle_y - triangle_size as f32) as i32, - ), - Point::new( - (closest_x + triangle_size as f32) as i32, - (triangle_y - triangle_size as f32) as i32, - ), - Point::new(closest_x as i32, (triangle_y + triangle_size as f32) as i32), - ]; - - draw_polygon_mut(&mut img, &triangle_points, insulin_col); - - if !is_microbolus { - let insulin_text = format!("{:.1}u", insulin_amount); - let text_width = insulin_text.len() as f32 * 9.0; - draw_text_mut( - &mut img, - bright, - (closest_x - text_width / 2.0) as i32, - (triangle_y + triangle_size as f32 + 8.0) as i32, - PxScale::from(18.0), - &handler.font, - &insulin_text, - ); - } } if treatment.is_carbs() { let carbs_amount = treatment.carbs.unwrap_or(0.0); - let circle_radius = if carbs_amount < 0.5 { - 4 - } else if carbs_amount <= 2.0 { - 7 - } else { - 12 - }; - - tracing::trace!( - "[GRAPH] Drawing carbs: {:.0}g at ({:.1}, {:.1})", + draw_carbs_treatment( + &mut img, carbs_amount, closest_x, - closest_y - ); - - let carbs_y = closest_y - 35.0; - - draw_filled_circle_mut( - &mut img, - (closest_x as i32, carbs_y as i32), - circle_radius, + closest_y, carbs_col, - ); - - let carbs_text = format!("{}g", carbs_amount as i32); - let text_width = carbs_text.len() as f32 * 9.0; - draw_text_mut( - &mut img, - carbs_col, - (closest_x - text_width / 2.0) as i32, - (carbs_y - circle_radius as f32 - 25.0) as i32, - PxScale::from(18.0), - &handler.font, - &carbs_text, + bg, + handler, ); } @@ -726,67 +735,31 @@ pub async fn draw_graph( && let Ok(glucose_value) = glucose_str.parse::() { let glucose_y = project_y(glucose_value); - - tracing::trace!( - "[GRAPH] Drawing glucose reading: {:.1} at ({:.1}, {:.1})", + draw_glucose_reading( + &mut img, glucose_value, closest_x, - glucose_y - ); - - let bg_check_radius = 6; - let grey_outline = Rgba([128u8, 128u8, 128u8, 255u8]); - let red_inside = Rgba([220u8, 38u8, 27u8, 255u8]); - - draw_filled_circle_mut( - &mut img, - (closest_x as i32, glucose_y as i32), - bg_check_radius, - grey_outline, - ); - - draw_filled_circle_mut( - &mut img, - (closest_x as i32, glucose_y as i32), - bg_check_radius - 2, - red_inside, - ); - - let glucose_text = match pref { - PrefUnit::MgDl => format!("{:.0}", glucose_value), - PrefUnit::Mmol => format!("{:.1}", glucose_value / 18.0), - }; - let text_width = glucose_text.len() as f32 * 8.0; - draw_text_mut( - &mut img, + glucose_y, + pref, + bg, bright, - (closest_x - text_width / 2.0) as i32, - (glucose_y - bg_check_radius as f32 - 20.0) as i32, - PxScale::from(16.0), - &handler.font, - &glucose_text, + handler, ); } } - for (i, e) in entries.iter().enumerate() { - let (x, y) = points_px[i]; - let color = if e.sgv > 180.0 { - high_col - } else if e.sgv < 70.0 { - low_col - } else { - axis_col - }; - draw_filled_circle_mut( - &mut img, - (x.round() as i32, y.round() as i32), - svg_radius, - color, - ); - } + draw_glucose_points( + &mut img, + &entries, + &points_px, + svg_radius, + high_col, + low_col, + axis_col, + target_high_mg, + target_low_mg, + ); - // Draw MBG (meter blood glucose) readings as finger check indicators let mbg_count = entries.iter().filter(|e| e.has_mbg()).count(); tracing::info!("[GRAPH] Found {} entries with MBG values", mbg_count); @@ -804,50 +777,12 @@ pub async fn draw_graph( entry.entry_type ); - let bg_check_radius = 8; - let mbg_outline = Rgba([255u8, 255u8, 255u8, 255u8]); // White outline for MBG - let mbg_inside = Rgba([255u8, 152u8, 0u8, 255u8]); // Orange inside for MBG - - // For MBG entries (type == "mbg"), draw directly at the glucose level - // For regular entries with MBG data, maintain current behavior - let bg_y = mbg_y; - - // Draw outer circle - draw_filled_circle_mut( - &mut img, - (x as i32, bg_y as i32), - bg_check_radius, - mbg_outline, - ); - - // Draw inner circle - draw_filled_circle_mut( - &mut img, - (x as i32, bg_y as i32), - bg_check_radius - 2, - mbg_inside, - ); - - // Draw MBG value text - let mbg_text = match pref { - PrefUnit::MgDl => format!("{:.0}", mbg_value), - PrefUnit::Mmol => format!("{:.1}", mbg_value / 18.0), - }; - let text_width = mbg_text.len() as f32 * 8.0; - draw_text_mut( - &mut img, - bright, - (x - text_width / 2.0) as i32, - (bg_y - bg_check_radius as f32 - 15.0) as i32, - PxScale::from(16.0), - &handler.font, - &mbg_text, - ); + draw_glucose_reading(&mut img, mbg_value, x, mbg_y, pref, bg, bright, handler); } } - let header_x = (plot_left - 72.0) as i32; - let header_y = (plot_bottom + 30.) as i32; + let header_x = (plot_left - 144.0) as i32; + let header_y = (plot_bottom + 60.) as i32; match pref { PrefUnit::MgDl => { draw_text_mut( @@ -863,7 +798,7 @@ pub async fn draw_graph( &mut img, dim, header_x, - header_y + 18, + header_y + 36, PxScale::from(secondary_legend_font_size), &handler.font, "mmol/L", @@ -883,7 +818,7 @@ pub async fn draw_graph( &mut img, dim, header_x, - header_y + 18, + header_y + 36, PxScale::from(secondary_legend_font_size), &handler.font, "mg/dL", @@ -894,246 +829,13 @@ pub async fn draw_graph( draw_text_mut( &mut img, dim, + 20, 10, - 5, PxScale::from(secondary_legend_font_size), &handler.font, "Beetroot", ); - tracing::info!("[GRAPH] Drawing {} stickers", stickers.len()); - - let mut occupied_areas: Vec<(f32, f32, f32)> = Vec::new(); - let sticker_radius = 60.0; - let curve_avoidance_distance = 100.0; // Increased distance to keep from glucose curve - let treatment_avoidance_distance = 80.0; // Distance to keep from treatments and MBGs - - // Collect all treatment and MBG positions to avoid - let mut treatment_positions: Vec<(f32, f32)> = Vec::new(); - - // Add treatment positions - for treatment in treatments { - let treatment_time = if let Some(created_at) = &treatment.created_at { - match chrono::DateTime::parse_from_rfc3339(created_at) { - Ok(dt) => dt.with_timezone(&user_tz), - Err(_) => continue, - } - } else if let Some(ts) = treatment.date.or(treatment.mills) { - chrono::DateTime::from_timestamp_millis(ts as i64) - .map(|dt| dt.with_timezone(&user_tz)) - .unwrap_or(now) - } else { - continue; - }; - - let treatment_x = calculate_x_position(treatment_time); - let mut closest_y = inner_plot_bottom - inner_plot_h / 2.0; - - // Find closest entry for Y positioning - for (i, entry) in entries.iter().enumerate() { - let entry_time = entry.millis_to_user_timezone(user_timezone); - let time_diff = (treatment_time.timestamp() - entry_time.timestamp()).abs(); - - if time_diff < i64::MAX { - closest_y = points_px[i].1; - break; - } - } - - treatment_positions.push((treatment_x, closest_y)); - } - - // Add MBG positions - for (i, entry) in entries.iter().enumerate() { - if entry.has_mbg() { - let (x, _) = points_px[i]; - let mbg_y = project_y(entry.mbg.unwrap_or(0.0)); - treatment_positions.push((x, mbg_y)); - } - } - - // Helper function to check if a position is too close to the glucose curve - let is_too_close_to_curve = |x: f32, y: f32| -> bool { - for (px, py) in &points_px { - let distance = ((x - px).powi(2) + (y - py).powi(2)).sqrt(); - if distance < curve_avoidance_distance { - return true; - } - } - false - }; - - // Helper function to check if a position is too close to treatments/MBGs - let is_too_close_to_treatments = |x: f32, y: f32| -> bool { - for (tx, ty) in &treatment_positions { - let distance = ((x - tx).powi(2) + (y - ty).powi(2)).sqrt(); - if distance < treatment_avoidance_distance { - return true; - } - } - false - }; - - for sticker in stickers { - let mut attempts = 0; - let max_attempts = 100; // Increased attempts due to curve avoidance - let (random_x, random_y) = loop { - // Create preferential zones - corners and edges are better for stickers - let (x, y) = if attempts < max_attempts / 2 { - // First half of attempts: try corner and edge areas - match rand::random::() % 4 { - 0 => ( - rand::random::() * 0.3 + 0.1, - rand::random::() * 0.3 + 0.1, - ), // Top-left - 1 => ( - rand::random::() * 0.3 + 0.6, - rand::random::() * 0.3 + 0.1, - ), // Top-right - 2 => ( - rand::random::() * 0.3 + 0.1, - rand::random::() * 0.3 + 0.6, - ), // Bottom-left - _ => ( - rand::random::() * 0.3 + 0.6, - rand::random::() * 0.3 + 0.6, - ), // Bottom-right - } - } else { - // Second half: fall back to anywhere in the safer zone - ( - rand::random::() * 0.6 + 0.2, - rand::random::() * 0.6 + 0.2, - ) - }; - - let abs_x = inner_plot_left + x * inner_plot_w; - let abs_y = inner_plot_top + y * inner_plot_h; - - let has_collision = occupied_areas.iter().any(|(ox, oy, r)| { - let distance = ((abs_x - ox).powi(2) + (abs_y - oy).powi(2)).sqrt(); - distance < (sticker_radius + r) - }); - - let too_close_to_curve = is_too_close_to_curve(abs_x, abs_y); - let too_close_to_treatments = is_too_close_to_treatments(abs_x, abs_y); - - if (!has_collision && !too_close_to_curve && !too_close_to_treatments) - || attempts >= max_attempts - { - occupied_areas.push((abs_x, abs_y, sticker_radius)); - break (x, y); - } - - attempts += 1; - }; - - let random_rotation = rand::random::() * 30.0 - 15.0; - - tracing::debug!( - "[GRAPH] Drawing sticker: {} at ({:.2}, {:.2}) with rotation {:.1}°", - sticker.file_name, - random_x, - random_y, - random_rotation - ); - - let sticker_img = if sticker.file_name.starts_with("http") { - match download_sticker_image(&sticker.file_name).await { - Ok(img) => img, - Err(e) => { - tracing::warn!( - "[GRAPH] Failed to download sticker from {}: {}", - sticker.file_name, - e - ); - continue; - } - } - } else { - match image::open(&sticker.file_name) { - Ok(img) => img, - Err(e) => { - tracing::warn!( - "[GRAPH] Failed to load sticker image {}: {}", - sticker.file_name, - e - ); - continue; - } - } - }; - - let sticker_rgba = sticker_img.to_rgba8(); - let (sticker_w, sticker_h) = sticker_rgba.dimensions(); - - let sticker_x = (inner_plot_left + random_x * inner_plot_w) as i32; - let sticker_y = (inner_plot_top + random_y * inner_plot_h) as i32; - - let max_size = 100; - let scale_factor = if sticker_w > sticker_h { - max_size as f32 / sticker_w as f32 - } else { - max_size as f32 / sticker_h as f32 - }; - let new_w = (sticker_w as f32 * scale_factor) as u32; - let new_h = (sticker_h as f32 * scale_factor) as u32; - - let resized_sticker = image::imageops::resize( - &sticker_rgba, - new_w, - new_h, - image::imageops::FilterType::Lanczos3, - ); - - let start_x = (sticker_x - new_w as i32 / 2).max(0); - let start_y = (sticker_y - new_h as i32 / 2).max(0); - - for y in 0..new_h { - for x in 0..new_w { - let img_x = start_x + x as i32; - let img_y = start_y + y as i32; - - if img_x >= 0 - && img_x < img.width() as i32 - && img_y >= 0 - && img_y < img.height() as i32 - { - let sticker_pixel = resized_sticker.get_pixel(x, y); - - if sticker_pixel[3] > 128 { - let base_alpha = sticker_pixel[3] as f32 / 255.0; - let alpha = base_alpha * 0.8; - let bg_pixel = img.get_pixel(img_x as u32, img_y as u32); - - let darkened_r = (sticker_pixel[0] as f32 * 0.8) as u8; - let darkened_g = (sticker_pixel[1] as f32 * 0.8) as u8; - let darkened_b = (sticker_pixel[2] as f32 * 0.8) as u8; - - let blended = Rgba([ - ((darkened_r as f32 * alpha) + (bg_pixel[0] as f32 * (1.0 - alpha))) - as u8, - ((darkened_g as f32 * alpha) + (bg_pixel[1] as f32 * (1.0 - alpha))) - as u8, - ((darkened_b as f32 * alpha) + (bg_pixel[2] as f32 * (1.0 - alpha))) - as u8, - 255, - ]); - - img.put_pixel(img_x as u32, img_y as u32, blended); - } - } - } - } - - tracing::trace!( - "[GRAPH] Successfully drew sticker {} at ({}, {})", - sticker.file_name, - sticker_x, - sticker_y - ); - } - let dyna = DynamicImage::ImageRgba8(img); let mut out_buf: Vec = Vec::new(); dyna.write_to(&mut Cursor::new(&mut out_buf), image::ImageFormat::Png) diff --git a/src/utils/graph/stickers.rs b/src/utils/graph/stickers.rs new file mode 100644 index 0000000..8ed73d9 --- /dev/null +++ b/src/utils/graph/stickers.rs @@ -0,0 +1,400 @@ +use anyhow::Result; +use image::{Rgba, RgbaImage}; + +use super::helpers::download_sticker_image; +use super::types::GlucoseStatus; +use crate::bot::Handler; +use crate::utils::database::{Sticker, StickerCategory}; +use crate::utils::nightscout::Entry; + +/// Maximum number of stickers to show per graph +pub const MAX_STICKERS_PER_GRAPH: usize = 3; + +/// Configuration for sticker placement +pub struct StickerConfig { + pub sticker_radius: f32, + pub curve_avoidance_distance: f32, + pub treatment_avoidance_distance: f32, + pub max_attempts: usize, +} + +impl Default for StickerConfig { + fn default() -> Self { + Self { + sticker_radius: 120.0, + curve_avoidance_distance: 100.0, + treatment_avoidance_distance: 120.0, + max_attempts: 500, + } + } +} + +/// Identify glucose status ranges from entries +pub fn identify_status_ranges( + entries: &[Entry], + _user_timezone: &str, + target_low: f32, + target_high: f32, +) -> Vec<(GlucoseStatus, usize, usize)> { + let mut status_ranges: Vec<(GlucoseStatus, usize, usize)> = Vec::new(); + + if entries.is_empty() { + return status_ranges; + } + + let mut current_status = GlucoseStatus::from_sgv(entries[0].sgv, target_low, target_high); + let mut range_start = 0; + + for (i, entry) in entries.iter().enumerate().skip(1) { + let status = GlucoseStatus::from_sgv(entry.sgv, target_low, target_high); + if status != current_status { + status_ranges.push((current_status, range_start, i - 1)); + current_status = status; + range_start = i; + } + } + status_ranges.push((current_status, range_start, entries.len() - 1)); + + tracing::debug!( + "[GRAPH] Identified {} glucose status ranges", + status_ranges.len() + ); + + status_ranges +} + +/// Filter ranges by duration (Low >= 0min, InRange/High >= 30min) +pub fn filter_ranges_by_duration( + status_ranges: Vec<(GlucoseStatus, usize, usize)>, + entries: &[Entry], + user_timezone: &str, +) -> Vec<(GlucoseStatus, usize, usize)> { + let mut filtered_ranges: Vec<(GlucoseStatus, usize, usize)> = Vec::new(); + + for (status, start_idx, end_idx) in status_ranges { + if start_idx < entries.len() && end_idx < entries.len() { + let start_time = entries[start_idx].millis_to_user_timezone(user_timezone); + let end_time = entries[end_idx].millis_to_user_timezone(user_timezone); + let duration_minutes = ((end_time.timestamp() - start_time.timestamp()).abs()) / 60; + + let min_duration = match status { + GlucoseStatus::Low => 0, + GlucoseStatus::InRange | GlucoseStatus::High => 30, + }; + + if duration_minutes >= min_duration { + filtered_ranges.push((status, start_idx, end_idx)); + } else { + tracing::debug!( + "[GRAPH] Skipping {:?} range ({}min < {}min threshold)", + status, + duration_minutes, + min_duration + ); + } + } + } + + tracing::debug!( + "[GRAPH] After filtering: {} ranges >= duration threshold", + filtered_ranges.len() + ); + + filtered_ranges +} + +/// Select stickers to place based on glucose status ranges +pub fn select_stickers_to_place<'a>( + stickers: &'a [Sticker], + status_ranges: &[(GlucoseStatus, usize, usize)], +) -> Vec<(&'a Sticker, Option<(usize, usize)>)> { + let mut stickers_to_place: Vec<(&Sticker, Option<(usize, usize)>)> = Vec::new(); + + let mut stickers_by_category: std::collections::HashMap> = + std::collections::HashMap::new(); + for sticker in stickers { + stickers_by_category + .entry(sticker.category) + .or_insert_with(Vec::new) + .push(sticker); + } + + let mut status_counts: std::collections::HashMap = + std::collections::HashMap::new(); + for (status, _, _) in status_ranges { + *status_counts.entry(*status).or_insert(0) += 1; + } + + let empty_vec: Vec<&Sticker> = Vec::new(); + + while stickers_to_place.len() < MAX_STICKERS_PER_GRAPH && !status_ranges.is_empty() { + let range_idx = (rand::random::() * status_ranges.len() as f32) as usize; + let range_idx = range_idx.min(status_ranges.len() - 1); + let (status, start_idx, end_idx) = status_ranges[range_idx]; + + let category = status.to_sticker_category(); + let contextual_stickers = stickers_by_category.get(&category).unwrap_or(&empty_vec); + let any_stickers = stickers_by_category + .get(&StickerCategory::Any) + .unwrap_or(&empty_vec); + + if contextual_stickers.is_empty() && any_stickers.is_empty() { + break; + } + + let status_count = *status_counts.get(&status).unwrap_or(&1); + let use_any_sticker = if status_count > 1 { + rand::random::() < 0.3 + } else { + false + }; + + let selected_sticker = if use_any_sticker && !any_stickers.is_empty() { + let idx = (rand::random::() * any_stickers.len() as f32) as usize; + any_stickers[idx.min(any_stickers.len() - 1)] + } else if !contextual_stickers.is_empty() { + let idx = (rand::random::() * contextual_stickers.len() as f32) as usize; + contextual_stickers[idx.min(contextual_stickers.len() - 1)] + } else if !any_stickers.is_empty() { + let idx = (rand::random::() * any_stickers.len() as f32) as usize; + any_stickers[idx.min(any_stickers.len() - 1)] + } else { + break; + }; + + stickers_to_place.push((selected_sticker, Some((start_idx, end_idx)))); + } + + let any_stickers = stickers_by_category + .get(&StickerCategory::Any) + .unwrap_or(&empty_vec); + + while stickers_to_place.len() < MAX_STICKERS_PER_GRAPH && !any_stickers.is_empty() { + let idx = (rand::random::() * any_stickers.len() as f32) as usize; + let sticker = any_stickers[idx.min(any_stickers.len() - 1)]; + stickers_to_place.push((sticker, None)); + } + + tracing::info!( + "[GRAPH] Placing {} stickers (max {})", + stickers_to_place.len(), + MAX_STICKERS_PER_GRAPH + ); + + stickers_to_place +} + +/// Find a valid position for a sticker +#[allow(clippy::too_many_arguments)] +pub fn find_sticker_position( + range: Option<(usize, usize)>, + entries: &[Entry], + points_px: &[(f32, f32)], + occupied_areas: &[(f32, f32, f32)], + treatment_positions: &[(f32, f32)], + inner_plot_left: f32, + inner_plot_right: f32, + inner_plot_top: f32, + inner_plot_bottom: f32, + config: &StickerConfig, +) -> Option<(f32, f32)> { + let inner_plot_w = inner_plot_right - inner_plot_left; + let inner_plot_h = inner_plot_bottom - inner_plot_top; + + let target_entry_idx = if let Some((start_idx, end_idx)) = range { + let range_size = end_idx - start_idx + 1; + let offset = (rand::random::() * range_size as f32) as usize; + start_idx + offset.min(range_size - 1) + } else { + let idx = (rand::random::() * entries.len() as f32) as usize; + idx.min(entries.len() - 1) + }; + + for attempts in 0..config.max_attempts { + let search_expansion = (attempts as f32 / config.max_attempts as f32) * 0.5; + + let (x, y) = if range.is_some() { + let target_x = points_px[target_entry_idx].0; + let target_y = points_px[target_entry_idx].1; + let target_x_normalized = (target_x - inner_plot_left) / inner_plot_w; + let target_y_normalized = (target_y - inner_plot_top) / inner_plot_h; + + let base_vertical = 0.20; + let vertical_offset = if rand::random::() { + -(base_vertical + search_expansion) + } else { + base_vertical + search_expansion + }; + let horizontal_range = 0.15 + search_expansion; + let horizontal_offset = (rand::random::() - 0.5) * horizontal_range; + + ( + (target_x_normalized + horizontal_offset).clamp(0.05, 0.95), + (target_y_normalized + vertical_offset).clamp(0.05, 0.95), + ) + } else if attempts < config.max_attempts / 2 { + match rand::random::() % 4 { + 0 => ( + rand::random::() * 0.3 + 0.1, + rand::random::() * 0.3 + 0.1, + ), + 1 => ( + rand::random::() * 0.3 + 0.6, + rand::random::() * 0.3 + 0.1, + ), + 2 => ( + rand::random::() * 0.3 + 0.1, + rand::random::() * 0.3 + 0.6, + ), + _ => ( + rand::random::() * 0.3 + 0.6, + rand::random::() * 0.3 + 0.6, + ), + } + } else { + ( + rand::random::() * 0.6 + 0.2, + rand::random::() * 0.6 + 0.2, + ) + }; + + let abs_x = inner_plot_left + x * inner_plot_w; + let abs_y = inner_plot_top + y * inner_plot_h; + + let has_collision = occupied_areas.iter().any(|(ox, oy, r)| { + let distance = ((abs_x - ox).powi(2) + (abs_y - oy).powi(2)).sqrt(); + distance < (config.sticker_radius + r) + }); + + let too_close_to_curve = points_px.iter().any(|(px, py)| { + let distance = ((abs_x - px).powi(2) + (abs_y - py).powi(2)).sqrt(); + distance < config.curve_avoidance_distance + }); + + let too_close_to_treatments = treatment_positions.iter().any(|(tx, ty)| { + let distance = ((abs_x - tx).powi(2) + (abs_y - ty).powi(2)).sqrt(); + distance < config.treatment_avoidance_distance + }); + + if !has_collision && !too_close_to_curve && !too_close_to_treatments { + return Some((x, y)); + } + } + + None +} + +/// Draw a single sticker on the graph +#[allow(clippy::too_many_arguments)] +pub async fn draw_sticker( + img: &mut RgbaImage, + sticker: &Sticker, + x: f32, + y: f32, + inner_plot_left: f32, + inner_plot_right: f32, + inner_plot_top: f32, + inner_plot_bottom: f32, + _handler: &Handler, +) -> Result<()> { + let inner_plot_w = inner_plot_right - inner_plot_left; + let inner_plot_h = inner_plot_bottom - inner_plot_top; + + tracing::debug!( + "[GRAPH] Drawing sticker: {} at ({:.2}, {:.2})", + sticker.file_name, + x, + y + ); + + let sticker_img = if sticker.file_name.starts_with("http") { + download_sticker_image(&sticker.file_name).await? + } else { + image::open(&sticker.file_name)? + }; + + let sticker_rgba = sticker_img.to_rgba8(); + let (sticker_w, sticker_h) = sticker_rgba.dimensions(); + + let sticker_x = (inner_plot_left + x * inner_plot_w) as i32; + let sticker_y = (inner_plot_top + y * inner_plot_h) as i32; + + let max_size = 200; + let scale_factor = if sticker_w > sticker_h { + max_size as f32 / sticker_w as f32 + } else { + max_size as f32 / sticker_h as f32 + }; + let new_w = (sticker_w as f32 * scale_factor) as u32; + let new_h = (sticker_h as f32 * scale_factor) as u32; + + let resized_sticker = image::imageops::resize( + &sticker_rgba, + new_w, + new_h, + image::imageops::FilterType::Lanczos3, + ); + + let start_x = (sticker_x - new_w as i32 / 2).max(0); + let start_y = (sticker_y - new_h as i32 / 2).max(0); + + for y in 0..new_h { + for x in 0..new_w { + let img_x = start_x + x as i32; + let img_y = start_y + y as i32; + + if img_x >= 0 && img_x < img.width() as i32 && img_y >= 0 && img_y < img.height() as i32 + { + let sticker_pixel = resized_sticker.get_pixel(x, y); + + if sticker_pixel[3] > 128 { + let base_alpha = sticker_pixel[3] as f32 / 255.0; + let alpha = base_alpha * 0.8; + let bg_pixel = img.get_pixel(img_x as u32, img_y as u32); + + let darkened_r = (sticker_pixel[0] as f32 * 0.8) as u8; + let darkened_g = (sticker_pixel[1] as f32 * 0.8) as u8; + let darkened_b = (sticker_pixel[2] as f32 * 0.8) as u8; + + let blended = Rgba([ + ((darkened_r as f32 * alpha) + (bg_pixel[0] as f32 * (1.0 - alpha))) as u8, + ((darkened_g as f32 * alpha) + (bg_pixel[1] as f32 * (1.0 - alpha))) as u8, + ((darkened_b as f32 * alpha) + (bg_pixel[2] as f32 * (1.0 - alpha))) as u8, + 255, + ]); + + img.put_pixel(img_x as u32, img_y as u32, blended); + } + } + } + } + + tracing::trace!( + "[GRAPH] Successfully drew sticker {} at ({}, {})", + sticker.file_name, + sticker_x, + sticker_y + ); + + // let debug_label = match sticker.category { + // StickerCategory::Low => "low_sticker", + // StickerCategory::InRange => "inrange_sticker", + // StickerCategory::High => "high_sticker", + // StickerCategory::Any => "any_sticker", + // }; + + // let label_y = (sticker_y + new_h as i32 / 2 + 50).min(img.height() as i32 - 20); + // let label_x = (sticker_x - 60).max(10); + + // draw_text_mut( + // img, + // bright, + // label_x, + // label_y, + // PxScale::from(32.0), + // &handler.font, + // debug_label, + // ); + + Ok(()) +} diff --git a/src/utils/graph/types.rs b/src/utils/graph/types.rs new file mode 100644 index 0000000..7373df2 --- /dev/null +++ b/src/utils/graph/types.rs @@ -0,0 +1,36 @@ +/// Preference unit for glucose display +#[derive(Clone, Copy, Debug)] +#[allow(dead_code)] +pub enum PrefUnit { + MgDl, + Mmol, +} + +/// Glucose status ranges for contextual sticker placement +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum GlucoseStatus { + Low, + InRange, + High, +} + +impl GlucoseStatus { + pub fn from_sgv(sgv: f32, target_low: f32, target_high: f32) -> Self { + if sgv < target_low { + Self::Low + } else if sgv > target_high { + Self::High + } else { + Self::InRange + } + } + + pub fn to_sticker_category(self) -> crate::utils::database::StickerCategory { + use crate::utils::database::StickerCategory; + match self { + Self::Low => StickerCategory::Low, + Self::InRange => StickerCategory::InRange, + Self::High => StickerCategory::High, + } + } +} diff --git a/src/utils/migration.rs b/src/utils/migration.rs index 950ce5b..9b512bc 100644 --- a/src/utils/migration.rs +++ b/src/utils/migration.rs @@ -179,4 +179,36 @@ impl Migration { tracing::info!("[MIGRATION] Last seen version field migration completed"); Ok(()) } + + pub async fn add_sticker_category_field(&self) -> Result<(), sqlx::Error> { + tracing::info!("[MIGRATION] Adding category field to stickers table"); + + let check_category_query = sqlx::query( + "SELECT COUNT(*) as count FROM pragma_table_info('stickers') WHERE name = 'category'", + ); + + let category_exists = check_category_query + .fetch_one(&self.pool) + .await? + .get::("count") + > 0; + + if !category_exists { + sqlx::query("ALTER TABLE stickers ADD COLUMN category TEXT DEFAULT 'any'") + .execute(&self.pool) + .await?; + tracing::info!("[MIGRATION] Added category column"); + + // Update existing stickers to have 'any' category + sqlx::query( + "UPDATE stickers SET category = 'any' WHERE category IS NULL OR category = ''", + ) + .execute(&self.pool) + .await?; + tracing::info!("[MIGRATION] Updated existing stickers with default 'any' category"); + } + + tracing::info!("[MIGRATION] Sticker category field migration completed"); + Ok(()) + } } diff --git a/src/utils/nightscout.rs b/src/utils/nightscout.rs index f6a0452..4ee69ab 100644 --- a/src/utils/nightscout.rs +++ b/src/utils/nightscout.rs @@ -417,12 +417,12 @@ impl Entry { /// Check if this entry has a meter blood glucose (finger stick) reading pub fn has_mbg(&self) -> bool { - // Check if this is an MBG entry (type == "mbg") or if it has an mbg field - if let Some(entry_type) = &self.entry_type { - entry_type == "mbg" && self.mbg.is_some() && self.mbg.unwrap_or(0.0) > 0.0 - } else { - self.mbg.is_some() && self.mbg.unwrap_or(0.0) > 0.0 + if let Some(entry_type) = &self.entry_type + && entry_type == "mbg" + { + return self.mbg.is_some() && self.mbg.unwrap_or(0.0) > 0.0; } + self.mbg.is_some() && self.mbg.unwrap_or(0.0) > 0.0 } } @@ -480,11 +480,25 @@ impl Treatment { } } +#[derive(Deserialize, Debug, Clone)] +pub struct TargetRange { + #[allow(dead_code)] + pub time: String, + pub value: f32, + #[allow(dead_code)] + #[serde(rename = "timeAsSeconds")] + pub time_as_seconds: u32, +} + #[derive(Deserialize, Debug, Clone)] pub struct ProfileStore { pub timezone: String, #[serde(default)] pub units: Option, + #[serde(default)] + pub target_low: Option>, + #[serde(default)] + pub target_high: Option>, } #[derive(Deserialize, Debug, Clone)] @@ -494,6 +508,76 @@ pub struct Profile { pub store: std::collections::HashMap, } +impl ProfileStore { + pub fn get_target_low(&self, status_thresholds: Option<&StatusThresholds>) -> f32 { + self.target_low + .as_ref() + .and_then(|ranges| ranges.first()) + .map(|range| range.value) + .or_else(|| status_thresholds.map(|thresholds| thresholds.bg_target_bottom as f32)) + .unwrap_or(70.0) + } + + pub fn get_target_high(&self, status_thresholds: Option<&StatusThresholds>) -> f32 { + self.target_high + .as_ref() + .and_then(|ranges| ranges.first()) + .map(|range| range.value) + .or_else(|| status_thresholds.map(|thresholds| thresholds.bg_target_top as f32)) + .unwrap_or(180.0) + } + + pub fn get_target_low_mg(&self, status_thresholds: Option<&StatusThresholds>) -> f32 { + let low = self.get_target_low(status_thresholds); + if self.units.as_deref() == Some("mmol") { + low * 18.0 + } else { + low + } + } + + pub fn get_target_high_mg(&self, status_thresholds: Option<&StatusThresholds>) -> f32 { + let high = self.get_target_high(status_thresholds); + if self.units.as_deref() == Some("mmol") { + high * 18.0 + } else { + high + } + } +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct StatusThresholds { + #[allow(dead_code)] + #[serde(rename = "bgHigh")] + pub bg_high: u16, + #[serde(rename = "bgTargetTop")] + pub bg_target_top: u16, + #[serde(rename = "bgTargetBottom")] + pub bg_target_bottom: u16, + #[allow(dead_code)] + #[serde(rename = "bgLow")] + pub bg_low: u16, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct StatusSettings { + #[serde(default)] + pub custom_title: Option, + #[serde(default)] + pub thresholds: Option, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Status { + #[allow(dead_code)] + pub name: String, + #[serde(default)] + pub settings: Option, +} + #[derive(Deserialize, Debug, Clone)] #[allow(dead_code)] pub struct DeviceStatus { @@ -830,7 +914,7 @@ impl Nightscout { let end_timestamp = now.timestamp_millis() as u64; let mut query_params = format!( - "api/v1/entries/sgv.json?find[date][$gte]={}&find[date][$lte]={}", + "api/v1/entries.json?find[date][$gte]={}&find[date][$lte]={}", start_timestamp, end_timestamp ); @@ -839,7 +923,7 @@ impl Nightscout { base.join(&query_params)? } else { let count = options.count.unwrap_or(2000); // Increase default count from u8::MAX (255) to 2000 - base.join(&format!("api/v1/entries/sgv.json?count={count}"))? + base.join(&format!("api/v1/entries.json?count={count}"))? }; tracing::debug!("[API] Entries API URL: {}", url); let mut req = self.http_client.get(url.clone()); @@ -874,11 +958,23 @@ impl Nightscout { }; let entries: Vec = res.json().await?; - // TEMPORARY: Skip entry cleaning to avoid sanitization issues tracing::debug!( "[ENTRIES] Retrieved {} entries (cleaning disabled)", entries.len() ); + + let mbg_count = entries + .iter() + .filter(|e| { + e.entry_type.as_deref() == Some("mbg") + || (e.mbg.is_some() && e.mbg.unwrap_or(0.0) > 0.0) + }) + .count(); + tracing::info!( + "[ENTRIES] Found {} entries with type='mbg' or mbg field", + mbg_count + ); + if entries.is_empty() { Err(NightscoutError::NoEntries) } else { @@ -984,17 +1080,24 @@ impl Nightscout { let entry_timestamp = entry.date.or(entry.mills).unwrap_or(0); let entry_sgv = (entry.sgv * 100.0) as i32; + let entry_mbg = entry.mbg.map(|v| (v * 100.0) as i32); - // Check for duplicate based on timestamp and SGV let is_duplicate = processed_entries.iter().any(|existing: &Entry| { let existing_timestamp = existing.date.or(existing.mills).unwrap_or(0); let existing_sgv = (existing.sgv * 100.0) as i32; + let existing_mbg = existing.mbg.map(|v| (v * 100.0) as i32); let time_diff = (entry_timestamp as i64 - existing_timestamp as i64).abs(); - let same_sgv = entry_sgv == existing_sgv; - // Consider duplicate if within 30 seconds and same SGV - time_diff <= 30000 && same_sgv + let same_value = if entry_mbg.is_some() && existing_mbg.is_some() { + entry_mbg == existing_mbg + } else if entry_mbg.is_none() && existing_mbg.is_none() { + entry_sgv == existing_sgv + } else { + false + }; + + time_diff <= 30000 && same_value }); if !is_duplicate { @@ -1226,4 +1329,49 @@ impl Nightscout { } } } + + pub async fn get_status( + &self, + base_url: &str, + token: Option<&str>, + ) -> Result { + tracing::debug!("[API] Fetching status from URL: '{}'", base_url); + + let base = Self::parse_base_url(base_url)?; + let url = base.join("api/v1/status.json")?; + tracing::debug!("[API] Status API URL: {}", url); + + let mut req = self.http_client.get(url.clone()); + + let auth_method = token.map(AuthMethod::from_token); + if let Some(auth) = auth_method { + req = auth.apply_to_request(req); + tracing::debug!("[OK] Applied {} authentication", auth.description()); + } + + tracing::debug!("[HTTP] Sending status request..."); + let res = match req.send().await { + Ok(response) => { + tracing::debug!("[HTTP] Received status response"); + response + } + Err(e) => return Err(Self::handle_connection_error(e, &url)), + }; + + let res = match res.error_for_status() { + Ok(response) => { + tracing::info!("[HTTP] Status response status: {}", response.status()); + response + } + Err(e) => { + tracing::error!("[ERROR] Status request returned error status: {}", e); + return Err(NightscoutError::Network(e)); + } + }; + + let status: Status = res.json().await?; + tracing::info!("[STATUS] Successfully retrieved status"); + + Ok(status) + } }