diff --git a/Cargo.lock b/Cargo.lock index dced31a..ff1e61d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1437,7 +1437,7 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "payphone" -version = "4.2.21" +version = "4.2.24" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 6c47dba..55b6429 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "payphone" -version = "4.2.21" +version = "4.2.24" edition = "2024" default-run = "payphone" @@ -33,7 +33,7 @@ twilight-cache-inmemory = { version = "0.16.0", features = ["permission-calculat 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", "permission-calculator"] } +twilight-util = { version = "0.16.0", features = ["builder", "permission-calculator", "snowflake"] } twilight-validate = "0.16.0" unicode-segmentation = "1.12.0" unidecode = "0.3.0" diff --git a/src/database/models.rs b/src/database/models.rs index 95916e8..333b535 100644 --- a/src/database/models.rs +++ b/src/database/models.rs @@ -21,7 +21,7 @@ use twilight_model::{ use crate::{ constants::DEFAULT_PREFIX, - groups::calls::DisplayProfile, + groups::calls::{DisplayProfile, masks::get_random_mask}, utils::{get_random_u64, i64_to_u64, u64_to_i64}, }; @@ -47,6 +47,9 @@ pub enum DatabaseError { #[error("The resulting row could not be deserialized into an object")] DeserializationFailed, + + #[error("An unknown error occurred")] + Unknown(&'static str), } trait Object<'a> { @@ -1744,3 +1747,131 @@ impl UserIncident { incident.insert(session).await } } + +#[derive(Debug, Clone)] +pub struct Participant { + pub call_id: u64, + pub user_id: u64, + pub channel_id: u64, + + pub user_name: String, + pub user_avatar_url: String, + pub user_mask: String, + + pub is_masked: bool, +} + +impl<'a> Object<'a> for Participant { + type Row = (i64, i64, i64, String, String, String, bool); + + fn from_row(row: Self::Row) -> Self { + Self { + call_id: i64_to_u64(row.0), + user_id: i64_to_u64(row.1), + channel_id: i64_to_u64(row.2), + user_name: row.3, + user_avatar_url: row.4, + user_mask: row.5, + is_masked: row.6, + } + } + + fn to_row(value: Self) -> Self::Row { + ( + u64_to_i64(value.call_id), + u64_to_i64(value.user_id), + u64_to_i64(value.channel_id), + value.user_name, + value.user_avatar_url, + value.user_mask, + value.is_masked, + ) + } +} + +impl Participant { + pub async fn get_for_call(call_id: u64, session: &Session) -> Result, DatabaseError> { + let call_id = u64_to_i64(call_id); + + let rows = session + .query_unpaged( + r#" + SELECT * FROM payphone.participants + WHERE call_id = ?; + "#, + (call_id,), + ) + .await + .map_err(DatabaseError::QueryError)?; + + Ok(rows + .into_rows_result() + .map_err(|_| DatabaseError::ResultNotRows)? + .rows() + .map_err(|_| DatabaseError::ResultNotRows)? + .collect::, _>>() + .map_err(|_| DatabaseError::DeserializationFailed)? + .into_iter() + .map(Self::from_row) + .collect::>()) + } + + pub async fn create( + user_id: u64, + channel_id: u64, + call_id: u64, + profile: DisplayProfile, + is_masked: bool, + session: &Session, + ) -> Result<(), DatabaseError> { + let masks_in_use = Self::get_for_call(call_id, session) + .await? + .iter() + .filter_map(|p| { + if p.channel_id == channel_id { + Some(p.user_mask.clone()) + } else { + None + } + }) + .collect::>(); + + let (mask, _) = get_random_mask(&masks_in_use).ok_or(DatabaseError::Unknown( + "No remaining masks available for the channel", + ))?; + + let participant = Participant { + call_id, + user_id, + channel_id, + user_name: profile.name, + user_avatar_url: profile.avatar_url, + user_mask: mask, + is_masked, + }; + + participant.insert(session).await + } + + pub async fn insert(&self, session: &Session) -> Result<(), DatabaseError> { + session + .query_unpaged( + r#" + INSERT INTO payphone.participants ( + call_id, + user_id, + channel_id, + user_name, + user_avatar_url, + user_mask, + is_masked + ) IF NOT EXISTS VALUES (?, ?, ?, ?, ?, ?, ?); + "#, + Self::to_row(self.clone()), + ) + .await + .map_err(DatabaseError::QueryError)?; + + Ok(()) + } +} diff --git a/src/groups/calls/commands.rs b/src/groups/calls/commands.rs index f3b6a91..2925de9 100644 --- a/src/groups/calls/commands.rs +++ b/src/groups/calls/commands.rs @@ -14,7 +14,7 @@ use crate::{ }, groups::calls::{ get_display_profile, - permissions::NotBannedPermission, + permissions::{NonNewAccountPermission, NotBannedPermission}, strings::{ get_string_call_answered, get_string_call_hangup_other_party, get_string_call_hangup_this_party, get_string_call_no_answer, @@ -53,7 +53,10 @@ impl Command for CallCommand { } fn permissions(&self) -> Vec + Send + Sync + 'static>> { - vec![Box::new(NotBannedPermission)] + vec![ + Box::new(NotBannedPermission), + Box::new(NonNewAccountPermission), + ] } async fn execute( @@ -146,7 +149,10 @@ impl Command for SkipCommand { } fn permissions(&self) -> Vec + Send + Sync + 'static>> { - vec![Box::new(NotBannedPermission)] + vec![ + Box::new(NotBannedPermission), + Box::new(NonNewAccountPermission), + ] } async fn execute( @@ -254,7 +260,10 @@ impl Command for HangupCommand { } fn permissions(&self) -> Vec + Send + Sync + 'static>> { - vec![Box::new(NotBannedPermission)] + vec![ + Box::new(NotBannedPermission), + Box::new(NonNewAccountPermission), + ] } async fn execute( @@ -354,7 +363,10 @@ impl Command for FriendRequestCommand { } fn permissions(&self) -> Vec + Send + Sync + 'static>> { - vec![Box::new(NotBannedPermission)] + vec![ + Box::new(NotBannedPermission), + Box::new(NonNewAccountPermission), + ] } async fn execute( @@ -494,7 +506,6 @@ impl Command for MaskCommand { }; if let Some(call_id) = channel.call_id { - } else { ctx .send() diff --git a/src/groups/calls/events.rs b/src/groups/calls/events.rs index 8b17406..f1c0897 100644 --- a/src/groups/calls/events.rs +++ b/src/groups/calls/events.rs @@ -2,12 +2,16 @@ use std::{error::Error, sync::Arc}; use async_trait::async_trait; use itertools::Itertools; +use time::{Duration, UtcDateTime}; use twilight_http::{api_error::ApiError, error::ErrorType}; use twilight_model::{ gateway::payload::incoming::{MessageCreate, MessageDelete, MessageUpdate}, id::Id, }; -use twilight_util::builder::embed::{EmbedAuthorBuilder, EmbedBuilder, ImageSource}; +use twilight_util::{ + builder::embed::{EmbedAuthorBuilder, EmbedBuilder, ImageSource}, + snowflake::Snowflake, +}; use crate::{ constants::COLOR_WHITE, @@ -15,14 +19,18 @@ use crate::{ discord::sanitizing::SanitizeDiscordMarkdown, extras::Extras, framework::{ - commands::extras::ExtrasGetter, events::traits::{ + commands::{extras::ExtrasGetter, permissions::Permission}, + events::traits::{ EventResult, OnMessageDeleteHandler, OnMessageEditHandler, OnMessageHandler, - }, interactions::components::ActionRow, runtime::Runtime + }, + interactions::components::ActionRow, + runtime::Runtime, }, groups::calls::{ components::{CodeOfConductButton, NeedHelpButton}, embeds, get_display_profile, - moderation::{escape_message, Action, Rule}, + moderation::{Action, Rule, escape_message}, + permissions::NonNewAccountPermission, }, state::State, utils::{ellipse, i64_to_u64}, @@ -96,6 +104,12 @@ impl OnMessageHandler for CallMessageHandler { return Ok(()); } + if UtcDateTime::from_unix_timestamp(event.author.id.timestamp() / 1000).unwrap() + > UtcDateTime::now() - Duration::days(30) + { + return Ok(()); + } + let prefixes = rt .get_prefixes(event.guild_id.map(|i| i.get()), Some(event.author.id.get())) .await; @@ -239,7 +253,11 @@ impl OnMessageHandler for CallMessageHandler { embeds.push( EmbedBuilder::new() .author( - EmbedAuthorBuilder::new(format!("Replying to {}", replying_to.data.author_name)).icon_url( + EmbedAuthorBuilder::new(format!( + "Replying to {}", + replying_to.data.author_name + )) + .icon_url( ImageSource::url(replying_to.data.author_avatar_url).unwrap(), ), ) @@ -323,6 +341,10 @@ impl OnMessageEditHandler for CallMessageEditHandler { event: &MessageUpdate, rt: Arc>, ) -> EventResult { + if event.edited_timestamp.is_none() { + return Ok(()); + } + log::trace!( "Edited message was '{}' sent by {} (bot? {:?})", event.content, diff --git a/src/groups/calls/lists/casual-insults-and-teasing.txt b/src/groups/calls/lists/casual-insults-and-teasing.txt index d4d664f..f906d1d 100644 --- a/src/groups/calls/lists/casual-insults-and-teasing.txt +++ b/src/groups/calls/lists/casual-insults-and-teasing.txt @@ -1,7 +1,5 @@ scum idiot -stupid -dumb moron fool stfu diff --git a/src/groups/calls/masks/dev/cats.json b/src/groups/calls/masks/dev/cats.json new file mode 100644 index 0000000..9cc2193 --- /dev/null +++ b/src/groups/calls/masks/dev/cats.json @@ -0,0 +1,7 @@ +{ + "dinosaur": 1278255435652010014, + "shleepy cat": 1278255475066146816, + "cozy cat": 1278255420124696663, + "coquette cat": 1278255408254816300, + "panic cat": 1278255451993145345 +} \ No newline at end of file diff --git a/src/groups/calls/masks/dev/common.json b/src/groups/calls/masks/dev/common.json new file mode 100644 index 0000000..96cb402 --- /dev/null +++ b/src/groups/calls/masks/dev/common.json @@ -0,0 +1,52 @@ +{ + "ferret": 1278176917123760139, + "octopus": 1278176904410697880, + "cat": 1278176891290783824, + "monkey": 1278176881195094049, + "panda": 1278176868767629455, + "sheep": 1278176856910073946, + "horse": 1278176842116894760, + "bee": 1278176823846506577, + "wild boar": 1278176806498996235, + "duck": 1278176794591232102, + "chicken": 1278176781790216233, + "elephant": 1278176771413512213, + "cow": 1278176761565413483, + "deer": 1278176744691597312, + "turkey": 1278176730619576321, + "dog": 1278176719932751903, + "beaver": 1278176709530882091, + "camel": 1278176699921465394, + "koala": 1278176687493746709, + "hippo": 1278176675846295603, + "ostrich": 1278176661317095494, + "rhino": 1278176650340864000, + "squirrel": 1278176637627924570, + "chamaleon": 1278176625392877648, + "frog": 1278176614512857088, + "skunk": 1278176604509442079, + "walrus": 1278176554723311678, + "hedgehog": 1278176541750067252, + "pig": 1278176531415564332, + "hen": 1278176518643646485, + "turtle": 1278176507428343891, + "mouse": 1278175957047578696, + "rabbit": 1278175944007487489, + "goat": 1278175933915856938, + "lion": 1278175922964664382, + "wolf": 1278175902047535165, + "snake": 1278175869331968061, + "llama": 1278175850008674325, + "crab": 1278175835634925620, + "owl": 1278175822947029152, + "bat": 1278175813572886550, + "zebra": 1278175799962505216, + "fox": 1278175788818104474, + "gorilla": 1278175775891259456, + "sloth": 1278175765661356175, + "puffer fish": 1278175753497743360, + "bear": 1278175741569142867, + "giraffe": 1278175732601983006, + "penguin": 1278175721956573236, + "parrot": 1278175706798489642 +} diff --git a/src/groups/calls/masks/mod.rs b/src/groups/calls/masks/mod.rs new file mode 100644 index 0000000..a81f4da --- /dev/null +++ b/src/groups/calls/masks/mod.rs @@ -0,0 +1,32 @@ +use std::collections::HashMap; + +use once_cell::sync::Lazy; +use rand::{SeedableRng, seq::IndexedRandom}; +use rand_chacha::ChaCha20Rng; + +static MASKS_COMMON: Lazy> = Lazy::new(|| { + let (file_name, json) = if cfg!(debug_assertions) { + ("dev/common.json", include_str!("dev/common.json")) + } else { + ("prod/common.json", include_str!("prod/common.json")) + }; + + let masks: HashMap = + serde_json::from_str(json).expect(&format!("Failed to parse masks/{file_name} masks")); + + assert_ne!(masks.len(), 0, "No masks found in masks/{file_name}"); + + masks +}); + +pub fn get_random_mask(not_in: &[String]) -> Option<(String, u64)> { + let mask_names = MASKS_COMMON + .iter() + .filter(|(name, _)| !not_in.contains(name)) + .map(|(name, value)| (name.clone(), *value)) + .collect::>(); + + let mut rng = ChaCha20Rng::from_os_rng(); + + mask_names.choose(&mut rng).cloned() +} diff --git a/src/groups/calls/masks/prod/cats.json b/src/groups/calls/masks/prod/cats.json new file mode 100644 index 0000000..a8d7eeb --- /dev/null +++ b/src/groups/calls/masks/prod/cats.json @@ -0,0 +1,7 @@ +{ + "dinosaur": 1278254725833297965, + "shleepy cat": 1278254779767984181, + "cozy cat": 1278254704324771893, + "coquette cat": 1278254684200636428, + "panic cat": 1278254760700674098 +} \ No newline at end of file diff --git a/src/groups/calls/masks/prod/common.json b/src/groups/calls/masks/prod/common.json new file mode 100644 index 0000000..537639d --- /dev/null +++ b/src/groups/calls/masks/prod/common.json @@ -0,0 +1,52 @@ +{ + "ferret": 1278173356423909448, + "octopus": 1278173331421397072, + "cat": 1278173317034934282, + "monkey": 1278173297456185344, + "panda": 1278173272390762538, + "sheep": 1278173251914432563, + "horse": 1278173237582237708, + "bee": 1278173216652787826, + "wild boar": 1278173196658413649, + "duck": 1278173181483679918, + "chicken": 1278173167218589726, + "elephant": 1278173150063886346, + "cow": 1278173125024153733, + "deer": 1278173110105014406, + "turkey": 1278173091796877363, + "dog": 1278173070846201856, + "beaver": 1278173051485425705, + "camel": 1278173035018584086, + "koala": 1278173021345021952, + "hippo": 1278173007419932804, + "ostrich": 1278172994669248523, + "rhino": 1278172980848885832, + "squirrel": 1278172965590274088, + "chamaleon": 1278172953305022496, + "frog": 1278172937043710094, + "skunk": 1278172923495976981, + "walrus": 1278172912368619603, + "hedgehog": 1278172899911405678, + "pig": 1278172885239861338, + "hen": 1278172871302184994, + "turtle": 1278172858199314455, + "mouse": 1278172846442414081, + "rabbit": 1278172831921999945, + "goat": 1278172815421472808, + "lion": 1278172803534684235, + "wolf": 1278172791052570687, + "snake": 1278172776259260547, + "llama": 1278172764913799168, + "crab": 1278172752322232330, + "owl": 1278172738309193749, + "bat": 1278172726426865724, + "zebra": 1278172715114565753, + "fox": 1278172702955274292, + "gorilla": 1278172687797194824, + "sloth": 1278172676837343242, + "puffer fish": 1278172665093558294, + "bear": 1278172653076746301, + "giraffe": 1278172640296702073, + "penguin": 1278172627629899808, + "parrot": 1278172600702337074 +} \ No newline at end of file diff --git a/src/groups/calls/mod.rs b/src/groups/calls/mod.rs index a5c4579..3c81fd7 100644 --- a/src/groups/calls/mod.rs +++ b/src/groups/calls/mod.rs @@ -9,6 +9,7 @@ pub mod commands; pub(super) mod components; pub(super) mod embeds; pub mod events; +pub mod masks; pub(super) mod moderation; pub(super) mod permissions; pub(super) mod strings; @@ -34,7 +35,10 @@ fn make_name_valid(name: String) -> 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.map(make_name_valid)) { + let name = if let Some(nick) = &member + .clone() + .and_then(|member| member.nick.map(make_name_valid)) + { if webhook_username(nick).is_err() { "Invalid Nickname".to_string() } else { diff --git a/src/groups/calls/permissions.rs b/src/groups/calls/permissions.rs index 485c539..8f313e3 100644 --- a/src/groups/calls/permissions.rs +++ b/src/groups/calls/permissions.rs @@ -1,4 +1,6 @@ use async_trait::async_trait; +use time::{Duration, UtcDateTime}; +use twilight_util::snowflake::{self, Snowflake}; use crate::{ database::models::UserIncident, @@ -41,3 +43,32 @@ impl Permission for NotBannedPermission { Ok(()) } } + +#[derive(Debug)] +pub struct NonNewAccountPermission; + +#[async_trait] +impl Permission for NonNewAccountPermission { + async fn check(&self, ctx: &CommandContext) -> Result { + Ok( + UtcDateTime::from_unix_timestamp(ctx.event.author.id.timestamp() / 1000).unwrap() + < UtcDateTime::now() - Duration::days(30), + ) + } + + async fn on_handle_denied( + &self, + ctx: &CommandContext, + ) -> Result<(), CommandError> { + ctx.send() + .content("❌ Your account must be at least 30 days old to use Payphone.") + .component(CodeOfConductButton, 1) + .component(NeedHelpButton, 1) + .await + .inspect_err(|e| { + log::error!("Failed to send message: {e}"); + })?; + + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index 5836b50..c922709 100644 --- a/src/main.rs +++ b/src/main.rs @@ -137,7 +137,7 @@ use crate::{ groups::{ about::{commands::HelpCommand, components::HelpGoBackButton}, calls::{ - commands::{CallCommand, FriendRequestCommand, HangupCommand, SkipCommand}, + commands::{CallCommand, FriendRequestCommand, HangupCommand, MaskCommand, SkipCommand}, events::{CallMessageDeleteHandler, CallMessageEditHandler, CallMessageHandler}, }, gateway::events::{ReadyHandler, ReconnectHandler}, @@ -289,8 +289,9 @@ async fn start( .command(SkipCommand) .command(HangupCommand) .command(FriendRequestCommand) - .command(PrefixCommand) + .command(MaskCommand) .command(LeaderboardCommand) + .command(PrefixCommand) .command(ModeratorCommand) .command(IncidentsCommand) .command(StatsCommand)