From 0fbebbe9af03f6880b3c0d585b757f7809aa27e7 Mon Sep 17 00:00:00 2001 From: gennyble Date: Sat, 19 Apr 2025 06:36:34 -0500 Subject: works --- src/database.rs | 149 ++++++++++++++++++++++++++++ src/main.rs | 293 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 442 insertions(+) create mode 100644 src/database.rs create mode 100644 src/main.rs (limited to 'src') diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 0000000..e7726a9 --- /dev/null +++ b/src/database.rs @@ -0,0 +1,149 @@ +use std::{path::Path, sync::Mutex}; + +use rusqlite::{Connection, OptionalExtension, params}; +use twilight_http::request::guild; + +#[derive(Debug)] +pub struct Database { + conn: Mutex, +} + +impl Database { + pub fn new>(db_path: P) -> Self { + Self { + conn: Mutex::new(Connection::open(db_path).unwrap()), + } + } + + pub fn create_tables(&self) { + let conn = self.conn.lock().unwrap(); + conn.execute(CREATE_TABLE_LEADERBOARDS, ()).unwrap(); + } + + pub fn create_leaderboard(&self, guild_id: u64) -> Result<(), Error> { + let mut conn = self.conn.lock().unwrap(); + let trans = conn.transaction().unwrap(); + + trans + .execute( + "INSERT INTO leaderboards(guild_id) VALUES(?1)", + params![guild_id], + ) + .unwrap(); + + let leaderboard_id = trans.last_insert_rowid(); + let leaderboard_create_sql = + CREATE_TABLE_LEADERBOARD.replace("LBID", &leaderboard_id.to_string()); + + trans.execute(&leaderboard_create_sql, params![]).unwrap(); + trans.commit().unwrap(); + + Ok(()) + } + + pub fn leaderboard_id(&self, guild_id: u64) -> Result { + let conn = self.conn.lock().unwrap(); + + let leaderboard_id = conn + .query_row( + "SELECT * FROM leaderboards WHERE guild_id=?1", + params![guild_id], + |row| row.get(0), + ) + .map_err(|_| Error::TableNotExist); + + leaderboard_id + } + + pub fn get_leaderboard(&self, guild_id: u64) -> Result, Error> { + // Don't deadlock! + let leaderboard_id = self.leaderboard_id(guild_id)?; + let conn = self.conn.lock().unwrap(); + + let mut query = conn + .prepare(&format!("SELECT * FROM leaderboard_{leaderboard_id}")) + .unwrap(); + let vec = query + .query_map((), |row| { + Ok(BoardRow { + user_id: row.get(0)?, + user_handle: row.get(1)?, + user_nickname: row.get(2)?, + points: row.get(3)?, + }) + }) + .optional() + .unwrap() + .map(|iter| iter.map(|e| e.unwrap()).collect::>()) + .unwrap(); + + Ok(vec) + } + + pub fn add_user_to_leaderboard(&self, guild: u64, row: BoardRow) -> Result<(), Error> { + // Don't deadlock! + let leaderboard_id = self.leaderboard_id(guild)?; + let table = format!("leaderboard_{leaderboard_id}"); + let conn = self.conn.lock().unwrap(); + + let BoardRow { + user_id, + user_handle, + user_nickname, + points, + } = row; + + let sql = format!( + "INSERT INTO {table}(user_id, user_handle, user_nickname, points) VALUES(?1, ?2, ?3, ?4)" + ); + conn.execute(&sql, params![user_id, user_handle, user_nickname, points]) + .unwrap(); + + Ok(()) + } + + pub fn give_user_points(&self, guild_id: u64, user: u64, points: i64) -> Result<(), Error> { + // Don't deadlock! + let leaderboard_id = self.leaderboard_id(guild_id)?; + let table = format!("leaderboard_{leaderboard_id}"); + let conn = self.conn.lock().unwrap(); + + let sql = format!("SELECT * FROM {table} WHERE user_id=?1"); + let user_handle: String = conn + .query_row(&sql, params![user], |row| row.get(1)) + .map_err(|_| Error::UserNotExist)?; + + let sql = format!("UPDATE {table} SET points = points + ?1 WHERE user_id=?2"); + conn.execute(&sql, params![points, user]).unwrap(); + + Ok(()) + } +} + +const CREATE_TABLE_LEADERBOARDS: &'static str = "\ + CREATE TABLE IF NOT EXISTS leaderboards( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id INTEGER + );"; + +const CREATE_TABLE_LEADERBOARD: &'static str = "\ + CREATE TABLE IF NOT EXISTS leaderboard_LBID( + user_id INTEGER PRIMARY KEY NOT NULL, + user_handle TEXT NOT NULL, + user_nickname TEXT, + points INTEGER NOT NULL DEFAULT 0 + );"; + +#[derive(Clone, Debug)] +pub struct BoardRow { + pub user_id: u64, + pub user_handle: String, + pub user_nickname: Option, + pub points: i64, +} + +#[derive(Debug)] +pub enum Error { + TableNotExist, + UserNotExist, +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e590460 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,293 @@ +use std::{env, error::Error, sync::Arc}; + +use database::{BoardRow, Database, Error as DbError}; +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}, + }, + }, + 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, NumberBuilder, StringBuilder, UserBuilder}, +}; + +mod database; + +const APP_ID: u64 = 1363055126264283136; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + dotenv::dotenv()?; + + let db = Arc::new(Database::new("kindbloot.db")); + db.create_tables(); + + let mut shard = Shard::new( + ShardId::ONE, + env::var("DISCORD_TOKEN")?, + Intents::GUILD_MESSAGES, + ); + + let http = Arc::new(HttpClient::new(env::var("DISCORD_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(&iclient).await; + iclient.set_global_commands(&commands_vec).await.unwrap(); + + println!("Set commands!"); + + let cmd_ap = commands_vec[1].clone(); + let cmd_ap_id = cmd_ap.id; + + let cmd_ap_global = iclient + .global_commands() + .await + .unwrap() + .models() + .await + .unwrap(); + + for cmd in cmd_ap_global { + println!("got appoints global? {}: {}", 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) => { + 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, + "addpoints" => Commands::AddPoints, + _ => panic!("meow"), + }; + + command_handler(&db, http, guild, &create, command, cmd).await; + } + } + Event::GatewayClose(close) => { + println!("GatewayClose - {close:?}") + } + _ => (), + } + + Ok(()) +} + +async fn commands(ic: &InteractionClient<'_>) -> Vec { + let leaderboard = CommandBuilder::new( + "leaderboard", + "View the server leaderboard", + CommandType::ChatInput, + ) + .build(); + + let addpoints = CommandBuilder::new( + "addpoints", + "Give someone, or multiple people, points!", + CommandType::ChatInput, + ) + .option(IntegerBuilder::new("points", "number of points").required(true)) + .option(StringBuilder::new("users", "person to give the points").required(true)) + .validate() + .unwrap() + .build(); + + vec![leaderboard, addpoints] +} + +async fn command_handler( + db: &Database, + http: Arc, + guild: Id, + create: &InteractionCreate, + command: Commands, + command_data: &CommandData, +) { + match command { + Commands::Leaderboard => { + let data = get_leaderboard(&db, guild); + + let response = InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(data), + }; + + let iclient = http.interaction(Id::new(APP_ID)); + iclient + .create_response(create.id, &create.token, &response) + .await + .unwrap(); + } + Commands::AddPoints => { + let data = add_points(&db, &http, guild, command_data).await; + + let response = InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some(data), + }; + + let iclient = http.interaction(Id::new(APP_ID)); + iclient + .create_response(create.id, &create.token, &response) + .await + .unwrap(); + } + } +} + +fn get_leaderboard(db: &Database, guild: Id) -> InteractionResponseData { + let board = db.get_leaderboard(guild.get()); + + match board { + Err(DbError::TableNotExist) => InteractionResponseDataBuilder::new() + .content( + "❌ No leaderboard exists for this server! Create a leaderboard by giving someone points!", + ) + .build(), + Err(DbError::UserNotExist) => unreachable!(), + Ok(data) => { + let str = data + .into_iter() + .map(|br| format!("{}: {}", br.user_handle, br.points)) + .collect::>() + .join("\n"); + + InteractionResponseDataBuilder::new().content(str).build() + } + } +} + +macro_rules! aumau { + ($meow:expr) => { + $meow.await.unwrap().model().await.unwrap() + }; +} + +async fn add_points( + db: &Database, + http: &HttpClient, + guild: Id, + 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 db.get_leaderboard(guild.get()).is_err() { + db.create_leaderboard(guild.get()).unwrap(); + } + + for user in &users { + match db.give_user_points(guild.get(), user.get(), points.unwrap()) { + 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: points.unwrap(), + }; + db.add_user_to_leaderboard(guild.get(), row).unwrap(); + } + _ => (), + } + } + + let meow = users + .into_iter() + .map(|u| format!("<@{u}>")) + .collect::>() + .join(", "); + + InteractionResponseDataBuilder::new() + .content(format!("added {} points to {meow}", points.unwrap())) + .build() +} + +enum Commands { + Leaderboard, + AddPoints, +} + +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 +} -- cgit 1.4.1-3-g733a5