From 8868b18be3602da349af9868bbe6acc48b9097af Mon Sep 17 00:00:00 2001 From: Rafael Bradley Date: Fri, 22 Aug 2025 18:06:52 -0300 Subject: [PATCH] Add support for extras, conditionally displaying commands in the help command, and start working on moderation commands --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/cli.rs | 9 + .../migrations/0003_users_by_name.cql | 4 + src/database/models.rs | 178 ++++++++++++------ src/extras.rs | 5 + src/framework/bot.rs | 95 ++++++---- src/framework/commands/arguments.rs | 132 ++++++++----- src/framework/commands/context.rs | 24 ++- src/framework/commands/extras.rs | 32 ++++ src/framework/commands/messages.rs | 32 ++-- src/framework/commands/mod.rs | 24 ++- src/framework/commands/permissions.rs | 6 +- src/framework/commands/runner.rs | 22 ++- src/framework/events/traits.rs | 58 ++++-- src/framework/interactions/components.rs | 32 ++-- src/framework/interactions/context.rs | 32 +++- src/framework/interactions/mod.rs | 3 + src/framework/interactions/registry.rs | 25 ++- src/framework/interactions/runner.rs | 13 +- src/framework/runtime.rs | 12 +- src/groups/about/commands.rs | 15 +- src/groups/about/components.rs | 18 +- src/groups/about/embeds.rs | 21 ++- src/groups/calls/commands.rs | 49 ++--- src/groups/calls/components.rs | 6 +- src/groups/calls/events.rs | 43 +++-- .../calls/lists/explicit-sexual-language.txt | 4 +- src/groups/calls/permissions.rs | 12 +- src/groups/gateway/events.rs | 9 +- src/groups/leveling/commands.rs | 13 +- src/groups/leveling/events.rs | 28 ++- src/groups/moderation/arguments.rs | 148 +++++++++++++++ src/groups/moderation/commands.rs | 158 ++++++++++++++++ src/groups/moderation/mod.rs | 3 + src/groups/moderation/permissions.rs | 52 +++++ src/groups/servers/commands.rs | 26 +-- src/groups/servers/events.rs | 25 ++- src/groups/servers/permissions.rs | 23 ++- src/main.rs | 47 ++++- src/state.rs | 1 + 41 files changed, 1075 insertions(+), 368 deletions(-) create mode 100644 src/database/migrations/0003_users_by_name.cql create mode 100644 src/extras.rs create mode 100644 src/framework/commands/extras.rs create mode 100644 src/groups/moderation/arguments.rs create mode 100644 src/groups/moderation/commands.rs create mode 100644 src/groups/moderation/permissions.rs diff --git a/Cargo.lock b/Cargo.lock index 3decb88..3d97f8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1437,7 +1437,7 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "payphone" -version = "4.1.0" +version = "4.1.1" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 50ba600..0d41cd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "payphone" -version = "4.1.0" +version = "4.1.1" edition = "2024" default-run = "payphone" diff --git a/src/cli.rs b/src/cli.rs index 6e64338..a0b7db8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -221,6 +221,15 @@ pub enum Commands { default_values = &["redis://127.0.0.1:6379"], )] redis_urls: Vec, + + /// The bot owner's ID, this user will be allowed to assign moderators. + #[arg( + long, + env = "PAYPHONE_OWNER_ID", + value_name = "ID", + default_value_t = 0 + )] + bot_owner_id: u64, }, /// Starts a documentation server for the project which serves `cargo doc` and refreshes on diff --git a/src/database/migrations/0003_users_by_name.cql b/src/database/migrations/0003_users_by_name.cql new file mode 100644 index 0000000..dd56340 --- /dev/null +++ b/src/database/migrations/0003_users_by_name.cql @@ -0,0 +1,4 @@ +CREATE MATERIALIZED VIEW IF NOT EXISTS payphone.users_by_name AS + SELECT user_id, name FROM payphone.users + WHERE user_id IS NOT NULL AND name IS NOT NULL + PRIMARY KEY (name, user_id); diff --git a/src/database/models.rs b/src/database/models.rs index 9ad9e13..46876e2 100644 --- a/src/database/models.rs +++ b/src/database/models.rs @@ -172,15 +172,48 @@ impl User { .into_rows_result() .map_err(|_| DatabaseError::ResultNotRows)?; - match result.single_row::<::Row>() { - Ok(row) => Ok(Some(Self::from_row(row))), - Err(error) => match error { - SingleRowError::DeserializationFailed(_) | SingleRowError::TypeCheckFailed(_) => { - Err(DatabaseError::DeserializationFailed) - } - SingleRowError::UnexpectedRowCount(_) => Ok(None), - }, + get_single_row_from_result(&result).await + } + + pub async fn get_by_name( + name: String, + session: &Session, + ) -> Result, DatabaseError> { + let Some(user_by_name) = UserByName::get(name, session).await? else { + return Ok(None); + }; + + Self::get_by_id(user_by_name.user_id, session).await + } + + /// Gets a user from the DB and sets their last seen timestamp or inserts a new user from Discord. + /// + /// It also updates the user's username. + /// + /// Arguments: + /// * `user` - The Twilight user to get or create. + /// * `session` - The Scylla session to use for database operations. + /// + /// Returns: + /// [`Result`] - The user from the database or a new user created from + /// Discord. + pub async fn get_or_create_and_set_last_seen( + user: TwilightUser, + session: &Session, + ) -> Result { + let db_user = User::get_by_id(user.id.get(), session).await?; + + if let Some(mut db_user) = db_user { + db_user.last_seen_at = UtcDateTime::now(); + db_user.name = user.name; + + return Ok(db_user); } + + let new_user = User::from(user); + new_user.insert(session).await?; + + Ok(new_user) } pub async fn insert(&self, session: &Session) -> Result<(), DatabaseError> { @@ -272,34 +305,50 @@ impl From for User { } } -/// Gets a user from the DB and sets their last seen timestamp or inserts a new user from Discord. -/// -/// It also updates the user's username. -/// -/// Arguments: -/// * `user` - The Twilight user to get or create. -/// * `session` - The Scylla session to use for database operations. -/// -/// Returns: -/// [`Result`] - The user from the database or a new user created from -/// Discord. -pub async fn get_or_create_user_and_set_last_seen( - user: TwilightUser, - session: &Session, -) -> Result { - let db_user = User::get_by_id(user.id.get(), session).await?; +#[derive(Debug, Clone)] +struct UserByName { + pub user_id: u64, + pub name: String, +} - if let Some(mut db_user) = db_user { - db_user.last_seen_at = UtcDateTime::now(); - db_user.name = user.name; +impl<'a> Object<'a> for UserByName { + type Row = (i64, String); - return Ok(db_user); + fn from_row(row: Self::Row) -> Self { + Self { + user_id: i64_to_u64(row.0), + name: row.1, + } } - let new_user = User::from(user); - new_user.insert(session).await?; + fn to_row(value: Self) -> Self::Row { + (u64_to_i64(value.user_id), value.name) + } +} - Ok(new_user) +impl UserByName { + async fn get(name: String, session: &Session) -> Result, DatabaseError> { + let result = session + .query_unpaged( + r#" + SELECT + user_id, + name + FROM + payphone.users_by_name + WHERE + name = ? + LIMIT 1; + "#, + (name,), + ) + .await + .map_err(DatabaseError::QueryError)? + .into_rows_result() + .map_err(|_| DatabaseError::ResultNotRows)?; + + get_single_row_from_result(&result).await + } } #[derive(Debug, Clone)] @@ -1192,37 +1241,50 @@ impl Message { .map_err(|_| DatabaseError::ResultNotRows)?; if result.rows_num() == 0 { - let message_by_propagation_id = - MessageByPropagationId::get_by_propagation_message_id(message_id, session).await?; + Self::get_by_propagation_message_id(message_id, session).await + } else { + get_single_row_from_result(&result).await + } + } - if let Some(message_by_propagation_id) = message_by_propagation_id { - let result = session - .query_unpaged( - r#" - SELECT - call_id, - message_id, - author_id, - propagation_message_id, - data - FROM - payphone.messages - WHERE - call_id = ? AND message_id = ? - LIMIT 1; - "#, - (call_id, u64_to_i64(message_by_propagation_id.message_id)), - ) - .await - .map_err(DatabaseError::QueryError)? - .into_rows_result() - .map_err(|_| DatabaseError::ResultNotRows)?; + pub async fn get_by_propagation_message_id( + propagation_message_id: u64, + session: &Session, + ) -> Result, DatabaseError> { + let message_by_propagation_id = + MessageByPropagationId::get_by_propagation_message_id(propagation_message_id, session) + .await?; - return get_single_row_from_result(&result).await; - } + if let Some(message_by_propagation_id) = message_by_propagation_id { + let result = session + .query_unpaged( + r#" + SELECT + call_id, + message_id, + author_id, + propagation_message_id, + data + FROM + payphone.messages + WHERE + call_id = ? AND message_id = ? + LIMIT 1; + "#, + ( + u64_to_i64(message_by_propagation_id.call_id), + u64_to_i64(message_by_propagation_id.message_id), + ), + ) + .await + .map_err(DatabaseError::QueryError)? + .into_rows_result() + .map_err(|_| DatabaseError::ResultNotRows)?; + + return get_single_row_from_result(&result).await; } - get_single_row_from_result(&result).await + Ok(None) } pub async fn insert(&self, session: &Session) -> Result<(), DatabaseError> { diff --git a/src/extras.rs b/src/extras.rs new file mode 100644 index 0000000..286d521 --- /dev/null +++ b/src/extras.rs @@ -0,0 +1,5 @@ +use crate::database::models::User; + +pub struct Extras { + pub user: User, +} diff --git a/src/framework/bot.rs b/src/framework/bot.rs index 076628c..0807ed4 100644 --- a/src/framework/bot.rs +++ b/src/framework/bot.rs @@ -9,7 +9,7 @@ use twilight_model::gateway::payload::outgoing::update_presence::UpdatePresenceP use crate::{ framework::{ - commands::{Command, runner::CommandRunner}, + commands::{Command, extras::ExtrasGetter, runner::CommandRunner}, events::traits::{ OnInteractionHandler, OnMessageDeleteHandler, OnMessageEditHandler, OnMessageHandler, OnReadyHandler, OnReconnectHandler, OnServerCreateHandler, OnServerDeleteHandler, @@ -32,7 +32,7 @@ pub enum RuntimeError { /// Getter trait for command prefixes. #[async_trait] -pub trait PrefixGetter { +pub trait PrefixGetter { /// Get the command prefixes for the given server and user IDs. /// /// The getter must never fail. In case an internal process does, it must fall back to default @@ -49,75 +49,87 @@ pub trait PrefixGetter { /// first one will always be the safest choice. async fn get( &self, - runtime: &Runtime, + runtime: &Runtime, server_id: Option, user_id: Option, ) -> Vec; } #[async_trait] -impl PrefixGetter for String { - async fn get(&self, _: &Runtime, _: Option, _: Option) -> Vec { +impl PrefixGetter for String { + async fn get(&self, _: &Runtime, _: Option, _: Option) -> Vec { vec![self.to_string()] } } #[async_trait] -impl PrefixGetter for &str { - async fn get(&self, _: &Runtime, _: Option, _: Option) -> Vec { +impl PrefixGetter for &str { + async fn get(&self, _: &Runtime, _: Option, _: Option) -> Vec { vec![self.to_string()] } } #[async_trait] -impl PrefixGetter for [T; N] +impl PrefixGetter for [T; N] where - T: ToString, + T: ToString + Send + Sync + 'static, + E: Send + Sync + 'static, { - async fn get(&self, _: &Runtime, _: Option, _: Option) -> Vec { + async fn get(&self, _: &Runtime, _: Option, _: Option) -> Vec { self.iter().map(|s| s.to_string()).collect() } } #[async_trait] -impl PrefixGetter for F +impl PrefixGetter for F where F: Fn() -> Fut + Send + Sync + 'static, Fut: Future> + Send, + E: Send + Sync + 'static, { - async fn get(&self, _: &Runtime, _: Option, _: Option) -> Vec { + async fn get(&self, _: &Runtime, _: Option, _: Option) -> Vec { (self)().await } } -pub struct Bot { +pub struct Bot { shard: ShardId, state: Arc, presence: Option, - prefixes: Arc + Send + Sync>, + prefixes: Arc + Send + Sync>, intents: Intents, + extras: Arc>, cached_resource_types: Vec, - commands: Vec>>, - components: Vec>>, - on_ready_handlers: Vec>>, - on_reconnect_handlers: Vec>>, - on_interaction_handlers: Vec>>, - on_message_handlers: Vec>>, - on_message_edit_handlers: Vec>>, - on_message_delete_handlers: Vec>>, - on_server_create_handlers: Vec>>, - on_server_update_handlers: Vec>>, - on_server_delete_handlers: Vec>>, + commands: Vec>>, + components: Vec>>, + on_ready_handlers: Vec>>, + on_reconnect_handlers: Vec>>, + on_interaction_handlers: Vec>>, + on_message_handlers: Vec>>, + on_message_edit_handlers: Vec>>, + on_message_delete_handlers: Vec>>, + on_server_create_handlers: Vec>>, + on_server_update_handlers: Vec>>, + on_server_delete_handlers: Vec>>, } -impl Bot { - pub fn new(state: T, prefixes: impl PrefixGetter + Send + Sync + 'static) -> Self { +impl Bot +where + T: 'static + Send + Sync, + E: 'static + Send + Sync, +{ + pub fn new( + state: T, + prefixes: impl PrefixGetter + Send + Sync + 'static, + extras: impl ExtrasGetter + 'static, + ) -> Self { Self { shard: ShardId::new(0, 1), state: Arc::new(state), presence: None, prefixes: Arc::new(prefixes), intents: Intents::empty(), + extras: Arc::new(extras), cached_resource_types: Vec::new(), commands: Vec::new(), components: Vec::new(), @@ -148,12 +160,12 @@ impl Bot { self } - pub fn command(mut self, command: impl Command + 'static) -> Self { + pub fn command(mut self, command: impl Command + 'static) -> Self { self.commands.push(Arc::new(command)); self } - pub fn component(mut self, component: impl Component + 'static) -> Self { + pub fn component(mut self, component: impl Component + 'static) -> Self { self.components.push(Arc::new(component)); self } @@ -166,50 +178,53 @@ impl Bot { self } - pub fn on_ready(mut self, handler: impl OnReadyHandler + 'static) -> Self { + pub fn on_ready(mut self, handler: impl OnReadyHandler + 'static) -> Self { self.on_ready_handlers.push(Arc::new(handler)); self } - pub fn on_reconnect(mut self, handler: impl OnReconnectHandler + 'static) -> Self { + pub fn on_reconnect(mut self, handler: impl OnReconnectHandler + 'static) -> Self { self.on_reconnect_handlers.push(Arc::new(handler)); self } #[allow(dead_code)] - pub fn on_interaction(mut self, handler: impl OnInteractionHandler + 'static) -> Self { + pub fn on_interaction(mut self, handler: impl OnInteractionHandler + 'static) -> Self { self.on_interaction_handlers.push(Arc::new(handler)); self } - pub fn on_message(mut self, handler: impl OnMessageHandler + 'static) -> Self { + pub fn on_message(mut self, handler: impl OnMessageHandler + 'static) -> Self { self.on_message_handlers.push(Arc::new(handler)); self } #[allow(dead_code)] - pub fn on_message_edit(mut self, handler: impl OnMessageEditHandler + 'static) -> Self { + pub fn on_message_edit(mut self, handler: impl OnMessageEditHandler + 'static) -> Self { self.on_message_edit_handlers.push(Arc::new(handler)); self } #[allow(dead_code)] - pub fn on_message_delete(mut self, handler: impl OnMessageDeleteHandler + 'static) -> Self { + pub fn on_message_delete( + mut self, + handler: impl OnMessageDeleteHandler + 'static, + ) -> Self { self.on_message_delete_handlers.push(Arc::new(handler)); self } - pub fn on_server_create(mut self, handler: impl OnServerCreateHandler + 'static) -> Self { + pub fn on_server_create(mut self, handler: impl OnServerCreateHandler + 'static) -> Self { self.on_server_create_handlers.push(Arc::new(handler)); self } - pub fn on_server_update(mut self, handler: impl OnServerUpdateHandler + 'static) -> Self { + pub fn on_server_update(mut self, handler: impl OnServerUpdateHandler + 'static) -> Self { self.on_server_update_handlers.push(Arc::new(handler)); self } - pub fn on_server_delete(mut self, handler: impl OnServerDeleteHandler + 'static) -> Self { + pub fn on_server_delete(mut self, handler: impl OnServerDeleteHandler + 'static) -> Self { self.on_server_delete_handlers.push(Arc::new(handler)); self } @@ -302,7 +317,7 @@ impl Bot { Event::InteractionCreate(interaction) => { for handler in &task_bot.on_interaction_handlers { handler - .on_interaction(&interaction, task_rt.clone()) + .on_interaction(&interaction, task_rt.clone(), task_bot.extras.clone()) .await .inspect_err( |e| log::error!("An error occurred while handling on interaction for {handler:?}! {e:?}") @@ -315,7 +330,7 @@ impl Bot { log::trace!("Running {handler:?}"); handler - .on_message(&message, task_rt.clone()) + .on_message(&message, task_rt.clone(), task_bot.extras.clone()) .await .inspect_err( |e| log::error!("An error occurred while handling on message for {handler:?}! {e:?}") diff --git a/src/framework/commands/arguments.rs b/src/framework/commands/arguments.rs index 701b691..3cb2277 100644 --- a/src/framework/commands/arguments.rs +++ b/src/framework/commands/arguments.rs @@ -5,6 +5,8 @@ use std::{ use thiserror::Error; +use crate::framework::commands::context::CommandContext; + #[derive(Debug, Error)] pub enum ArgumentParsingError { #[error("This argument was missing in the arguments array")] @@ -12,14 +14,23 @@ pub enum ArgumentParsingError { #[error("An argument was there but it could not be parsed into the specified value")] InvalidArgument, + + #[error("A database operation failed")] + DatabaseFailed, } -pub trait FromArguments: Sized { - fn from_args(args: &str) -> Result<(Self, &str), ArgumentParsingError>; +pub trait FromArguments: Sized { + async fn from_args<'a>( + args: &'a str, + ctx: &'a CommandContext<'_, S, E>, + ) -> Result<(Self, &'a str), ArgumentParsingError>; } -impl FromArguments for String { - fn from_args(args: &str) -> Result<(Self, &str), ArgumentParsingError> { +impl FromArguments for String { + async fn from_args<'a>( + args: &'a str, + _ctx: &'a CommandContext<'_, S, E>, + ) -> Result<(Self, &'a str), ArgumentParsingError> { if !args.is_empty() { match args.split_once(" ") { Some((arg, rest)) => Ok((arg.to_string(), rest)), @@ -37,8 +48,11 @@ impl FromArguments for String { /// Parsing this argument type never fails. pub struct GreedyString(pub String); -impl FromArguments for GreedyString { - fn from_args<'a>(args: &str) -> Result<(Self, &str), ArgumentParsingError> { +impl FromArguments for GreedyString { + async fn from_args<'a>( + args: &'a str, + _ctx: &'a CommandContext<'_, S, E>, + ) -> Result<(Self, &'a str), ArgumentParsingError> { let value = GreedyString(args.to_string()); Ok((value, "")) @@ -87,8 +101,11 @@ impl fmt::Display for NeedyString { } } -impl FromArguments for NeedyString { - fn from_args(args: &str) -> Result<(Self, &str), ArgumentParsingError> { +impl FromArguments for NeedyString { + async fn from_args<'a>( + args: &'a str, + _ctx: &'a CommandContext<'_, S, E>, + ) -> Result<(Self, &'a str), ArgumentParsingError> { if !args.is_empty() { match args.split_once(" ") { Some((arg, rest)) => Ok((Self(arg.to_string()), rest)), @@ -122,8 +139,11 @@ impl fmt::Display for NeedyGreedyString { } } -impl FromArguments for NeedyGreedyString { - fn from_args(args: &str) -> Result<(Self, &str), ArgumentParsingError> { +impl FromArguments for NeedyGreedyString { + async fn from_args<'a>( + args: &'a str, + _ctx: &'a CommandContext<'_, S, E>, + ) -> Result<(Self, &'a str), ArgumentParsingError> { if !args.is_empty() { Ok((Self(args.to_string()), "")) } else { @@ -132,8 +152,11 @@ impl FromArguments for NeedyGreedyString { } } -impl FromArguments for bool { - fn from_args(args: &str) -> Result<(Self, &str), ArgumentParsingError> { +impl FromArguments for bool { + async fn from_args<'a>( + args: &'a str, + _ctx: &'a CommandContext<'_, S, E>, + ) -> Result<(Self, &'a str), ArgumentParsingError> { let arg: &str; let rest: &str; @@ -156,73 +179,86 @@ impl FromArguments for bool { } } -impl FromArguments for Option +impl FromArguments for Option where - T: FromArguments, + T: FromArguments, { - fn from_args(args: &str) -> Result<(Self, &str), ArgumentParsingError> { - match T::from_args(args) { + async fn from_args<'a>( + args: &'a str, + _ctx: &'a CommandContext<'_, S, E>, + ) -> Result<(Self, &'a str), ArgumentParsingError> { + match T::from_args(args, _ctx).await { Ok((result, rest)) => Ok((Some(result), rest)), Err(_) => Ok((None, args)), } } } -pub trait ParseArguments: Sized { - fn parse(args: &str) -> Result; -} +pub struct Arg(pub T); -impl ParseArguments for () { - fn parse(_: &str) -> Result { - Ok(()) +impl Deref for Arg { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.0 } } -impl ParseArguments for A -where - A: FromArguments, -{ - fn parse(args: &str) -> Result { - let (a, _) = A::from_args(args)?; - - Ok(a) +impl DerefMut for Arg { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 } } -impl ParseArguments for (A,) +pub trait ParseArguments: Sized { + async fn parse(args: &str, ctx: &CommandContext) -> Result; +} + +impl ParseArguments for Arg where - A: FromArguments, + T: FromArguments, { - fn parse(args: &str) -> Result { - let (a, _) = A::from_args(args)?; + async fn parse(args: &str, ctx: &CommandContext<'_, S, E>) -> Result { + let (value, _) = T::from_args(args, ctx).await?; + + Ok(Arg(value)) + } +} + +impl ParseArguments for (A,) +where + A: FromArguments, +{ + async fn parse(args: &str, ctx: &CommandContext<'_, S, E>) -> Result { + let (a, _) = A::from_args(args, ctx).await?; Ok((a,)) } } -impl ParseArguments for (A, B) +impl ParseArguments for (A, B) where - A: FromArguments, - B: FromArguments, + A: FromArguments, + B: FromArguments, { - fn parse(args: &str) -> Result { - let (a, args) = A::from_args(args)?; - let (b, _) = B::from_args(args)?; + async fn parse(args: &str, ctx: &CommandContext<'_, S, E>) -> Result { + let (a, args) = A::from_args(args, ctx).await?; + let (b, _) = B::from_args(args, ctx).await?; Ok((a, b)) } } -impl ParseArguments for (A, B, C) +impl ParseArguments for (A, B, C) where - A: FromArguments, - B: FromArguments, - C: FromArguments, + A: FromArguments, + B: FromArguments, + C: FromArguments, { - fn parse(args: &str) -> Result { - let (a, args) = A::from_args(args)?; - let (b, args) = B::from_args(args)?; - let (c, _) = C::from_args(args)?; + async fn parse(args: &str, ctx: &CommandContext<'_, S, E>) -> Result { + let (a, args) = A::from_args(args, ctx).await?; + let (b, args) = B::from_args(args, ctx).await?; + let (c, _) = C::from_args(args, ctx).await?; Ok((a, b, c)) } diff --git a/src/framework/commands/context.rs b/src/framework/commands/context.rs index b54868d..20fe4ad 100644 --- a/src/framework/commands/context.rs +++ b/src/framework/commands/context.rs @@ -4,21 +4,33 @@ use twilight_model::gateway::payload::incoming::MessageCreate; use crate::framework::{commands::messages::MessageSender, runtime::Runtime}; -pub struct CommandContext<'a, T> { +pub struct CommandContext<'a, T, E> { pub event: &'a MessageCreate, - pub rt: Arc>, + pub rt: Arc>, pub prefix: &'a str, + pub extras: E, } -impl<'a, T> CommandContext<'a, T> +impl<'a, T, E> CommandContext<'a, T, E> where T: Send + Sync + 'static, + E: Send + Sync + 'static, { - pub fn new(event: &'a MessageCreate, rt: Arc>, prefix: &'a str) -> Self { - Self { event, rt, prefix } + pub fn new( + event: &'a MessageCreate, + rt: Arc>, + prefix: &'a str, + extras: E, + ) -> Self { + Self { + event, + rt, + prefix, + extras, + } } - pub fn send(&self) -> MessageSender { + pub fn send(&self) -> MessageSender { MessageSender::new(self.event.channel_id, self.rt.clone()) } } diff --git a/src/framework/commands/extras.rs b/src/framework/commands/extras.rs new file mode 100644 index 0000000..0b967fc --- /dev/null +++ b/src/framework/commands/extras.rs @@ -0,0 +1,32 @@ +use std::error::Error; + +use async_trait::async_trait; +use twilight_model::user::User; + +use crate::framework::runtime::Runtime; + +#[async_trait] +pub trait ExtrasGetter: Send + Sync { + type Extras; + + async fn get_extra( + &self, + user: &User, + rt: &Runtime, + ) -> Result>; +} + +pub struct DefaultExtraGetter; + +#[async_trait] +impl ExtrasGetter for DefaultExtraGetter { + type Extras = (); + + async fn get_extra( + &self, + _user: &User, + _rt: &Runtime, + ) -> Result> { + Ok(()) + } +} diff --git a/src/framework/commands/messages.rs b/src/framework/commands/messages.rs index 55c4d61..e2e5716 100644 --- a/src/framework/commands/messages.rs +++ b/src/framework/commands/messages.rs @@ -23,23 +23,24 @@ use crate::framework::{ runtime::Runtime, }; -pub struct MessageSender { +pub struct MessageSender { channel_id: Id, - rt: Arc>, + rt: Arc>, content: Option, embeds: Vec, // Row index, component - components: Vec<(u8, Arc>)>, + components: Vec<(u8, Arc>)>, } -impl MessageSender +impl MessageSender where T: Send + Sync + 'static, + E: Send + Sync + 'static, { - pub fn new(channel_id: Id, rt: Arc>) -> Self { + pub fn new(channel_id: Id, rt: Arc>) -> Self { Self { channel_id, rt, @@ -61,7 +62,7 @@ where self } - pub fn component(mut self, component: impl Component + 'static, row: u8) -> Self { + pub fn component(mut self, component: impl Component + 'static, row: u8) -> Self { self.components.push((row, Arc::new(component))); self @@ -120,7 +121,7 @@ where } } -impl IntoFuture for MessageSender { +impl IntoFuture for MessageSender { type Output = Result, Error>; type IntoFuture = Pin + Send>>; @@ -129,27 +130,28 @@ impl IntoFuture for MessageSender { } } -pub struct OriginalMessageUpdater { +pub struct OriginalMessageUpdater { interaction_id: Id, interaction_token: String, - rt: Arc>, + rt: Arc>, content: Option, embeds: Vec, // Row index, component - components: Vec<(u8, Arc>)>, + components: Vec<(u8, Arc>)>, } -impl OriginalMessageUpdater +impl OriginalMessageUpdater where T: Send + Sync + 'static, + E: Send + Sync + 'static, { pub fn new( interaction_id: Id, interaction_token: String, - rt: Arc>, + rt: Arc>, ) -> Self { Self { interaction_id, @@ -174,7 +176,7 @@ where self } - pub fn component(mut self, component: impl Component + 'static, row: u8) -> Self { + pub fn component(mut self, component: impl Component + 'static, row: u8) -> Self { self.components.push((row, Arc::new(component))); self @@ -245,7 +247,9 @@ where } } -impl IntoFuture for OriginalMessageUpdater { +impl IntoFuture + for OriginalMessageUpdater +{ type Output = Result, Error>; type IntoFuture = Pin + Send>>; diff --git a/src/framework/commands/mod.rs b/src/framework/commands/mod.rs index c2bd6f9..d902a31 100644 --- a/src/framework/commands/mod.rs +++ b/src/framework/commands/mod.rs @@ -5,12 +5,13 @@ use deadpool_redis::{cluster::PoolError, redis::RedisError}; use thiserror::Error; use twilight_http::response::DeserializeBodyError; -use crate::framework::commands::{ +use crate::framework::{commands::{ arguments::Argument, context::CommandContext, permissions::Permission, -}; +}, runtime::Runtime}; pub mod arguments; pub mod context; +pub mod extras; pub mod messages; pub mod permissions; pub mod runner; @@ -21,7 +22,7 @@ pub enum CommandError { MessageSendingFailed, #[error("A database query failed")] - DatabaseQueryFailed, + DatabaseFailed, #[error("A database operation failed due to an inconsistent state")] DatabaseInconsistentState, @@ -35,9 +36,6 @@ pub enum CommandError { #[error(transparent)] DiscordDeserializationFailed(#[from] DeserializeBodyError), - #[error("The cache's state was inconsistent.")] - CacheStateInconsistent, - #[error(transparent)] RedisPoolFailed(#[from] PoolError), @@ -46,21 +44,29 @@ pub enum CommandError { } #[async_trait] -pub trait Command: Send + Sync + Debug { +pub trait Command: Send + Sync + Debug { fn name(&self) -> &'static str; + fn aliases(&self) -> &'static [&'static str] { &[] } + fn description(&self) -> &'static str { "A Payphone command." } + fn arguments(&self) -> &'static [Argument] { &[] } - fn permissions(&self) -> Vec + Send + Sync + 'static>> { + + fn permissions(&self) -> Vec + Send + Sync + 'static>> { vec![] } + fn show_in_help(&self, rt: &Runtime, extras: &E) -> bool { + true + } + /// Returns the command name + all its aliases. /// /// Returns: @@ -71,7 +77,7 @@ pub trait Command: Send + Sync + Debug { all } - async fn execute(&self, ctx: CommandContext<'_, T>, args: &str) -> Result<(), CommandError>; + async fn execute(&self, ctx: CommandContext<'_, T, E>, args: &str) -> Result<(), CommandError>; } pub struct CommandParts<'a> { diff --git a/src/framework/commands/permissions.rs b/src/framework/commands/permissions.rs index 4d2226b..02735da 100644 --- a/src/framework/commands/permissions.rs +++ b/src/framework/commands/permissions.rs @@ -5,10 +5,10 @@ use async_trait::async_trait; use crate::framework::commands::{CommandError, context::CommandContext}; #[async_trait] -pub trait Permission: Debug { - async fn check(&self, ctx: &CommandContext) -> Result; +pub trait Permission: Debug { + async fn check(&self, ctx: &CommandContext) -> Result; - async fn on_handle_denied(&self, _ctx: &CommandContext) -> Result<(), CommandError> { + async fn on_handle_denied(&self, _ctx: &CommandContext) -> Result<(), CommandError> { Ok(()) } } diff --git a/src/framework/commands/runner.rs b/src/framework/commands/runner.rs index f210491..e8eb412 100644 --- a/src/framework/commands/runner.rs +++ b/src/framework/commands/runner.rs @@ -4,7 +4,7 @@ use async_trait::async_trait; use twilight_model::gateway::payload::incoming::MessageCreate; use crate::framework::{ - commands::{context::CommandContext, get_command_parts}, + commands::{context::CommandContext, extras::ExtrasGetter, get_command_parts}, events::traits::{EventResult, OnMessageHandler}, runtime::Runtime, }; @@ -13,8 +13,17 @@ use crate::framework::{ pub struct CommandRunner; #[async_trait] -impl OnMessageHandler for CommandRunner { - async fn on_message(&self, event: &MessageCreate, rt: Arc>) -> EventResult { +impl OnMessageHandler for CommandRunner +where + T: Send + Sync + 'static, + E: Send + Sync + 'static, +{ + async fn on_message( + &self, + event: &MessageCreate, + rt: Arc>, + extras: Arc>, + ) -> EventResult { if event.author.bot || event.author.system.unwrap_or(false) { return Ok(()); } @@ -31,7 +40,12 @@ impl OnMessageHandler for CommandRunner { }); if let Some(command) = command { - let ctx = CommandContext::new(event, rt.clone(), &prefixes[0]); + let extras = extras + .get_extra(&event.author, &rt) + .await + .inspect_err(|e| log::error!("Failed to get extras: {e}"))?; + + let ctx = CommandContext::new(event, rt.clone(), &prefixes[0], extras); for permission in command.permissions() { if !permission diff --git a/src/framework/events/traits.rs b/src/framework/events/traits.rs index 6154bd7..b1767ab 100644 --- a/src/framework/events/traits.rs +++ b/src/framework/events/traits.rs @@ -6,78 +6,98 @@ use twilight_model::gateway::payload::incoming::{ MessageUpdate, Ready, }; -use crate::framework::runtime::Runtime; +use crate::framework::{commands::extras::ExtrasGetter, runtime::Runtime}; pub type EventResult = Result<(), Box>; #[async_trait] -pub trait OnReadyHandler: Send + Sync + Debug +pub trait OnReadyHandler: Send + Sync + Debug where T: Send + Sync, + E: Send + Sync, { - async fn on_ready(&self, event: &Ready, rt: Arc>) -> EventResult; + async fn on_ready(&self, event: &Ready, rt: Arc>) -> EventResult; } #[async_trait] -pub trait OnReconnectHandler: Send + Sync + Debug +pub trait OnReconnectHandler: Send + Sync + Debug where T: Send + Sync, + E: Send + Sync, { - async fn on_reconnect(&self, rt: Arc>) -> EventResult; + async fn on_reconnect(&self, rt: Arc>) -> EventResult; } #[async_trait] -pub trait OnInteractionHandler: Send + Sync + Debug +pub trait OnInteractionHandler: Send + Sync + Debug where T: Send + Sync, + E: Send + Sync, { - async fn on_interaction(&self, event: &InteractionCreate, rt: Arc>) -> EventResult; + async fn on_interaction( + &self, + event: &InteractionCreate, + rt: Arc>, + extras: Arc>, + ) -> EventResult; } #[async_trait] -pub trait OnMessageHandler: Send + Sync + Debug +pub trait OnMessageHandler: Send + Sync + Debug where T: Send + Sync, + E: Send + Sync, { - async fn on_message(&self, event: &MessageCreate, rt: Arc>) -> EventResult; + async fn on_message( + &self, + event: &MessageCreate, + rt: Arc>, + extras: Arc>, + ) -> EventResult; } #[async_trait] -pub trait OnMessageEditHandler: Send + Sync + Debug +pub trait OnMessageEditHandler: Send + Sync + Debug where T: Send + Sync, + E: Send + Sync, { - async fn on_message_edit(&self, event: &MessageUpdate, rt: Arc>) -> EventResult; + async fn on_message_edit(&self, event: &MessageUpdate, rt: Arc>) -> EventResult; } #[async_trait] -pub trait OnMessageDeleteHandler: Send + Sync + Debug +pub trait OnMessageDeleteHandler: Send + Sync + Debug where T: Send + Sync, + E: Send + Sync, { - async fn on_message_delete(&self, event: &MessageDelete, rt: Arc>) -> EventResult; + async fn on_message_delete(&self, event: &MessageDelete, rt: Arc>) + -> EventResult; } #[async_trait] -pub trait OnServerCreateHandler: Send + Sync + Debug +pub trait OnServerCreateHandler: Send + Sync + Debug where T: Send + Sync, + E: Send + Sync, { - async fn on_server_create(&self, event: &GuildCreate, rt: Arc>) -> EventResult; + async fn on_server_create(&self, event: &GuildCreate, rt: Arc>) -> EventResult; } #[async_trait] -pub trait OnServerUpdateHandler: Send + Sync + Debug +pub trait OnServerUpdateHandler: Send + Sync + Debug where T: Send + Sync, + E: Send + Sync, { - async fn on_server_update(&self, event: &GuildUpdate, rt: Arc>) -> EventResult; + async fn on_server_update(&self, event: &GuildUpdate, rt: Arc>) -> EventResult; } #[async_trait] -pub trait OnServerDeleteHandler: Send + Sync + Debug +pub trait OnServerDeleteHandler: Send + Sync + Debug where T: Send + Sync, + E: Send + Sync, { - async fn on_server_delete(&self, event: &GuildDelete, rt: Arc>) -> EventResult; + async fn on_server_delete(&self, event: &GuildDelete, rt: Arc>) -> EventResult; } diff --git a/src/framework/interactions/components.rs b/src/framework/interactions/components.rs index 8786547..c588f99 100644 --- a/src/framework/interactions/components.rs +++ b/src/framework/interactions/components.rs @@ -8,20 +8,20 @@ use twilight_model::channel::message::{ use crate::framework::interactions::{InteractionError, context::InteractionContext}; -type ComponentWithID = (Option, Arc>); +type ComponentWithID = (Option, Arc>); -pub struct ActionRow { - components: Vec>, +pub struct ActionRow { + components: Vec>, } -impl ActionRow { - pub fn new(components: &[ComponentWithID]) -> Self { +impl ActionRow { + pub fn new(components: &[ComponentWithID]) -> Self { Self { components: components.to_vec(), } } - pub fn add(&mut self, id: Option, component: Arc>) { + pub fn add(&mut self, id: Option, component: Arc>) { self.components.push((id, component)); } @@ -41,19 +41,21 @@ impl ActionRow { } #[async_trait] -pub trait Component: Send + Sync + Debug { +pub trait Component: Send + Sync + Debug { fn custom_id(&self) -> Option { Some(String::from("component")) } fn build(&self, id: Option) -> TwilightComponent; - async fn execute(&self, ctx: InteractionContext) -> Result<(), InteractionError>; + async fn execute(&self, ctx: InteractionContext) -> Result<(), InteractionError>; } #[async_trait] -impl Component for T +impl Component for T where - T: Button, + T: Button, + S: Send + Sync + 'static, + E: Send + Sync + 'static, { fn custom_id(&self) -> Option { Button::id(self) @@ -75,13 +77,17 @@ where }) } - async fn execute(&self, ctx: InteractionContext) -> Result<(), InteractionError> { + async fn execute(&self, ctx: InteractionContext) -> Result<(), InteractionError> { self.on_press(ctx).await } } #[async_trait] -pub trait Button: Send + Sync + Debug { +pub trait Button: Send + Sync + Debug +where + S: Send + Sync + 'static, + E: Send + Sync + 'static, +{ fn id(&self) -> Option { if self.url().is_none() { Some(String::from("Button")) @@ -99,7 +105,7 @@ pub trait Button: Send + Sync + Debug { None } - async fn on_press(&self, ctx: InteractionContext) -> Result<(), InteractionError> { + async fn on_press(&self, ctx: InteractionContext) -> Result<(), InteractionError> { ctx.defer().await } } diff --git a/src/framework/interactions/context.rs b/src/framework/interactions/context.rs index 0654e6b..b11d001 100644 --- a/src/framework/interactions/context.rs +++ b/src/framework/interactions/context.rs @@ -10,15 +10,26 @@ use crate::framework::{ commands::messages::OriginalMessageUpdater, interactions::InteractionError, runtime::Runtime, }; -pub struct InteractionContext { +pub struct InteractionContext { pub event: InteractionCreate, - pub rt: Arc>, + pub rt: Arc>, pub prefix: String, + pub extras: E, } -impl InteractionContext { - pub fn new(event: InteractionCreate, rt: Arc>, prefix: String) -> Self { - Self { event, rt, prefix } +impl InteractionContext { + pub fn new( + event: InteractionCreate, + rt: Arc>, + prefix: String, + extras: E, + ) -> Self { + Self { + event, + rt, + prefix, + extras, + } } pub async fn defer(&self) -> Result<(), InteractionError> { @@ -39,7 +50,7 @@ impl InteractionContext { Ok(()) } - pub fn original_message(&self) -> Option> { + pub fn original_message(&self) -> Option> { self.event.message.as_ref().map(|_| OriginalMessage { interaction_id: self.event.id, interaction_token: self.event.token.clone(), @@ -48,17 +59,18 @@ impl InteractionContext { } } -pub struct OriginalMessage { +pub struct OriginalMessage { interaction_id: Id, interaction_token: String, - rt: Arc>, + rt: Arc>, } -impl OriginalMessage +impl OriginalMessage where T: Send + Sync + 'static, + E: Send + Sync + 'static, { - pub fn edit(&self) -> OriginalMessageUpdater { + pub fn edit(&self) -> OriginalMessageUpdater { OriginalMessageUpdater::new( self.interaction_id, self.interaction_token.clone(), diff --git a/src/framework/interactions/mod.rs b/src/framework/interactions/mod.rs index ac63d52..bd406d1 100644 --- a/src/framework/interactions/mod.rs +++ b/src/framework/interactions/mod.rs @@ -23,4 +23,7 @@ pub enum InteractionError { #[error("No prefixes were returned for the event")] PrefixesVectorEmpty, + + #[error("The extras getter failed to retrieve extras")] + ExtrasGetterFailed, } diff --git a/src/framework/interactions/registry.rs b/src/framework/interactions/registry.rs index abc490a..d09045c 100644 --- a/src/framework/interactions/registry.rs +++ b/src/framework/interactions/registry.rs @@ -7,6 +7,7 @@ use twilight_model::{ }; use crate::framework::{ + commands::extras::ExtrasGetter, interactions::{InteractionError, context::InteractionContext}, runtime::Runtime, }; @@ -14,13 +15,14 @@ use crate::framework::{ use super::components::Component; #[derive(Default)] -pub struct ComponentRegistry { - inner: HashMap>>, +pub struct ComponentRegistry { + inner: HashMap>>, } -impl ComponentRegistry +impl ComponentRegistry where T: Send + Sync + 'static, + E: Send + Sync + 'static, { pub fn new() -> Self { Self { @@ -28,7 +30,7 @@ where } } - pub fn register(&self, id: impl ToString, component: Arc>) { + pub fn register(&self, id: impl ToString, component: Arc>) { self.inner.upsert(id.to_string(), component); } @@ -39,7 +41,8 @@ where pub async fn execute( &self, event: &InteractionCreate, - rt: Arc>, + rt: Arc>, + extras: Arc>, ) -> Result<(), InteractionError> { if let Some(data) = &event.data { match data { @@ -55,11 +58,23 @@ where .first() .ok_or(InteractionError::PrefixesVectorEmpty)?; + let extras = extras + .get_extra( + &event.author().expect("Component interaction had no author"), + &rt, + ) + .await + .inspect_err(|e| { + log::error!("Failed to get extras for component interaction: {e:?}") + }) + .map_err(|_| InteractionError::ExtrasGetterFailed)?; + component .execute(InteractionContext::new( event.clone(), rt.clone(), prefix.to_string(), + extras, )) .await } else { diff --git a/src/framework/interactions/runner.rs b/src/framework/interactions/runner.rs index 79892bf..aa0df7f 100644 --- a/src/framework/interactions/runner.rs +++ b/src/framework/interactions/runner.rs @@ -4,6 +4,7 @@ use async_trait::async_trait; use twilight_model::gateway::payload::incoming::InteractionCreate; use crate::framework::{ + commands::extras::ExtrasGetter, events::traits::{EventResult, OnInteractionHandler}, runtime::Runtime, }; @@ -12,11 +13,17 @@ use crate::framework::{ pub struct InteractionRunner; #[async_trait] -impl OnInteractionHandler for InteractionRunner +impl OnInteractionHandler for InteractionRunner where T: Send + Sync + 'static, + E: Send + Sync + 'static, { - async fn on_interaction(&self, event: &InteractionCreate, rt: Arc>) -> EventResult { - Ok(rt.registry.execute(event, rt.clone()).await?) + async fn on_interaction( + &self, + event: &InteractionCreate, + rt: Arc>, + extras: Arc>, + ) -> EventResult { + Ok(rt.registry.execute(event, rt.clone(), extras).await?) } } diff --git a/src/framework/runtime.rs b/src/framework/runtime.rs index 66205e6..212f7a4 100644 --- a/src/framework/runtime.rs +++ b/src/framework/runtime.rs @@ -28,7 +28,7 @@ use crate::framework::{ }; /// The application's runtime tools. -pub struct Runtime { +pub struct Runtime { /// The HTTP client used for making requests to the HTTP API. pub http: Client, @@ -36,13 +36,13 @@ pub struct Runtime { pub app: Application, /// The component registry. - pub registry: ComponentRegistry, + pub registry: ComponentRegistry, /// The command prefixes getter. - pub prefixes: Arc + Send + Sync + 'static>, + pub prefixes: Arc + Send + Sync + 'static>, /// The commands available in the application. - pub commands: Vec>>, + pub commands: Vec>>, /// The application state. pub state: Arc, @@ -50,7 +50,7 @@ pub struct Runtime { pub cache: InMemoryCache, } -impl Runtime { +impl Runtime { /// Get the command prefixes set for the server and user ID. /// /// Arguments: @@ -74,7 +74,7 @@ impl Runtime { /// /// Returns: /// [`Option>>`] - The command if found, otherwise `None`. - pub fn get_command(&self, name: &str) -> Option>> { + pub fn get_command(&self, name: &str) -> Option>> { self.commands .iter() .find(|cmd| { diff --git a/src/groups/about/commands.rs b/src/groups/about/commands.rs index 2c9871d..291917a 100644 --- a/src/groups/about/commands.rs +++ b/src/groups/about/commands.rs @@ -1,9 +1,10 @@ use async_trait::async_trait; use crate::{ + extras::Extras, framework::commands::{ Command, CommandError, - arguments::{Argument, NeedyString, ParseArguments}, + arguments::{Arg, Argument, NeedyString, ParseArguments}, context::CommandContext, }, groups::about::{ @@ -19,7 +20,7 @@ use crate::{ pub struct HelpCommand; #[async_trait] -impl Command for HelpCommand { +impl Command for HelpCommand { fn name(&self) -> &'static str { "help" } @@ -38,12 +39,12 @@ impl Command for HelpCommand { async fn execute( &self, - ctx: CommandContext<'_, State>, + ctx: CommandContext<'_, State, Extras>, args: &str, ) -> Result<(), CommandError> { - let command_name: Option = ParseArguments::parse(args).unwrap(); + let command_name = Arg::>::parse(args, &ctx).await.unwrap(); - if let Some(command_name) = command_name { + if let Some(command_name) = command_name.0 { if let Some(command) = ctx.rt.get_command(&command_name) { ctx.send() .embed(get_command_help_embed(&command, &ctx)) @@ -54,7 +55,7 @@ impl Command for HelpCommand { .map_err(|_| CommandError::MessageSendingFailed)?; } else { ctx.send() - .embed(get_command_not_found_embed(ctx.rt, command_name.0)) + .embed(get_command_not_found_embed(&ctx.rt, command_name.0)) .component(HelpGoBackButton, 1) .component(HelpSupportLinkButton, 1) .await @@ -62,7 +63,7 @@ impl Command for HelpCommand { } } else { ctx.send() - .embed(get_all_command_help_embed(ctx.prefix, ctx.rt)) + .embed(get_all_command_help_embed(ctx.prefix, &ctx.rt, &ctx.extras)) .component(HelpWebsiteLinkButton, 1) .component(HelpSupportLinkButton, 1) .component(HelpDonateLinkButton, 1) diff --git a/src/groups/about/components.rs b/src/groups/about/components.rs index 4d9ca4d..458b986 100644 --- a/src/groups/about/components.rs +++ b/src/groups/about/components.rs @@ -1,6 +1,7 @@ use async_trait::async_trait; use crate::{ + extras::Extras, framework::interactions::{InteractionError, components::Button, context::InteractionContext}, groups::about::embeds::get_all_command_help_embed, state::State, @@ -10,19 +11,22 @@ use crate::{ pub struct HelpGoBackButton; #[async_trait] -impl Button for HelpGoBackButton { +impl Button for HelpGoBackButton { fn label(&self) -> Option<&'static str> { Some("← Go back to commands list") } - async fn on_press(&self, ctx: InteractionContext) -> Result<(), InteractionError> { + async fn on_press( + &self, + ctx: InteractionContext, + ) -> Result<(), InteractionError> { ctx .original_message() .expect( "Buttons always have the original message in the interaction context, this one didn't (got None)" ) .edit() - .embed(get_all_command_help_embed(&ctx.prefix, ctx.rt)) + .embed(get_all_command_help_embed(&ctx.prefix, &ctx.rt, &ctx.extras)) .component(HelpWebsiteLinkButton, 1) .component(HelpSupportLinkButton, 1) .component(HelpDonateLinkButton, 1) @@ -41,7 +45,7 @@ impl Button for HelpGoBackButton { pub struct HelpSupportLinkButton; #[async_trait] -impl Button for HelpSupportLinkButton { +impl Button for HelpSupportLinkButton { fn label(&self) -> Option<&'static str> { Some("Get help") } @@ -55,7 +59,7 @@ impl Button for HelpSupportLinkButton { pub struct HelpWebsiteLinkButton; #[async_trait] -impl Button for HelpWebsiteLinkButton { +impl Button for HelpWebsiteLinkButton { fn label(&self) -> Option<&'static str> { Some("Website") } @@ -69,7 +73,7 @@ impl Button for HelpWebsiteLinkButton { pub struct HelpDonateLinkButton; #[async_trait] -impl Button for HelpDonateLinkButton { +impl Button for HelpDonateLinkButton { fn label(&self) -> Option<&'static str> { Some("Donate") } @@ -83,7 +87,7 @@ impl Button for HelpDonateLinkButton { pub struct CodeOfConductLinkButton; #[async_trait] -impl Button for CodeOfConductLinkButton { +impl Button for CodeOfConductLinkButton { fn label(&self) -> Option<&'static str> { Some("Code of Conduct") } diff --git a/src/groups/about/embeds.rs b/src/groups/about/embeds.rs index 5f7d02c..56d8c6c 100644 --- a/src/groups/about/embeds.rs +++ b/src/groups/about/embeds.rs @@ -8,6 +8,7 @@ use twilight_util::builder::embed::{ use crate::{ constants::{COLOR_RED, COLOR_WHITE}, discord::{images, sanitizing::SanitizeDiscordMarkdown}, + extras::Extras, framework::{ commands::{Command, context::CommandContext}, runtime::Runtime, @@ -17,8 +18,8 @@ use crate::{ }; pub(super) fn get_command_help_embed( - command: &Arc>, - ctx: &CommandContext<'_, State>, + command: &Arc>, + ctx: &CommandContext<'_, State, Extras>, ) -> Embed { let name = format!("{}{}", ctx.prefix, command.name()); let arguments = command @@ -79,7 +80,11 @@ pub(super) fn get_command_help_embed( .build() } -pub(super) fn get_all_command_help_embed(prefix: &str, rt: Arc>) -> Embed { +pub(super) fn get_all_command_help_embed( + prefix: &str, + rt: &Runtime, + extras: &Extras, +) -> Embed { EmbedBuilder::new() .author(EmbedAuthorBuilder::new(rt.app.name.clone()).icon_url(ImageSource::url(images::get_avatar_url( rt.app.bot.as_ref().unwrap().avatar, @@ -96,22 +101,24 @@ pub(super) fn get_all_command_help_embed(prefix: &str, rt: Arc>) - )) .unwrap()) .color(COLOR_WHITE) - .field(EmbedFieldBuilder::new("Commands", get_commands_table(rt)).build()) + .field(EmbedFieldBuilder::new("Commands", get_commands_table(rt, extras)).build()) .footer(EmbedFooterBuilder::new(format!("v{} - Use {}help to get more information about a specific command.", env!("CARGO_PKG_VERSION"), prefix))) .build() } -pub(super) fn get_commands_table(rt: Arc>) -> String { +pub(super) fn get_commands_table(rt: &Runtime, extras: &Extras) -> String { let mut table = String::new(); for command in rt.commands.iter() { - table.push_str(&format!("{:<12}", command.name())); + if command.show_in_help(rt, extras) { + table.push_str(&format!("{:<13}", command.name())); + } } format!("```\n{table}\n```") } -pub(super) fn get_command_not_found_embed(rt: Arc>, name: String) -> Embed { +pub(super) fn get_command_not_found_embed(rt: &Runtime, name: String) -> Embed { EmbedBuilder::new() .color(COLOR_RED) .author(EmbedAuthorBuilder::new(rt.app.name.clone()).icon_url(ImageSource::url(images::get_avatar_url( diff --git a/src/groups/calls/commands.rs b/src/groups/calls/commands.rs index a864372..3713c47 100644 --- a/src/groups/calls/commands.rs +++ b/src/groups/calls/commands.rs @@ -5,6 +5,7 @@ use twilight_model::id::Id; use crate::{ database::models::{Call, Channel}, discord::sanitizing::SanitizeDiscordMarkdown, + extras::Extras, framework::commands::{ Command, CommandError, context::CommandContext, permissions::Permission, }, @@ -39,7 +40,7 @@ impl CallCommand { } #[async_trait] -impl Command for CallCommand { +impl Command for CallCommand { fn name(&self) -> &'static str { "call" } @@ -48,13 +49,13 @@ impl Command for CallCommand { &["c"] } - fn permissions(&self) -> Vec + Send + Sync + 'static>> { + fn permissions(&self) -> Vec + Send + Sync + 'static>> { vec![Box::new(NotBannedPermission)] } async fn execute( &self, - ctx: CommandContext<'_, State>, + ctx: CommandContext<'_, State, Extras>, _args: &str, ) -> Result<(), CommandError> { let channel = Channel::get_or_create_by_id( @@ -66,7 +67,7 @@ impl Command for CallCommand { .inspect_err(|e| { log::error!("Failed to get or create channel: {e:?}"); }) - .map_err(|_| CommandError::DatabaseQueryFailed)?; + .map_err(|_| CommandError::DatabaseFailed)?; if channel.is_searching || self.channel_lock.contains(&ctx.event.channel_id.get()) { ctx.send() @@ -100,7 +101,7 @@ impl Command for CallCommand { log::error!("Failed to start call: {e:?}"); self.channel_lock.remove(&ctx.event.channel_id.get()); }) - .map_err(|_| CommandError::DatabaseQueryFailed)?; + .map_err(|_| CommandError::DatabaseFailed)?; self.channel_lock .remove_async(&ctx.event.channel_id.get()) @@ -141,7 +142,7 @@ impl Command for CallCommand { pub struct SkipCommand; #[async_trait] -impl Command for SkipCommand { +impl Command for SkipCommand { fn name(&self) -> &'static str { "skip" } @@ -150,13 +151,13 @@ impl Command for SkipCommand { &["s", "next"] } - fn permissions(&self) -> Vec + Send + Sync + 'static>> { + fn permissions(&self) -> Vec + Send + Sync + 'static>> { vec![Box::new(NotBannedPermission)] } async fn execute( &self, - ctx: CommandContext<'_, State>, + ctx: CommandContext<'_, State, Extras>, _args: &str, ) -> Result<(), CommandError> { let channel = Channel::get_or_create_by_id( @@ -168,7 +169,7 @@ impl Command for SkipCommand { .inspect_err(|e| { log::error!("Failed to get or create channel: {e:?}"); }) - .map_err(|_| CommandError::DatabaseQueryFailed)?; + .map_err(|_| CommandError::DatabaseFailed)?; if channel.call_id.is_none() { ctx.send() @@ -197,13 +198,13 @@ impl Command for SkipCommand { let call = Call::get_by_id(channel.call_id.unwrap(), &ctx.rt.state.db) .await .inspect_err(|e| log::error!("Failed to get call: {e:?}")) - .map_err(|_| CommandError::DatabaseQueryFailed)? + .map_err(|_| CommandError::DatabaseFailed)? .ok_or(CommandError::DatabaseInconsistentState)?; call.hangup(&ctx.rt.state.db) .await .inspect_err(|e| log::error!("Failed to hang up call: {e:?}")) - .map_err(|_| CommandError::DatabaseQueryFailed)?; + .map_err(|_| CommandError::DatabaseFailed)?; let other_channel_id = call.other_party_channel_id(ctx.event.channel_id.get()); @@ -225,7 +226,7 @@ impl Command for SkipCommand { .inspect_err(|e| { log::error!("Failed to start (skipped) call: {e:?}"); }) - .map_err(|_| CommandError::DatabaseQueryFailed)?; + .map_err(|_| CommandError::DatabaseFailed)?; if let Some(call) = call { ctx.send() @@ -259,7 +260,7 @@ impl Command for SkipCommand { pub struct HangupCommand; #[async_trait] -impl Command for HangupCommand { +impl Command for HangupCommand { fn name(&self) -> &'static str { "hangup" } @@ -268,19 +269,19 @@ impl Command for HangupCommand { &["h", "hang"] } - fn permissions(&self) -> Vec + Send + Sync + 'static>> { + fn permissions(&self) -> Vec + Send + Sync + 'static>> { vec![Box::new(NotBannedPermission)] } async fn execute( &self, - ctx: CommandContext<'_, State>, + ctx: CommandContext<'_, State, Extras>, _args: &str, ) -> Result<(), CommandError> { let Some(channel) = Channel::get_by_id(ctx.event.channel_id.get(), &ctx.rt.state.db) .await .inspect_err(|e| log::error!("Failed to get channel: {e:?}")) - .map_err(|_| CommandError::DatabaseQueryFailed)? + .map_err(|_| CommandError::DatabaseFailed)? else { ctx.send() .content(format!( @@ -300,7 +301,7 @@ impl Command for HangupCommand { .inspect_err(|e| { log::error!("Failed to cancel search: {e:?}"); }) - .map_err(|_| CommandError::DatabaseQueryFailed)?; + .map_err(|_| CommandError::DatabaseFailed)?; ctx.send() .content(format!("☎️ {}", get_string_call_no_answer(ctx.prefix))) @@ -314,7 +315,7 @@ impl Command for HangupCommand { let Some(call) = Call::get_by_id(call_id, &ctx.rt.state.db) .await .inspect_err(|e| log::error!("Failed to get call: {e:?}")) - .map_err(|_| CommandError::DatabaseQueryFailed)? + .map_err(|_| CommandError::DatabaseFailed)? else { ctx.send() .content("❌ Call not found.") @@ -327,7 +328,7 @@ impl Command for HangupCommand { call.hangup(&ctx.rt.state.db) .await .inspect_err(|e| log::error!("Failed to hang up call: {e:?}")) - .map_err(|_| CommandError::DatabaseQueryFailed)?; + .map_err(|_| CommandError::DatabaseFailed)?; ctx.send() .content(format!( @@ -366,7 +367,7 @@ impl Command for HangupCommand { pub struct FriendRequestCommand; #[async_trait] -impl Command for FriendRequestCommand { +impl Command for FriendRequestCommand { fn name(&self) -> &'static str { "friendreq" } @@ -379,19 +380,19 @@ impl Command for FriendRequestCommand { "Shows your username to the other party and asks them to send you a friend request on Discord." } - fn permissions(&self) -> Vec + Send + Sync + 'static>> { + fn permissions(&self) -> Vec + Send + Sync + 'static>> { vec![Box::new(NotBannedPermission)] } async fn execute( &self, - ctx: CommandContext<'_, State>, + ctx: CommandContext<'_, State, Extras>, _args: &str, ) -> Result<(), CommandError> { let channel = Channel::get_by_id(ctx.event.channel_id.get(), &ctx.rt.state.db) .await .inspect_err(|e| log::error!("Failed to get channel: {e:?}")) - .map_err(|_| CommandError::DatabaseQueryFailed)?; + .map_err(|_| CommandError::DatabaseFailed)?; let Some(channel) = channel else { ctx.send() @@ -409,7 +410,7 @@ impl Command for FriendRequestCommand { Call::get_by_id(call_id, &ctx.rt.state.db) .await .inspect_err(|e| log::error!("Failed to get call: {e:?}")) - .map_err(|_| CommandError::DatabaseQueryFailed)? + .map_err(|_| CommandError::DatabaseFailed)? } else { ctx.send() .content(format!( diff --git a/src/groups/calls/components.rs b/src/groups/calls/components.rs index 0b89ff6..2b24a7b 100644 --- a/src/groups/calls/components.rs +++ b/src/groups/calls/components.rs @@ -1,9 +1,9 @@ -use crate::{framework::interactions::components::Button, state::State}; +use crate::{extras::Extras, framework::interactions::components::Button, state::State}; #[derive(Debug)] pub struct CodeOfConductButton; -impl Button for CodeOfConductButton { +impl Button for CodeOfConductButton { fn label(&self) -> Option<&'static str> { Some("Code of Conduct") } @@ -16,7 +16,7 @@ impl Button for CodeOfConductButton { #[derive(Debug)] pub struct NeedHelpButton; -impl Button for NeedHelpButton { +impl Button for NeedHelpButton { fn label(&self) -> Option<&'static str> { Some("Need Help?") } diff --git a/src/groups/calls/events.rs b/src/groups/calls/events.rs index ba5b463..7086937 100644 --- a/src/groups/calls/events.rs +++ b/src/groups/calls/events.rs @@ -11,21 +11,18 @@ use twilight_util::builder::embed::{EmbedAuthorBuilder, EmbedBuilder, ImageSourc use crate::{ constants::COLOR_WHITE, - database::models::{ - Call, Channel, Message, User, UserIncident, get_or_create_user_and_set_last_seen, - }, + database::models::{Call, Channel, Message, User, UserIncident}, discord::sanitizing::SanitizeDiscordMarkdown, + extras::Extras, framework::{ - events::traits::{ + commands::extras::ExtrasGetter, events::traits::{ EventResult, OnMessageDeleteHandler, OnMessageEditHandler, OnMessageHandler, - }, - interactions::components::ActionRow, - runtime::Runtime, + }, interactions::components::ActionRow, runtime::Runtime }, groups::calls::{ components::{CodeOfConductButton, NeedHelpButton}, embeds, get_display_profile, - moderation::{Action, Rule, escape_message}, + moderation::{escape_message, Action, Rule}, }, state::State, utils::{ellipse, i64_to_u64}, @@ -35,7 +32,7 @@ async fn penalize_user( user: User, message: Message, broken_rules: Vec>, - rt: &Runtime, + rt: &Runtime, ) -> Result<(), Box> { let reason = format!( "You broke the following rules during a call:\n{}", @@ -88,8 +85,13 @@ async fn penalize_user( pub struct CallMessageHandler; #[async_trait] -impl OnMessageHandler for CallMessageHandler { - async fn on_message(&self, event: &MessageCreate, rt: Arc>) -> EventResult { +impl OnMessageHandler for CallMessageHandler { + async fn on_message( + &self, + event: &MessageCreate, + rt: Arc>, + extras: Arc>, + ) -> EventResult { if event.author.bot || event.author.system.unwrap_or(false) { return Ok(()); } @@ -144,7 +146,8 @@ impl OnMessageHandler for CallMessageHandler { return Ok(()); }; - let user = get_or_create_user_and_set_last_seen(event.author.clone(), &rt.state.db).await?; + let user = + User::get_or_create_and_set_last_seen(event.author.clone(), &rt.state.db).await?; let user_incidents = UserIncident::get_for_user(event.author.id.get(), &rt.state.db) .await? @@ -314,8 +317,12 @@ impl OnMessageHandler for CallMessageHandler { pub struct CallMessageEditHandler; #[async_trait] -impl OnMessageEditHandler for CallMessageEditHandler { - async fn on_message_edit(&self, event: &MessageUpdate, rt: Arc>) -> EventResult { +impl OnMessageEditHandler for CallMessageEditHandler { + async fn on_message_edit( + &self, + event: &MessageUpdate, + rt: Arc>, + ) -> EventResult { log::trace!( "Edited message was '{}' sent by {} (bot? {:?})", event.content, @@ -368,7 +375,9 @@ impl OnMessageEditHandler for CallMessageEditHandler { .reply(event.id) .await?; - let user = get_or_create_user_and_set_last_seen(event.author.clone(), &rt.state.db).await?; + let user = + User::get_or_create_and_set_last_seen(event.author.clone(), &rt.state.db) + .await?; penalize_user(user, message, rules, &rt).await?; @@ -404,11 +413,11 @@ impl OnMessageEditHandler for CallMessageEditHandler { pub struct CallMessageDeleteHandler; #[async_trait] -impl OnMessageDeleteHandler for CallMessageDeleteHandler { +impl OnMessageDeleteHandler for CallMessageDeleteHandler { async fn on_message_delete( &self, event: &MessageDelete, - rt: Arc>, + rt: Arc>, ) -> EventResult { let Some(channel) = Channel::get_by_id(event.channel_id.get(), &rt.state.db).await? else { return Ok(()); diff --git a/src/groups/calls/lists/explicit-sexual-language.txt b/src/groups/calls/lists/explicit-sexual-language.txt index f16f7dc..786cef7 100644 --- a/src/groups/calls/lists/explicit-sexual-language.txt +++ b/src/groups/calls/lists/explicit-sexual-language.txt @@ -80,6 +80,8 @@ gooncream gooncreaming goonbud goonbuds +goonete +goonetes boty hole suck my pp ass cracker @@ -98,7 +100,7 @@ masterbaiting masturbatigng horny hrny -whore +whore / who re whre tickle your pickle tickle yr pickle diff --git a/src/groups/calls/permissions.rs b/src/groups/calls/permissions.rs index cf51da4..2435d77 100644 --- a/src/groups/calls/permissions.rs +++ b/src/groups/calls/permissions.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use crate::{ database::models::UserIncident, + extras::Extras, framework::commands::{CommandError, context::CommandContext, permissions::Permission}, groups::calls::components::{CodeOfConductButton, NeedHelpButton}, state::State, @@ -11,12 +12,12 @@ use crate::{ pub struct NotBannedPermission; #[async_trait] -impl Permission for NotBannedPermission { - async fn check(&self, ctx: &CommandContext) -> Result { +impl Permission for NotBannedPermission { + async fn check(&self, ctx: &CommandContext) -> Result { let incidents = UserIncident::get_for_user(ctx.event.author.id.get(), &ctx.rt.state.db) .await .inspect_err(|e| log::error!("Failed to get user incidents: {e}")) - .map_err(|_| CommandError::DatabaseQueryFailed)?; + .map_err(|_| CommandError::DatabaseFailed)?; if incidents.iter().any(|i| i.is_active()) { Ok(false) @@ -25,7 +26,10 @@ impl Permission for NotBannedPermission { } } - async fn on_handle_denied(&self, ctx: &CommandContext) -> Result<(), CommandError> { + async fn on_handle_denied( + &self, + ctx: &CommandContext, + ) -> Result<(), CommandError> { ctx.send() .content("❌ You've been banned from Payphone.") .component(CodeOfConductButton, 1) diff --git a/src/groups/gateway/events.rs b/src/groups/gateway/events.rs index c94db5f..d59486f 100644 --- a/src/groups/gateway/events.rs +++ b/src/groups/gateway/events.rs @@ -6,6 +6,7 @@ use async_trait::async_trait; use twilight_model::gateway::payload::incoming::Ready; use crate::{ + extras::Extras, framework::{ events::traits::{EventResult, OnReadyHandler, OnReconnectHandler}, runtime::Runtime, @@ -18,8 +19,8 @@ use crate::{ pub struct ReadyHandler; #[async_trait] -impl OnReadyHandler for ReadyHandler { - async fn on_ready(&self, event: &Ready, _: Arc>) -> EventResult { +impl OnReadyHandler for ReadyHandler { + async fn on_ready(&self, event: &Ready, _: Arc>) -> EventResult { log::info!( "Ready! Logged in as {}#{:>04}", event.user.name, @@ -35,8 +36,8 @@ impl OnReadyHandler for ReadyHandler { pub struct ReconnectHandler; #[async_trait] -impl OnReconnectHandler for ReconnectHandler { - async fn on_reconnect(&self, _: Arc>) -> EventResult { +impl OnReconnectHandler for ReconnectHandler { + async fn on_reconnect(&self, _: Arc>) -> EventResult { log::info!("The gateway asked for a reconnection"); Ok(()) diff --git a/src/groups/leveling/commands.rs b/src/groups/leveling/commands.rs index 9bba665..6c7648f 100644 --- a/src/groups/leveling/commands.rs +++ b/src/groups/leveling/commands.rs @@ -6,19 +6,14 @@ use twilight_util::builder::embed::{ }; use crate::{ - constants::COLOR_WHITE, - database::models::User, - discord::{images, sanitizing::SanitizeDiscordMarkdown}, - framework::commands::{Command, CommandError, context::CommandContext}, - groups::leveling::constants::KEY_LEADERBOARD_MONTHLY, - state::State, + constants::COLOR_WHITE, database::models::User, discord::{images, sanitizing::SanitizeDiscordMarkdown}, extras::Extras, framework::commands::{context::CommandContext, Command, CommandError}, groups::leveling::constants::KEY_LEADERBOARD_MONTHLY, state::State }; #[derive(Debug)] pub struct LeaderboardCommand; #[async_trait] -impl Command for LeaderboardCommand { +impl Command for LeaderboardCommand { fn name(&self) -> &'static str { "leaderboard" } @@ -33,7 +28,7 @@ impl Command for LeaderboardCommand { async fn execute( &self, - ctx: CommandContext<'_, State>, + ctx: CommandContext<'_, State, Extras>, _args: &str, ) -> Result<(), CommandError> { let mut redis = ctx @@ -61,7 +56,7 @@ impl Command for LeaderboardCommand { for (user_id, score) in top { let Some(user) = User::get_by_id(user_id, &ctx.rt.state.db) .await - .map_err(|_| CommandError::DatabaseQueryFailed)? + .map_err(|_| CommandError::DatabaseFailed)? else { log::trace!("User ID {user_id} not found for leaderboard"); continue; diff --git a/src/groups/leveling/events.rs b/src/groups/leveling/events.rs index 8e15ab8..5c40171 100644 --- a/src/groups/leveling/events.rs +++ b/src/groups/leveling/events.rs @@ -8,10 +8,13 @@ use time::{Date, Month, OffsetDateTime, Time}; use twilight_model::gateway::payload::incoming::MessageCreate; use crate::{ - database::models::Channel, framework::{ - events::traits::{EventResult, OnMessageHandler}, - runtime::Runtime, - }, groups::leveling::constants::KEY_LEADERBOARD_MONTHLY, state::State + database::models::Channel, + extras::Extras, + framework::{ + commands::extras::ExtrasGetter, events::traits::{EventResult, OnMessageHandler}, runtime::Runtime + }, + groups::leveling::constants::KEY_LEADERBOARD_MONTHLY, + state::State, }; fn seconds_until_end_of_month() -> i64 { @@ -38,8 +41,13 @@ fn seconds_until_end_of_month() -> i64 { pub struct MessageLevelingHandler; #[async_trait] -impl OnMessageHandler for MessageLevelingHandler { - async fn on_message(&self, event: &MessageCreate, rt: Arc>) -> EventResult { +impl OnMessageHandler for MessageLevelingHandler { + async fn on_message( + &self, + event: &MessageCreate, + rt: Arc>, + extras: Arc>, + ) -> EventResult { if event.author.bot || event.author.system.unwrap_or(false) { return Ok(()); } @@ -72,7 +80,9 @@ impl OnMessageHandler for MessageLevelingHandler { let lock = redis.ttl(&key).await?; if let IntegerReplyOrNoOp::IntegerReply(seconds) = lock { - log::trace!("No XP for the message because the user is already in the cooldown period. Remaining seconds: {seconds}"); + log::trace!( + "No XP for the message because the user is already in the cooldown period. Remaining seconds: {seconds}" + ); return Ok(()); } @@ -92,7 +102,9 @@ impl OnMessageHandler for MessageLevelingHandler { let leaderboard_exat = redis.ttl(KEY_LEADERBOARD_MONTHLY).await?; if leaderboard_exat == IntegerReplyOrNoOp::ExistsButNotRelevant { - redis.expire(KEY_LEADERBOARD_MONTHLY, seconds_until_end_of_month()).await?; + redis + .expire(KEY_LEADERBOARD_MONTHLY, seconds_until_end_of_month()) + .await?; } Ok(()) diff --git a/src/groups/moderation/arguments.rs b/src/groups/moderation/arguments.rs new file mode 100644 index 0000000..a78f7bf --- /dev/null +++ b/src/groups/moderation/arguments.rs @@ -0,0 +1,148 @@ +use std::ops::{Deref, DerefMut}; + +use scylla::client::session::Session; + +use crate::{ + database::models::{Message, User}, + extras::Extras, + framework::commands::{ + arguments::{ArgumentParsingError, FromArguments, NeedyString}, + context::CommandContext, + }, + state::State, +}; + +/// Fetch a user by their ID from the database. +/// +/// Arguments: +/// * `id` - The user's ID. +/// * `db` - The database session. +/// +/// Returns: +/// [`Result`] - The user from the DB, or an [`ArgumentParsingError`] ready +/// to be returned by the parser. +async fn get_user_by_id(id: u64, db: &Session) -> Result { + let Some(user) = User::get_by_id(id, db) + .await + .map_err(|_| ArgumentParsingError::DatabaseFailed)? + else { + return Err(ArgumentParsingError::InvalidArgument); + }; + Ok(user) +} + +/// Fetches the user from the message's reply and falls back to the command executor. +/// +/// This function takes into account messages on call and traces them back to the original author. +/// +/// Arguments: +/// * `ctx` - The command context. +/// +/// Returns: +/// [`Result`] - The user from the message's reply, or an +/// [`ArgumentParsingError`] ready to be returned by the parser. +async fn user_from_reply_or_command_executor( + ctx: &CommandContext<'_, State, Extras>, +) -> Result { + if let Some(referenced) = &ctx.event.referenced_message { + if !referenced.author.system.unwrap_or(false) { + if referenced.author.bot { + if let Some(message) = + Message::get_by_propagation_message_id(referenced.id.get(), &ctx.rt.state.db) + .await + .map_err(|_| ArgumentParsingError::DatabaseFailed)? + { + return get_user_by_id(message.author_id, &ctx.rt.state.db).await; + } + } + + // If the replied message's author is not a bot or system user, use them. + return get_user_by_id(referenced.author.id.get(), &ctx.rt.state.db).await; + } + } + + // fallback to command executor + get_user_by_id(ctx.event.author.id.get(), &ctx.rt.state.db).await +} + +/// Parses a user mention or ID into a UserArgument. +/// +/// It parses from multiple sources, attempting in the following way: +/// +/// 1. If the argument is a Discord mention, parsing it. +/// 2. If the argument is a Discord ID, finding it in the DB. +/// 3. If the argument is a Discord mention, finding it in the DB. +/// 4. If there's no argument and the message replies to another message: +/// a. If the replied message's author is a bot, attempts to query the user who sent it from the +/// other side of the call. If it fails, the command executor is used. +/// b. If the replied message's author is not a bot, they're used. +/// 5. If there's no argument and the message is not replying to any message, the command executor +/// is used. +pub struct UserArgument(pub User); + +impl FromArguments for UserArgument { + async fn from_args<'a>( + args: &'a str, + ctx: &'a CommandContext<'_, State, Extras>, + ) -> Result<(Self, &'a str), ArgumentParsingError> { + let user: User; + + if !args.is_empty() { + // There's an argument, so that argument is used to find the user. + + let (identifier, args) = NeedyString::from_args(args, ctx).await?; + + if identifier.starts_with("<@") && identifier.ends_with(">") { + // The argument is a Discord mention. + + let user_id: u64 = identifier[2..identifier.len() - 1] + .to_string() + .parse() + .map_err(|_| ArgumentParsingError::InvalidArgument)?; + + user = get_user_by_id(user_id, &ctx.rt.state.db).await?; + } else if identifier.chars().all(|c| c.is_ascii_digit()) { + // The argument is a user ID. + + let user_id: u64 = identifier + .parse() + .map_err(|_| ArgumentParsingError::InvalidArgument)?; + + user = get_user_by_id(user_id, &ctx.rt.state.db).await?; + } else { + // The argument is (probably) a username. + + let Some(db_user) = User::get_by_name(identifier.0, &ctx.rt.state.db) + .await + .map_err(|_| ArgumentParsingError::DatabaseFailed)? + else { + // Check in the message's reply, and if not use the command executor. + user = user_from_reply_or_command_executor(ctx).await?; + + return Ok((UserArgument(user), args)); + }; + + user = db_user; + } + } else { + // Check in the message's reply, and if not use the command executor. + user = user_from_reply_or_command_executor(ctx).await?; + } + + Ok((UserArgument(user), args)) + } +} + +impl Deref for UserArgument { + type Target = User; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for UserArgument { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} diff --git a/src/groups/moderation/commands.rs b/src/groups/moderation/commands.rs new file mode 100644 index 0000000..240bbde --- /dev/null +++ b/src/groups/moderation/commands.rs @@ -0,0 +1,158 @@ +use async_trait::async_trait; + +use crate::{ + database::models::UserIncident, + extras::Extras, + framework::{ + commands::{ + Command, CommandError, + arguments::{Arg, Argument, ArgumentParsingError, ParseArguments}, + context::CommandContext, + permissions::Permission, + }, + runtime::Runtime, + }, + groups::moderation::{ + arguments::UserArgument, + permissions::{BotModeratorPermission, BotOwnerPermission}, + }, + state::State, + utils::Bye, +}; + +#[derive(Debug)] +pub struct ModeratorCommand; + +#[async_trait] +impl Command for ModeratorCommand { + fn name(&self) -> &'static str { + "moderator" + } + + fn aliases(&self) -> &'static [&'static str] { + &["mod"] + } + + fn permissions(&self) -> Vec + Send + Sync + 'static>> { + vec![Box::new(BotOwnerPermission)] + } + + fn show_in_help(&self, rt: &Runtime, extras: &Extras) -> bool { + extras.user.id == rt.state.bot_owner_id + } + + fn arguments(&self) -> &'static [Argument] { + &[Argument { + name: "user", + is_optional: false, + is_greedy: false, + }] + } + + async fn execute( + &self, + ctx: CommandContext<'_, State, Extras>, + args: &str, + ) -> Result<(), CommandError> { + let Arg(mut user) = match Arg::::parse(args, &ctx).await { + Ok(arg) => arg, + Err(e) => match e { + ArgumentParsingError::InvalidArgument => { + ctx.send() + .content("❌ User not found in DB.") + .await + .map_err(|_| CommandError::MessageSendingFailed)?; + return Ok(()); + } + ArgumentParsingError::DatabaseFailed => { + return Err(CommandError::DatabaseFailed); + } + e => { + unreachable!("Unexpected error: {e}"); + } + }, + }; + + user.is_moderator = !user.is_moderator; + + user.insert(&ctx.rt.state.db) + .await + .map_err(|_| CommandError::DatabaseFailed)?; + + ctx.send() + .content(format!( + "👍 Done. <@{}> {}", + user.id, + if user.is_moderator { + "**is now** a moderator" + } else { + "**is no longer** a moderator" + } + )) + .await + .map_err(|_| CommandError::MessageSendingFailed)?; + + Ok(()) + } +} + +#[derive(Debug)] +pub struct IncidentsCommand; + +#[async_trait] +impl Command for IncidentsCommand { + fn name(&self) -> &'static str { + "incidents" + } + + fn aliases(&self) -> &'static [&'static str] { + &["i"] + } + + fn permissions(&self) -> Vec + Send + Sync + 'static>> { + vec![Box::new(BotModeratorPermission)] + } + + fn show_in_help(&self, _rt: &Runtime, extras: &Extras) -> bool { + extras.user.is_moderator + } + + async fn execute( + &self, + ctx: CommandContext<'_, State, Extras>, + args: &str, + ) -> Result<(), CommandError> { + let user = match Arg::::parse(args, &ctx).await { + Ok(Arg(user)) => user, + Err(e) => match e { + ArgumentParsingError::InvalidArgument => { + ctx.send() + .content("❌ User not found in DB.") + .await + .map_err(|_| CommandError::MessageSendingFailed)?; + return Ok(()); + } + ArgumentParsingError::DatabaseFailed => { + return Err(CommandError::DatabaseFailed); + } + e => { + unreachable!("Unexpected error: {e}"); + } + }, + }; + + let incidents = UserIncident::get_for_user(user.id, &ctx.rt.state.db) + .await + .map_err(|_| CommandError::DatabaseFailed)?; + + ctx.send() + .content(format!( + "Great! The user fetched from the DB is {}", + user.name + )) + .await + .bye(); + + Ok(()) + } +} diff --git a/src/groups/moderation/mod.rs b/src/groups/moderation/mod.rs index e69de29..a9e43f7 100644 --- a/src/groups/moderation/mod.rs +++ b/src/groups/moderation/mod.rs @@ -0,0 +1,3 @@ +pub mod arguments; +pub mod commands; +pub mod permissions; diff --git a/src/groups/moderation/permissions.rs b/src/groups/moderation/permissions.rs new file mode 100644 index 0000000..090c591 --- /dev/null +++ b/src/groups/moderation/permissions.rs @@ -0,0 +1,52 @@ +use async_trait::async_trait; + +use crate::{ + extras::Extras, + framework::commands::{CommandError, context::CommandContext, permissions::Permission}, + state::State, +}; + +#[derive(Debug)] +pub struct BotOwnerPermission; + +#[async_trait] +impl Permission for BotOwnerPermission { + async fn check(&self, ctx: &CommandContext) -> Result { + Ok(ctx.extras.user.id == ctx.rt.state.bot_owner_id) + } + + async fn on_handle_denied( + &self, + ctx: &CommandContext, + ) -> Result<(), CommandError> { + ctx.send() + .content("⛔ Not enough aura to run this command.") + .await + .map_err(|_| CommandError::MessageSendingFailed)?; + + Ok(()) + } +} + + +#[derive(Debug)] +pub struct BotModeratorPermission; + +#[async_trait] +impl Permission for BotModeratorPermission { + async fn check(&self, ctx: &CommandContext) -> Result { + Ok(ctx.extras.user.is_moderator) + } + + async fn on_handle_denied( + &self, + ctx: &CommandContext, + ) -> Result<(), CommandError> { + ctx.send() + .content("⛔ You're not a Payphone moderator.\n-# If you need to check your incidents, contact a Payphone moderator in the official server.") + .await + .map_err(|_| CommandError::MessageSendingFailed)?; + + Ok(()) + } +} diff --git a/src/groups/servers/commands.rs b/src/groups/servers/commands.rs index 6b40ba8..206a065 100644 --- a/src/groups/servers/commands.rs +++ b/src/groups/servers/commands.rs @@ -1,24 +1,16 @@ use async_trait::async_trait; use crate::{ - constants::DEFAULT_PREFIX, - database::models::Server, - discord::sanitizing::SanitizeDiscordMarkdown, - framework::commands::{ - Command, CommandError, - arguments::{Argument, GreedyString, ParseArguments}, - context::CommandContext, - permissions::Permission, - }, - groups::servers::permissions::{GuildOnlyPermission, ServerModeratorPermission}, - state::State, + constants::DEFAULT_PREFIX, database::models::Server, discord::sanitizing::SanitizeDiscordMarkdown, extras::Extras, framework::commands::{ + arguments::{Arg, Argument, GreedyString, ParseArguments}, context::CommandContext, permissions::Permission, Command, CommandError + }, groups::servers::permissions::{GuildOnlyPermission, ServerModeratorPermission}, state::State }; #[derive(Debug)] pub struct PrefixCommand; #[async_trait] -impl Command for PrefixCommand { +impl Command for PrefixCommand { fn name(&self) -> &'static str { "prefix" } @@ -39,7 +31,7 @@ impl Command for PrefixCommand { }] } - fn permissions(&self) -> Vec + Send + Sync + 'static>> { + fn permissions(&self) -> Vec + Send + Sync + 'static>> { vec![ Box::new(GuildOnlyPermission), Box::new(ServerModeratorPermission), @@ -48,10 +40,10 @@ impl Command for PrefixCommand { async fn execute( &self, - ctx: CommandContext<'_, State>, + ctx: CommandContext<'_, State, Extras>, args: &str, ) -> Result<(), CommandError> { - let prefix: GreedyString = ParseArguments::parse(args).unwrap(); + let Arg(prefix) = Arg::::parse(args, &ctx).await.unwrap(); let mut prefix = prefix.trim(); let mut server = Server::get_or_create_by_id( @@ -64,7 +56,7 @@ impl Command for PrefixCommand { ) .await .inspect_err(|e| log::error!("Failed to get or create server: {e}")) - .map_err(|_| CommandError::DatabaseQueryFailed)?; + .map_err(|_| CommandError::DatabaseFailed)?; if prefix.is_empty() { prefix = DEFAULT_PREFIX; @@ -74,7 +66,7 @@ impl Command for PrefixCommand { server .upsert(&ctx.rt.state.db) .await - .map_err(|_| CommandError::DatabaseQueryFailed)?; + .map_err(|_| CommandError::DatabaseFailed)?; ctx.send() .content(format!( diff --git a/src/groups/servers/events.rs b/src/groups/servers/events.rs index 436a9b4..312b29f 100644 --- a/src/groups/servers/events.rs +++ b/src/groups/servers/events.rs @@ -7,6 +7,7 @@ use twilight_model::gateway::payload::incoming::{GuildCreate, GuildDelete, Guild use crate::{ database::models::Server, + extras::Extras, framework::{ events::traits::{ EventResult, OnServerCreateHandler, OnServerDeleteHandler, OnServerUpdateHandler, @@ -21,8 +22,12 @@ use crate::{ pub struct ServerCreateHandler; #[async_trait] -impl OnServerCreateHandler for ServerCreateHandler { - async fn on_server_create(&self, event: &GuildCreate, rt: Arc>) -> EventResult { +impl OnServerCreateHandler for ServerCreateHandler { + async fn on_server_create( + &self, + event: &GuildCreate, + rt: Arc>, + ) -> EventResult { let server = match event { GuildCreate::Available(guild) => Server::from(guild.clone()), GuildCreate::Unavailable(_) => { @@ -51,8 +56,12 @@ impl OnServerCreateHandler for ServerCreateHandler { pub struct ServerUpdateHandler; #[async_trait] -impl OnServerUpdateHandler for ServerUpdateHandler { - async fn on_server_update(&self, event: &GuildUpdate, rt: Arc>) -> EventResult { +impl OnServerUpdateHandler for ServerUpdateHandler { + async fn on_server_update( + &self, + event: &GuildUpdate, + rt: Arc>, + ) -> EventResult { let mut server = match Server::get_by_id(event.0.id.get(), &rt.state.db) .await .inspect_err(|e| { @@ -83,8 +92,12 @@ impl OnServerUpdateHandler for ServerUpdateHandler { pub struct ServerDeleteHandler; #[async_trait] -impl OnServerDeleteHandler for ServerDeleteHandler { - async fn on_server_delete(&self, event: &GuildDelete, rt: Arc>) -> EventResult { +impl OnServerDeleteHandler for ServerDeleteHandler { + async fn on_server_delete( + &self, + event: &GuildDelete, + rt: Arc>, + ) -> EventResult { Server::delete_by_id(event.id.get(), &rt.state.db) .await .inspect(|_| log::debug!("Deleted server with ID {} because Payphone left", event.id)) diff --git a/src/groups/servers/permissions.rs b/src/groups/servers/permissions.rs index 0499045..e9db92a 100644 --- a/src/groups/servers/permissions.rs +++ b/src/groups/servers/permissions.rs @@ -6,6 +6,7 @@ use twilight_model::{ use twilight_util::permission_calculator::PermissionCalculator; use crate::{ + extras::Extras, framework::commands::{CommandError, context::CommandContext, permissions::Permission}, state::State, }; @@ -14,12 +15,15 @@ use crate::{ pub struct GuildOnlyPermission; #[async_trait] -impl Permission for GuildOnlyPermission { - async fn check(&self, ctx: &CommandContext) -> Result { +impl Permission for GuildOnlyPermission { + async fn check(&self, ctx: &CommandContext) -> Result { Ok(ctx.event.guild_id.is_some()) } - async fn on_handle_denied(&self, ctx: &CommandContext) -> Result<(), CommandError> { + async fn on_handle_denied( + &self, + ctx: &CommandContext, + ) -> Result<(), CommandError> { ctx.send() .content("❌ This command can only be used in a server.") .await?; @@ -32,8 +36,8 @@ impl Permission for GuildOnlyPermission { pub struct ServerModeratorPermission; #[async_trait] -impl Permission for ServerModeratorPermission { - async fn check(&self, ctx: &CommandContext) -> Result { +impl Permission for ServerModeratorPermission { + async fn check(&self, ctx: &CommandContext) -> Result { if let Some(guild_id) = ctx.event.guild_id { let Some(guild) = ctx.rt.cache.guild(guild_id) else { log::warn!("Could not get guild for ID {guild_id}!"); @@ -76,14 +80,19 @@ impl Permission for ServerModeratorPermission { ) .owner_id(guild.owner_id()); - return Ok(calculator.root().contains(Permissions::MANAGE_CHANNELS | Permissions::MANAGE_MESSAGES)); + return Ok(calculator + .root() + .contains(Permissions::MANAGE_CHANNELS | Permissions::MANAGE_MESSAGES)); } // DMs channel, the user has all permissions. Ok(true) } - async fn on_handle_denied(&self, ctx: &CommandContext) -> Result<(), CommandError> { + async fn on_handle_denied( + &self, + ctx: &CommandContext, + ) -> Result<(), CommandError> { ctx.send() .content("❌ This command can only be run by a server moderator.\n-# 🛈 Moderators must have permission to manage messages and to manage channels.") .await?; diff --git a/src/main.rs b/src/main.rs index 42cdbae..c2db971 100644 --- a/src/main.rs +++ b/src/main.rs @@ -101,6 +101,7 @@ //! ``` use std::{ + error::Error, net::{IpAddr, Ipv4Addr, SocketAddr}, path::Path, process::Command, @@ -115,17 +116,22 @@ use tokio::net::TcpListener; use tower_http::services::ServeDir; use twilight_cache_inmemory::ResourceType; use twilight_gateway::{Intents, ShardId}; -use twilight_model::gateway::{ - payload::outgoing::update_presence::UpdatePresencePayload, - presence::{Activity, ActivityType, Status}, +use twilight_model::{ + gateway::{ + payload::outgoing::update_presence::UpdatePresencePayload, + presence::{Activity, ActivityType, Status}, + }, + user::User as TwilightUser, }; use crate::{ cli::{Args, Commands}, constants::DEFAULT_PREFIX, - database::models::Server, + database::models::{Server, User}, + extras::Extras, framework::{ bot::{Bot, PrefixGetter}, + commands::extras::ExtrasGetter, runtime::Runtime, }, groups::{ @@ -136,6 +142,7 @@ use crate::{ }, gateway::events::{ReadyHandler, ReconnectHandler}, leveling::{commands::LeaderboardCommand, events::MessageLevelingHandler}, + moderation::commands::{IncidentsCommand, ModeratorCommand}, servers::{ commands::PrefixCommand, events::{ServerCreateHandler, ServerDeleteHandler, ServerUpdateHandler}, @@ -148,6 +155,7 @@ mod cli; mod constants; mod database; mod discord; +mod extras; mod framework; mod groups; mod logging; @@ -176,6 +184,7 @@ pub async fn main() { database_password, shard, redis_urls, + bot_owner_id, } => { start( token, @@ -184,6 +193,7 @@ pub async fn main() { database_password, shard.into(), redis_urls, + bot_owner_id ) .await; } @@ -197,10 +207,10 @@ pub async fn main() { struct ServerPrefixOrPayphoneMention; #[async_trait] -impl PrefixGetter for ServerPrefixOrPayphoneMention { +impl PrefixGetter for ServerPrefixOrPayphoneMention { async fn get( &self, - rt: &Runtime, + rt: &Runtime, server_id: Option, _user_id: Option, ) -> Vec { @@ -218,6 +228,25 @@ impl PrefixGetter for ServerPrefixOrPayphoneMention { } } +struct PayphoneExtrasGetter; + +#[async_trait] +impl ExtrasGetter for PayphoneExtrasGetter { + type Extras = Extras; + + async fn get_extra( + &self, + user: &TwilightUser, + rt: &Runtime, + ) -> Result> { + log::trace!("Getting extras!"); + + let user = User::get_or_create_and_set_last_seen(user.clone(), &rt.state.db).await?; + + Ok(Extras { user }) + } +} + /// Starts a Payphone shard. async fn start( token: String, @@ -226,6 +255,7 @@ async fn start( database_password: Option, shard: ShardId, redis_urls: Vec, + bot_owner_id: u64, ) { log::info!("Welcome to Payphone, Nyeki!"); log::info!( @@ -246,9 +276,10 @@ async fn start( let state = State { db, redis: redis_pool, + bot_owner_id }; - let bot = Bot::new(state, ServerPrefixOrPayphoneMention) + let bot = Bot::new(state, ServerPrefixOrPayphoneMention, PayphoneExtrasGetter) .shard(shard) .intent(Intents::GUILDS) .intent(Intents::GUILD_MESSAGES) @@ -260,6 +291,8 @@ async fn start( .command(FriendRequestCommand) .command(PrefixCommand) .command(LeaderboardCommand) + .command(ModeratorCommand) + .command(IncidentsCommand) .component(HelpGoBackButton) .cache_resource_type(ResourceType::GUILD) .cache_resource_type(ResourceType::CHANNEL) diff --git a/src/state.rs b/src/state.rs index 9edf21f..d80a015 100644 --- a/src/state.rs +++ b/src/state.rs @@ -24,4 +24,5 @@ use scylla::client::session::Session; pub struct State { pub db: Session, pub redis: Pool, + pub bot_owner_id: u64, }