multi-track leveling system + user say command

This commit is contained in:
2025-12-17 18:44:02 +05:30
parent b22a1c6fee
commit 07f101a119
13 changed files with 4936 additions and 45 deletions

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
target/
.git/
.env
*.md

3935
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,12 +8,21 @@ edition = "2024"
publish = false publish = false
[dependencies] [dependencies]
ab_glyph = "0.2.32"
anyhow = "1.0.100" anyhow = "1.0.100"
base64 = "0.22.1"
chrono = "0.4.42"
dotenvy = "0.15.7" dotenvy = "0.15.7"
image = "0.25.9"
imageproc = "0.25.0"
poise = "0.6.1" 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 = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145" serde_json = "1.0.145"
serenity = "0.12.4" serenity = "0.12.4"
surrealdb = "2.4.0"
tokio = { version = "1.48.0", features = ["full"] } tokio = { version = "1.48.0", features = ["full"] }
tracing = "0.1.43" tracing = "0.1.43"
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "fmt"] } tracing-subscriber = { version = "0.3.22", features = ["env-filter", "fmt"] }

19
Dockerfile Normal file
View 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
View 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

View File

@@ -1,2 +1,8 @@
RUST_LOG="info" # Optional RUST_LOG="info" # Optional
DISCORD_TOKEN="" DISCORD_TOKEN=""
SURREAL_ADDRESS=""
SURREAL_USER=""
SURREAL_PASS=""
SURREAL_NS=""
SURREAL_DB=""
NVIDIA_API=""

Binary file not shown.

73
src/commands/fun.rs Normal file
View 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
View 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(())
}

View File

@@ -0,0 +1,2 @@
pub mod level;
pub mod fun;

View File

@@ -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; use tracing::info;
pub struct Handler; pub struct Handler;
@@ -13,10 +16,61 @@ impl EventHandler for Handler {
let gateway = http.get_bot_gateway().await.unwrap(); let gateway = http.get_bot_gateway().await.unwrap();
let total = gateway.session_start_limit.total; let total = gateway.session_start_limit.total;
let remaining = gateway.session_start_limit.remaining; 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(); let guilds_len = ready.guilds.len();
info!("Connected to {} guilds", 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);
}
}
}
} }
} }

View File

@@ -1,17 +1,22 @@
mod listener;
mod commands; mod commands;
mod listener;
use dotenvy::dotenv;
use poise::{serenity_prelude as serenity, Framework, FrameworkOptions};
use ::serenity::all::GatewayIntents; use ::serenity::all::GatewayIntents;
use dotenvy::dotenv;
use poise::{Framework, FrameworkOptions, serenity_prelude as serenity};
use std::{env, sync::Arc}; 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; type Error = anyhow::Error;
use tracing_subscriber::{FmtSubscriber, EnvFilter}; use tracing_subscriber::{EnvFilter, FmtSubscriber};
type Context<'a> = poise::Context<'a, Data, Error>; type Context<'a> = poise::Context<'a, Data, Error>;
struct Data {
struct Data {} db: Surreal<Client>,
}
#[tokio::main()] #[tokio::main()]
async fn main() -> Result<(), Error> { async fn main() -> Result<(), Error> {
@@ -19,8 +24,7 @@ async fn main() -> Result<(), Error> {
let subscriber = FmtSubscriber::builder() let subscriber = FmtSubscriber::builder()
.with_env_filter( .with_env_filter(
EnvFilter::try_from_default_env() EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
.unwrap_or_else(|_| EnvFilter::new("info"))
) )
.finish(); .finish();
@@ -28,13 +32,40 @@ async fn main() -> Result<(), Error> {
let token = env::var("DISCORD_TOKEN")?; 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() let framework = Framework::builder()
.options(FrameworkOptions::<Data, Error> { .options(FrameworkOptions::<Data, Error> {
commands: vec![], commands: vec![
prefix_options: poise::PrefixFrameworkOptions { 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()), prefix: Some("!".into()),
edit_tracker: Some(Arc::new(poise::EditTracker::for_timespan( 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, case_insensitive_commands: true,
..Default::default() ..Default::default()
@@ -44,7 +75,7 @@ async fn main() -> Result<(), Error> {
.setup(move |context, _ready, framework| { .setup(move |context, _ready, framework| {
Box::pin(async move { Box::pin(async move {
poise::builtins::register_globally(context, &framework.options().commands).await?; poise::builtins::register_globally(context, &framework.options().commands).await?;
Ok(Data {}) Ok(Data { db: db_clone })
}) })
}) })
.build(); .build();
@@ -54,9 +85,14 @@ async fn main() -> Result<(), Error> {
.framework(framework) .framework(framework)
.await?; .await?;
{
let mut data = client.data.write().await;
data.insert::<commands::level::DbKey>(db.clone());
}
if let Err(why) = client.start_autosharded().await { if let Err(why) = client.start_autosharded().await {
eprintln!("An error occurred while running the client: {why}"); eprintln!("An error occurred while running the client: {why}");
} }
Ok(()) Ok(())
} }

BIN
void-sentinel Executable file

Binary file not shown.