multi-track leveling system + user say command
This commit is contained in:
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
target/
|
||||
.git/
|
||||
.env
|
||||
*.md
|
||||
3935
Cargo.lock
generated
3935
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -8,12 +8,21 @@ edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
ab_glyph = "0.2.32"
|
||||
anyhow = "1.0.100"
|
||||
base64 = "0.22.1"
|
||||
chrono = "0.4.42"
|
||||
dotenvy = "0.15.7"
|
||||
image = "0.25.9"
|
||||
imageproc = "0.25.0"
|
||||
poise = "0.6.1"
|
||||
rand = "0.9.2"
|
||||
regex = "1.12.2"
|
||||
reqwest = { version = "0.12.26", features = ["json"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.145"
|
||||
serenity = "0.12.4"
|
||||
surrealdb = "2.4.0"
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
tracing = "0.1.43"
|
||||
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "fmt"] }
|
||||
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM rust:latest as builder
|
||||
|
||||
WORKDIR /usr/src/void-sentinel
|
||||
COPY . .
|
||||
RUN cargo build --release --bin void-sentinel
|
||||
RUN strip target/release/void-sentinel -o /usr/local/bin/void-sentinel
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates tzdata && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /usr/local/bin/void-sentinel /usr/local/bin/void-sentinel
|
||||
|
||||
RUN groupadd -g 1001 appgroup && useradd -u 1001 -g appgroup -s /bin/false -m appuser
|
||||
|
||||
USER appuser
|
||||
WORKDIR /app
|
||||
|
||||
CMD ["/usr/local/bin/void-sentinel"]
|
||||
|
||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
void-sentinel:
|
||||
build: .
|
||||
environment:
|
||||
- RUST_LOG
|
||||
- DISCORD_TOKEN
|
||||
- SURREAL_ADDRESS
|
||||
- SURREAL_USER
|
||||
- SURREAL_PASS
|
||||
- SURREAL_NS
|
||||
- SURREAL_DB
|
||||
- NVIDIA_API_KEY
|
||||
restart: unless-stopped
|
||||
@@ -1,2 +1,8 @@
|
||||
RUST_LOG="info" # Optional
|
||||
DISCORD_TOKEN=""
|
||||
SURREAL_ADDRESS=""
|
||||
SURREAL_USER=""
|
||||
SURREAL_PASS=""
|
||||
SURREAL_NS=""
|
||||
SURREAL_DB=""
|
||||
NVIDIA_API=""
|
||||
BIN
src/assets/Roboto-Regular.ttf
Normal file
BIN
src/assets/Roboto-Regular.ttf
Normal file
Binary file not shown.
73
src/commands/fun.rs
Normal file
73
src/commands/fun.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use crate::{Context, Error};
|
||||
use poise::serenity_prelude as serenity;
|
||||
|
||||
#[poise::command(slash_command, prefix_command, guild_only)]
|
||||
pub async fn say(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Message to say"] msg: String,
|
||||
#[description = "User to impersonate (optional)"] user: Option<serenity::User>,
|
||||
) -> Result<(), Error> {
|
||||
let guild_id = ctx
|
||||
.guild_id()
|
||||
.ok_or_else(|| Error::msg("Guild only command"))?;
|
||||
|
||||
if let poise::Context::Prefix(prefix_ctx) = ctx {
|
||||
let _ = prefix_ctx.msg.delete(ctx.http()).await;
|
||||
}
|
||||
|
||||
let channel_id = ctx.channel_id();
|
||||
|
||||
// Determine the user to impersonate (target user or author)
|
||||
let target_user = user.as_ref().unwrap_or_else(|| ctx.author());
|
||||
let username = if let Ok(member) = guild_id.member(ctx.http(), target_user.id).await {
|
||||
member.display_name().to_string()
|
||||
} else {
|
||||
target_user
|
||||
.global_name
|
||||
.as_ref()
|
||||
.unwrap_or(&target_user.name)
|
||||
.clone()
|
||||
};
|
||||
let avatar_url = target_user.face();
|
||||
|
||||
// Find or create webhook
|
||||
let webhooks = ctx.http().get_channel_webhooks(channel_id).await?;
|
||||
let mut webhook = webhooks
|
||||
.into_iter()
|
||||
.find(|w| w.name.as_deref() == Some("void_sentinel"));
|
||||
|
||||
if webhook.is_none() {
|
||||
let new_webhook = ctx
|
||||
.http()
|
||||
.create_webhook(
|
||||
channel_id,
|
||||
&serenity::CreateWebhook::new("void_sentinel"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
webhook = Some(new_webhook);
|
||||
}
|
||||
|
||||
let webhook = webhook.unwrap();
|
||||
|
||||
// Execute webhook
|
||||
let builder = serenity::ExecuteWebhook::new()
|
||||
.content(&msg)
|
||||
.username(username)
|
||||
.avatar_url(&avatar_url);
|
||||
|
||||
webhook.execute(ctx.http(), false, builder).await?;
|
||||
|
||||
// If it was a slash command, we might want to acknowledge it ephemerally so it doesn't fail
|
||||
if let poise::Context::Application(_) = ctx {
|
||||
// For slash command, send an ephemeral confirmation
|
||||
ctx.send(
|
||||
poise::CreateReply::default()
|
||||
.content("Message sent!")
|
||||
.ephemeral(true),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
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(())
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod level;
|
||||
pub mod fun;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use serenity::{all::{ActivityData, Context, EventHandler, OnlineStatus, Ready}, async_trait};
|
||||
use serenity::{
|
||||
all::{ActivityData, Context, EventHandler, OnlineStatus, Ready},
|
||||
async_trait,
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
pub struct Handler;
|
||||
@@ -13,10 +16,61 @@ impl EventHandler for Handler {
|
||||
let gateway = http.get_bot_gateway().await.unwrap();
|
||||
let total = gateway.session_start_limit.total;
|
||||
let remaining = gateway.session_start_limit.remaining;
|
||||
info!("Connected to the Discord API (version {version}) with {remaining}/{total} sessions remaining.");
|
||||
info!(
|
||||
"Connected to the Discord API (version {version}) with {remaining}/{total} sessions remaining."
|
||||
);
|
||||
|
||||
let guilds_len = ready.guilds.len();
|
||||
info!("Connected to {} guilds", guilds_len);
|
||||
context.set_presence(Some(ActivityData::listening(format!("void"))), OnlineStatus::Idle);
|
||||
context.set_presence(
|
||||
Some(ActivityData::listening(format!("void"))),
|
||||
OnlineStatus::Idle,
|
||||
);
|
||||
}
|
||||
|
||||
async fn message(&self, ctx: Context, msg: serenity::all::Message) {
|
||||
if let Err(e) = crate::commands::level::process_message(&ctx, &msg).await {
|
||||
tracing::error!("Error processing message for leveling: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
async fn guild_member_update(
|
||||
&self,
|
||||
ctx: Context,
|
||||
old: Option<serenity::all::Member>,
|
||||
new: Option<serenity::all::Member>,
|
||||
event: serenity::all::GuildMemberUpdateEvent,
|
||||
) {
|
||||
// The `new` parameter is Option<Member>, but we can construct a Member from the event if needed,
|
||||
// or just use the event data. However, poise/serenity's event handler signature for guild_member_update
|
||||
// provides `old_if_available` and `new`.
|
||||
// Wait, checking serenity 0.12 docs or looking at the trait definition.
|
||||
// Let's check the trait definition in the file first or assume standard serenity 0.12 signature.
|
||||
// Standard serenity 0.12 `EventHandler`:
|
||||
// async fn guild_member_update(&self, ctx: Context, old_if_available: Option<Member>, new: Option<Member>, event: GuildMemberUpdateEvent)
|
||||
// Actually, `new` is `Option<Member>`? Let's check.
|
||||
// If `cache` is enabled, `new` might be populated.
|
||||
// But we can also construct a partial member from `event`.
|
||||
// Let's try to use `new` if available, or fetch it.
|
||||
|
||||
if let Some(new_member) = new {
|
||||
if let Err(e) =
|
||||
crate::commands::level::on_guild_member_update(&ctx, &old, &new_member).await
|
||||
{
|
||||
tracing::error!("Error processing guild member update: {}", e);
|
||||
}
|
||||
} else {
|
||||
// If member is not in cache, we might need to fetch it or construct it.
|
||||
// For now, let's rely on cache or just fetch it.
|
||||
// The event has `user` and `roles`.
|
||||
// We can construct a Member-like object or just fetch the full member.
|
||||
if let Ok(member) = ctx.http.get_member(event.guild_id, event.user.id).await {
|
||||
if let Err(e) =
|
||||
crate::commands::level::on_guild_member_update(&ctx, &old, &member).await
|
||||
{
|
||||
tracing::error!("Error processing guild member update: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
58
src/main.rs
58
src/main.rs
@@ -1,17 +1,22 @@
|
||||
mod listener;
|
||||
mod commands;
|
||||
mod listener;
|
||||
|
||||
use dotenvy::dotenv;
|
||||
use poise::{serenity_prelude as serenity, Framework, FrameworkOptions};
|
||||
use ::serenity::all::GatewayIntents;
|
||||
use dotenvy::dotenv;
|
||||
use poise::{Framework, FrameworkOptions, serenity_prelude as serenity};
|
||||
use std::{env, sync::Arc};
|
||||
use surrealdb::Surreal;
|
||||
use surrealdb::engine::remote::ws::{Client, Wss};
|
||||
use surrealdb::opt::auth::Root;
|
||||
use tracing::info;
|
||||
|
||||
type Error = anyhow::Error;
|
||||
use tracing_subscriber::{FmtSubscriber, EnvFilter};
|
||||
use tracing_subscriber::{EnvFilter, FmtSubscriber};
|
||||
type Context<'a> = poise::Context<'a, Data, Error>;
|
||||
|
||||
|
||||
struct Data {}
|
||||
struct Data {
|
||||
db: Surreal<Client>,
|
||||
}
|
||||
|
||||
#[tokio::main()]
|
||||
async fn main() -> Result<(), Error> {
|
||||
@@ -19,8 +24,7 @@ async fn main() -> Result<(), Error> {
|
||||
|
||||
let subscriber = FmtSubscriber::builder()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("info"))
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||
)
|
||||
.finish();
|
||||
|
||||
@@ -28,13 +32,40 @@ async fn main() -> Result<(), Error> {
|
||||
|
||||
let token = env::var("DISCORD_TOKEN")?;
|
||||
|
||||
let surreal_address =
|
||||
env::var("SURREAL_ADDRESS").expect("Expected SURREAL_ADDRESS in environment");
|
||||
let surreal_user = env::var("SURREAL_USER").expect("Expected SURREAL_USER in environment");
|
||||
let surreal_pass = env::var("SURREAL_PASS").expect("Expected SURREAL_PASS in environment");
|
||||
let surreal_ns = env::var("SURREAL_NS").expect("Expected SURREAL_NS in environment");
|
||||
let surreal_db = env::var("SURREAL_DB").expect("Expected SURREAL_DB in environment");
|
||||
|
||||
let db = Surreal::new::<Wss>(&surreal_address).await?;
|
||||
|
||||
db.signin(Root {
|
||||
username: &surreal_user,
|
||||
password: &surreal_pass,
|
||||
})
|
||||
.await?;
|
||||
info!("Successfully signed in to SurrealDB");
|
||||
|
||||
db.use_ns(&surreal_ns).use_db(&surreal_db).await?;
|
||||
|
||||
let db_clone = db.clone();
|
||||
let framework = Framework::builder()
|
||||
.options(FrameworkOptions::<Data, Error> {
|
||||
commands: vec![],
|
||||
commands: vec![
|
||||
commands::level::set_level_roles(),
|
||||
commands::level::get_level_roles(),
|
||||
commands::level::leaderboard(),
|
||||
commands::level::set_levelup_message_channel(),
|
||||
commands::level::set_levelup_message(),
|
||||
commands::level::levelup_role_bridger(),
|
||||
commands::fun::say(),
|
||||
],
|
||||
prefix_options: poise::PrefixFrameworkOptions {
|
||||
prefix: Some("!".into()),
|
||||
edit_tracker: Some(Arc::new(poise::EditTracker::for_timespan(
|
||||
std::time::Duration::from_mins(10)
|
||||
std::time::Duration::from_secs(600),
|
||||
))),
|
||||
case_insensitive_commands: true,
|
||||
..Default::default()
|
||||
@@ -44,7 +75,7 @@ async fn main() -> Result<(), Error> {
|
||||
.setup(move |context, _ready, framework| {
|
||||
Box::pin(async move {
|
||||
poise::builtins::register_globally(context, &framework.options().commands).await?;
|
||||
Ok(Data {})
|
||||
Ok(Data { db: db_clone })
|
||||
})
|
||||
})
|
||||
.build();
|
||||
@@ -54,6 +85,11 @@ async fn main() -> Result<(), Error> {
|
||||
.framework(framework)
|
||||
.await?;
|
||||
|
||||
{
|
||||
let mut data = client.data.write().await;
|
||||
data.insert::<commands::level::DbKey>(db.clone());
|
||||
}
|
||||
|
||||
if let Err(why) = client.start_autosharded().await {
|
||||
eprintln!("An error occurred while running the client: {why}");
|
||||
}
|
||||
|
||||
BIN
void-sentinel
Executable file
BIN
void-sentinel
Executable file
Binary file not shown.
Reference in New Issue
Block a user