about summary refs log tree commit diff
path: root/src/command
diff options
context:
space:
mode:
Diffstat (limited to 'src/command')
-rw-r--r--src/command/add_points.rs64
-rw-r--r--src/command/leaderboard.rs91
-rw-r--r--src/command/mod.rs367
-rw-r--r--src/command/revise.rs82
4 files changed, 604 insertions, 0 deletions
diff --git a/src/command/add_points.rs b/src/command/add_points.rs
new file mode 100644
index 0000000..97e3981
--- /dev/null
+++ b/src/command/add_points.rs
@@ -0,0 +1,64 @@
+use std::sync::Arc;
+
+use twilight_model::{
+	application::interaction::application_command::CommandData,
+	gateway::payload::incoming::InteractionCreate,
+	http::interaction::InteractionResponseData,
+	id::{Id, marker::GuildMarker},
+};
+use twilight_util::builder::{InteractionResponseDataBuilder, embed::EmbedBuilder};
+
+use crate::{
+	brain::Brain,
+	command::{check_command_permissions, parse_points_command_data},
+	database::{BoardRow, Error as DbError},
+};
+
+pub async fn add_points(
+	brain: Arc<Brain>,
+	guild: Id<GuildMarker>,
+	create: &InteractionCreate,
+	data: &CommandData,
+) -> InteractionResponseData {
+	let point_data = match parse_points_command_data(data) {
+		Err(e) => return e,
+		Ok(pcd) => pcd,
+	};
+
+	brain.create_leaderboard_if_not_exists(guild);
+	if let Some(err) = check_command_permissions(&brain, guild, create.member.clone().unwrap()) {
+		return err;
+	}
+
+	for user in &point_data.users {
+		match brain
+			.db
+			.give_user_points(guild.get(), user.get(), point_data.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: point_data.points,
+				};
+				brain.db.add_user_to_leaderboard(guild.get(), row).unwrap();
+			}
+			_ => (),
+		}
+	}
+
+	let users_string = point_data.user_string();
+	let points_verb = point_data.points_verb;
+
+	let msg = format!(
+		"{} points {points_verb} {users_string}",
+		point_data.points.abs()
+	);
+
+	let embed = EmbedBuilder::new().description(msg).build();
+	InteractionResponseDataBuilder::new()
+		.embeds([embed])
+		.build()
+}
diff --git a/src/command/leaderboard.rs b/src/command/leaderboard.rs
new file mode 100644
index 0000000..97971f5
--- /dev/null
+++ b/src/command/leaderboard.rs
@@ -0,0 +1,91 @@
+use std::sync::Arc;
+
+use twilight_model::{
+	application::interaction::application_command::{CommandData, CommandOptionValue},
+	http::interaction::InteractionResponseData,
+	id::{Id, marker::GuildMarker},
+};
+use twilight_util::builder::{InteractionResponseDataBuilder, embed::EmbedBuilder};
+
+use crate::{brain::Brain, database::Error as DbError, fail, util};
+
+enum Style {
+	TiesEqual,
+	TiesBroken,
+}
+
+pub fn leaderboard(
+	brain: Arc<Brain>,
+	guild: Id<GuildMarker>,
+	data: Option<&CommandData>,
+) -> InteractionResponseData {
+	let board = brain.db.get_leaderboard(guild.get());
+
+	let mut style = Style::TiesBroken;
+	if let Some(data) = data {
+		for opt in &data.options {
+			match opt.name.as_str() {
+				"style" => match &opt.value {
+					CommandOptionValue::String(raw) => match raw.as_str() {
+						"equal" => style = Style::TiesEqual,
+						"broken" => style = Style::TiesBroken,
+						_ => {
+							return fail!(format!(
+								"{raw} is not a valid style option. Try 'equal' or 'broken'"
+							));
+						}
+					},
+					_ => unreachable!(),
+				},
+				_ => unreachable!(),
+			}
+		}
+	}
+
+	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 placed = util::tiebreak_shared_positions(data);
+
+			let str = match style {
+				Style::TiesEqual => placed
+					.into_iter()
+					.map(|placement| {
+						if placement.placement_first_of_tie {
+							format!(
+								"`{:>2}` <@{}>: {}",
+								placement.placement_tie,
+								placement.row.user_id,
+								placement.row.points
+							)
+						} else {
+							format!(
+								"`  ` <@{}>: {}",
+								placement.row.user_id, placement.row.points
+							)
+						}
+					})
+					.collect::<Vec<String>>()
+					.join("\n"),
+				Style::TiesBroken => placed
+					.into_iter()
+					.map(|placement| {
+						format!(
+							"`{:>2}` <@{}>: {}",
+							placement.placement, placement.row.user_id, placement.row.points
+						)
+					})
+					.collect::<Vec<String>>()
+					.join("\n"),
+			};
+
+			let embed = EmbedBuilder::new().description(str).build();
+			InteractionResponseDataBuilder::new()
+				.embeds([embed])
+				.build()
+		}
+	}
+}
diff --git a/src/command/mod.rs b/src/command/mod.rs
new file mode 100644
index 0000000..c3dc7a9
--- /dev/null
+++ b/src/command/mod.rs
@@ -0,0 +1,367 @@
+mod add_points;
+mod leaderboard;
+mod revise;
+
+pub use add_points::add_points;
+pub use leaderboard::leaderboard;
+pub use revise::revise;
+use time::{Date, PrimitiveDateTime, Time, format_description};
+
+use std::sync::Arc;
+
+use twilight_model::{
+	application::{
+		command::{Command, CommandType},
+		interaction::application_command::{CommandData, CommandOptionValue},
+	},
+	gateway::payload::incoming::InteractionCreate,
+	guild::PartialMember,
+	http::interaction::InteractionResponseData,
+	id::{
+		Id,
+		marker::{GuildMarker, UserMarker},
+	},
+};
+use twilight_util::builder::{
+	InteractionResponseDataBuilder,
+	command::{CommandBuilder, IntegerBuilder, RoleBuilder, StringBuilder, SubCommandBuilder},
+	embed::{EmbedBuilder, EmbedFieldBuilder},
+};
+
+use crate::{bail, brain::Brain, database::PermissionSetting, fail, import, success, util};
+
+pub enum Commands {
+	About,
+	Leaderboard,
+	Points,
+	Revise,
+	Permission,
+	Import,
+}
+
+pub async fn handler(
+	brain: Arc<Brain>,
+	guild: Id<GuildMarker>,
+	create: &InteractionCreate,
+	command: Commands,
+	command_data: &CommandData,
+) {
+	// Handle the command and create our interaction response data
+	let data = match command {
+		Commands::About => about(brain.clone(), guild),
+		Commands::Leaderboard => leaderboard(brain.clone(), guild, Some(command_data)),
+		Commands::Points => add_points(brain.clone(), guild, create, command_data).await,
+		Commands::Revise => revise(brain.clone(), guild, create, command_data).await,
+		Commands::Permission => permission(brain.clone(), guild, create, command_data).await,
+		Commands::Import => {
+			import(brain.clone(), guild, create, command_data).await;
+			return;
+		}
+	};
+
+	// Finally, send back the response
+	brain.interaction_respond(create, data).await;
+}
+
+pub async fn build() -> Vec<Command> {
+	let about = CommandBuilder::new(
+		"about",
+		"Get information about the bot",
+		CommandType::ChatInput,
+	)
+	.build();
+
+	let leaderboard = CommandBuilder::new(
+		"leaderboard",
+		"View the server leaderboard",
+		CommandType::ChatInput,
+	)
+	.option(
+		StringBuilder::new("style", "style of leaderboard to display")
+			.choices([("Ties Equal", "equal"), ("Ties Broken", "broken")]),
+	)
+	.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 revise = CommandBuilder::new(
+		"revise",
+		"Change history by adding/removing points at a specific point in time",
+		CommandType::ChatInput,
+	)
+	.option(IntegerBuilder::new("points", "number of points. - or +").required(true))
+	.option(StringBuilder::new("users", "mention people to modify their points!!").required(true))
+	.option(
+		StringBuilder::new(
+			"date",
+			"Date string in the YYYY-DD-MM format, with optional time component (HH:MM:SS, 24-hour time)",
+		)
+		.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![about, leaderboard, points, revise, permission, import]
+}
+
+pub fn about(_brain: Arc<Brain>, _guild: Id<GuildMarker>) -> InteractionResponseData {
+	let embed = EmbedBuilder::new()
+		.title("About Leaberblord")
+		.description(
+			"A single-purpose bot for keeping score. Written in Rust using the twilight set of crates.",
+		).field(EmbedFieldBuilder::new("Source", "The source is available on the author's [cgit instance](https://git.dreamy.place/whimsy/leaberblord/about)"))
+		.field(EmbedFieldBuilder::new("Author", "Written by @gennyble. Her homepage is [dreamy.place](https://dreamy.place)"))
+		.color(0x33aa88).build();
+
+	InteractionResponseDataBuilder::new()
+		.embeds([embed])
+		.build()
+}
+
+pub async fn permission(
+	brain: Arc<Brain>,
+	guild: Id<GuildMarker>,
+	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()),
+			_ => {
+				eprintln!("permission command, unknown option '{}'", opt.name);
+				fail!("this should be unreachable. report the bug maybe? to gennyble on discord")
+			}
+		};
+	}
+
+	fail!("this should be unreachable. report the bug maybe? to gennyble on discord")
+}
+
+fn permission_none(brain: Arc<Brain>, guild: Id<GuildMarker>) -> 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<Brain>,
+	guild: Id<GuildMarker>,
+	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"
+	))
+}
+
+pub async fn import(
+	brain: Arc<Brain>,
+	guild: Id<GuildMarker>,
+	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;
+	}
+}
+
+/// Checks the settings of the leaderboard associated with the provided guild.
+///
+/// Returns `None` if:
+/// - The guild does not have permissions set
+/// - The guild member has the appropriate permissions
+///
+/// Returns `Some` with InteractionResponseData if:
+/// - The guild member does not have proper permissions
+/// - The guild is set to role required, but no role is set, which should be
+///   impossible to happen, but.
+pub fn check_command_permissions(
+	brain: &Brain,
+	guild: Id<GuildMarker>,
+	member: PartialMember,
+) -> Option<InteractionResponseData> {
+	let settings = brain.db.get_leaderboard_settings(guild.get()).unwrap();
+	if let PermissionSetting::RoleRequired = settings.permission {
+		if let Some(role) = settings.role {
+			let member = member;
+			let found_role = member.roles.iter().find(|vrole| vrole.get() == role);
+
+			if found_role.is_none() {
+				Some(fail!(
+					"You do not have the right permissions to change the score"
+				))
+			} else {
+				None
+			}
+		} 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?
+			Some(fail!(
+				"Permissions set to Role Required, but no role is set. \
+				This shouldn't be able to happen. \
+				Maybe try to set the permissions again?"
+			))
+		}
+	} else {
+		None
+	}
+}
+
+pub struct PointsCommandData {
+	points: i64,
+	points_verb: &'static str,
+	users: Vec<Id<UserMarker>>,
+	date: Result<PrimitiveDateTime, InteractionResponseData>,
+}
+
+impl PointsCommandData {
+	pub fn user_string(&self) -> String {
+		self.users
+			.iter()
+			.map(|u| format!("<@{u}>"))
+			.collect::<Vec<String>>()
+			.join(", ")
+	}
+}
+
+pub fn parse_points_command_data(
+	data: &CommandData,
+) -> Result<PointsCommandData, InteractionResponseData> {
+	let mut points = None;
+	let mut users: Vec<Id<UserMarker>> = vec![];
+	let mut date_string = None;
+
+	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) => {
+					let mentions = util::extract_user_mentions(&raw);
+					users = mentions;
+				}
+				_ => unreachable!(),
+			},
+			"date" => match &opt.value {
+				CommandOptionValue::String(raw) => {
+					date_string = Some(raw);
+				}
+				_ => unreachable!(),
+			},
+			_ => unreachable!(),
+		}
+	}
+
+	if users.len() == 0 {
+		return Err(fail!("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(_) | None => unreachable!(),
+	};
+
+	let date_fmt = format_description::parse("[year]-[month]-[day]").unwrap();
+	let datetime_fmt =
+		format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]").unwrap();
+
+	let date = match date_string {
+		None => Err(fail!("What date were these points earned?")),
+		Some(str) if str.is_empty() => Err(fail!("You must specify a date!")),
+		Some(date) => match Date::parse(date.trim(), &date_fmt) {
+			Err(_e) => match PrimitiveDateTime::parse(date.trim(), &datetime_fmt) {
+				Err(_e) => Err(fail!(
+					"Cannot parse the given date/time. Did you write it like \"2025-04-13\" or, with time, \"2025-04-13 02:33\"?"
+				)),
+				Ok(datetime) => Ok(datetime),
+			},
+			Ok(date) => Ok(PrimitiveDateTime::new(
+				date,
+				Time::from_hms(12, 0, 0).unwrap(),
+			)),
+		},
+	};
+
+	Ok(PointsCommandData {
+		points,
+		points_verb,
+		users,
+		date,
+	})
+}
diff --git a/src/command/revise.rs b/src/command/revise.rs
new file mode 100644
index 0000000..50b8c4e
--- /dev/null
+++ b/src/command/revise.rs
@@ -0,0 +1,82 @@
+use std::sync::Arc;
+
+use time::format_description;
+use twilight_model::{
+	application::interaction::application_command::CommandData,
+	gateway::payload::incoming::InteractionCreate,
+	http::interaction::InteractionResponseData,
+	id::{Id, marker::GuildMarker},
+};
+use twilight_util::builder::{InteractionResponseDataBuilder, embed::EmbedBuilder};
+
+use crate::{
+	brain::Brain,
+	command::{check_command_permissions, parse_points_command_data},
+	database::{BoardRow, Error as DbError},
+};
+
+pub async fn revise(
+	brain: Arc<Brain>,
+	guild: Id<GuildMarker>,
+	create: &InteractionCreate,
+	data: &CommandData,
+) -> InteractionResponseData {
+	let (point_data, date) = match parse_points_command_data(data) {
+		Err(e) => return e,
+		Ok(pcd) => match pcd.date {
+			Err(e) => return e,
+			Ok(date) => (pcd, date),
+		},
+	};
+
+	brain.create_leaderboard_if_not_exists(guild);
+	if let Some(err) = check_command_permissions(&brain, guild, create.member.clone().unwrap()) {
+		return err;
+	}
+
+	for user in &point_data.users {
+		match brain
+			.db
+			.give_user_points(guild.get(), user.get(), point_data.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: point_data.points,
+				};
+				brain.db.add_user_to_leaderboard(guild.get(), row).unwrap();
+			}
+			_ => (),
+		}
+
+		//FIXME: gen 2025-08-14: do not assume UTC!
+		brain
+			.db
+			.revise_last_history_date(
+				guild.get(),
+				user.get(),
+				point_data.points,
+				date.assume_utc(),
+			)
+			.unwrap();
+	}
+
+	let datetime_fmt =
+		format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]").unwrap();
+
+	let points_verb = point_data.points_verb;
+	let users_string = point_data.user_string();
+
+	let msg = format!(
+		"{} points {points_verb} {users_string} on date {}",
+		point_data.points.abs(),
+		date.format(&datetime_fmt).unwrap()
+	);
+	let embed = EmbedBuilder::new().description(msg).build();
+	InteractionResponseDataBuilder::new()
+		.embeds([embed])
+		.build()
+}