about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorgennyble <gen@nyble.dev>2025-04-19 06:36:34 -0500
committergennyble <gen@nyble.dev>2025-04-19 06:36:34 -0500
commit0fbebbe9af03f6880b3c0d585b757f7809aa27e7 (patch)
tree0e446b6a9b9c7516ef1893a2fe5d18fa1511941e /src
downloadleaberblord-0fbebbe9af03f6880b3c0d585b757f7809aa27e7.tar.gz
leaberblord-0fbebbe9af03f6880b3c0d585b757f7809aa27e7.zip
works
Diffstat (limited to 'src')
-rw-r--r--src/database.rs149
-rw-r--r--src/main.rs293
2 files changed, 442 insertions, 0 deletions
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<Connection>,
+}
+
+impl Database {
+	pub fn new<P: AsRef<Path>>(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<u64, Error> {
+		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<Vec<BoardRow>, 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::<Vec<BoardRow>>())
+			.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<String>,
+	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<HttpClient>,
+	db: Arc<Database>,
+) -> Result<(), Box<dyn Error + Send + Sync>> {
+	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<Command> {
+	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<HttpClient>,
+	guild: Id<GuildMarker>,
+	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<GuildMarker>) -> 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::<Vec<String>>()
+				.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<GuildMarker>,
+	data: &CommandData,
+) -> InteractionResponseData {
+	let mut points = None;
+	let mut users: Vec<Id<UserMarker>> = 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::<Vec<String>>()
+		.join(", ");
+
+	InteractionResponseDataBuilder::new()
+		.content(format!("added {} points to {meow}", points.unwrap()))
+		.build()
+}
+
+enum Commands {
+	Leaderboard,
+	AddPoints,
+}
+
+fn extract_mentions(mut raw: &str) -> Vec<Id<UserMarker>> {
+	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
+}