From a004fe2308bfe0852b7f9dcc1d4a7eda57310897 Mon Sep 17 00:00:00 2001 From: Rafael Bradley Date: Sun, 10 Aug 2025 21:15:53 -0300 Subject: [PATCH] Finish refactoring --- Cargo.lock | 24 + Cargo.toml | 11 +- src/cli.rs | 45 ++ src/commands/about.rs | 221 ------ src/commands/mod.rs | 59 -- src/constants.rs | 3 +- src/database/migrations/0001_init.cql | 29 +- src/database/mod.rs | 75 +- src/database/models.rs | 828 +++++++++++++++++++++++ src/discord/events/core.rs | 13 - src/discord/events/interactions.rs | 11 - src/discord/events/messages.rs | 12 - src/discord/events/mod.rs | 25 - src/discord/images.rs | 15 + src/discord/mod.rs | 3 +- src/discord/sanitizing.rs | 38 ++ src/framework/bot.rs | 330 +++++++++ src/framework/commands/arguments.rs | 7 +- src/framework/commands/context.rs | 32 +- src/framework/commands/messages.rs | 58 +- src/framework/commands/mod.rs | 13 +- src/framework/commands/runner.rs | 45 ++ src/framework/events/mod.rs | 1 + src/framework/events/traits.rs | 83 +++ src/framework/handlers.rs | 12 - src/framework/interactions/components.rs | 32 +- src/framework/interactions/context.rs | 48 +- src/framework/interactions/mod.rs | 4 + src/framework/interactions/registry.rs | 40 +- src/framework/interactions/runner.rs | 26 + src/framework/mod.rs | 5 +- src/framework/runtime.rs | 33 + src/framework/utils.rs | 33 - src/groups/about/commands.rs | 75 ++ src/groups/about/components.rs | 79 +++ src/groups/about/embeds.rs | 132 ++++ src/groups/about/mod.rs | 3 + src/groups/calls/commands.rs | 204 ++++++ src/groups/calls/events.rs | 228 +++++++ src/groups/calls/mod.rs | 2 + src/groups/gateway/events.rs | 40 ++ src/groups/gateway/mod.rs | 1 + src/groups/mod.rs | 4 + src/groups/servers/events.rs | 95 +++ src/groups/servers/mod.rs | 1 + src/lib.rs | 3 + src/logging.rs | 14 +- src/main.rs | 101 +-- src/state.rs | 5 + src/utils.rs | 25 + 50 files changed, 2639 insertions(+), 587 deletions(-) delete mode 100644 src/commands/about.rs delete mode 100644 src/commands/mod.rs create mode 100644 src/database/models.rs delete mode 100644 src/discord/events/core.rs delete mode 100644 src/discord/events/interactions.rs delete mode 100644 src/discord/events/messages.rs delete mode 100644 src/discord/events/mod.rs create mode 100644 src/discord/images.rs create mode 100644 src/discord/sanitizing.rs create mode 100644 src/framework/bot.rs create mode 100644 src/framework/commands/runner.rs create mode 100644 src/framework/events/mod.rs create mode 100644 src/framework/events/traits.rs delete mode 100644 src/framework/handlers.rs create mode 100644 src/framework/interactions/runner.rs create mode 100644 src/framework/runtime.rs delete mode 100644 src/framework/utils.rs create mode 100644 src/groups/about/commands.rs create mode 100644 src/groups/about/components.rs create mode 100644 src/groups/about/embeds.rs create mode 100644 src/groups/about/mod.rs create mode 100644 src/groups/calls/commands.rs create mode 100644 src/groups/calls/events.rs create mode 100644 src/groups/calls/mod.rs create mode 100644 src/groups/gateway/events.rs create mode 100644 src/groups/gateway/mod.rs create mode 100644 src/groups/mod.rs create mode 100644 src/groups/servers/events.rs create mode 100644 src/groups/servers/mod.rs create mode 100644 src/lib.rs create mode 100644 src/state.rs diff --git a/Cargo.lock b/Cargo.lock index f7f4820..176e777 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -856,6 +856,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linkify" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780" +dependencies = [ + "memchr", +] + [[package]] name = "lock_api" version = "0.4.13" @@ -871,6 +880,9 @@ name = "log" version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +dependencies = [ + "serde", +] [[package]] name = "lz4_flex" @@ -989,8 +1001,11 @@ dependencies = [ "colored", "dotenvy", "include_dir", + "linkify", "log", "once_cell", + "rand", + "rand_chacha", "scc", "scylla", "thiserror 2.0.12", @@ -1000,6 +1015,8 @@ dependencies = [ "twilight-http", "twilight-model", "twilight-util", + "twilight-validate", + "unidecode", ] [[package]] @@ -1331,6 +1348,7 @@ dependencies = [ "snap", "stable_deref_trait", "thiserror 2.0.12", + "time", "tokio", "uuid", "yoke", @@ -1842,6 +1860,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unidecode" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402bb19d8e03f1d1a7450e2bd613980869438e0666331be3e073089124aa1adc" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 1b5c33e..6b27257 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,14 +5,17 @@ edition = "2024" [dependencies] async-trait = "0.1.88" -clap = { version = "4.5.42", features = ["derive", "env"] } +clap = { version = "4.5.42", features = ["derive", "env", "string"] } colored = "3.0.0" dotenvy = "0.15.7" include_dir = "0.7.4" -log = "0.4.27" +linkify = "0.10.0" +log = { version = "0.4.27", features = ["serde"] } once_cell = "1.21.3" +rand = "0.9.2" +rand_chacha = { version = "0.9.0", features = ["os_rng"] } scc = "2.3.4" -scylla = "1.3.1" +scylla = { version = "1.3.1", features = ["time-03"] } thiserror = "2.0.12" time = { version = "0.3.41", features = ["formatting", "local-offset", "macros"] } tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } @@ -20,3 +23,5 @@ twilight-gateway = "0.16.0" twilight-http = { version = "0.16.0", features = ["simd-json"] } twilight-model = "0.16.0" twilight-util = { version = "0.16.0", features = ["builder"] } +twilight-validate = "0.16.0" +unidecode = "0.3.0" diff --git a/src/cli.rs b/src/cli.rs index e68b5c4..d5ff5c2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,5 +1,50 @@ #[derive(clap::Parser)] pub struct Args { + /// The Discord bot token to run the bot with. #[arg(env = "PAYPHONE_TOKEN")] pub token: String, + + #[arg( + long, + short, + value_enum, + default_value_t = LevelFilter::Info, + env = "PAYPHONE_LOGGING", + value_name = "LEVEL", + long_help = "The minimum level logs have to be to be outputted into the console, or off for no logs." + )] + pub logging: LevelFilter, + + #[arg( + long, + short, + env = "PAYPHONE_DATABASE_URL", + value_name = "URL", + default_value_t = String::from("127.0.0.1:9042")) + ] + pub database_url: String, +} + +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum)] +pub enum LevelFilter { + Off, + Trace, + Debug, + Info, + Warn, + Error, +} + +#[allow(clippy::from_over_into)] +impl Into for LevelFilter { + fn into(self) -> log::LevelFilter { + match self { + Self::Off => log::LevelFilter::Off, + Self::Trace => log::LevelFilter::Trace, + Self::Debug => log::LevelFilter::Debug, + Self::Info => log::LevelFilter::Info, + Self::Warn => log::LevelFilter::Warn, + Self::Error => log::LevelFilter::Error, + } + } } diff --git a/src/commands/about.rs b/src/commands/about.rs deleted file mode 100644 index 7118d01..0000000 --- a/src/commands/about.rs +++ /dev/null @@ -1,221 +0,0 @@ -use std::sync::Arc; - -use async_trait::async_trait; -use twilight_model::channel::message::Embed; -use twilight_util::builder::embed::{ - EmbedAuthorBuilder, EmbedBuilder, EmbedFieldBuilder, EmbedFooterBuilder, ImageSource, -}; - -use crate::{ - commands::{COMMANDS, PREFIX, get_command_by_name}, - constants::{BOT_AVATAR, BOT_NAME, COLOR_RED, COLOR_WHITE}, - framework::{ - commands::{ - Command, CommandError, - arguments::{Argument, NeedyString, ParseArguments}, - context::CommandContext, - }, - interactions::{InteractionError, components::Button, context::InteractionContext}, - utils::EscapeDiscordMarkdown, - }, - utils::ToTitleCase, -}; - -pub struct HelpCommand; - -#[async_trait] -impl Command for HelpCommand { - fn name(&self) -> &'static str { - "help" - } - - fn arguments(&self) -> &'static [Argument] { - &[Argument { - name: "command", - is_optional: true, - is_greedy: false, - }] - } - - fn description(&self) -> &'static str { - "Lists all commands and shows information about them individually." - } - - async fn execute(&self, ctx: &CommandContext, args: &str) -> Result<(), CommandError> { - let command_name: Option = ParseArguments::parse(args).unwrap(); - - if let Some(command_name) = command_name { - if let Some(command) = get_command_by_name(&command_name) { - ctx.send() - .embed(get_command_help_embed(&command)) - .component(HelpGoBackButton, 1) - .component(HelpSupportLinkButton, 1) - .await - .inspect_err(|e| log::error!("Failed to send message! {e}")) - .map_err(|_| CommandError::MessageSendingFailed)?; - } else { - ctx.send() - .embed(get_command_not_found_embed(command_name.0)) - .component(HelpGoBackButton, 1) - .component(HelpSupportLinkButton, 1) - .await - .map_err(|_| CommandError::MessageSendingFailed)?; - } - } else { - ctx.send() - .embed(get_all_command_help_embed()) - .component(HelpWebsiteLinkButton, 1) - .component(HelpSupportLinkButton, 1) - .component(HelpDonateLinkButton, 1) - .await - .map_err(|_| CommandError::MessageSendingFailed)?; - } - - Ok(()) - } -} - -fn get_command_help_embed(command: &Arc) -> Embed { - let name = format!("{PREFIX}{}", command.name()); - let arguments = command - .arguments() - .iter() - .map(|arg| arg.to_string()) - .collect::>() - .join(" "); - - let usage = if !arguments.is_empty() { - format!("{name} {arguments}") - } else { - name - }; - - EmbedBuilder::new() - .author( - EmbedAuthorBuilder::new(BOT_NAME) - .icon_url(ImageSource::url(BOT_AVATAR).unwrap()) - .build(), - ) - .thumbnail(ImageSource::url(BOT_AVATAR).unwrap()) - .color(COLOR_WHITE) - .title(format!("{} Command", command.name().to_title_case())) - .description(command.description()) - .field(EmbedFieldBuilder::new("Usage", format!("`{usage}`")).build()) - .field(EmbedFieldBuilder::new( - "Aliases", - if !command.aliases().is_empty() { - command - .aliases() - .iter() - .map(|alias| format!("`{PREFIX}{alias}`")) - .collect::>() - .join(", ") - } else { - String::from("*This command doesn't have aliases.*") - }, - )) - .footer(EmbedFooterBuilder::new(format!( - "Use {PREFIX}help to get help about a specific command." - ))) - .build() -} - -fn get_all_command_help_embed() -> Embed { - EmbedBuilder::new() - .author(EmbedAuthorBuilder::new(BOT_NAME).icon_url(ImageSource::url(BOT_AVATAR).unwrap())) - .title("Pick up the phone and start making friends!") - .description(format!("Here is the list of all available commands. You can see more details about a specific command with `{PREFIX}help `.")) - .thumbnail(ImageSource::url(BOT_AVATAR).unwrap()) - .color(COLOR_WHITE) - .field(EmbedFieldBuilder::new("Commands", get_commands_table()).build()) - .footer(EmbedFooterBuilder::new(format!("v{} - Use {PREFIX}help to get more information about a specific command.", env!("CARGO_PKG_VERSION")))) - .build() -} - -fn get_commands_table() -> String { - let mut table = String::new(); - - for command in COMMANDS.iter() { - table.push_str(&format!("{:<12}", command.name())); - } - - format!("```\n{table}\n```") -} - -fn get_command_not_found_embed(name: String) -> Embed { - EmbedBuilder::new() - .color(COLOR_RED) - .author(EmbedAuthorBuilder::new(BOT_NAME).icon_url(ImageSource::url(BOT_AVATAR).unwrap())) - .title("That's not a command") - .description(format!("`{}` is not a valid command name (nor alias). If you put it with the prefix on, take the prefix off before trying again. For example, if you did `p.help p.call`, do `p.help call` instead.\n\nIf you still can't figure it out, press the button below to go back to the full list. Everything's there!", name.escape())) - .thumbnail(ImageSource::url(BOT_AVATAR).unwrap()) - .build() -} - -pub struct HelpGoBackButton; - -#[async_trait] -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> { - 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()) - .component(HelpWebsiteLinkButton, 1) - .component(HelpSupportLinkButton, 1) - .component(HelpDonateLinkButton, 1) - .await - .inspect_err(|e| log::error!("An error occurred while attempting to edit the original message of an interaction. {e:?}")) - .map_err( - |_| InteractionError::OriginalMessageEditingFailed - )?; - - Ok(()) - } -} - -pub struct HelpSupportLinkButton; - -#[async_trait] -impl Button for HelpSupportLinkButton { - fn label(&self) -> Option<&'static str> { - Some("Get help") - } - - fn url(&self) -> Option<&'static str> { - Some("https://discord.gg/9daSsrfTmE") - } -} - -pub struct HelpWebsiteLinkButton; - -#[async_trait] -impl Button for HelpWebsiteLinkButton { - fn label(&self) -> Option<&'static str> { - Some("Website") - } - - fn url(&self) -> Option<&'static str> { - Some("https://payphone.lat/") - } -} - -pub struct HelpDonateLinkButton; - -#[async_trait] -impl Button for HelpDonateLinkButton { - fn label(&self) -> Option<&'static str> { - Some("Donate") - } - - fn url(&self) -> Option<&'static str> { - Some("https://ko-fi.com/payphonedev") - } -} diff --git a/src/commands/mod.rs b/src/commands/mod.rs deleted file mode 100644 index c09af01..0000000 --- a/src/commands/mod.rs +++ /dev/null @@ -1,59 +0,0 @@ -mod about; - -use once_cell::sync::Lazy; -use std::sync::Arc; -use twilight_http::Client; -use twilight_model::{gateway::payload::incoming::MessageCreate, oauth::Application}; - -use crate::framework::{ - commands::{context::CommandContext, get_command_parts, Command}, handlers::Handlers, interactions::{components::Button, registry::Registry} -}; - -const PREFIX: &str = "pd."; - -static COMMANDS: Lazy>> = Lazy::new(|| vec![Arc::new(about::HelpCommand)]); - -pub fn get_command_by_name(name: &str) -> Option> { - for command in COMMANDS.iter() { - if command - .identifiers() - .contains(&name.to_ascii_lowercase().as_str()) - { - return Some(command.clone()); - } - } - - None -} - -pub async fn execute_command( - event: MessageCreate, - handlers: Arc -) { - let parts = get_command_parts(PREFIX, &event.content); - - if let Some(parts) = parts { - if let Some(command) = get_command_by_name(parts.name) { - let _ = command - .execute( - &CommandContext::new(&event, handlers), - parts.args, - ) - .await - .inspect_err(|e| log::error!("Failed to execute command! {e:?}")); - } - } -} - -pub fn register_default_components(registry: &Registry) { - let components = vec![Arc::new(about::HelpGoBackButton)]; - - for component in components { - registry.register( - component.id().expect( - "All components registered by default must have an ID, there's one which didn't", - ), - component, - ); - } -} diff --git a/src/constants.rs b/src/constants.rs index 02503a5..5ff69f5 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -1,5 +1,4 @@ -pub const BOT_NAME: &str = "Payphone"; -pub const BOT_AVATAR: &str = "https://images-ext-1.discordapp.net/external/BymicERFXlYKd52uNU2ihrbUoSm2e5hV3Ae7XY1436o/%3Fsize%3D1024/https/cdn.discordapp.com/avatars/1212940413795827763/f4b7180bb467b035d6e2e6f458e93b7c.png?format=webp&quality=lossless&width=1058&height=1058"; +pub const DEFAULT_PREFIX: &str = "p."; pub const COLOR_WHITE: u32 = 0xE2D9D0; pub const COLOR_RED: u32 = 0xFF8787; diff --git a/src/database/migrations/0001_init.cql b/src/database/migrations/0001_init.cql index f47af7b..c292854 100644 --- a/src/database/migrations/0001_init.cql +++ b/src/database/migrations/0001_init.cql @@ -2,7 +2,9 @@ CREATE KEYSPACE IF NOT EXISTS payphone WITH replication = { 'class': 'NetworkTopologyStrategy', 'replication_factor': 3 -}; + } AND TABLETS = { + 'enabled': false + }; CREATE TABLE IF NOT EXISTS payphone.users ( user_id BIGINT PRIMARY KEY, @@ -15,13 +17,15 @@ CREATE TABLE IF NOT EXISTS payphone.users ( joined_at TIMESTAMP, last_seen_at TIMESTAMP, - is_supporter_since TIMESTAMP, - is_supporter_until TIMESTAMP + supporter_since TIMESTAMP, + supporter_until TIMESTAMP ); CREATE TABLE IF NOT EXISTS payphone.calls ( call_id BIGINT PRIMARY KEY, + channel_ids LIST, + started_at TIMESTAMP, finished_at TIMESTAMP, ); @@ -30,22 +34,30 @@ CREATE TABLE IF NOT EXISTS payphone.channels ( channel_id BIGINT PRIMARY KEY, server_id BIGINT, - name TEXT, - + call_id BIGINT, is_searching BOOLEAN, - recently_called_channel_ids LIST + recently_called_server_ids LIST, + + webhook_id BIGINT, + webhook_token TEXT ); +CREATE MATERIALIZED VIEW IF NOT EXISTS payphone.searching_channels AS + SELECT channel_id, server_id, is_searching, recently_called_server_ids FROM payphone.channels + WHERE channel_id IS NOT NULL AND is_searching = true + PRIMARY KEY (is_searching, channel_id); + CREATE TABLE IF NOT EXISTS payphone.participants ( call_id BIGINT, user_id BIGINT, + channel_id BIGINT, user_name TEXT, user_avatar_url TEXT, user_mask TEXT, - PRIMARY KEY (call_id, user_id) + PRIMARY KEY (call_id, user_id, channel_id) ); -- TTL 30 days. @@ -62,6 +74,9 @@ CREATE TABLE IF NOT EXISTS payphone.messages ( attachments LIST, sticker BIGINT, + propagation_message_id BIGINT, + propagation_channel_id BIGINT, + replying_to_id BIGINT, is_deleted BOOLEAN, diff --git a/src/database/mod.rs b/src/database/mod.rs index 9162913..38ccdd4 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,9 +1,12 @@ +pub mod models; + use include_dir::{Dir, include_dir}; use scylla::{ client::{session::Session, session_builder::SessionBuilder}, errors::ExecutionError, }; use thiserror::Error; +use time::OffsetDateTime; #[derive(Debug, Error)] pub enum DatabaseError { @@ -44,22 +47,66 @@ pub async fn init(uri: impl ToString) -> Result { .into(), ) .await - .inspect(|_| log::info!("The migrations keyspace was successfully set up")) .inspect_err(|e| log::error!("Could not set up migrations keyspace and table! {e:?}")) .map_err(|_| DatabaseError::MigrationFailed)?; - for file in MIGRATIONS.files() { - execute_file( - &session, - file.contents_utf8() - .ok_or(DatabaseError::MigrationFileInvalid( - file.path().to_string_lossy().to_string(), - ))? - .to_string(), - ) - .await - .inspect_err(|e| log::error!("Could not migrate {}! {e:?}", file.path().to_str().unwrap())) - .map_err(|_| DatabaseError::MigrationFailed)?; + let mut sorted_migration_files = MIGRATIONS.files().collect::>(); + sorted_migration_files.sort_by(|a, b| { + a.path() + .to_string_lossy() + .to_string() + .cmp(&b.path().to_string_lossy().to_string()) + }); + + for file in sorted_migration_files { + let migration_name = file.path().to_string_lossy().to_string(); + + let row = session + .query_unpaged( + "SELECT name FROM migrations.applied WHERE name = ? LIMIT 1;", + (&migration_name,), + ) + .await + .inspect_err(|e| { + log::error!( + "Could not check whether the migration \"{migration_name}\" was applied! {e:?}" + ) + }) + .map_err(|_| DatabaseError::MigrationFailed)? + .into_rows_result() + .inspect_err(|e| log::error!("The applied migrations' database result were not rows! {e:?}")) + .map_err(|_| DatabaseError::MigrationFailed)? + .maybe_first_row::<(String,)>() + .inspect_err(|e| log::error!("The applied migrations' row returned by the database could not be parsed! {e:?}")) + .map_err(|_| DatabaseError::MigrationFailed)?; + + if row.is_none() { + execute_file( + &session, + file.contents_utf8() + .ok_or(DatabaseError::MigrationFileInvalid(migration_name.clone()))? + .to_string(), + ) + .await + .inspect(|_| log::debug!("Migration {migration_name} applied!")) + .inspect_err(|e| { + log::error!("Could not migrate {}! {e:?}", file.path().to_str().unwrap()) + }) + .map_err(|_| DatabaseError::MigrationFailed)?; + + session + .query_unpaged( + "INSERT INTO migrations.applied (name, applied_at) VALUES (?, ?);", + (migration_name.clone(), OffsetDateTime::now_utc()), + ) + .await + .inspect_err(|e| { + log::error!("Could not mark migration {migration_name} as applied in DB! {e:?}") + }) + .map_err(|_| DatabaseError::MigrationFailed)?; + } else { + log::trace!("Skipped migration {migration_name} because it was already applied."); + } } Ok(session) @@ -85,7 +132,7 @@ async fn execute_file(session: &Session, file: String) -> Result<(), FileExecuti .await .map_err(|error| FileExecutionError { error, - statement_index, + statement_index: statement_index + 1, })?; } diff --git a/src/database/models.rs b/src/database/models.rs new file mode 100644 index 0000000..5c3834b --- /dev/null +++ b/src/database/models.rs @@ -0,0 +1,828 @@ +use scylla::{ + client::session::Session, + deserialize::row::DeserializeRow, + errors::{ExecutionError, SingleRowError}, + statement::{ + Statement, + batch::{Batch, BatchType}, + }, +}; +use thiserror::Error; +use time::{OffsetDateTime, UtcDateTime}; +use twilight_http::Client; +use twilight_model::{ + guild::{Guild as TwilightGuild, GuildInfo as TwilightGuildInfo}, + id::Id, + user::User as TwilightUser, +}; + +use crate::{constants::DEFAULT_PREFIX, utils::get_random_u64}; + +#[derive(Debug, Error)] +pub enum DatabaseOrDiscordError { + #[error("An error occurred when querying the database")] + Database(#[from] DatabaseError), + + #[error("An error occurred when querying the Discord API")] + Discord(#[from] twilight_http::Error), + + #[error("The Discord API returned a response that could not be deserialized")] + DiscordDeserializationFailed, +} + +#[derive(Debug, Error)] +pub enum DatabaseError { + #[error("An error occurred when querying the database")] + QueryError(ExecutionError), + + #[error("The database responded but the result of the query weren't rows")] + ResultNotRows, + + #[error("The resulting row could not be deserialized into an object")] + DeserializationFailed, +} + +/// Copies the bytes from [`i64`] to [`u64`]. +/// +/// WARNING: This does NOT keep the same "represented" value. This only keeps the bytes intact, +/// e.g. 0b0101 will still be 0b0101, but the value represented will change since the type changes. +/// +/// Arguments: +/// * `i` - The [`i64`] to convert. +/// +/// Returns: +/// [`u64`] - The resulting [`u64`]. +fn i64_to_u64(i: i64) -> u64 { + u64::from_ne_bytes(i.to_ne_bytes()) +} + +/// Copies the bytes from [`u64`] to [`i64`]. +/// +/// WARNING: This does NOT keep the same "represented" value. This only keeps the bytes intact, +/// e.g. 0b0101 will still be 0b0101, but the value represented will change since the type changes. +/// +/// Arguments: +/// * `u` - The [`u64`] to convert. +/// +/// Returns: +/// [`i64`] - The resulting [`i64`]. +fn u64_to_i64(u: u64) -> i64 { + i64::from_ne_bytes(u.to_ne_bytes()) +} + +trait Object<'a> { + type Row: DeserializeRow<'a, 'a>; + + fn from_row(row: Self::Row) -> Self; + fn to_row(value: Self) -> Self::Row; +} + +#[derive(Debug, Clone)] +pub struct User { + pub id: u64, + pub name: String, + pub email: Option, + pub is_masked: bool, + pub is_moderator: bool, + pub joined_at: UtcDateTime, + pub last_seen_at: UtcDateTime, + pub supporter_since: Option, + pub supporter_until: Option, +} + +impl<'a> Object<'a> for User { + type Row = ( + i64, + String, + Option, + bool, + bool, + OffsetDateTime, + OffsetDateTime, + Option, + Option, + ); + + fn from_row(row: Self::Row) -> Self { + Self { + id: i64_to_u64(row.0), + name: row.1, + email: row.2, + is_masked: row.3, + is_moderator: row.4, + joined_at: row.5.into(), + last_seen_at: row.6.into(), + supporter_since: row.7.map(|i| i.into()), + supporter_until: row.8.map(|i| i.into()), + } + } + + fn to_row(value: Self) -> Self::Row { + ( + u64_to_i64(value.id), + value.name, + value.email, + value.is_masked, + value.is_moderator, + value.joined_at.into(), + value.last_seen_at.into(), + value.supporter_since.map(|i| i.into()), + value.supporter_until.map(|i| i.into()), + ) + } +} + +impl User { + pub async fn get_by_id(id: u64, session: &Session) -> Result, DatabaseError> { + let id = u64_to_i64(id); + + let result = session + .query_unpaged( + r#" + SELECT + user_id, + name, + email, + is_masked, + is_moderator, + joined_at, + last_seen_at, + supporter_since, + supporter_until + FROM + payphone.users + WHERE + user_id = ? + LIMIT 1; + "#, + (id,), + ) + .await + .map_err(DatabaseError::QueryError)? + .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), + }, + } + } +} + +impl From for User { + fn from(value: TwilightUser) -> Self { + Self { + id: value.id.get(), + name: value.name, + email: value.email, + is_masked: false, + is_moderator: false, + joined_at: UtcDateTime::now(), + last_seen_at: UtcDateTime::now(), + supporter_since: None, + supporter_until: None, + } + } +} + +#[derive(Debug, Clone)] +pub struct Server { + pub id: u64, + pub name: String, + pub prefix: String, +} + +impl<'a> Object<'a> for Server { + type Row = (i64, String, String); + + fn from_row(row: Self::Row) -> Self { + Self { + id: i64_to_u64(row.0), + name: row.1, + prefix: row.2, + } + } + + fn to_row(value: Self) -> Self::Row { + (u64_to_i64(value.id), value.name, value.prefix) + } +} + +impl Server { + pub async fn get_by_id(id: u64, session: &Session) -> Result, DatabaseError> { + let id = u64_to_i64(id); + + let result = session + .query_unpaged( + r#" + SELECT + server_id, + name, + prefix + FROM + payphone.servers + WHERE + server_id = ? + LIMIT 1; + "#, + (id,), + ) + .await + .map_err(DatabaseError::QueryError)? + .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), + }, + } + } + + pub async fn insert(&self, session: &Session) -> Result<(), DatabaseError> { + session + .query_unpaged( + r#" + INSERT INTO payphone.servers ( + server_id, + name, + prefix + ) VALUES (?, ?, ?) IF NOT EXISTS; + "#, + Self::to_row(self.clone()), + ) + .await + .inspect_err(|e| log::error!("{e:?}")) + .map_err(DatabaseError::QueryError)?; + + Ok(()) + } + + pub async fn upsert(&self, session: &Session) -> Result<(), DatabaseError> { + session + .query_unpaged( + r#" + INSERT INTO payphone.servers ( + server_id, + name, + prefix + ) VALUES ( + ?, + ?, + ? + ); + "#, + Self::to_row(self.clone()), + ) + .await + .map_err(DatabaseError::QueryError)?; + + Ok(()) + } + + pub async fn delete_by_id(id: u64, session: &Session) -> Result<(), DatabaseError> { + session + .query_unpaged( + "DELETE FROM payphone.servers WHERE server_id = ?;", + (u64_to_i64(id),), + ) + .await + .map_err(DatabaseError::QueryError)?; + + Ok(()) + } +} + +impl From for Server { + fn from(value: TwilightGuild) -> Self { + Self { + id: value.id.get(), + name: value.name, + prefix: DEFAULT_PREFIX.into(), + } + } +} + +impl From for Server { + fn from(value: TwilightGuildInfo) -> Self { + Self { + id: value.id.get(), + name: value.name, + prefix: DEFAULT_PREFIX.into(), + } + } +} + +#[derive(Debug, Clone)] +pub struct Channel { + pub id: u64, + pub server_id: u64, + pub call_id: Option, + pub is_searching: bool, + pub recently_called_server_ids: Vec, + pub webhook_id: Option, + pub webhook_token: Option, +} + +impl<'a> Object<'a> for Channel { + type Row = ( + i64, + i64, + Option, + bool, + Vec, + Option, + Option, + ); + + fn from_row(row: Self::Row) -> Self { + Self { + id: i64_to_u64(row.0), + server_id: i64_to_u64(row.1), + call_id: row.2.map(i64_to_u64), + is_searching: row.3, + recently_called_server_ids: row.4.iter().map(|&i| i64_to_u64(i)).collect(), + webhook_id: row.5.map(i64_to_u64), + webhook_token: row.6, + } + } + + fn to_row(value: Self) -> Self::Row { + ( + u64_to_i64(value.id), + u64_to_i64(value.server_id), + value.call_id.map(u64_to_i64), + value.is_searching, + value + .recently_called_server_ids + .iter() + .map(|&u| u64_to_i64(u)) + .collect(), + value.webhook_id.map(u64_to_i64), + value.webhook_token, + ) + } +} + +impl Channel { + pub async fn get_by_id(id: u64, session: &Session) -> Result, DatabaseError> { + let id = u64_to_i64(id); + + let result = session + .query_unpaged( + r#" + SELECT + channel_id, + server_id, + call_id, + is_searching, + recently_called_server_ids, + webhook_id, + webhook_token + FROM + payphone.channels + WHERE + channel_id = ? + LIMIT 1; + "#, + (id,), + ) + .await + .map_err(DatabaseError::QueryError)? + .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), + }, + } + } + + pub async fn get_or_create_by_id( + id: u64, + session: &Session, + http: &Client, + ) -> Result { + if let Some(channel) = Channel::get_by_id(id, session) + .await + .map_err(DatabaseOrDiscordError::Database)? + { + Ok(channel) + } else { + let webhook = http + .create_webhook(Id::new(id), "Payphone Messages") + .await + .inspect_err(|e| log::error!("Failed to create webhook for channel {id}: {e:?}")) + .map_err(DatabaseOrDiscordError::Discord)? + .model() + .await + .inspect_err(|e| log::error!("Failed to get model for webhook {id}: {e:?}")) + .map_err(|_| DatabaseOrDiscordError::DiscordDeserializationFailed)?; + + let response = http + .channel(Id::new(id)) + .await + .map_err(DatabaseOrDiscordError::Discord)? + .model() + .await + .inspect_err(|e| { + log::error!("The discord API didn't return a parseable channel! {e:?}") + }) + .map_err(|_| DatabaseOrDiscordError::DiscordDeserializationFailed)?; + + let channel = Channel { + id: response.id.get(), + server_id: response.guild_id.map(|i| i.get()).unwrap_or(0), + call_id: None, + is_searching: false, + recently_called_server_ids: vec![], + webhook_id: Some(webhook.id.get()), + webhook_token: webhook.token, + }; + channel + .upsert(session) + .await + .inspect_err(|e| log::error!("Could not upsert channel in database! {e:?}")) + .map_err(DatabaseOrDiscordError::Database)?; + + Ok(channel) + } + } + + pub async fn upsert(&self, session: &Session) -> Result<(), DatabaseError> { + session + .query_unpaged( + r#" + INSERT INTO payphone.channels ( + channel_id, + server_id, + call_id, + is_searching, + recently_called_server_ids, + webhook_id, + webhook_token + ) VALUES (?, ?, ?, ?, ?, ?, ?); + "#, + Self::to_row(self.clone()), + ) + .await + .map_err(DatabaseError::QueryError)?; + + Ok(()) + } + + pub async fn cancel_search(&self, session: &Session) -> Result<(), DatabaseError> { + session + .query_unpaged( + "UPDATE payphone.channels SET is_searching = false WHERE channel_id = ?;", + (u64_to_i64(self.id),), + ) + .await + .map_err(DatabaseError::QueryError)?; + + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct SearchingChannel { + pub id: u64, + pub server_id: u64, + pub recently_called_server_ids: Vec, +} + +impl<'a> Object<'a> for SearchingChannel { + type Row = (i64, i64, Vec); + + fn from_row(row: Self::Row) -> Self { + Self { + id: i64_to_u64(row.0), + server_id: i64_to_u64(row.1), + recently_called_server_ids: row.2.iter().map(|&i| i64_to_u64(i)).collect(), + } + } + + fn to_row(value: Self) -> Self::Row { + ( + u64_to_i64(value.id), + u64_to_i64(value.server_id), + value + .recently_called_server_ids + .iter() + .map(|&u| u64_to_i64(u)) + .collect(), + ) + } +} + +impl SearchingChannel { + pub async fn get_all(session: &Session) -> Result, DatabaseError> { + let result = session + .query_unpaged( + r#" + SELECT + channel_id, + server_id, + recently_called_server_ids + FROM + payphone.searching_channels; + "#, + (), + ) + .await + .map_err(DatabaseError::QueryError)? + .into_rows_result() + .map_err(|_| DatabaseError::ResultNotRows)?; + + Ok(result + .rows::<::Row>() + .map_err(|_| DatabaseError::ResultNotRows)? + .collect::, _>>() + .map_err(|_| DatabaseError::DeserializationFailed)? + .into_iter() + .map(Self::from_row) + .collect::>()) + } +} + +#[derive(Debug, Clone)] +pub struct Call { + pub id: u64, + pub channel_ids: Vec, + pub started_at: UtcDateTime, + pub finished_at: Option, +} + +impl<'a> Object<'a> for Call { + type Row = (i64, Vec, OffsetDateTime, Option); + + fn from_row(row: Self::Row) -> Self { + Self { + id: i64_to_u64(row.0), + channel_ids: row.1.iter().map(|&i| i64_to_u64(i)).collect(), + started_at: row.2.into(), + finished_at: row.3.map(|i| i.into()), + } + } + + fn to_row(value: Self) -> Self::Row { + ( + u64_to_i64(value.id), + value.channel_ids.iter().map(|&u| u64_to_i64(u)).collect(), + value.started_at.into(), + value.finished_at.map(|i| i.into()), + ) + } +} + +fn push_and_cap(vec: &mut Vec, value: T, max_size: usize) { + if vec.len() >= max_size { + vec.remove(0); + } + + vec.push(value); +} + +impl Call { + pub async fn get_by_id(id: u64, session: &Session) -> Result, DatabaseError> { + let id = u64_to_i64(id); + + let result = session + .query_unpaged( + r#" + SELECT + call_id, + channel_ids, + started_at, + finished_at + FROM + payphone.calls + WHERE + call_id = ? + LIMIT 1; + "#, + (id,), + ) + .await + .map_err(DatabaseError::QueryError)? + .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), + }, + } + } + + /// Returns the opposite party's channel ID. + pub fn other_party_channel_id(&self, channel_id: u64) -> u64 { + if self.channel_ids[0] == channel_id { + self.channel_ids[1] + } else { + self.channel_ids[0] + } + } + + pub fn is_active(&self) -> bool { + self.finished_at.is_none() + } + + /// Starts a call, or marks the channel as searching if the call is not started. + /// + /// Arguments: + /// * `channel` - The channel to start the call in. + /// * `session` - The Scylla session to use for database operations. + /// + /// Returns: + /// [`Result, DatabaseError>`] - `Ok(Some(Call))` if the call was started + /// successfully, or `Ok(None)` if the channel was marked as searching. If an error occurs, it + /// returns `Err(DatabaseError)`. + pub async fn start( + channel: &Channel, + session: &Session, + ) -> Result, DatabaseError> { + let searching_channels = SearchingChannel::get_all(session).await?; + + let call_id = get_random_u64(); + + log::trace!("Call ID is {call_id}"); + + for searching_channel in searching_channels + .iter() + .filter(|sc| !sc.recently_called_server_ids.contains(&channel.server_id)) + { + if searching_channel.server_id == channel.server_id { + return Ok(None); + } + + if channel + .recently_called_server_ids + .contains(&searching_channel.server_id) + { + continue; + } + + log::trace!("Found a searching channel: {searching_channel:?}"); + + let mut searching_channel_recently_called_server_ids = searching_channel + .recently_called_server_ids + .clone() + .into_iter() + .map(u64_to_i64) + .collect::>(); + push_and_cap( + &mut searching_channel_recently_called_server_ids, + u64_to_i64(channel.server_id), + 5, + ); + + let (is_applied, ..) = session + .query_unpaged( + r#" + UPDATE payphone.channels + SET + is_searching = false, + call_id = ?, + recently_called_server_ids = ? + WHERE + channel_id = ? + IF call_id = NULL; + "#, + ( + u64_to_i64(call_id), + searching_channel_recently_called_server_ids, + u64_to_i64(searching_channel.id), + ), + ) + .await + .inspect_err(|e| log::error!("Failed to add channel to the call! {e:?}")) + .map_err(DatabaseError::QueryError)? + .into_rows_result() + .inspect_err(|e| log::error!("Failed to get result of the query! {e:?}")) + .map_err(|_| DatabaseError::ResultNotRows)? + .first_row::<(bool, Option)>() + .inspect_err(|e| log::error!("Failed to get first row of the result! {e:?}")) + .map_err(|_| DatabaseError::DeserializationFailed)?; + + log::trace!("Is applied: {is_applied:?}"); + + if is_applied { + let mut channel_recently_called_server_ids = channel + .recently_called_server_ids + .clone() + .into_iter() + .map(u64_to_i64) + .collect::>(); + push_and_cap( + &mut channel_recently_called_server_ids, + u64_to_i64(searching_channel.server_id), + 5, + ); + + session + .query_unpaged( + "UPDATE payphone.channels SET call_id = ?, recently_called_server_ids = ? WHERE channel_id = ?", + (u64_to_i64(call_id), channel_recently_called_server_ids, u64_to_i64(channel.id)), + ) + .await + .inspect_err(|e| log::error!("Failed to update channel {e:?}")) + .map_err(DatabaseError::QueryError)?; + + let call = Call { + id: call_id, + channel_ids: vec![searching_channel.id, channel.id].into_iter().collect(), + started_at: UtcDateTime::now(), + finished_at: None, + }; + call.insert(session) + .await + .inspect_err(|e| log::error!("Failed to insert call {e:?}"))?; + + return Ok(Some(call)); + } else { + // This channel already got a call, so we skip it, preventing race conditions. + continue; + } + } + + session.query_unpaged( + "UPDATE payphone.channels SET is_searching = true WHERE channel_id = ? IF call_id = NULL;", + (u64_to_i64(channel.id),), + ) + .await + .inspect_err(|e| log::error!("Failed to mark channel as searching! {e:?}")) + .map_err(DatabaseError::QueryError)?; + + Ok(None) + } + + pub async fn insert(&self, session: &Session) -> Result<(), DatabaseError> { + session + .query_unpaged( + r#" + INSERT INTO payphone.calls ( + call_id, + channel_ids, + started_at, + finished_at + ) VALUES (?, ?, ?, ?); + "#, + Self::to_row(self.clone()), + ) + .await + .map_err(DatabaseError::QueryError)?; + + Ok(()) + } + + pub async fn hangup(&self, session: &Session) -> Result<(), DatabaseError> { + let mut batch = Batch::new(BatchType::Unlogged); + + batch.append_statement(Statement::new( + "UPDATE payphone.calls SET finished_at = ? WHERE call_id = ?;", + )); + batch.append_statement(Statement::new( + "UPDATE payphone.channels SET call_id = NULL WHERE channel_id = ?;", + )); + batch.append_statement(Statement::new( + "UPDATE payphone.channels SET call_id = NULL WHERE channel_id = ?;", + )); + + let call_id = u64_to_i64(self.id); + + session + .batch( + &batch, + ( + (OffsetDateTime::now_utc(), call_id), + (u64_to_i64(self.channel_ids[0]),), + (u64_to_i64(self.channel_ids[1]),), + ), + ) + .await + .map_err(DatabaseError::QueryError)?; + + log::trace!("Hung up call {call_id}"); + + Ok(()) + } +} diff --git a/src/discord/events/core.rs b/src/discord/events/core.rs deleted file mode 100644 index 95e551a..0000000 --- a/src/discord/events/core.rs +++ /dev/null @@ -1,13 +0,0 @@ -use twilight_model::gateway::payload::incoming::Ready; - -pub async fn handle_on_ready(event: Ready) { - log::info!( - "Ready! Logged in as {}#{:>04}", - event.user.name, - event.user.discriminator - ); -} - -pub async fn handle_on_reconnect() { - log::info!("The gateway asked for a reconnection"); -} diff --git a/src/discord/events/interactions.rs b/src/discord/events/interactions.rs deleted file mode 100644 index ed49678..0000000 --- a/src/discord/events/interactions.rs +++ /dev/null @@ -1,11 +0,0 @@ -use std::sync::Arc; - -use twilight_model::gateway::payload::incoming::InteractionCreate; - -use crate::framework::handlers::Handlers; - -pub async fn handle_on_interaction_create(event: InteractionCreate, handlers: Arc) { - if let Err(e) = handlers.registry.execute(event, handlers.clone()).await { - log::error!("An error occurred while processing the interaction! {e:?}"); - } -} diff --git a/src/discord/events/messages.rs b/src/discord/events/messages.rs deleted file mode 100644 index 8676e9c..0000000 --- a/src/discord/events/messages.rs +++ /dev/null @@ -1,12 +0,0 @@ -use std::sync::Arc; - -use twilight_model::{gateway::payload::incoming::MessageCreate}; - -use crate::{commands, framework::{handlers::Handlers}}; - -pub fn handle_on_message_create( - event: MessageCreate, - handlers: Arc -) -> impl Future { - commands::execute_command(event, handlers) -} diff --git a/src/discord/events/mod.rs b/src/discord/events/mod.rs deleted file mode 100644 index cb5817b..0000000 --- a/src/discord/events/mod.rs +++ /dev/null @@ -1,25 +0,0 @@ -use std::sync::Arc; - -use twilight_gateway::Event; - -use crate::framework::handlers::Handlers; - -mod core; -mod interactions; -mod messages; - -pub async fn handle_event(event: Event, handlers: Arc) { - log::trace!("{event:?}"); - - match event { - Event::Ready(event) => core::handle_on_ready(*event).await, - Event::GatewayReconnect => core::handle_on_reconnect().await, - Event::MessageCreate(event) => { - messages::handle_on_message_create(*event, handlers).await - } - Event::InteractionCreate(event) => { - interactions::handle_on_interaction_create(*event, handlers).await - } - _ => {} - } -} diff --git a/src/discord/images.rs b/src/discord/images.rs new file mode 100644 index 0000000..41c674e --- /dev/null +++ b/src/discord/images.rs @@ -0,0 +1,15 @@ +use twilight_model::util::ImageHash; + +pub fn get_avatar_url(hash: Option, user_id: u64, discriminator: u64) -> String { + if let Some(hash) = hash { + format!("https://cdn.discordapp.com/avatars/{user_id}/{hash}.webp?size=256") + } else { + format!( + "https://cdn.discordapp.com/embed/avatars/{}.png?size=256", + match discriminator.to_string().as_str() { + "0" => (user_id >> 22) % 6, + _ => discriminator % 6, + } + ) + } +} diff --git a/src/discord/mod.rs b/src/discord/mod.rs index a9970c2..6750b28 100644 --- a/src/discord/mod.rs +++ b/src/discord/mod.rs @@ -1 +1,2 @@ -pub mod events; +pub mod images; +pub mod sanitizing; diff --git a/src/discord/sanitizing.rs b/src/discord/sanitizing.rs new file mode 100644 index 0000000..f220c4d --- /dev/null +++ b/src/discord/sanitizing.rs @@ -0,0 +1,38 @@ +pub trait SanitizeDiscordMarkdown { + fn sanitize(&self, hard: bool) -> String; +} + +impl SanitizeDiscordMarkdown for T +where + T: ToString, +{ + fn sanitize(&self, hard: bool) -> String { + let input = self.to_string(); + + let escaped = if hard { + let mut escaped = String::with_capacity(input.len()); + + for ch in input.chars() { + match ch { + '\\' | '*' | '_' | '~' | '`' | '>' | '<' | '|' | '#' => { + escaped.push('\\'); + escaped.push(ch); + } + _ => escaped.push(ch), + } + } + + escaped + } else { + input + }; + + // Now block ping attempts — break mentions + escaped + .replace("@everyone", "@\u{200B}everyone") + .replace("@here", "@\u{200B}here") + .replace("<@", "<@\u{200B}") // user mentions + .replace("<@&", "<@&\u{200B}") // role mentions + .replace("<#", "<#\u{200B}") // channel mentions + } +} diff --git a/src/framework/bot.rs b/src/framework/bot.rs new file mode 100644 index 0000000..769c283 --- /dev/null +++ b/src/framework/bot.rs @@ -0,0 +1,330 @@ +use std::{fmt::Debug, sync::Arc}; + +use async_trait::async_trait; +use thiserror::Error; +use twilight_gateway::{Event, EventTypeFlags, Intents, Shard, ShardId, StreamExt}; +use twilight_http::Client; + +use crate::{ + framework::{ + commands::{Command, runner::CommandRunner}, + events::traits::{ + OnInteractionHandler, OnMessageDeleteHandler, OnMessageEditHandler, OnMessageHandler, + OnReadyHandler, OnReconnectHandler, OnServerCreateHandler, OnServerDeleteHandler, + OnServerUpdateHandler, + }, + interactions::{components::Component, registry::ComponentRegistry}, + runtime::Runtime, + }, + utils::Bye, +}; + +#[derive(Debug, Error)] +pub enum RuntimeError { + #[error("The application's details could not be fetched")] + ApplicationFetchingFailed, +} + +#[async_trait] +pub trait IntoPrefixes { + async fn get( + &self, + runtime: &Runtime, + server_id: Option, + user_id: Option, + ) -> Vec; +} + +#[async_trait] +impl IntoPrefixes for String { + async fn get( + &self, + _: &Runtime, + _: Option, + _: Option, + ) -> Vec { + vec![self.to_string()] + } +} + +#[async_trait] +impl IntoPrefixes for &str { + async fn get( + &self, + _: &Runtime, + _: Option, + _: Option, + ) -> Vec { + vec![self.to_string()] + } +} + +#[async_trait] +impl IntoPrefixes for [T; N] +where + T: ToString, +{ + async fn get( + &self, + _: &Runtime, + _: Option, + _: Option, + ) -> Vec { + self.iter().map(|s| s.to_string()).collect() + } +} + +#[async_trait] +impl IntoPrefixes for F +where + F: Fn() -> Fut + Send + Sync + 'static, + Fut: Future> + Send, +{ + async fn get( + &self, + _: &Runtime, + _: Option, + _: Option, + ) -> Vec { + (self)().await + } +} + +pub struct Bot { + state: Arc, + prefixes: Arc + Send + Sync>, + intents: Intents, + 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 IntoPrefixes + Send + Sync + 'static) -> Self { + Self { + state: Arc::new(state), + prefixes: Arc::new(prefixes), + intents: Intents::empty(), + commands: Vec::new(), + components: Vec::new(), + on_ready_handlers: Vec::new(), + on_reconnect_handlers: Vec::new(), + on_interaction_handlers: Vec::new(), + on_message_handlers: vec![Arc::new(CommandRunner)], + on_message_edit_handlers: Vec::new(), + on_message_delete_handlers: Vec::new(), + on_server_create_handlers: Vec::new(), + on_server_update_handlers: Vec::new(), + on_server_delete_handlers: Vec::new(), + } + } + + pub fn intent(mut self, intent: Intents) -> Self { + self.intents.insert(intent); + 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 { + self.components.push(Arc::new(component)); + 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 { + self.on_reconnect_handlers.push(Arc::new(handler)); + 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 { + self.on_message_handlers.push(Arc::new(handler)); + self + } + + pub fn on_message_edit(mut self, handler: impl OnMessageEditHandler + 'static) -> Self { + self.on_message_edit_handlers.push(Arc::new(handler)); + 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 { + self.on_server_create_handlers.push(Arc::new(handler)); + 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 { + self.on_server_delete_handlers.push(Arc::new(handler)); + self + } + + pub async fn run(self, token: &str) -> Result<(), RuntimeError> { + let http = Client::new(token.to_string()); + let app = http + .current_user_application() + .await + .inspect_err(|e| log::error!("Could not fetch application data! {e:?}")) + .map_err(|_| RuntimeError::ApplicationFetchingFailed)? + .model() + .await + .inspect_err(|e| log::error!("Could not parse application data! {e:?}")) + .map_err(|_| RuntimeError::ApplicationFetchingFailed)?; + let registry = ComponentRegistry::new(); + + for component in &self.components { + let component_id = component.custom_id(); + + registry.register( + component_id.expect("The component registered by default must have a custom ID"), + component.clone(), + ); + } + + let bot = Arc::new(self); + + let runtime = Arc::new(Runtime { + http, + app, + registry, + state: bot.state.clone(), + prefixes: bot.prefixes.clone(), + commands: bot.commands.clone(), + }); + + let mut gateway = Shard::new(ShardId::new(0, 1), token.to_string(), bot.intents); + + loop { + if let Some(Ok(event)) = gateway.next_event(EventTypeFlags::all()).await { + let task_rt = runtime.clone(); + let task_bot = bot.clone(); + + tokio::spawn(async move { + match event { + Event::Ready(event) => { + for handler in &task_bot.on_ready_handlers { + handler + .on_ready(&event, task_rt.clone()) + .await + .inspect_err( + |e| log::error!("An error occurred while handling on ready for {handler:?}! {e:?}") + ) + .bye(); + } + } + Event::GatewayReconnect => { + for handler in &task_bot.on_reconnect_handlers { + handler + .on_reconnect(task_rt.clone()) + .await + .inspect_err( + |e| log::error!("An error occurred while handling on reconnect for {handler:?}! {e:?}") + ) + .bye(); + } + } + Event::InteractionCreate(interaction) => { + for handler in &task_bot.on_interaction_handlers { + handler + .on_interaction(&interaction, task_rt.clone()) + .await + .inspect_err( + |e| log::error!("An error occurred while handling on interaction for {handler:?}! {e:?}") + ) + .bye(); + } + } + Event::MessageCreate(message) => { + for handler in &task_bot.on_message_handlers { + log::trace!("Running {handler:?}"); + + handler + .on_message(&message, task_rt.clone()) + .await + .inspect_err( + |e| log::error!("An error occurred while handling on message for {handler:?}! {e:?}") + ) + .bye(); + } + } + Event::MessageUpdate(message) => { + for handler in &task_bot.on_message_edit_handlers { + handler + .on_message_edit(&message, task_rt.clone()) + .await + .inspect_err( + |e| log::error!("An error occurred while handling on message edit for {handler:?}! {e:?}") + ) + .bye(); + } + } + Event::MessageDelete(message) => { + for handler in &task_bot.on_message_delete_handlers { + handler + .on_message_delete(&message, task_rt.clone()) + .await + .inspect_err( + |e| log::error!("An error occurred while handling on message delete for {handler:?}! {e:?}") + ) + .bye(); + } + } + Event::GuildCreate(guild) => { + for handler in &task_bot.on_server_create_handlers { + handler + .on_server_create(&guild, task_rt.clone()) + .await + .inspect_err( + |e| log::error!("An error occurred while handling on server create for {handler:?}! {e:?}") + ) + .bye(); + } + } + Event::GuildDelete(guild) => { + for handler in &task_bot.on_server_delete_handlers { + handler + .on_server_delete(&guild, task_rt.clone()) + .await + .inspect_err( + |e| log::error!("An error occurred while handling on server delete for {handler:?}! {e:?}") + ) + .bye(); + } + } + _ => { + log::trace!("Unhandled event {:?} received", event.kind()); + } + } + }); + } + } + } +} diff --git a/src/framework/commands/arguments.rs b/src/framework/commands/arguments.rs index 63414cc..7b43985 100644 --- a/src/framework/commands/arguments.rs +++ b/src/framework/commands/arguments.rs @@ -1,4 +1,7 @@ -use std::{fmt, ops::{Deref, DerefMut}}; +use std::{ + fmt, + ops::{Deref, DerefMut}, +}; use thiserror::Error; @@ -216,7 +219,7 @@ impl fmt::Display for Argument { } else { argument.push('>'); } - + write!(f, "{argument}") } } diff --git a/src/framework/commands/context.rs b/src/framework/commands/context.rs index e2daf8b..b54868d 100644 --- a/src/framework/commands/context.rs +++ b/src/framework/commands/context.rs @@ -1,30 +1,24 @@ use std::sync::Arc; -use twilight_http::Client; -use twilight_model::{gateway::payload::incoming::MessageCreate, oauth::Application}; +use twilight_model::gateway::payload::incoming::MessageCreate; -use crate::framework::{commands::messages::MessageSender, handlers::Handlers, interactions::registry::Registry}; +use crate::framework::{commands::messages::MessageSender, runtime::Runtime}; -pub struct CommandContext<'a> { +pub struct CommandContext<'a, T> { pub event: &'a MessageCreate, - pub handlers: Arc + pub rt: Arc>, + pub prefix: &'a str, } -impl<'a> CommandContext<'a> { - pub fn new( - event: &'a MessageCreate, - handlers: Arc, - ) -> Self { - Self { - event, - handlers - } +impl<'a, T> CommandContext<'a, T> +where + T: Send + Sync + 'static, +{ + pub fn new(event: &'a MessageCreate, rt: Arc>, prefix: &'a str) -> Self { + Self { event, rt, prefix } } - pub fn send(&self) -> MessageSender { - MessageSender::new( - self.event.channel_id, - self.handlers.clone() - ) + pub fn send(&self) -> MessageSender { + MessageSender::new(self.event.channel_id, self.rt.clone()) } } diff --git a/src/framework/commands/messages.rs b/src/framework/commands/messages.rs index 6a3a0f0..dcf286f 100644 --- a/src/framework/commands/messages.rs +++ b/src/framework/commands/messages.rs @@ -19,27 +19,30 @@ use twilight_model::{ use twilight_util::builder::InteractionResponseDataBuilder; use crate::framework::{ - handlers::Handlers, interactions::components::{ActionRow, Component}, + runtime::Runtime, }; -pub struct MessageSender { +pub struct MessageSender { channel_id: Id, - handlers: Arc, + rt: Arc>, content: Option, embeds: Vec, // Row index, component - components: Vec<(u8, Arc)>, + components: Vec<(u8, Arc>)>, } -impl MessageSender { - pub fn new(channel_id: Id, handlers: Arc) -> Self { +impl MessageSender +where + T: Send + Sync + 'static, +{ + pub fn new(channel_id: Id, rt: Arc>) -> Self { Self { channel_id, - handlers, + rt, content: None, components: vec![], embeds: vec![], @@ -58,14 +61,14 @@ impl MessageSender { 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 } pub async fn send(self) -> Result, Error> { - let mut builder = self.handlers.http.create_message(self.channel_id); + let mut builder = self.rt.http.create_message(self.channel_id); if let Some(content) = &self.content { builder = builder.content(content); @@ -92,9 +95,7 @@ impl MessageSender { if let Some(custom_id) = &custom_id { custom_ids.push(custom_id.clone()); - self.handlers - .registry - .register(custom_id, component.clone()); + self.rt.registry.register(custom_id, component.clone()); } current_row.add(custom_id.clone(), component.clone()); @@ -111,7 +112,7 @@ impl MessageSender { if result.is_err() { for custom_id in custom_ids { - self.handlers.registry.unregister(custom_id); + self.rt.registry.unregister(custom_id); } } @@ -119,7 +120,7 @@ impl MessageSender { } } -impl IntoFuture for MessageSender { +impl IntoFuture for MessageSender { type Output = Result, Error>; type IntoFuture = Pin + Send>>; @@ -128,29 +129,32 @@ impl IntoFuture for MessageSender { } } -pub struct OriginalMessageUpdater { +pub struct OriginalMessageUpdater { interaction_id: Id, interaction_token: String, - handlers: 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, +{ pub fn new( interaction_id: Id, interaction_token: String, - handlers: Arc, + rt: Arc>, ) -> Self { Self { interaction_id, interaction_token, - handlers, + rt, content: None, components: vec![], embeds: vec![], @@ -169,7 +173,7 @@ impl OriginalMessageUpdater { 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 @@ -203,9 +207,7 @@ impl OriginalMessageUpdater { if let Some(custom_id) = &custom_id { custom_ids.push(custom_id.clone()); - self.handlers - .registry - .register(custom_id, component.clone()); + self.rt.registry.register(custom_id, component.clone()); } current_row.add(custom_id.clone(), component.clone()); @@ -219,9 +221,9 @@ impl OriginalMessageUpdater { builder = builder.embeds(self.embeds); let result = self - .handlers + .rt .http - .interaction(self.handlers.app.id) + .interaction(self.rt.app.id) .create_response( self.interaction_id, &self.interaction_token, @@ -234,7 +236,7 @@ impl OriginalMessageUpdater { if result.is_err() { for custom_id in custom_ids { - self.handlers.registry.unregister(custom_id); + self.rt.registry.unregister(custom_id); } } @@ -242,7 +244,7 @@ impl OriginalMessageUpdater { } } -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 7d4ebd4..f222a0b 100644 --- a/src/framework/commands/mod.rs +++ b/src/framework/commands/mod.rs @@ -1,3 +1,5 @@ +use std::fmt::Debug; + use async_trait::async_trait; use thiserror::Error; @@ -6,15 +8,22 @@ use crate::framework::commands::{arguments::Argument, context::CommandContext}; pub mod arguments; pub mod context; pub mod messages; +pub mod runner; #[derive(Debug, Error)] pub enum CommandError { #[error("A message was attempted to be sent but it failed")] MessageSendingFailed, + + #[error("A database query failed")] + DatabaseQueryFailed, + + #[error("A database operation failed due to an inconsistent state")] + DatabaseInconsistentState, } #[async_trait] -pub trait Command: Send + Sync { +pub trait Command: Send + Sync + Debug { fn name(&self) -> &'static str; fn aliases(&self) -> &'static [&'static str] { &[] @@ -36,7 +45,7 @@ pub trait Command: Send + Sync { all } - async fn execute(&self, ctx: &CommandContext, args: &str) -> Result<(), CommandError>; + async fn execute(&self, ctx: CommandContext<'_, T>, args: &str) -> Result<(), CommandError>; } pub struct CommandParts<'a> { diff --git a/src/framework/commands/runner.rs b/src/framework/commands/runner.rs new file mode 100644 index 0000000..82a0348 --- /dev/null +++ b/src/framework/commands/runner.rs @@ -0,0 +1,45 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use twilight_model::gateway::payload::incoming::MessageCreate; + +use crate::framework::{ + commands::{context::CommandContext, get_command_parts}, + events::traits::{EventResult, OnMessageHandler}, + runtime::Runtime, +}; + +#[derive(Debug)] +pub struct CommandRunner; + +#[async_trait] +impl OnMessageHandler for CommandRunner { + async fn on_message(&self, event: &MessageCreate, rt: Arc>) -> EventResult { + if event.author.bot || event.author.system.unwrap_or(false) { + return Ok(()); + } + + let prefixes = rt + .get_prefixes(event.guild_id.map(|i| i.get()), Some(event.author.id.get())) + .await; + + for prefix in prefixes { + if let Some(parts) = get_command_parts(&prefix, &event.content) { + let command = rt.commands.iter().find(|cmd| { + cmd.identifiers() + .contains(&parts.name.to_ascii_lowercase().as_str()) + }); + + if let Some(command) = command { + command + .execute(CommandContext::new(event, rt.clone(), &prefix), parts.args) + .await?; + + break; + } + } + } + + Ok(()) + } +} diff --git a/src/framework/events/mod.rs b/src/framework/events/mod.rs new file mode 100644 index 0000000..f6ac8fc --- /dev/null +++ b/src/framework/events/mod.rs @@ -0,0 +1 @@ +pub mod traits; diff --git a/src/framework/events/traits.rs b/src/framework/events/traits.rs new file mode 100644 index 0000000..6154bd7 --- /dev/null +++ b/src/framework/events/traits.rs @@ -0,0 +1,83 @@ +use std::{error::Error, fmt::Debug, sync::Arc}; + +use async_trait::async_trait; +use twilight_model::gateway::payload::incoming::{ + GuildCreate, GuildDelete, GuildUpdate, InteractionCreate, MessageCreate, MessageDelete, + MessageUpdate, Ready, +}; + +use crate::framework::runtime::Runtime; + +pub type EventResult = Result<(), Box>; + +#[async_trait] +pub trait OnReadyHandler: Send + Sync + Debug +where + T: Send + Sync, +{ + async fn on_ready(&self, event: &Ready, rt: Arc>) -> EventResult; +} + +#[async_trait] +pub trait OnReconnectHandler: Send + Sync + Debug +where + T: Send + Sync, +{ + async fn on_reconnect(&self, rt: Arc>) -> EventResult; +} + +#[async_trait] +pub trait OnInteractionHandler: Send + Sync + Debug +where + T: Send + Sync, +{ + async fn on_interaction(&self, event: &InteractionCreate, rt: Arc>) -> EventResult; +} + +#[async_trait] +pub trait OnMessageHandler: Send + Sync + Debug +where + T: Send + Sync, +{ + async fn on_message(&self, event: &MessageCreate, rt: Arc>) -> EventResult; +} + +#[async_trait] +pub trait OnMessageEditHandler: Send + Sync + Debug +where + T: Send + Sync, +{ + async fn on_message_edit(&self, event: &MessageUpdate, rt: Arc>) -> EventResult; +} + +#[async_trait] +pub trait OnMessageDeleteHandler: Send + Sync + Debug +where + T: Send + Sync, +{ + async fn on_message_delete(&self, event: &MessageDelete, rt: Arc>) -> EventResult; +} + +#[async_trait] +pub trait OnServerCreateHandler: Send + Sync + Debug +where + T: Send + Sync, +{ + async fn on_server_create(&self, event: &GuildCreate, rt: Arc>) -> EventResult; +} + +#[async_trait] +pub trait OnServerUpdateHandler: Send + Sync + Debug +where + T: Send + Sync, +{ + async fn on_server_update(&self, event: &GuildUpdate, rt: Arc>) -> EventResult; +} + +#[async_trait] +pub trait OnServerDeleteHandler: Send + Sync + Debug +where + T: Send + Sync, +{ + async fn on_server_delete(&self, event: &GuildDelete, rt: Arc>) -> EventResult; +} diff --git a/src/framework/handlers.rs b/src/framework/handlers.rs deleted file mode 100644 index 00ee5b7..0000000 --- a/src/framework/handlers.rs +++ /dev/null @@ -1,12 +0,0 @@ -use scylla::client::session::Session; -use twilight_http::Client; -use twilight_model::oauth::Application; - -use crate::framework::interactions::registry::Registry; - -pub struct Handlers { - pub http: Client, - pub app: Application, - pub db: Session, - pub registry: Registry, -} diff --git a/src/framework/interactions/components.rs b/src/framework/interactions/components.rs index 138699c..faf0eed 100644 --- a/src/framework/interactions/components.rs +++ b/src/framework/interactions/components.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{fmt::Debug, sync::Arc}; use async_trait::async_trait; use twilight_model::channel::message::{ @@ -8,18 +8,20 @@ use twilight_model::channel::message::{ use crate::framework::interactions::{InteractionError, context::InteractionContext}; -pub struct ActionRow { - components: Vec<(Option, Arc)>, +type ComponentWithID = (Option, Arc>); + +pub struct ActionRow { + components: Vec>, } -impl ActionRow { - pub fn new(components: &[(Option, Arc)]) -> 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)); } @@ -27,6 +29,10 @@ impl ActionRow { self.components.len() } + pub fn is_empty(&self) -> bool { + self.components.is_empty() + } + pub fn build(self) -> TwilightComponent { TwilightComponent::ActionRow(TwilightActionRow { components: self @@ -39,19 +45,19 @@ impl ActionRow { } #[async_trait] -pub trait Component: Send + Sync { +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, { fn custom_id(&self) -> Option { Button::id(self) @@ -73,13 +79,13 @@ 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 { +pub trait Button: Send + Sync + Debug { fn id(&self) -> Option { if self.url().is_none() { Some(String::from("Button")) @@ -97,7 +103,7 @@ pub trait Button: Send + Sync { 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 fbebb06..fa83e8d 100644 --- a/src/framework/interactions/context.rs +++ b/src/framework/interactions/context.rs @@ -1,36 +1,30 @@ use std::sync::Arc; -use twilight_http::Client; use twilight_model::{ gateway::payload::incoming::InteractionCreate, http::interaction::{InteractionResponse, InteractionResponseType}, id::{Id, marker::InteractionMarker}, - oauth::Application, }; use crate::framework::{ - commands::messages::OriginalMessageUpdater, handlers::Handlers, interactions::{registry::Registry, InteractionError} + commands::messages::OriginalMessageUpdater, interactions::InteractionError, runtime::Runtime, }; -pub struct InteractionContext { +pub struct InteractionContext { pub event: InteractionCreate, - pub handlers: Arc, + pub rt: Arc>, + pub prefix: String, } -impl InteractionContext { - pub fn new( - event: InteractionCreate, - handlers: Arc, - ) -> Self { - Self { - event, - handlers - } +impl InteractionContext { + pub fn new(event: InteractionCreate, rt: Arc>, prefix: String) -> Self { + Self { event, rt, prefix } } pub async fn defer(&self) -> Result<(), InteractionError> { - self.handlers.http - .interaction(self.handlers.app.id) + self.rt + .http + .interaction(self.rt.app.id) .create_response( self.event.id, &self.event.token, @@ -46,8 +40,9 @@ impl InteractionContext { } pub async fn think(&self) -> Result<(), InteractionError> { - self.handlers.http - .interaction(self.handlers.app.id) + self.rt + .http + .interaction(self.rt.app.id) .create_response( self.event.id, &self.event.token, @@ -62,27 +57,30 @@ 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(), - handlers: self.handlers.clone() + rt: self.rt.clone(), }) } } -pub struct OriginalMessage { +pub struct OriginalMessage { interaction_id: Id, interaction_token: String, - handlers: Arc + rt: Arc>, } -impl OriginalMessage { - pub fn edit(&self) -> OriginalMessageUpdater { +impl OriginalMessage +where + T: Send + Sync + 'static, +{ + pub fn edit(&self) -> OriginalMessageUpdater { OriginalMessageUpdater::new( self.interaction_id, self.interaction_token.clone(), - self.handlers.clone(), + self.rt.clone(), ) } } diff --git a/src/framework/interactions/mod.rs b/src/framework/interactions/mod.rs index 4977032..ac63d52 100644 --- a/src/framework/interactions/mod.rs +++ b/src/framework/interactions/mod.rs @@ -3,6 +3,7 @@ use thiserror::Error; pub mod components; pub mod context; pub mod registry; +pub mod runner; #[derive(Debug, Error)] pub enum InteractionError { @@ -19,4 +20,7 @@ pub enum InteractionError { #[error("The original message was attempted to be edited, but it failed")] OriginalMessageEditingFailed, + + #[error("No prefixes were returned for the event")] + PrefixesVectorEmpty, } diff --git a/src/framework/interactions/registry.rs b/src/framework/interactions/registry.rs index dd44b5c..abc490a 100644 --- a/src/framework/interactions/registry.rs +++ b/src/framework/interactions/registry.rs @@ -1,28 +1,34 @@ use std::sync::Arc; use scc::HashMap; -use twilight_http::Client; + use twilight_model::{ application::interaction::InteractionData, gateway::payload::incoming::InteractionCreate, - oauth::Application, }; -use crate::framework::{handlers::Handlers, interactions::{context::InteractionContext, InteractionError}}; +use crate::framework::{ + interactions::{InteractionError, context::InteractionContext}, + runtime::Runtime, +}; use super::components::Component; -pub struct Registry { - inner: HashMap>, +#[derive(Default)] +pub struct ComponentRegistry { + inner: HashMap>>, } -impl Registry { +impl ComponentRegistry +where + T: Send + Sync + 'static, +{ pub fn new() -> Self { Self { inner: Default::default(), } } - 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); } @@ -32,15 +38,29 @@ impl Registry { pub async fn execute( &self, - event: InteractionCreate, - handlers: Arc, + event: &InteractionCreate, + rt: Arc>, ) -> Result<(), InteractionError> { if let Some(data) = &event.data { match data { InteractionData::MessageComponent(data) => { if let Some(component) = self.inner.get_async(&data.custom_id).await { + let prefixes = rt + .get_prefixes( + event.guild_id.map(|id| id.get()), + event.author_id().map(|id| id.get()), + ) + .await; + let prefix = prefixes + .first() + .ok_or(InteractionError::PrefixesVectorEmpty)?; + component - .execute(InteractionContext::new(event, handlers)) + .execute(InteractionContext::new( + event.clone(), + rt.clone(), + prefix.to_string(), + )) .await } else { Err(InteractionError::InteractionNotFound) diff --git a/src/framework/interactions/runner.rs b/src/framework/interactions/runner.rs new file mode 100644 index 0000000..b27456e --- /dev/null +++ b/src/framework/interactions/runner.rs @@ -0,0 +1,26 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use twilight_model::gateway::payload::incoming::InteractionCreate; + +use crate::{ + framework::{ + events::traits::{EventResult, OnInteractionHandler}, + runtime::Runtime, + }, + state::State, +}; + +#[derive(Debug)] +pub struct InteractionRunner; + +#[async_trait] +impl OnInteractionHandler for InteractionRunner { + async fn on_interaction( + &self, + event: &InteractionCreate, + rt: Arc>, + ) -> EventResult { + Ok(rt.registry.execute(event, rt.clone()).await?) + } +} diff --git a/src/framework/mod.rs b/src/framework/mod.rs index fdabf55..40c9625 100644 --- a/src/framework/mod.rs +++ b/src/framework/mod.rs @@ -1,4 +1,5 @@ +pub mod bot; pub mod commands; +pub mod events; pub mod interactions; -pub mod utils; -pub mod handlers; +pub mod runtime; diff --git a/src/framework/runtime.rs b/src/framework/runtime.rs new file mode 100644 index 0000000..0571d12 --- /dev/null +++ b/src/framework/runtime.rs @@ -0,0 +1,33 @@ +use std::sync::Arc; + +use twilight_http::Client; +use twilight_model::oauth::Application; + +use crate::framework::{ + bot::IntoPrefixes, commands::Command, interactions::registry::ComponentRegistry, +}; + +pub struct Runtime { + pub http: Client, + pub app: Application, + pub registry: ComponentRegistry, + pub prefixes: Arc + Send + Sync + 'static>, + pub commands: Vec>>, + pub state: Arc, +} + +impl Runtime { + pub async fn get_prefixes(&self, server_id: Option, user_id: Option) -> Vec { + self.prefixes.clone().get(self, server_id, user_id).await + } + + pub fn get_command(&self, name: &str) -> Option>> { + self.commands + .iter() + .find(|cmd| { + cmd.identifiers() + .contains(&name.to_ascii_lowercase().as_str()) + }) + .cloned() + } +} diff --git a/src/framework/utils.rs b/src/framework/utils.rs deleted file mode 100644 index 8bfd7ad..0000000 --- a/src/framework/utils.rs +++ /dev/null @@ -1,33 +0,0 @@ -pub trait EscapeDiscordMarkdown { - fn escape(self) -> String; -} - -impl EscapeDiscordMarkdown for T -where - T: ToString, -{ - fn escape(self) -> String { - let input = self.to_string(); - - let mut escaped = String::with_capacity(input.len()); - - for ch in input.chars() { - match ch { - '\\' | '*' | '_' | '~' | '`' | '>' | '|' | '[' | ']' | '(' | ')' | '#' | '+' - | '-' | '=' | '.' | '!' => { - escaped.push('\\'); - escaped.push(ch); - } - _ => escaped.push(ch), - } - } - - // Now block ping attempts — break mentions - escaped - .replace("@everyone", "@\u{200B}everyone") - .replace("@here", "@\u{200B}here") - .replace("<@", "<@\u{200B}") // user mentions - .replace("<@&", "<@&\u{200B}") // role mentions - .replace("<#", "<#\u{200B}") // channel mentions - } -} diff --git a/src/groups/about/commands.rs b/src/groups/about/commands.rs new file mode 100644 index 0000000..2c9871d --- /dev/null +++ b/src/groups/about/commands.rs @@ -0,0 +1,75 @@ +use async_trait::async_trait; + +use crate::{ + framework::commands::{ + Command, CommandError, + arguments::{Argument, NeedyString, ParseArguments}, + context::CommandContext, + }, + groups::about::{ + components::{ + HelpDonateLinkButton, HelpGoBackButton, HelpSupportLinkButton, HelpWebsiteLinkButton, + }, + embeds::{get_all_command_help_embed, get_command_help_embed, get_command_not_found_embed}, + }, + state::State, +}; + +#[derive(Debug)] +pub struct HelpCommand; + +#[async_trait] +impl Command for HelpCommand { + fn name(&self) -> &'static str { + "help" + } + + fn arguments(&self) -> &'static [Argument] { + &[Argument { + name: "command", + is_optional: true, + is_greedy: false, + }] + } + + fn description(&self) -> &'static str { + "Lists all commands and shows information about them individually." + } + + async fn execute( + &self, + ctx: CommandContext<'_, State>, + args: &str, + ) -> Result<(), CommandError> { + let command_name: Option = ParseArguments::parse(args).unwrap(); + + if let Some(command_name) = command_name { + if let Some(command) = ctx.rt.get_command(&command_name) { + ctx.send() + .embed(get_command_help_embed(&command, &ctx)) + .component(HelpGoBackButton, 1) + .component(HelpSupportLinkButton, 1) + .await + .inspect_err(|e| log::error!("Failed to send message! {e}")) + .map_err(|_| CommandError::MessageSendingFailed)?; + } else { + ctx.send() + .embed(get_command_not_found_embed(ctx.rt, command_name.0)) + .component(HelpGoBackButton, 1) + .component(HelpSupportLinkButton, 1) + .await + .map_err(|_| CommandError::MessageSendingFailed)?; + } + } else { + ctx.send() + .embed(get_all_command_help_embed(ctx.prefix, ctx.rt)) + .component(HelpWebsiteLinkButton, 1) + .component(HelpSupportLinkButton, 1) + .component(HelpDonateLinkButton, 1) + .await + .map_err(|_| CommandError::MessageSendingFailed)?; + } + + Ok(()) + } +} diff --git a/src/groups/about/components.rs b/src/groups/about/components.rs new file mode 100644 index 0000000..bac775c --- /dev/null +++ b/src/groups/about/components.rs @@ -0,0 +1,79 @@ +use async_trait::async_trait; + +use crate::{ + framework::interactions::{InteractionError, components::Button, context::InteractionContext}, + groups::about::embeds::get_all_command_help_embed, + state::State, +}; + +#[derive(Debug)] +pub struct HelpGoBackButton; + +#[async_trait] +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> { + 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)) + .component(HelpWebsiteLinkButton, 1) + .component(HelpSupportLinkButton, 1) + .component(HelpDonateLinkButton, 1) + .await + .inspect_err(|e| log::error!("An error occurred while attempting to edit the original message of an interaction. {e:?}")) + .map_err( + |_| InteractionError::OriginalMessageEditingFailed + )?; + + Ok(()) + } +} + +#[derive(Debug)] +pub struct HelpSupportLinkButton; + +#[async_trait] +impl Button for HelpSupportLinkButton { + fn label(&self) -> Option<&'static str> { + Some("Get help") + } + + fn url(&self) -> Option<&'static str> { + Some("https://discord.gg/9daSsrfTmE") + } +} + +#[derive(Debug)] +pub struct HelpWebsiteLinkButton; + +#[async_trait] +impl Button for HelpWebsiteLinkButton { + fn label(&self) -> Option<&'static str> { + Some("Website") + } + + fn url(&self) -> Option<&'static str> { + Some("https://payphone.lat/") + } +} + +#[derive(Debug)] +pub struct HelpDonateLinkButton; + +#[async_trait] +impl Button for HelpDonateLinkButton { + fn label(&self) -> Option<&'static str> { + Some("Donate") + } + + fn url(&self) -> Option<&'static str> { + Some("https://ko-fi.com/payphonedev") + } +} diff --git a/src/groups/about/embeds.rs b/src/groups/about/embeds.rs new file mode 100644 index 0000000..5f7d02c --- /dev/null +++ b/src/groups/about/embeds.rs @@ -0,0 +1,132 @@ +use std::sync::Arc; + +use twilight_model::channel::message::Embed; +use twilight_util::builder::embed::{ + EmbedAuthorBuilder, EmbedBuilder, EmbedFieldBuilder, EmbedFooterBuilder, ImageSource, +}; + +use crate::{ + constants::{COLOR_RED, COLOR_WHITE}, + discord::{images, sanitizing::SanitizeDiscordMarkdown}, + framework::{ + commands::{Command, context::CommandContext}, + runtime::Runtime, + }, + state::State, + utils::ToTitleCase, +}; + +pub(super) fn get_command_help_embed( + command: &Arc>, + ctx: &CommandContext<'_, State>, +) -> Embed { + let name = format!("{}{}", ctx.prefix, command.name()); + let arguments = command + .arguments() + .iter() + .map(|arg| arg.to_string()) + .collect::>() + .join(" "); + + let usage = if !arguments.is_empty() { + format!("{name} {arguments}") + } else { + name + }; + + EmbedBuilder::new() + .author( + EmbedAuthorBuilder::new(ctx.rt.app.name.clone()) + .icon_url( + ImageSource::url(images::get_avatar_url( + ctx.rt.app.bot.as_ref().unwrap().avatar, + ctx.rt.app.bot.as_ref().unwrap().id.get(), + ctx.rt.app.bot.as_ref().unwrap().discriminator as u64, + )) + .unwrap(), + ) + .build(), + ) + .thumbnail( + ImageSource::url(images::get_avatar_url( + ctx.rt.app.bot.as_ref().unwrap().avatar, + ctx.rt.app.bot.as_ref().unwrap().id.get(), + ctx.rt.app.bot.as_ref().unwrap().discriminator as u64, + )) + .unwrap(), + ) + .color(COLOR_WHITE) + .title(format!("{} Command", command.name().to_title_case())) + .description(command.description()) + .field(EmbedFieldBuilder::new("Usage", format!("`{usage}`")).build()) + .field(EmbedFieldBuilder::new( + "Aliases", + if !command.aliases().is_empty() { + command + .aliases() + .iter() + .map(|alias| format!("`{}{alias}`", ctx.prefix)) + .collect::>() + .join(", ") + } else { + String::from("*This command doesn't have aliases.*") + }, + )) + .footer(EmbedFooterBuilder::new(format!( + "Use {}help to get help about a specific command.", + ctx.prefix + ))) + .build() +} + +pub(super) fn get_all_command_help_embed(prefix: &str, rt: Arc>) -> 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, + rt.app.bot.as_ref().unwrap().id.get(), + rt.app.bot.as_ref().unwrap().discriminator as u64, + )) + .unwrap())) + .title("Pick up the phone and start making friends!") + .description(format!("Here is the list of all available commands. You can see more details about a specific command with `{prefix}help `.")) + .thumbnail(ImageSource::url(images::get_avatar_url( + rt.app.bot.as_ref().unwrap().avatar, + rt.app.bot.as_ref().unwrap().id.get(), + rt.app.bot.as_ref().unwrap().discriminator as u64, + )) + .unwrap()) + .color(COLOR_WHITE) + .field(EmbedFieldBuilder::new("Commands", get_commands_table(rt)).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 { + let mut table = String::new(); + + for command in rt.commands.iter() { + table.push_str(&format!("{:<12}", command.name())); + } + + format!("```\n{table}\n```") +} + +pub(super) fn get_command_not_found_embed(rt: Arc>, name: String) -> Embed { + EmbedBuilder::new() + .color(COLOR_RED) + .author(EmbedAuthorBuilder::new(rt.app.name.clone()).icon_url(ImageSource::url(images::get_avatar_url( + rt.app.bot.as_ref().unwrap().avatar, + rt.app.bot.as_ref().unwrap().id.get(), + rt.app.bot.as_ref().unwrap().discriminator as u64, + )) + .unwrap())) + .title("That's not a command") + .description(format!("`{}` is not a valid command name (nor alias). If you put it with the prefix on, take the prefix off before trying again. For example, if you did `p.help p.call`, do `p.help call` instead.\n\nIf you still can't figure it out, press the button below to go back to the full list. Everything's there!", name.sanitize(true))) + .thumbnail(ImageSource::url(images::get_avatar_url( + rt.app.bot.as_ref().unwrap().avatar, + rt.app.bot.as_ref().unwrap().id.get(), + rt.app.bot.as_ref().unwrap().discriminator as u64, + )) + .unwrap()) + .build() +} diff --git a/src/groups/about/mod.rs b/src/groups/about/mod.rs new file mode 100644 index 0000000..8d675fb --- /dev/null +++ b/src/groups/about/mod.rs @@ -0,0 +1,3 @@ +pub mod commands; +pub mod components; +pub mod embeds; diff --git a/src/groups/calls/commands.rs b/src/groups/calls/commands.rs new file mode 100644 index 0000000..44b2390 --- /dev/null +++ b/src/groups/calls/commands.rs @@ -0,0 +1,204 @@ +use async_trait::async_trait; +use scc::HashSet; +use twilight_model::id::Id; + +use crate::{ + database::models::{Call, Channel}, + framework::commands::{Command, CommandError, context::CommandContext}, + state::State, + utils::Bye, +}; + +#[derive(Default, Debug)] +pub struct CallCommand { + /// A set of channel IDs that are currently searching for a call. This works as a lock for two + /// users not to use p.call at the same time in the same channel, as it could create a race + /// condition. IDs are removed from this set once the DB is properly updated with the channel's + /// state, meaning that not all searching channels will be in this set at all times. + channel_lock: HashSet, +} + +impl CallCommand { + pub fn new() -> Self { + Self { + channel_lock: HashSet::new(), + } + } +} + +#[async_trait] +impl Command for CallCommand { + fn name(&self) -> &'static str { + "call" + } + + fn aliases(&self) -> &'static [&'static str] { + &["c"] + } + + async fn execute( + &self, + ctx: CommandContext<'_, State>, + _args: &str, + ) -> Result<(), CommandError> { + let channel = Channel::get_or_create_by_id( + ctx.event.channel_id.get(), + &ctx.rt.state.db, + &ctx.rt.http, + ) + .await + .inspect_err(|e| { + log::error!("Failed to get or create channel: {e:?}"); + }) + .map_err(|_| CommandError::DatabaseQueryFailed)?; + + if channel.is_searching || self.channel_lock.contains(&ctx.event.channel_id.get()) { + ctx.send() + .content(format!( + "☎️ You are already searching for a call! Use `{}hangup` to hang up the phone.", + ctx.prefix + )) + .await + .map_err(|_| CommandError::MessageSendingFailed)?; + + return Ok(()); + } + + if channel.call_id.is_some() { + ctx.send() + .content(format!("☎️ You are already on a call! Use `{}hangup` to hang up the phone, or `{}skip` to skip to the next call.", ctx.prefix, ctx.prefix)) + .await + .map_err(|_| CommandError::MessageSendingFailed)?; + + return Ok(()); + } + + self.channel_lock + .insert_async(ctx.event.channel_id.get()) + .await + .bye(); + + let call = Call::start(&channel, &ctx.rt.state.db) + .await + .inspect_err(|e| { + log::error!("Failed to start call: {e:?}"); + self.channel_lock.remove(&ctx.event.channel_id.get()); + }) + .map_err(|_| CommandError::DatabaseQueryFailed)?; + + self.channel_lock + .remove_async(&ctx.event.channel_id.get()) + .await; + + if let Some(call) = call { + ctx.send().content("☎️ Call connected. Say hi!").await.bye(); + ctx.rt + .http + .create_message(Id::new(call.other_party_channel_id(channel.id))) + .content("☎️ Call connected. Say hi!") + .await + .bye(); + } else { + ctx.send() + .content("☎️ Searching for a call...") + .await + .map_err(|_| CommandError::MessageSendingFailed)?; + } + + Ok(()) + } +} + +#[derive(Debug)] +pub struct HangupCommand; + +#[async_trait] +impl Command for HangupCommand { + fn name(&self) -> &'static str { + "hangup" + } + + fn aliases(&self) -> &'static [&'static str] { + &["h", "hang"] + } + + async fn execute( + &self, + ctx: CommandContext<'_, State>, + _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)? + else { + ctx.send() + .content(format!( + "❌ You're not on a call! Use `{}call` to start one.", + ctx.prefix + )) + .await + .map_err(|_| CommandError::MessageSendingFailed)?; + + return Ok(()); + }; + + if channel.is_searching { + channel + .cancel_search(&ctx.rt.state.db) + .await + .inspect_err(|e| { + log::error!("Failed to cancel search: {e:?}"); + }) + .map_err(|_| CommandError::DatabaseQueryFailed)?; + + ctx.send() + .content("☎️ The phone was hung up.") + .await + .map_err(|_| CommandError::MessageSendingFailed)?; + + return Ok(()); + } + + if let Some(call_id) = channel.call_id { + 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)? + else { + ctx.send() + .content("❌ Call not found.") + .await + .map_err(|_| CommandError::MessageSendingFailed)?; + + return Err(CommandError::DatabaseInconsistentState); + }; + + call.hangup(&ctx.rt.state.db) + .await + .inspect_err(|e| log::error!("Failed to hang up call: {e:?}")) + .map_err(|_| CommandError::DatabaseQueryFailed)?; + + ctx.send().content("☎️ You hung up the phone.").await.bye(); + + ctx.rt + .http + .create_message(Id::new(call.other_party_channel_id(channel.id))) + .content("☎️ The other party hung up the phone.") + .await + .map_err(|_| CommandError::MessageSendingFailed)?; + } else { + ctx.send() + .content(format!( + "❌ You're not on a call! Use `{}call` to start one.", + ctx.prefix + )) + .await + .map_err(|_| CommandError::MessageSendingFailed)?; + + return Ok(()); + } + + Ok(()) + } +} diff --git a/src/groups/calls/events.rs b/src/groups/calls/events.rs new file mode 100644 index 0000000..9fb054a --- /dev/null +++ b/src/groups/calls/events.rs @@ -0,0 +1,228 @@ +use std::{collections::HashMap, sync::Arc}; + +use async_trait::async_trait; +use linkify::LinkFinder; +use twilight_model::{gateway::payload::incoming::MessageCreate, id::Id}; +use twilight_validate::request::webhook_username; + +use crate::{ + database::models::{Call, Channel}, + discord::{images, sanitizing::SanitizeDiscordMarkdown}, + framework::{ + events::traits::{EventResult, OnMessageHandler}, + runtime::Runtime, + }, + state::State, +}; + +#[derive(Debug)] +pub struct MessageRedirector; + +#[async_trait] +impl OnMessageHandler for MessageRedirector { + async fn on_message(&self, event: &MessageCreate, rt: Arc>) -> EventResult { + if event.author.bot || event.author.system.unwrap_or(false) { + return Ok(()); + } + + let prefixes = rt + .get_prefixes(event.guild_id.map(|i| i.get()), Some(event.author.id.get())) + .await; + + if prefixes.iter().any(|prefix| { + event + .content + .to_ascii_lowercase() + .starts_with(&prefix.to_ascii_lowercase()) + }) { + return Ok(()); + } + + let Some(channel) = Channel::get_by_id(event.channel_id.get(), &rt.state.db) + .await + .inspect_err(|e| { + log::error!( + "Failed to get channel from DB when processing message create event! {e:?}" + ); + })? + else { + return Ok(()); + }; + + let call = if let Some(call_id) = channel.call_id { + if let Some(call) = Call::get_by_id(call_id, &rt.state.db) + .await + .inspect_err(|e| { + log::error!( + "Failed to get call from DB when processing message create event! {e:?}" + ); + })? + { + if !call.is_active() { + return Ok(()); + } + + call + } else { + log::error!( + "Call ID {} in channel {} does not correspond to an active call.", + call_id, + channel.id + ); + return Ok(()); + } + } else { + return Ok(()); + }; + + let Some(opposite_channel) = Channel::get_by_id( + call.other_party_channel_id(event.channel_id.get()), + &rt.state.db, + ) + .await + .inspect_err(|e| { + log::error!( + "Failed to get opposite channel from DB when processing message create event! {e:?}" + ); + })? + else { + return Ok(()); + }; + + let Some((webhook_id, webhook_token)) = opposite_channel + .webhook_id + .zip(opposite_channel.webhook_token) + else { + // Handle DM calls in the future. + return Ok(()); + }; + + let profile = get_display_profile(event); + + rt.http + .execute_webhook(Id::new(webhook_id), &webhook_token) + .allowed_mentions(None) + .username(&profile.name) + .avatar_url(&profile.avatar_url) + .content(&escape_message(event)) + .await + .inspect_err(|e| { + log::error!( + "Failed to send message to webhook for call {} in channel {}: {e:?}", + call.id, + opposite_channel.id + ); + })?; + + Ok(()) + } +} + +struct DisplayProfile { + pub name: String, + pub avatar_url: String, +} + +fn get_display_profile(event: &MessageCreate) -> DisplayProfile { + let member = event.member.clone(); + + let name = if let Some(nick) = &member.clone().and_then(|member| member.nick) { + if webhook_username(nick).is_err() { + "Invalid Nickname".to_string() + } else { + nick.clone() + } + } else if let Some(name) = &event.author.global_name { + if webhook_username(name).is_err() { + "Invalid Global Name".to_string() + } else { + name.clone() + } + } else if webhook_username(&event.author.name).is_err() { + "Invalid Username".to_string() + } else { + event.author.name.clone() + }; + + let avatar_hash = member + .and_then(|inner| inner.avatar) + .or(event.author.avatar); + let avatar_url = images::get_avatar_url( + avatar_hash, + event.author.id.get(), + event.author.discriminator as u64, + ); + + DisplayProfile { name, avatar_url } +} + +fn escape_message(event: &MessageCreate) -> String { + let mut message = event.content.to_string(); + + for mention in &event.mentions { + message = message.replace( + &format!("<@{}>", mention.id), + &format!("**@{}**", mention.name.sanitize(true)), + ); + } + + for mention in &event.mention_roles { + message = message.replace(&format!("<@&{mention}>"), "**@Role**"); + } + + for mention in &event.mention_channels { + message = message.replace( + &format!("<#{}>", mention.id), + &format!("**#{}**", mention.name.sanitize(true)), + ); + } + + let mut finder = LinkFinder::new(); + finder.url_must_have_scheme(false); + + for link in finder.links(&message.clone()) { + let url = link.as_str(); + + message = message.replace(url, &"\\#".repeat(url.len())); + } + + let replacements: HashMap> = [ + ('@', Some('a')), + ('0', Some('o')), + ('1', Some('i')), + ('2', Some('z')), + ('3', Some('e')), + ('4', Some('a')), + ('5', Some('s')), + ('6', Some('b')), + ('7', Some('t')), + ('8', Some('g')), + ('9', Some('q')), + ] + .iter() + .cloned() + .collect(); + + let normalized_unidecoded = unidecode::unidecode(&message) + .to_lowercase() + .split_whitespace() + .collect::>() + .join(" "); + let mut normalized = String::with_capacity(normalized_unidecoded.len()); + + for ch in normalized_unidecoded.chars() { + if let Some(&replacement) = replacements.get(&ch) { + if let Some(replacement) = replacement { + normalized.push(replacement); + } + } else if ch.is_alphanumeric() || ch.is_whitespace() { + normalized.push(ch); + } + + // skip everything else (punctuation not in map, zero-width, etc.) + } + + log::debug!("Escaped message: {normalized}"); + + message.sanitize(false) +} diff --git a/src/groups/calls/mod.rs b/src/groups/calls/mod.rs new file mode 100644 index 0000000..2bddc4f --- /dev/null +++ b/src/groups/calls/mod.rs @@ -0,0 +1,2 @@ +pub mod commands; +pub mod events; diff --git a/src/groups/gateway/events.rs b/src/groups/gateway/events.rs new file mode 100644 index 0000000..462b25e --- /dev/null +++ b/src/groups/gateway/events.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use twilight_model::gateway::payload::incoming::Ready; + +use crate::{ + framework::{ + events::traits::{EventResult, OnReadyHandler, OnReconnectHandler}, + runtime::Runtime, + }, + state::State, +}; + +#[derive(Debug)] +pub struct ReadyHandler; + +#[async_trait] +impl OnReadyHandler for ReadyHandler { + async fn on_ready(&self, event: &Ready, _: Arc>) -> EventResult { + log::info!( + "Ready! Logged in as {}#{:>04}", + event.user.name, + event.user.discriminator + ); + + Ok(()) + } +} + +#[derive(Debug)] +pub struct ReconnectHandler; + +#[async_trait] +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/gateway/mod.rs b/src/groups/gateway/mod.rs new file mode 100644 index 0000000..a9970c2 --- /dev/null +++ b/src/groups/gateway/mod.rs @@ -0,0 +1 @@ +pub mod events; diff --git a/src/groups/mod.rs b/src/groups/mod.rs new file mode 100644 index 0000000..39a5d62 --- /dev/null +++ b/src/groups/mod.rs @@ -0,0 +1,4 @@ +pub mod about; +pub mod calls; +pub mod gateway; +pub mod servers; diff --git a/src/groups/servers/events.rs b/src/groups/servers/events.rs new file mode 100644 index 0000000..3fb423c --- /dev/null +++ b/src/groups/servers/events.rs @@ -0,0 +1,95 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use twilight_model::gateway::payload::incoming::{GuildCreate, GuildDelete, GuildUpdate}; + +use crate::{ + database::models::Server, + framework::{ + events::traits::{ + EventResult, OnServerCreateHandler, OnServerDeleteHandler, OnServerUpdateHandler, + }, + runtime::Runtime, + }, + state::State, +}; + +#[derive(Debug)] +pub struct ServerCreateHandler; + +#[async_trait] +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(_) => { + // If the guild is unavailable, Discord will later send this event again when the guild + // becomes available. + return Ok(()); + } + }; + + log::debug!("The server \"{}\" was created", server.name); + + server.insert(&rt.state.db).await.inspect_err(|e| { + log::error!( + "Could not insert the recently joined server into the DB!\n\n{server:?}\n\n{e:?}\n" + ) + })?; + + // TODO: Send hello message to the server's default channel on server join. + + Ok(()) + } +} + +#[derive(Debug)] +pub struct ServerUpdateHandler; + +#[async_trait] +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| { + log::error!("Could not get server from DB when it was updated! {e:?}") + })? { + Some(server) => server, + None => { + log::debug!("Server was updated but it was not in DB"); + return Ok(()); + } + }; + + if server.name != event.name { + server.name = event.name.clone(); + + server + .upsert(&rt.state.db) + .await + .inspect_err(|e| log::error!("Could not update server's name. {e:?}"))?; + } + + Ok(()) + } +} + +#[derive(Debug)] +pub struct ServerDeleteHandler; + +#[async_trait] +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)) + .inspect_err(|e| { + log::error!( + "Could not delete server with ID {} from DB when Payphone left! {e:?}", + event.id + ) + })?; + + Ok(()) + } +} diff --git a/src/groups/servers/mod.rs b/src/groups/servers/mod.rs new file mode 100644 index 0000000..a9970c2 --- /dev/null +++ b/src/groups/servers/mod.rs @@ -0,0 +1 @@ +pub mod events; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..26f5e03 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +pub mod framework; +pub mod utils; +pub mod state; diff --git a/src/logging.rs b/src/logging.rs index 13600b5..a432b89 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -2,13 +2,11 @@ use colored::Colorize; use log::{Level, LevelFilter}; use time::{OffsetDateTime, macros::format_description}; -pub struct Logger { - level: Level, -} +pub struct Logger; impl log::Log for Logger { fn enabled(&self, metadata: &log::Metadata) -> bool { - metadata.level() <= self.level + metadata.target() == "payphone" || metadata.target().starts_with("payphone::") } fn log(&self, record: &log::Record) { @@ -25,11 +23,9 @@ impl log::Log for Logger { fn flush(&self) {} } -static LOGGER: Logger = Logger { level: Level::Info }; - -pub fn init() { - log::set_logger(&LOGGER) - .map(|()| log::set_max_level(LevelFilter::Trace)) +pub fn init(level: LevelFilter) { + log::set_logger(&Logger) + .map(|()| log::set_max_level(level)) .unwrap(); } diff --git a/src/main.rs b/src/main.rs index aa5222b..92db333 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,68 +1,38 @@ -use std::sync::Arc; - use clap::Parser; -use twilight_gateway::{ConfigBuilder, EventTypeFlags, Intents, Shard, ShardId, StreamExt}; -use twilight_http::Client; -use twilight_model::gateway::{ - payload::outgoing::update_presence::UpdatePresencePayload, - presence::{ActivityType, MinimalActivity, Status}, -}; +use twilight_gateway::Intents; use crate::{ - cli::Args, commands::register_default_components, discord::events, - framework::{handlers::Handlers, interactions::registry::Registry}, + cli::Args, + framework::bot::Bot, + groups::{ + about::{commands::HelpCommand, components::HelpGoBackButton}, + calls::{ + commands::{CallCommand, HangupCommand}, + events::MessageRedirector, + }, + gateway::events::{ReadyHandler, ReconnectHandler}, + servers::events::{ServerCreateHandler, ServerDeleteHandler, ServerUpdateHandler}, + }, + state::State, }; mod cli; -mod commands; mod constants; mod database; mod discord; mod framework; +mod groups; mod logging; +mod state; mod utils; #[tokio::main] -async fn main() { +pub async fn main() { dotenvy::dotenv().ok(); let args = Args::parse(); - logging::init(); - - let intents = Intents::GUILD_MESSAGES | Intents::MESSAGE_CONTENT; - let config = ConfigBuilder::new(args.token.clone(), intents) - .presence( - UpdatePresencePayload::new( - vec![ - MinimalActivity { - kind: ActivityType::Listening, - name: "Hello Payphone!".into(), - url: None, - } - .into(), - ], - false, - None, - Status::Online, - ) - .expect("Could not set presence!"), - ) - .build(); - let mut shard = Shard::with_config(ShardId::new(0, 1), config); - - let http = Client::new(args.token.clone()); - let registry = Registry::new(); - let app = - http.current_user_application() - .await - .expect("Could not get information about the current application") - .model() - .await - .expect("Could not get model of the information of the current application") - ; - - register_default_components(®istry); + logging::init(args.logging.into()); log::info!("Welcome to Payphone, Nyeki!"); log::info!( @@ -70,25 +40,28 @@ async fn main() { env!("CARGO_PKG_VERSION") ); - let db = database::init("127.0.0.1:9042") + let db = database::init(args.database_url) .await .expect("Could not connect to the database!"); - let handlers = Arc::new(Handlers { - app, - db, - http, - registry - }); + log::info!("Database connection established successfully!"); - while let Some(event) = shard.next_event(EventTypeFlags::all()).await { - if let Ok(event) = event { - tokio::spawn(events::handle_event( - event, - handlers.clone(), - )); - } else { - log::error!("Could not receive the next event."); - } - } + let state = State { db }; + + let bot = Bot::new(state, ["p."]) + .intent(Intents::GUILDS) + .intent(Intents::GUILD_MESSAGES) + .intent(Intents::MESSAGE_CONTENT) + .command(HelpCommand) + .command(CallCommand::new()) + .command(HangupCommand) + .component(HelpGoBackButton) + .on_ready(ReadyHandler) + .on_reconnect(ReconnectHandler) + .on_message(MessageRedirector) + .on_server_create(ServerCreateHandler) + .on_server_update(ServerUpdateHandler) + .on_server_delete(ServerDeleteHandler); + + bot.run(&args.token).await.expect("Could not run bot!"); } diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..99b77ba --- /dev/null +++ b/src/state.rs @@ -0,0 +1,5 @@ +use scylla::client::session::Session; + +pub struct State { + pub db: Session, +} diff --git a/src/utils.rs b/src/utils.rs index a0d8e6a..0b7d7e9 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,3 +1,6 @@ +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha20Rng; + pub trait ToTitleCase { fn to_title_case(&self) -> String; } @@ -6,6 +9,10 @@ impl ToTitleCase for T where T: ToString, { + /// Removes duplicated whitespaces and capitalizes the first letter of each word. + /// + /// Returns: + /// [`String`] - The title-cased version of the string. fn to_title_case(&self) -> String { self.to_string() .split_whitespace() @@ -20,3 +27,21 @@ where .join(" ") } } + +/// Returns a random `u64` generated using a cryptographically secure random number generator. +/// +/// Returns: +/// [`u64`] - A randomly generated 64-bit unsigned integer. +pub fn get_random_u64() -> u64 { + let mut rng = ChaCha20Rng::from_os_rng(); + + rng.random() +} + +pub trait Bye: Sized { + /// Whatever the value is, it consumes it and returns a unit value. Used to remove warnings + /// about unused results. + fn bye(self) {} +} + +impl Bye for T {}