use std::{env, error::Error, sync::Arc}; 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, CommandDataOption, CommandOptionValue}, }, }, channel::message::MessageFlags, gateway::payload::incoming::InteractionCreate, http::interaction::{InteractionResponse, InteractionResponseData, InteractionResponseType}, id::{ Id, marker::{GuildMarker, InteractionMarker, UserMarker}, }, }; use twilight_util::builder::{ InteractionResponseDataBuilder, command::{ CommandBuilder, IntegerBuilder, MentionableBuilder, NumberBuilder, RoleBuilder, StringBuilder, SubCommandBuilder, SubCommandGroupBuilder, UserBuilder, }, embed::{EmbedBuilder, EmbedFieldBuilder}, }; mod database; const APP_ID: u64 = 1363055126264283136; 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 = Confindent::from_file("/etc/leaberblord.conf").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 = Arc::new(Database::new(db_dir)); db.create_tables(); let mut shard = Shard::new(ShardId::ONE, token.clone(), Intents::GUILD_MESSAGES); let http = Arc::new(HttpClient::new(token)); let mut cache = DefaultInMemoryCache::builder() .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 ); } while let Some(item) = shard.next_event(EventTypeFlags::all()).await { let Ok(event) = item else { eprintln!("error receiving event"); continue; }; cache.update(&event); tokio::spawn(handle_event(event, Arc::clone(&http), Arc::clone(&db))); } Ok(()) } async fn handle_event( event: Event, http: Arc, db: Arc, ) -> Result<(), Box> { match event { Event::InteractionCreate(create) => { // Don't allow direct message commanding if create.is_dm() { let iclient = http.interaction(Id::new(APP_ID)); iclient .create_response( create.id, &create.token, &InteractionResponse { kind: InteractionResponseType::ChannelMessageWithSource, data: Some(fail!("leaderboards not supported in DMs")), }, ) .await .unwrap(); } 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, _ => panic!("'{}' is not a command", cmd.name), }; command_handler(&db, http, 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(); vec![leaderboard, points, permission] } async fn command_handler( db: &Database, http: 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(&db, guild), Commands::Points => add_points(&db, &http, guild, create, command_data).await, Commands::Permission => permission(&db, &http, guild, create, command_data).await, }; // Finally, send back the response http.interaction(Id::new(APP_ID)) .create_response( create.id, &create.token, &InteractionResponse { kind: InteractionResponseType::ChannelMessageWithSource, data: Some(data), }, ) .await .unwrap(); } fn get_leaderboard(db: &Database, guild: Id) -> InteractionResponseData { let board = 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() } } } // A Absolutely Remarkably Terrible Thing macro_rules! aumau { ($meow:expr) => { $meow.await.unwrap().model().await.unwrap() }; } async fn add_points( db: &Database, http: &HttpClient, 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!(), } } 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(_) | None => unreachable!(), }; // Make sure we have a leaderboard to work on. if db.get_leaderboard(guild.get()).is_err() { db.create_leaderboard(guild.get()).unwrap(); } let settings = 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 db.give_user_points(guild.get(), user.get(), points) { Err(DbError::UserNotExist) => { let member = aumau!(http.guild_member(guild, *user)); let row = BoardRow { user_id: user.get(), user_handle: member.user.name, user_nickname: member.nick.or(member.user.global_name), points, }; 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( db: &Database, http: &HttpClient, 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 command"); } for opt in &data.options { return match opt.name.as_str() { "none" => permission_none(db, guild), "role" => permission_role(db, guild, opt.value.clone()), _ => panic!(), }; } fail!("this should be unreachable. report the bug maybe? to gennyble on discord") } fn permission_none(db: &Database, guild: Id) -> InteractionResponseData { if db.get_leaderboard(guild.get()).is_err() { db.create_leaderboard(guild.get()).unwrap(); } else { 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( db: &Database, 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!() }; // Make sure we have a leaderboard to work on if db.get_leaderboard(guild.get()).is_err() { db.create_leaderboard(guild.get()).unwrap(); } db.set_leaderboard_permission(guild.get(), PermissionSetting::RoleRequired) .unwrap(); 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" )) } enum Commands { Leaderboard, Points, Permission, } 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 }