multi-track leveling system + user say command
This commit is contained in:
796
src/commands/level.rs
Normal file
796
src/commands/level.rs
Normal file
@@ -0,0 +1,796 @@
|
||||
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<Client>;
|
||||
}
|
||||
|
||||
#[derive(Deserialize, serde::Serialize, Debug)]
|
||||
pub struct UserLevel {
|
||||
pub xp: u64,
|
||||
pub level: u64,
|
||||
pub track: String,
|
||||
pub last_message: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GuildRecord {
|
||||
level_role_stack: Option<HashMap<String, Vec<u64>>>,
|
||||
levelup_channel: Option<u64>,
|
||||
levelup_message: Option<String>,
|
||||
level_up_role_mapper: Option<HashMap<String, u64>>,
|
||||
}
|
||||
|
||||
#[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"
|
||||
)]
|
||||
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::<u64>() {
|
||||
role_ids.push(serenity::RoleId::new(id));
|
||||
}
|
||||
} else if let Ok(id) = part.parse::<u64>() {
|
||||
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<u64> = role_ids.iter().map(|r| r.get()).collect();
|
||||
|
||||
tracing::info!("Updating level_role_stack for guild {}", guild_id);
|
||||
|
||||
let updated: Option<serde::de::IgnoredAny> = 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<serde::de::IgnoredAny> = 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<GuildRecord> = 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<String> =
|
||||
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"
|
||||
)]
|
||||
pub async fn set_levelup_message_channel(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Channel to send level up messages to (default: current channel)"]
|
||||
channel: Option<serenity::Channel>,
|
||||
) -> 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<serde::de::IgnoredAny> = 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"
|
||||
)]
|
||||
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<serde::de::IgnoredAny> = 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"
|
||||
)]
|
||||
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<serde::de::IgnoredAny> = 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::<DbKey>() {
|
||||
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<UserLevel> = 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<GuildRecord> =
|
||||
db.select(("guilds", guild_id.to_string())).await?;
|
||||
|
||||
let mut role_assigned_id: Option<serenity::RoleId> = 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<String> =
|
||||
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::<u64>(), caps[2].parse::<u64>())
|
||||
{
|
||||
// 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<UserLevel> = db.update(record_id).content(level_data).await?;
|
||||
} else {
|
||||
// User doesn't exist, check if they have a starting role
|
||||
let guild_record: Option<GuildRecord> = 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<UserLevel> =
|
||||
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<LeaderboardEntry> = 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<Vec<u8>, 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::<u64>().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<u8> = 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<serenity::Member>,
|
||||
new: &serenity::Member,
|
||||
) -> Result<(), Error> {
|
||||
let guild_id = new.guild_id;
|
||||
let data = ctx.data.read().await;
|
||||
let db = match data.get::<DbKey>() {
|
||||
Some(db) => db,
|
||||
None => {
|
||||
tracing::error!("Database connection not found in context data");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let guild_record: Option<GuildRecord> = 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::<u64>() {
|
||||
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(())
|
||||
}
|
||||
Reference in New Issue
Block a user