diff --git a/Cargo.lock b/Cargo.lock index a252452..429dea9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1225,6 +1225,7 @@ dependencies = [ "colored", "dotenvy", "include_dir", + "itertools", "linkify", "log", "notify", diff --git a/Cargo.toml b/Cargo.toml index abaf6f0..77e4610 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ clap = { version = "4.5.42", features = ["derive", "env", "string"] } colored = "3.0.0" dotenvy = "0.15.7" include_dir = "0.7.4" +itertools = "0.14.0" linkify = "0.10.0" log = { version = "0.4.27", features = ["serde"] } notify = "8.2.0" diff --git a/src/constants.rs b/src/constants.rs index 2e7035f..55ff8ca 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -8,3 +8,6 @@ pub const COLOR_WHITE: u32 = 0xE2D9D0; /// A red color used for error messages and moderator embeds. pub const COLOR_RED: u32 = 0xFF8787; + +/// A yellow color used for mask notifications and warnings. +pub const COLOR_YELLOW: u32 = 0xFFC868; diff --git a/src/database/models.rs b/src/database/models.rs index 03c8b22..8864417 100644 --- a/src/database/models.rs +++ b/src/database/models.rs @@ -9,7 +9,7 @@ use scylla::{ }, }; use thiserror::Error; -use time::{OffsetDateTime, UtcDateTime}; +use time::{Duration, OffsetDateTime, UtcDateTime, UtcOffset}; use twilight_http::Client; use twilight_model::{ channel::Message as TwilightMessage, @@ -134,8 +134,13 @@ impl<'a> Object<'a> for User { } } +pub struct NewUserIncident { + pub incident: UserIncident, + pub counting_incidents: usize, + pub is_first_incident: bool, +} + impl User { - #[allow(dead_code)] pub async fn get_by_id(id: u64, session: &Session) -> Result, DatabaseError> { let id = u64_to_i64(id); @@ -175,6 +180,77 @@ impl User { }, } } + + pub async fn insert(&self, session: &Session) -> Result<(), DatabaseError> { + session + .query_unpaged( + r#" + INSERT INTO payphone.users ( + user_id, + name, + email, + is_masked, + is_moderator, + joined_at, + last_seen_at, + supporter_since, + supporter_until + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); + "#, + Self::to_row(self.clone()), + ) + .await + .map_err(DatabaseError::QueryError)?; + + Ok(()) + } + + pub async fn incident( + &self, + reason: String, + message: MessageData, + created_by_id: Option, + session: &Session, + ) -> Result { + let incidents = UserIncident::get_for_user(self.id, session).await?; + + let counting_incidents = incidents + .iter() + .filter(|incident| incident.created_at + Duration::weeks(12) > UtcDateTime::now()) + .count(); + + let incident_type = if counting_incidents != 0 { + UserIncidentType::Ban + } else { + UserIncidentType::Warning + }; + + let duration = match counting_incidents { + 0 => None, // Warning + 1 => Some(Duration::days(1)), // First ban + _ => None, // Permanent ban + }; + + let incident = UserIncident { + user_id: self.id, + incident_id: get_random_u64(), + report_id: None, + r#type: incident_type, + created_at: UtcDateTime::now(), + expires_at: duration.map(|d| UtcDateTime::now() + d), + message, + reason, + created_by_id, + duration: duration.map(|_| "1 day".into()), + }; + incident.insert(session).await?; + + Ok(NewUserIncident { + incident, + counting_incidents, + is_first_incident: incidents.len() == 0, + }) + } } impl From for User { @@ -1008,3 +1084,153 @@ impl Message { Ok(()) } } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UserIncidentType { + Warning, + Ban, +} + +#[derive(Debug, Clone)] +pub struct UserIncident { + pub user_id: u64, + pub incident_id: u64, + pub report_id: Option, + + pub r#type: UserIncidentType, + + pub message: MessageData, + + pub reason: String, + + pub created_by_id: Option, + + pub created_at: UtcDateTime, + pub expires_at: Option, + pub duration: Option, +} + +impl<'a> Object<'a> for UserIncident { + type Row = ( + i64, + i64, + Option, + String, + MessageData, + String, + Option, + OffsetDateTime, + Option, + Option, + ); + + fn from_row(row: Self::Row) -> Self { + Self { + user_id: i64_to_u64(row.0), + incident_id: i64_to_u64(row.1), + report_id: row.2.map(i64_to_u64), + r#type: match row.3.as_str() { + "warning" => UserIncidentType::Warning, + "ban" => UserIncidentType::Ban, + _ => unreachable!(), + }, + message: row.4, + reason: row.5, + created_by_id: row.6.map(i64_to_u64), + created_at: row.7.to_utc(), + expires_at: row.8.map(|i| i.to_utc()), + duration: row.9, + } + } + + fn to_row(value: Self) -> Self::Row { + ( + u64_to_i64(value.user_id), + u64_to_i64(value.incident_id), + value.report_id.map(u64_to_i64), + match value.r#type { + UserIncidentType::Warning => "warning".into(), + UserIncidentType::Ban => "ban".into(), + }, + value.message, + value.reason, + value.created_by_id.map(u64_to_i64), + value.created_at.to_offset(UtcOffset::UTC), + value.expires_at.map(|i| i.to_offset(UtcOffset::UTC)), + value.duration, + ) + } +} + +impl UserIncident { + pub fn is_active(&self) -> bool { + self.expires_at + .map(|e| e > UtcDateTime::now()) + .unwrap_or(true) + && self.r#type == UserIncidentType::Ban + } + + pub async fn get_for_user(user_id: u64, session: &Session) -> Result, DatabaseError> { + let user_id = u64_to_i64(user_id); + + let result = session + .query_unpaged( + r#" + SELECT + user_id, + incident_id, + report_id, + type, + message, + reason, + created_by_id, + created_at, + expires_at, + duration + FROM + payphone.user_incidents + WHERE + user_id = ?; + "#, + (user_id,), + ) + .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::>()) + } + + pub async fn insert(&self, session: &Session) -> Result<(), DatabaseError> { + session + .query_unpaged( + r#" + INSERT INTO payphone.user_incidents ( + user_id, + incident_id, + report_id, + type, + message, + reason, + created_by_id, + created_at, + expires_at, + duration + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + "#, + Self::to_row(self.clone()), + ) + .await + .map_err(DatabaseError::QueryError)?; + + Ok(()) + } +} diff --git a/src/groups/calls/components.rs b/src/groups/calls/components.rs new file mode 100644 index 0000000..0b89ff6 --- /dev/null +++ b/src/groups/calls/components.rs @@ -0,0 +1,27 @@ +use crate::{framework::interactions::components::Button, state::State}; + +#[derive(Debug)] +pub struct CodeOfConductButton; + +impl Button for CodeOfConductButton { + fn label(&self) -> Option<&'static str> { + Some("Code of Conduct") + } + + fn url(&self) -> Option<&'static str> { + Some("https://payphone.lat/code-of-conduct") + } +} + +#[derive(Debug)] +pub struct NeedHelpButton; + +impl Button for NeedHelpButton { + fn label(&self) -> Option<&'static str> { + Some("Need Help?") + } + + fn url(&self) -> Option<&'static str> { + Some("https://discord.gg/9daSsrfTmE") + } +} diff --git a/src/groups/calls/embeds.rs b/src/groups/calls/embeds.rs new file mode 100644 index 0000000..6a54c51 --- /dev/null +++ b/src/groups/calls/embeds.rs @@ -0,0 +1,185 @@ +use std::sync::Arc; + +use twilight_model::{ + channel::{Message, message::Embed}, + oauth::Application, +}; +use twilight_util::builder::embed::{ + EmbedAuthorBuilder, EmbedBuilder, EmbedFieldBuilder, EmbedFooterBuilder, ImageSource, +}; + +use crate::{ + constants::{COLOR_RED, COLOR_YELLOW}, discord::images, groups::calls::moderation::Rule, utils::ellipse, +}; + +pub fn first_warning_embed( + app: &Application, + message: &Message, + rules_broken: Vec>, +) -> Embed { + EmbedBuilder::new() + .author( + EmbedAuthorBuilder::new(app.name.clone()) + .icon_url( + ImageSource::url( + images::get_avatar_url( + app.icon, + app.bot.as_ref().unwrap().id.get(), + app.bot.as_ref().unwrap().discriminator as u64, + ) + ).unwrap() + ) + ) + .title("Warning") + .description("You broke [Payphone's code of conduct](https://payphone.lat/code-of-conduct) during a call. Since it's your first offense, we assumed you didn't read the rules and made this a warning instead. Read the code of conduct to prevent getting banned.") + .field(EmbedFieldBuilder::new("What message broke the rules?", ellipse(message.content.clone(), 500))) + .field(EmbedFieldBuilder::new( + "What rules did you break?", + rules_broken + .iter() + .map(|rule| format!("- {}", rule.name())) + .collect::>() + .join("\n"), + )) + .color(COLOR_YELLOW) + .thumbnail( + ImageSource::url( + images::get_avatar_url( + app.icon, + app.bot.as_ref().unwrap().id.get(), + app.bot.as_ref().unwrap().discriminator as u64, + )) + .unwrap() + ) + .footer(EmbedFooterBuilder::new("This is a warning because it's your first offense. Breaking the rules again will result in a ban.")) + .build() +} + +pub fn repeat_warning_embed( + app: &Application, + message: &Message, + rules_broken: Vec>, +) -> Embed { + EmbedBuilder::new() + .author( + EmbedAuthorBuilder::new(app.name.clone()) + .icon_url( + ImageSource::url( + images::get_avatar_url( + app.icon, + app.bot.as_ref().unwrap().id.get(), + app.bot.as_ref().unwrap().discriminator as u64, + ) + ).unwrap() + ) + ) + .title("Long Time no See!") + .description("You broke [Payphone's code of conduct](https://payphone.lat/code-of-conduct) during a call. It's not your first offense, yet you've gone for a long time with a good record so we're making this a warning instead. In case you've forgotten the rules, consider this a reminder to re-read them.") + .field(EmbedFieldBuilder::new("What message broke the rules?", ellipse(message.content.clone(), 500))) + .field(EmbedFieldBuilder::new( + "What rules did you break?", + rules_broken + .iter() + .map(|rule| format!("- {}", rule.name())) + .collect::>() + .join("\n"), + )) + .color(COLOR_YELLOW) + .thumbnail( + ImageSource::url( + images::get_avatar_url( + app.icon, + app.bot.as_ref().unwrap().id.get(), + app.bot.as_ref().unwrap().discriminator as u64, + )) + .unwrap() + ) + .footer(EmbedFooterBuilder::new("Breaking the rules again will result in a ban.")) + .build() +} + +pub fn first_ban_embed( + app: &Application, + message: &Message, + rules_broken: Vec>, +) -> Embed { + EmbedBuilder::new() + .author( + EmbedAuthorBuilder::new(app.name.clone()) + .icon_url( + ImageSource::url( + images::get_avatar_url( + app.icon, + app.bot.as_ref().unwrap().id.get(), + app.bot.as_ref().unwrap().discriminator as u64, + ) + ).unwrap() + ) + ) + .title("Banned") + .description("You broke [Payphone's code of conduct](https://payphone.lat/code-of-conduct) during a call. This is your first offense, and as such, you are being issued a ban. This ban will last for 24 hours, next one will be permanent.") + .field(EmbedFieldBuilder::new("What message broke the rules?", ellipse(message.content.clone(), 500))) + .field(EmbedFieldBuilder::new( + "What rules did you break?", + rules_broken + .iter() + .map(|rule| format!("- {}", rule.name())) + .collect::>() + .join("\n"), + )) + .color(COLOR_RED) + .thumbnail( + ImageSource::url( + images::get_avatar_url( + app.icon, + app.bot.as_ref().unwrap().id.get(), + app.bot.as_ref().unwrap().discriminator as u64, + )) + .unwrap() + ) + .footer(EmbedFooterBuilder::new("Breaking the rules again will result in being permanently banned.")) + .build() +} + +pub fn final_ban_embed( + app: &Application, + message: &Message, + rules_broken: Vec> +) -> Embed { + EmbedBuilder::new() + .author( + EmbedAuthorBuilder::new(app.name.clone()) + .icon_url( + ImageSource::url( + images::get_avatar_url( + app.icon, + app.bot.as_ref().unwrap().id.get(), + app.bot.as_ref().unwrap().discriminator as u64, + ) + ).unwrap() + ) + ) + .title("Permanently Banned") + .description("You broke [Payphone's code of conduct](https://payphone.lat/code-of-conduct) during a call. This is your final offense, and as such, you are being issued a permanent ban.") + .field(EmbedFieldBuilder::new("What message broke the rules?", ellipse(message.content.clone(), 500))) + .field(EmbedFieldBuilder::new( + "What rules did you break?", + rules_broken + .iter() + .map(|rule| format!("- {}", rule.name())) + .collect::>() + .join("\n"), + )) + .color(COLOR_RED) + .thumbnail( + ImageSource::url( + images::get_avatar_url( + app.icon, + app.bot.as_ref().unwrap().id.get(), + app.bot.as_ref().unwrap().discriminator as u64, + ) + ).unwrap() + ) + .footer(EmbedFooterBuilder::new("You broke the rules 3 times.")) + .build() +} \ No newline at end of file diff --git a/src/groups/calls/events.rs b/src/groups/calls/events.rs index 749fb9a..8761b3e 100644 --- a/src/groups/calls/events.rs +++ b/src/groups/calls/events.rs @@ -1,15 +1,22 @@ use std::sync::Arc; use async_trait::async_trait; +use itertools::Itertools; use twilight_model::{gateway::payload::incoming::MessageCreate, id::Id}; use crate::{ - database::models::{Call, Channel, Message}, + database::models::{Call, Channel, Message, User, UserIncident}, + discord::sanitizing::SanitizeDiscordMarkdown, framework::{ events::traits::{EventResult, OnMessageHandler}, + interactions::components::ActionRow, runtime::Runtime, }, - groups::calls::{get_display_profile, moderation::escape_message}, + groups::calls::{ + components::{CodeOfConductButton, NeedHelpButton}, + embeds, get_display_profile, + moderation::{Action, escape_message}, + }, state::State, }; @@ -73,6 +80,16 @@ impl OnMessageHandler for MessageRedirector { return Ok(()); }; + let user_incidents = UserIncident::get_for_user(event.author.id.get(), &rt.state.db) + .await? + .iter() + .filter(|incident| incident.is_active()) + .count(); + + if user_incidents > 0 { + return Ok(()); + } + let Some(opposite_channel) = Channel::get_by_id( call.other_party_channel_id(event.channel_id.get()), &rt.state.db, @@ -97,31 +114,108 @@ impl OnMessageHandler for MessageRedirector { let profile = get_display_profile(event); - Message::build() + let message = Message::build() .call_id(call.id) .message(event.0.clone()) .profile(profile.clone()) .build() .expect( "The message builder was missing either the call ID, the message, or the profile", - ) - .insert(&rt.state.db) - .await?; + ); + message.insert(&rt.state.db).await?; - 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 - ); - })?; + let (content, rules) = escape_message(event); + + if rules + .iter() + .any(|rule| rule.action() == &Action::BlockAndPenalty) + { + let user = User::get_by_id(event.author.id.get(), &rt.state.db).await?; + + let user = if let Some(user) = user { + user + } else { + let user: User = event.author.clone().into(); + + user.insert(&rt.state.db).await?; + + user + }; + + let reason = format!( + "You broke the following rules during a call:\n{}", + rules + .iter() + .map(|rule| format!("- {}", rule.name())) + .join("\n") + ); + + let new_incident = user + .incident( + reason.clone(), + message.data.clone(), + Some(event.author.id.get()), + &rt.state.db, + ) + .await?; + + rt.http + .create_message(event.channel_id) + .content( + "❌ Your message has been blocked because it broke Payphone's code of conduct. Check your DMs for more information.", + ) + .await?; + + rt.http + .create_message(Id::new(opposite_channel.id)) + .content(&format!( + "❌ **{}** Sent a message that was blocked.\n-# If you see an inappropiate message, please report it using `Message > Apps > Report Message`.", + profile.name.sanitize(false) + )) + .await?; + + let dm_channel = rt + .http + .create_private_channel(event.author.id) + .await? + .model() + .await?; + + let embed = if new_incident.is_first_incident { + embeds::first_warning_embed(&rt.app, &event.0, rules) + } else if new_incident.counting_incidents == 0 { + embeds::repeat_warning_embed(&rt.app, &event.0, rules) + } else if new_incident.counting_incidents == 1 { + embeds::first_ban_embed(&rt.app, &event.0, rules) + } else { + embeds::final_ban_embed(&rt.app, &event.0, rules) + }; + + rt.http + .create_message(dm_channel.id) + .embeds(&[embed]) + .components(&[ActionRow::new(&[ + (None, Arc::new(CodeOfConductButton)), + (None, Arc::new(NeedHelpButton)), + ]) + .build()]) + .await?; + } else { + rt.http + .execute_webhook(Id::new(webhook_id), &webhook_token) + .allowed_mentions(None) + .username(&profile.name) + .avatar_url(&profile.avatar_url) + .content(&content) + .await + .inspect_err(|e| { + log::error!( + "Failed to send message to webhook for call {} in channel {}: {e:?}", + call.id, + opposite_channel.id + ); + })?; + } Ok(()) } diff --git a/src/groups/calls/mod.rs b/src/groups/calls/mod.rs index c6426ef..fef51cc 100644 --- a/src/groups/calls/mod.rs +++ b/src/groups/calls/mod.rs @@ -5,6 +5,8 @@ use twilight_validate::request::webhook_username; use crate::discord::images; +pub(super) mod components; +pub(super) mod embeds; pub mod commands; pub mod events; pub(super) mod moderation; diff --git a/src/groups/calls/moderation.rs b/src/groups/calls/moderation.rs index 82d77f7..431a009 100644 --- a/src/groups/calls/moderation.rs +++ b/src/groups/calls/moderation.rs @@ -85,7 +85,6 @@ use std::{collections::HashMap, sync::Arc}; -use linkify::LinkFinder; use once_cell::sync::Lazy; use regex::Regex; use twilight_model::gateway::payload::incoming::MessageCreate; @@ -101,8 +100,9 @@ use crate::discord::sanitizing::SanitizeDiscordMarkdown; /// * `event`: The incoming message event to escape. /// /// Returns: -/// [`String`] - The escaped and censored message. -pub fn escape_message(event: &MessageCreate) -> String { +/// [`(String, Vec>)`] - The escaped and censored message, and a vector of the rules +/// broken. +pub fn escape_message(event: &MessageCreate) -> (String, Vec>) { let mut message = event.content.to_string(); for mention in &event.mentions { @@ -123,20 +123,11 @@ pub fn escape_message(event: &MessageCreate) -> String { ); } - 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 (censored, _) = moderate_message(&message); + let (censored, rules) = moderate_message(&message); log::debug!("Escaped and normalized message: {censored:?}"); - censored + (censored, rules) } /// A normalized message and its mappings to the original message's individual grapheme clusters. @@ -157,6 +148,8 @@ pub struct NormalizedMessage { } /// Generates a normalized message out of the original message string. +/// +/// TODO: Replace * in the message with wildcards to match censored words. /// /// Arguments: /// * `message`: The original message to normalize. @@ -165,6 +158,7 @@ pub struct NormalizedMessage { /// [`NormalizedMessage`] - The normalized message and its mappings to the original string. pub fn normalize(message: String) -> NormalizedMessage { let replacements: HashMap = [ + ('$', 's'), ('@', 'a'), ('0', 'o'), ('1', 'i'), diff --git a/src/utils.rs b/src/utils.rs index 523f813..62bfd04 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -54,3 +54,21 @@ pub trait Bye: Sized { } impl Bye for T {} + +/// Trims a string to a maximum length, adding an ellipsis if it exceeds the limit. +/// +/// Arguments: +/// * `string` - The string to trim with ellipse. +/// * `max_length` - The max length the string may have. +/// +/// Returns: +/// [`String`] - The trimmed string with an ellipsis if it exceeded the limit. +pub fn ellipse(string: String, max_length: usize) -> String { + if string.len() <= max_length { + string + } else { + let len = max_length.saturating_sub(3); + + format!("{}...", &string[..len]) + } +}