use std::{env, error::Error, path::PathBuf, sync::Arc}; use brain::Brain; use confindent::Confindent; use database::{BoardRow, Database, Error as DbError, PermissionSetting}; use twilight_cache_inmemory::{DefaultInMemoryCache, ResourceType}; use twilight_gateway::{Event, EventTypeFlags, Intents, Shard, ShardId, StreamExt}; use twilight_http::{Client as HttpClient, client::InteractionClient}; use twilight_model::{ application::{ command::{Command, CommandType}, interaction::{ InteractionData, application_command::{CommandData, CommandOptionValue}, }, }, gateway::payload::incoming::InteractionCreate, http::interaction::{InteractionResponse, InteractionResponseData, InteractionResponseType}, id::{ Id, marker::{GuildMarker, UserMarker}, }, }; use twilight_util::builder::{ InteractionResponseDataBuilder, command::{CommandBuilder, IntegerBuilder, RoleBuilder, StringBuilder, SubCommandBuilder}, embed::EmbedBuilder, }; mod brain; mod database; mod import; const PROD_APP_ID: u64 = 1363055126264283136; const DEV_APP_ID: u64 = 1363494986699771984; const APP_ID: u64 = DEV_APP_ID; macro_rules! bail { ($msg:expr) => { return InteractionResponseDataBuilder::new() .content(&format!("❌ {}", $msg)) .build() }; } macro_rules! fail { ($msg:expr) => { InteractionResponseDataBuilder::new() .content(&format!("❌ {}", $msg)) .build() }; } macro_rules! success { ($msg:expr) => { InteractionResponseDataBuilder::new() .content(&format!("✅ {}", $msg)) .build() }; } #[tokio::main] async fn main() -> anyhow::Result<()> { //dotenv::dotenv().ok(); let conf_path = if !PathBuf::from("/etc/leaberblord.conf").exists() { "leaberblord.conf" } else { "/etc/leaberblord.conf" }; let conf = Confindent::from_file(conf_path).unwrap(); let db_dir = conf .child_owned("Database") .unwrap_or("/var/leaberblord/leaberblord.sqlite".to_string()); let token = env::var("DISCORD_TOKEN") .ok() .unwrap_or_else(|| conf.child_owned("DiscordToken").unwrap()); let db = Database::new(db_dir); db.create_tables(); let arg = std::env::args().nth(1); match arg.as_deref() { Some("import") => { let Some(file) = std::env::args().nth(2) else { eprintln!("import subcommand requires a file path argument to read data from."); return Ok(()); }; let data = std::fs::read_to_string(file).unwrap(); import::import(&db, data); println!("Import finished!"); return Ok(()); } _ => (), } let mut shard = Shard::new(ShardId::ONE, token.clone(), Intents::GUILD_MESSAGES); let http = HttpClient::new(token); let cache = DefaultInMemoryCache::builder() .resource_types(ResourceType::GUILD) .resource_types(ResourceType::MESSAGE) .resource_types(ResourceType::MEMBER) .build(); let iclient = http.interaction(Id::new(APP_ID)); let commands_vec = commands().await; iclient.set_global_commands(&commands_vec).await.unwrap(); println!("Set commands!"); let cmd_ap_global = iclient .global_commands() .await .unwrap() .models() .await .unwrap(); for cmd in cmd_ap_global { println!( "global command- [{}] {}: {}", cmd.id .map(|c| c.get().to_string()) .unwrap_or("-".to_owned()), cmd.name, cmd.description ); } let brain = Arc::new(Brain::new(db, http, cache)); while let Some(item) = shard.next_event(EventTypeFlags::all()).await { let Ok(event) = item else { eprintln!("error receiving event"); continue; }; brain.update_cache(&event); tokio::spawn(handle_event(event, Arc::clone(&brain))); } Ok(()) } async fn handle_event(event: Event, brain: Arc) -> Result<(), Box> { match event { Event::InteractionCreate(create) => { // Don't allow direct message commanding if create.is_dm() { brain .interaction_respond(&create, fail!("leaderboards not supported in DMs")) .await; return Ok(()); } let data = create.data.as_ref().unwrap(); if let InteractionData::ApplicationCommand(cmd) = data { let guild = create.guild.as_ref().unwrap().id.unwrap(); let command = match cmd.name.as_str() { "leaderboard" => Commands::Leaderboard, "points" => Commands::Points, "permission" => Commands::Permission, "import" => Commands::Import, _ => panic!("'{}' is not a command", cmd.name), }; command_handler(brain, guild, &create, command, cmd).await; } } Event::GatewayClose(close) => { println!("GatewayClose - {close:?}") } _ => (), } Ok(()) } async fn commands() -> Vec { let leaderboard = CommandBuilder::new( "leaderboard", "View the server leaderboard", CommandType::ChatInput, ) .build(); let points = CommandBuilder::new("points", "Add and remove points!", CommandType::ChatInput) .option(IntegerBuilder::new("points", "number of points. - or +").required(true)) .option( StringBuilder::new("users", "mention people to modify their points!!").required(true), ) .validate() .unwrap() .build(); let permission = CommandBuilder::new( "permission", "set who is allowed to change points", CommandType::ChatInput, ) .option( SubCommandBuilder::new("role", "require a role to change the score") .option(RoleBuilder::new("role", "role required").required(true)), ) .option(SubCommandBuilder::new( "none", "anyone can change the score", )) .build(); let import = CommandBuilder::new( "import", "import this server's emboard leaderboard by adding the points in emboard, to leaberblord's", CommandType::ChatInput, ) .build(); vec![leaderboard, points, permission, import] } async fn command_handler( brain: Arc, guild: Id, create: &InteractionCreate, command: Commands, command_data: &CommandData, ) { // Handle the command and create our interaction response data let data = match command { Commands::Leaderboard => get_leaderboard(brain.clone(), guild), Commands::Points => add_points(brain.clone(), guild, create, command_data).await, Commands::Permission => permission(brain.clone(), guild, create, command_data).await, Commands::Import => { import_cmd(brain.clone(), guild, create, command_data).await; return; } }; // Finally, send back the response brain.interaction_respond(create, data).await; } fn get_leaderboard(brain: Arc, guild: Id) -> InteractionResponseData { let board = brain.db.get_leaderboard(guild.get()); match board { Err(DbError::TableNotExist) => { fail!("No leaderboard exists for this server! Create one by giving someone points.") } Err(DbError::UserNotExist) => unreachable!(), Ok(data) => { let str = data .into_iter() .enumerate() .map(|(idx, br)| format!("{idx}. <@{}>: {}", br.user_id, br.points)) .collect::>() .join("\n"); let embed = EmbedBuilder::new().description(str).build(); InteractionResponseDataBuilder::new() .embeds([embed]) .build() } } } async fn add_points( brain: Arc, guild: Id, create: &InteractionCreate, data: &CommandData, ) -> InteractionResponseData { let mut points = None; let mut users: Vec> = vec![]; for opt in &data.options { match opt.name.as_str() { "points" => match opt.value { CommandOptionValue::Integer(num) => points = Some(num), _ => unreachable!(), }, "users" => match &opt.value { CommandOptionValue::String(raw) => { println!("raw: {raw}"); let mentions = extract_mentions(&raw); users = mentions; } _ => unreachable!(), }, _ => unreachable!(), } } if users.len() == 0 { bail!("No users mentioned! Who do we add points to?") } let (points, points_display, points_verb) = match points { Some(p) if p > 0 => (p, p, "added to"), Some(p) if p < 0 => (p, -p, "removed from"), Some(0) => { return success!("adding 0 points is a no-operation! I won't do anything :)"); } Some(_) | None => unreachable!(), }; brain.create_leaderboard_if_not_exists(guild); let settings = brain.db.get_leaderboard_settings(guild.get()).unwrap(); if let PermissionSetting::RoleRequired = settings.permission { if let Some(role) = settings.role { let member = create.member.clone().unwrap(); let found_role = member.roles.iter().find(|vrole| vrole.get() == role); if found_role.is_none() { bail!("You do not have the right permissions to change the score"); } } else { // Seeing as the role is a required input on the subcommand, this // would only happen with direct database fiddling or like, some // really weird arcane bullshit? bail!( "Permissions set to Role Required, but no role is set. \ This shouldn't be able to happen. \ Maybe try to set the permissions again?" ); } } for user in &users { match brain.db.give_user_points(guild.get(), user.get(), points) { Err(DbError::UserNotExist) => { let member = brain.guild_member(guild, *user).await; let row = BoardRow { user_id: user.get(), user_handle: member.handle, user_nickname: member.nick.or(member.global_name), points, }; brain.db.add_user_to_leaderboard(guild.get(), row).unwrap(); } _ => (), } } let users_string = users .into_iter() .map(|u| format!("<@{u}>")) .collect::>() .join(", "); let msg = format!("{points_display} points {points_verb} {users_string}"); let embed = EmbedBuilder::new().description(msg).build(); InteractionResponseDataBuilder::new() .embeds([embed]) .build() } async fn permission( brain: Arc, guild: Id, create: &InteractionCreate, data: &CommandData, ) -> InteractionResponseData { let member = create.member.clone().unwrap(); let permissions = member.permissions.unwrap(); if !permissions.contains(twilight_model::guild::Permissions::MANAGE_GUILD) { bail!("You must have the Manage Server permission to perform this action"); } for opt in &data.options { return match opt.name.as_str() { "none" => permission_none(brain, guild), "role" => permission_role(brain, guild, opt.value.clone()), _ => panic!(), }; } fail!("this should be unreachable. report the bug maybe? to gennyble on discord") } fn permission_none(brain: Arc, guild: Id) -> InteractionResponseData { if brain.create_leaderboard_if_not_exists(guild) { // If it already existed, we might not be on default none. So set it. brain .db .set_leaderboard_permission(guild.get(), PermissionSetting::None) .unwrap(); } success!("Permission for leaderboard changed to none. Anyone may now set the score.") } fn permission_role( brain: Arc, guild: Id, data: CommandOptionValue, ) -> InteractionResponseData { let role = if let CommandOptionValue::SubCommand(data) = data { match data.iter().find(|d| d.name == "role").unwrap().value { CommandOptionValue::Role(role) => role, _ => panic!(), } } else { panic!() }; brain.create_leaderboard_if_not_exists(guild); brain .db .set_leaderboard_permission(guild.get(), PermissionSetting::RoleRequired) .unwrap(); brain .db .set_leaderboard_permission_role(guild.get(), role.get()) .unwrap(); success!(format!( "Permission for leaderboard changed to Role Required. \ Only members of <@&{role}> may now set the score" )) } async fn import_cmd( brain: Arc, guild: Id, create: &InteractionCreate, data: &CommandData, ) { let member = create.member.clone().unwrap(); let permissions = member.permissions.unwrap(); if !permissions.contains(twilight_model::guild::Permissions::MANAGE_GUILD) { brain .interaction_respond( create, fail!("You must have the Manage Server permission to perform this action"), ) .await; } else { import::emboard_direct::import(brain, guild, create).await; } } enum Commands { Leaderboard, Points, Permission, Import, } fn extract_mentions(mut raw: &str) -> Vec> { let mut ret = vec![]; loop { let Some(start) = raw.find("<@") else { break; }; let Some(end) = raw.find('>') else { break; }; let id_str = &raw[start + 2..end]; raw = &raw[end + 1..]; let Ok(id) = u64::from_str_radix(id_str, 10) else { continue; }; ret.push(Id::new(id)); } ret }