use crate::{Context, Error}; use ab_glyph::{FontRef, PxScale}; use image::{ImageBuffer, Rgba}; use imageproc::drawing::{draw_filled_rect_mut, draw_text_mut}; use imageproc::rect::Rect; use poise::serenity_prelude as serenity; use serde::Deserialize; use serenity::Mentionable; use std::collections::HashMap; use std::io::Cursor; use serenity::prelude::TypeMapKey; use surrealdb::Surreal; use surrealdb::engine::remote::ws::Client; pub struct DbKey; impl TypeMapKey for DbKey { type Value = Surreal; } #[derive(Deserialize, serde::Serialize, Debug)] pub struct UserLevel { pub xp: u64, pub level: u64, pub track: String, pub last_message: chrono::DateTime, } #[derive(Deserialize)] struct GuildRecord { level_role_stack: Option>>, levelup_channel: Option, levelup_message: Option, level_up_role_mapper: Option>, } #[derive(Deserialize, Debug)] struct LeaderboardEntry { id: surrealdb::sql::Thing, xp: u64, level: u64, } #[poise::command( slash_command, prefix_command, guild_only, default_member_permissions = "MANAGE_GUILD", required_permissions = "MANAGE_GUILD" )] pub async fn set_level_roles( ctx: Context<'_>, #[description = "Name of the level role stack"] name: String, #[description = "Comma-separated list of role mentions (e.g. @Role1, @Role2, ...)"] roles: String, ) -> Result<(), Error> { let guild_id = ctx .guild_id() .ok_or_else(|| Error::msg("Guild only command"))?; let mut role_ids = Vec::new(); for part in roles.split(',') { let part = part.trim(); if let Some(id_str) = part.strip_prefix("<@&").and_then(|s| s.strip_suffix(">")) { if let Ok(id) = id_str.parse::() { role_ids.push(serenity::RoleId::new(id)); } } else if let Ok(id) = part.parse::() { role_ids.push(serenity::RoleId::new(id)); } } if role_ids.is_empty() { ctx.say("No valid roles found. Please use role mentions (e.g. @Role, @Role2, ...).") .await?; return Ok(()); } let role_count = role_ids.len(); let db = &ctx.data().db; let role_ids_u64: Vec = role_ids.iter().map(|r| r.get()).collect(); tracing::info!("Updating level_role_stack for guild {}", guild_id); let updated: Option = db .update(("guilds", guild_id.to_string())) .merge(serde_json::json!({ "level_role_stack": { name.clone(): role_ids_u64 } })) .await?; if updated.is_none() { tracing::info!("Guild record not found, creating new record"); // Record didn't exist, create it let _: Option = db .create(("guilds", guild_id.to_string())) .content(serde_json::json!({ "level_role_stack": { name.clone(): role_ids_u64 } })) .await?; } ctx.say(format!( "Level role stack `{}` updated successfully with {} roles.", name, role_count )) .await?; Ok(()) } #[poise::command(slash_command, prefix_command, guild_only)] pub async fn get_level_roles(ctx: Context<'_>) -> Result<(), Error> { let guild_id = ctx .guild_id() .ok_or_else(|| Error::msg("Guild only command"))?; let db = &ctx.data().db; let record: Option = db.select(("guilds", guild_id.to_string())).await?; let mut response = String::new(); if let Some(record) = record { if let Some(stack) = record.level_role_stack { if stack.is_empty() { response = "No level role stacks found.".to_string(); } else { for (name, roles) in stack { response.push_str(&format!("**{}:** ", name)); let role_mentions: Vec = roles.iter().map(|id| format!("<@&{}>", id)).collect(); response.push_str(&role_mentions.join(", ")); response.push('\n'); } } } else { response = "No level role stacks found.".to_string(); } } else { response = "No configuration found for this guild.".to_string(); } ctx.send( poise::CreateReply::default() .content(response) .allowed_mentions(serenity::CreateAllowedMentions::new().empty_roles()), ) .await?; Ok(()) } #[poise::command( slash_command, prefix_command, guild_only, default_member_permissions = "MANAGE_GUILD", required_permissions = "MANAGE_GUILD" )] pub async fn set_levelup_message_channel( ctx: Context<'_>, #[description = "Channel to send level up messages to (default: current channel)"] channel: Option, ) -> Result<(), Error> { let guild_id = ctx .guild_id() .ok_or_else(|| Error::msg("Guild only command"))?; let db = &ctx.data().db; let channel_id = match channel { Some(c) => c.id().get(), None => ctx.channel_id().get(), }; let _: Option = db .update(("guilds", guild_id.to_string())) .merge(serde_json::json!({ "levelup_channel": channel_id })) .await?; ctx.say(format!( "Level up messages will now be sent to <#{}>.", channel_id )) .await?; Ok(()) } #[poise::command( slash_command, prefix_command, guild_only, default_member_permissions = "MANAGE_GUILD", required_permissions = "MANAGE_GUILD" )] pub async fn set_levelup_message( ctx: Context<'_>, #[description = "Template message (use {user.mention} and {user.level})"] message: String, ) -> Result<(), Error> { let guild_id = ctx .guild_id() .ok_or_else(|| Error::msg("Guild only command"))?; let db = &ctx.data().db; let _: Option = db .update(("guilds", guild_id.to_string())) .merge(serde_json::json!({ "levelup_message": message })) .await?; ctx.say(format!("Level up message template updated to: {}", message)) .await?; Ok(()) } #[poise::command( slash_command, prefix_command, guild_only, default_member_permissions = "MANAGE_GUILD", required_permissions = "MANAGE_GUILD" )] pub async fn levelup_role_bridger( ctx: Context<'_>, #[description = "The role to bridge from"] in_role: serenity::Role, #[description = "The role to bridge to"] out_role: serenity::Role, ) -> Result<(), Error> { let guild_id = ctx .guild_id() .ok_or_else(|| Error::msg("Guild only command"))?; let db = &ctx.data().db; let _: Option = db .update(("guilds", guild_id.to_string())) .merge(serde_json::json!({ "level_up_role_mapper": { in_role.id.to_string(): out_role.id.get() } })) .await?; ctx.say(format!( "Role bridge created: {} -> {}", in_role.name, out_role.name )) .await?; Ok(()) } pub async fn process_message( ctx: &serenity::Context, msg: &serenity::Message, ) -> Result<(), Error> { if msg.author.bot { return Ok(()); } let guild_id = match msg.guild_id { Some(id) => id, None => return Ok(()), }; let data = ctx.data.read().await; let db = match data.get::() { Some(db) => db, None => { tracing::error!("Database connection not found in context data"); return Ok(()); } }; let user_id = msg.author.id; let record_id = ("levels", format!("{}:{}", guild_id, user_id)); let user_level: Option = db.select(record_id.clone()).await?; if let Some(mut level_data) = user_level { tracing::info!( "Processing message for user {} in guild {}", user_id, guild_id ); // User exists, add XP // Check cooldown let now = chrono::Utc::now(); if now .signed_duration_since(level_data.last_message) .num_seconds() < 60 { return Ok(()); } use rand::Rng; let xp_gain = rand::rng().random_range(15..=25); level_data.xp += xp_gain; level_data.last_message = now; tracing::info!( "Added {} XP to user {}. Total XP: {}", xp_gain, user_id, level_data.xp ); let next_level_xp = (level_data.level + 1) * 100; if level_data.xp >= next_level_xp { level_data.level += 1; level_data.xp = 0; tracing::info!( "User {} leveled up to {} in guild {}", user_id, level_data.level, guild_id ); // Handle role assignment let guild_record: Option = db.select(("guilds", guild_id.to_string())).await?; let mut role_assigned_id: Option = None; if let Some(record) = &guild_record { if let Some(stack) = &record.level_role_stack { if let Some(roles) = stack.get(&level_data.track) { // Check all roles in the stack for level requirements // We need to fetch role names to check for patterns like "(min - max)" // Try to get from cache first let guild = guild_id.to_guild_cached(&ctx.cache).map(|g| g.clone()); // Regex to match "(min - max)" let re = regex::Regex::new(r"\((\d+)\s*-\s*(\d+)\)").unwrap(); for role_id_u64 in roles { let role_id = serenity::RoleId::new(*role_id_u64); // Try to find the role name let role_name: Option = if let Some(guild) = &guild { guild.roles.get(&role_id).map(|r| r.name.clone()) } else { // Fallback to HTTP if not in cache (expensive but safe) ctx.http.get_guild_roles(guild_id).await.ok().and_then( |roles| { roles .iter() .find(|r| r.id == role_id) .map(|r| r.name.clone()) }, ) }; if let Some(name) = role_name { if let Some(caps) = re.captures(&name) { if let (Ok(min), Ok(_max)) = (caps[1].parse::(), caps[2].parse::()) { // Check if we just hit the min level if level_data.level == min { tracing::info!( "Assigning role {} ({}) to user {}", role_id, name, user_id ); if let Err(e) = ctx .http .add_member_role( guild_id, user_id, role_id, Some("Level up"), ) .await { tracing::error!("Failed to add role: {}", e); } else { role_assigned_id = Some(role_id); } } } } } } } } } // Determine target channel let target_channel_id = if let Some(record) = &guild_record { if let Some(channel_id) = record.levelup_channel { serenity::ChannelId::new(channel_id) } else { msg.channel_id } } else { msg.channel_id }; // Determine message content let message_content = if let Some(record) = &guild_record { if let Some(template) = &record.levelup_message { let mut content = template .replace("{user.mention}", &msg.author.mention().to_string()) .replace("{user.level}", &level_data.level.to_string()); if let Some(role_id) = role_assigned_id { content.push_str(&format!("\nYou also received the <@&{}> role!", role_id)); } content } else { // Default message if let Some(role_id) = role_assigned_id { format!( "Congratulations {}, you leveled up to level {} and received the <@&{}> role!", msg.author.mention(), level_data.level, role_id ) } else { format!( "Congratulations {}, you leveled up to level {}!", msg.author.mention(), level_data.level ) } } } else { // Default message if no record if let Some(role_id) = role_assigned_id { format!( "Congratulations {}, you leveled up to level {} and received the <@&{}> role!", msg.author.mention(), level_data.level, role_id ) } else { format!( "Congratulations {}, you leveled up to level {}!", msg.author.mention(), level_data.level ) } }; let message = serenity::CreateMessage::new() .content(message_content) .allowed_mentions(serenity::CreateAllowedMentions::new().users(vec![user_id])); if let Err(e) = target_channel_id.send_message(&ctx.http, message).await { tracing::error!("Failed to send level up message: {}", e); } } let _: Option = db.update(record_id).content(level_data).await?; } else { // User doesn't exist, check if they have a starting role let guild_record: Option = db.select(("guilds", guild_id.to_string())).await?; if let Some(record) = guild_record { if let Some(stack) = record.level_role_stack { let member = match guild_id.member(&ctx.http, user_id).await { Ok(m) => m, Err(_) => return Ok(()), }; for (track_name, roles) in stack { if let Some(first_role_id) = roles.first() { if member .roles .contains(&serenity::RoleId::new(*first_role_id)) { // User has the starting role for this track let new_level = UserLevel { xp: 0, level: 0, track: track_name.clone(), last_message: chrono::Utc::now(), }; tracing::info!( "Initializing user {} on track {} in guild {}", user_id, track_name, guild_id ); let _: Option = db.create(record_id).content(new_level).await?; break; // Only assign to one track } } } } } } Ok(()) } #[poise::command(slash_command, prefix_command, guild_only)] pub async fn leaderboard(ctx: Context<'_>) -> Result<(), Error> { let guild_id = ctx .guild_id() .ok_or_else(|| Error::msg("Guild only command"))?; let db = &ctx.data().db; // Defer interaction as image generation might take time ctx.defer().await?; // Query top 10 users for this guild // We filter by ID starting with "levels:guild_id:" // Note: In SurrealDB, record IDs are `table:id_part`. // The id_part here is `guild_id:user_id`. // We can use string functions on the ID. let sql = "SELECT * FROM levels WHERE string::starts_with(record::id(id), $prefix) ORDER BY level DESC, xp DESC LIMIT 10"; let prefix = format!("{}:", guild_id); let mut response = db.query(sql).bind(("prefix", prefix)).await?; let entries: Vec = response.take(0)?; if entries.is_empty() { ctx.say("No leaderboard data found for this guild.").await?; return Ok(()); } // Generate image let image_data = generate_leaderboard_image(&ctx, &entries).await?; ctx.send( poise::CreateReply::default().attachment(serenity::CreateAttachment::bytes( image_data, "leaderboard.png", )), ) .await?; Ok(()) } async fn generate_leaderboard_image( ctx: &Context<'_>, entries: &[LeaderboardEntry], ) -> Result, Error> { // Image dimensions let width = 800; let height = 100 + (entries.len() as u32 * 80); // Header + rows let mut image = ImageBuffer::from_pixel(width, height, Rgba([40, 44, 52, 255])); // Dark background // Load font let font_bytes = include_bytes!("../assets/Roboto-Regular.ttf"); let font = FontRef::try_from_slice(font_bytes)?; let white = Rgba([255, 255, 255, 255]); let gray = Rgba([180, 180, 180, 255]); let bar_bg = Rgba([60, 63, 69, 255]); let bar_fill = Rgba([114, 137, 218, 255]); // Blurple-ish // Draw Title let scale_title = PxScale::from(40.0); draw_text_mut(&mut image, white, 20, 20, scale_title, &font, "Leaderboard"); let scale_text = PxScale::from(24.0); let scale_small = PxScale::from(18.0); for (i, entry) in entries.iter().enumerate() { let y_offset = 100 + (i as u32 * 80); // Extract user ID from record ID // id.id is String("guild_id:user_id") let id_str = entry.id.id.to_string(); let clean_id_str = id_str.trim_matches(|c| c == '"' || c == '⟨' || c == '⟩'); let parts: Vec<&str> = clean_id_str.split(':').collect(); let user_id_str = parts.last().unwrap_or(&"0"); let user_id = user_id_str.parse::().unwrap_or(0); // Fetch user info let user = if user_id != 0 { match ctx.http().get_user(serenity::UserId::new(user_id)).await { Ok(u) => u, Err(_) => { // Fallback if user not found let mut u = serenity::User::default(); u.name = "Unknown User".to_string(); u } } } else { tracing::warn!("Invalid user ID parsed from {}: {}", id_str, user_id_str); let mut u = serenity::User::default(); u.name = "Unknown User".to_string(); u }; // Draw Rank draw_text_mut( &mut image, white, 20, y_offset as i32 + 20, scale_text, &font, &format!("#{}", i + 1), ); // Draw Avatar let avatar_url = user.face(); // We need to fetch the avatar image if let Ok(response) = reqwest::get(&avatar_url).await { if let Ok(bytes) = response.bytes().await { if let Ok(avatar_img) = image::load_from_memory(&bytes) { let avatar_resized = avatar_img.resize(60, 60, image::imageops::FilterType::Lanczos3); image::imageops::overlay(&mut image, &avatar_resized, 80, y_offset as i64 + 10); } } } // Draw Username draw_text_mut( &mut image, white, 160, y_offset as i32 + 15, scale_text, &font, &user.name, ); // Draw Level draw_text_mut( &mut image, gray, 160, y_offset as i32 + 45, scale_small, &font, &format!("Level {}", entry.level), ); // Draw XP Bar let bar_width = 300; let bar_height = 20; let bar_x = 450; let bar_y = y_offset + 30; draw_filled_rect_mut( &mut image, Rect::at(bar_x as i32, bar_y as i32).of_size(bar_width, bar_height), bar_bg, ); let next_level_xp = (entry.level + 1) * 100; let progress = if next_level_xp > 0 { entry.xp as f32 / next_level_xp as f32 } else { 0.0 }; let fill_width = (progress * bar_width as f32) as u32; if fill_width > 0 { draw_filled_rect_mut( &mut image, Rect::at(bar_x as i32, bar_y as i32).of_size(fill_width, bar_height), bar_fill, ); } // Draw XP Text let xp_text = format!("{}/{} XP", entry.xp, next_level_xp); draw_text_mut( &mut image, white, bar_x as i32 + 5, bar_y as i32 + 2, // Centering vertically roughly PxScale::from(14.0), &font, &xp_text, ); } let mut bytes: Vec = Vec::new(); image.write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Png)?; Ok(bytes) } pub async fn on_guild_member_update( ctx: &serenity::Context, _old: &Option, new: &serenity::Member, ) -> Result<(), Error> { let guild_id = new.guild_id; let data = ctx.data.read().await; let db = match data.get::() { Some(db) => db, None => { tracing::error!("Database connection not found in context data"); return Ok(()); } }; let guild_record: Option = db.select(("guilds", guild_id.to_string())).await?; if let Some(record) = guild_record { if let Some(mapper) = record.level_up_role_mapper { // Check if user has any "in_role" // We iterate through the mapper to find if any key (in_role) matches user's roles for (in_role_str, out_role_id) in &mapper { if let Ok(in_role_id) = in_role_str.parse::() { let in_role = serenity::RoleId::new(in_role_id); if new.roles.contains(&in_role) { // User has the in_role. // Now check if they have ANY other out_role from the mapper let mut has_any_out_role = false; for (_, other_out_role_id) in &mapper { if new .roles .contains(&serenity::RoleId::new(*other_out_role_id)) { has_any_out_role = true; break; } } if !has_any_out_role { // User has no out_roles, so we bridge them tracing::info!( "Bridging user {} from role {} to {}", new.user.id, in_role_id, out_role_id ); // Add out_role if let Err(e) = ctx .http .add_member_role( guild_id, new.user.id, serenity::RoleId::new(*out_role_id), Some("Role Bridge"), ) .await { tracing::error!("Failed to add bridged role: {}", e); } // Remove in_role if let Err(e) = ctx .http .remove_member_role( guild_id, new.user.id, in_role, Some("Role Bridge"), ) .await { tracing::error!("Failed to remove bridged role: {}", e); } } else { // User already has an out_role, just remove the in_role tracing::info!( "User {} already has an out_role, removing in_role {}", new.user.id, in_role_id ); if let Err(e) = ctx .http .remove_member_role( guild_id, new.user.id, in_role, Some("Role Bridge cleanup"), ) .await { tracing::error!("Failed to remove bridged role: {}", e); } } } } } } } Ok(()) }