Add support for editing messages

This commit is contained in:
2025-08-15 01:38:54 -03:00
parent b9410bedd7
commit 12948dcc15
5 changed files with 320 additions and 130 deletions
+81 -34
View File
@@ -18,7 +18,7 @@ use twilight_model::{
user::User as TwilightUser, user::User as TwilightUser,
}; };
use crate::{constants::DEFAULT_PREFIX, groups::calls::DisplayProfile, utils::get_random_u64}; use crate::{constants::DEFAULT_PREFIX, groups::calls::DisplayProfile, utils::{get_random_u64, i64_to_u64, u64_to_i64}};
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum DatabaseOrDiscordError { pub enum DatabaseOrDiscordError {
@@ -44,34 +44,6 @@ pub enum DatabaseError {
DeserializationFailed, 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> { trait Object<'a> {
type Row: DeserializeRow<'a, 'a>; type Row: DeserializeRow<'a, 'a>;
@@ -967,6 +939,11 @@ pub struct MessageBuilder {
message: Option<TwilightMessage>, message: Option<TwilightMessage>,
profile: Option<DisplayProfile>, profile: Option<DisplayProfile>,
call_id: Option<u64>, call_id: Option<u64>,
is_deleted: bool,
is_censored: bool,
propagation_message_id: Option<u64>,
propagation_channel_id: Option<u64>,
replying_to_id: Option<u64>,
} }
impl MessageBuilder { impl MessageBuilder {
@@ -975,6 +952,11 @@ impl MessageBuilder {
message: None, message: None,
profile: None, profile: None,
call_id: None, call_id: None,
is_deleted: false,
is_censored: false,
propagation_message_id: None,
propagation_channel_id: None,
replying_to_id: None,
} }
} }
@@ -993,6 +975,31 @@ impl MessageBuilder {
self self
} }
pub fn is_deleted(mut self, is_deleted: bool) -> Self {
self.is_deleted = is_deleted;
self
}
pub fn is_censored(mut self, is_censored: bool) -> Self {
self.is_censored = is_censored;
self
}
pub fn propagation_message_id(mut self, propagation_message_id: u64) -> Self {
self.propagation_message_id = Some(propagation_message_id);
self
}
pub fn propagation_channel_id(mut self, propagation_channel_id: u64) -> Self {
self.propagation_channel_id = Some(propagation_channel_id);
self
}
pub fn replying_to_id(mut self, replying_to_id: u64) -> Self {
self.replying_to_id = Some(replying_to_id);
self
}
pub fn build(self) -> Result<Message, MessageBuilderError> { pub fn build(self) -> Result<Message, MessageBuilderError> {
let message = self.message.ok_or(MessageBuilderError)?; let message = self.message.ok_or(MessageBuilderError)?;
let profile = self.profile.ok_or(MessageBuilderError)?; let profile = self.profile.ok_or(MessageBuilderError)?;
@@ -1011,12 +1018,12 @@ impl MessageBuilder {
.sticker_items .sticker_items
.first() .first()
.map(|s| u64_to_i64(s.id.get())), .map(|s| u64_to_i64(s.id.get())),
propagation_message_id: None, propagation_message_id: self.propagation_message_id.map(u64_to_i64),
propagation_channel_id: None, propagation_channel_id: self.propagation_channel_id.map(u64_to_i64),
replying_to_id: None, replying_to_id: self.replying_to_id.map(u64_to_i64),
is_deleted: false, is_deleted: self.is_deleted,
is_edited: message.edited_timestamp.is_some(), is_edited: message.edited_timestamp.is_some(),
is_censored: false, is_censored: self.is_censored,
}; };
Ok(Message { Ok(Message {
@@ -1063,6 +1070,46 @@ impl Message {
MessageBuilder::new() MessageBuilder::new()
} }
pub async fn get_by_call_and_message_id(
call_id: u64,
message_id: u64,
session: &Session,
) -> Result<Option<Self>, DatabaseError> {
let call_id = u64_to_i64(call_id);
let message_id = u64_to_i64(message_id);
let result = session
.query_unpaged(
r#"
SELECT
call_id,
message_id,
author_id,
data
FROM
payphone.messages
WHERE
call_id = ? AND message_id = ?
LIMIT 1;
"#,
(call_id, message_id),
)
.await
.map_err(DatabaseError::QueryError)?
.into_rows_result()
.map_err(|_| DatabaseError::ResultNotRows)?;
match result.single_row::<<Message as Object>::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> { pub async fn insert(&self, session: &Session) -> Result<(), DatabaseError> {
session session
.query_unpaged( .query_unpaged(
+192 -78
View File
@@ -2,13 +2,16 @@ use std::{error::Error, sync::Arc};
use async_trait::async_trait; use async_trait::async_trait;
use itertools::Itertools; use itertools::Itertools;
use twilight_model::{gateway::payload::incoming::MessageCreate, id::Id}; use twilight_model::{
gateway::payload::incoming::{MessageCreate, MessageUpdate},
id::Id,
};
use crate::{ use crate::{
database::models::{Call, Channel, Message, User, UserIncident}, database::models::{Call, Channel, Message, User, UserIncident},
discord::sanitizing::SanitizeDiscordMarkdown, discord::sanitizing::SanitizeDiscordMarkdown,
framework::{ framework::{
events::traits::{EventResult, OnMessageHandler}, events::traits::{EventResult, OnMessageEditHandler, OnMessageHandler},
interactions::components::ActionRow, interactions::components::ActionRow,
runtime::Runtime, runtime::Runtime,
}, },
@@ -18,8 +21,62 @@ use crate::{
moderation::{Action, Rule, escape_message}, moderation::{Action, Rule, escape_message},
}, },
state::State, state::State,
utils::i64_to_u64,
}; };
async fn penalize_user(
user: User,
message: Message,
broken_rules: Vec<Arc<dyn Rule>>,
rt: &Runtime<State>,
) -> Result<(), Box<dyn Error>> {
let reason = format!(
"You broke the following rules during a call:\n{}",
broken_rules
.iter()
.map(|rule| format!("- {}", rule.name()))
.join("\n")
);
let new_incident = user
.incident(
reason.clone(),
message.data.clone(),
Some(user.id),
&rt.state.db,
)
.await?;
let dm_channel = rt
.http
.create_private_channel(Id::new(user.id))
.await?
.model()
.await?;
let embed = if new_incident.is_first_incident {
embeds::first_warning_embed(&rt.app, &message, broken_rules)
} else if new_incident.counting_incidents == 0 {
embeds::repeat_warning_embed(&rt.app, &message, broken_rules)
} else if new_incident.counting_incidents == 1 {
embeds::first_ban_embed(&rt.app, &message, broken_rules)
} else {
embeds::final_ban_embed(&rt.app, &message, broken_rules)
};
rt.http
.create_message(dm_channel.id)
.embeds(&[embed])
.components(&[ActionRow::new(&[
(None, Arc::new(CodeOfConductButton)),
(None, Arc::new(NeedHelpButton)),
])
.build()])
.await?;
Ok(())
}
#[derive(Debug)] #[derive(Debug)]
pub struct CallMessageHandler; pub struct CallMessageHandler;
@@ -114,17 +171,72 @@ impl OnMessageHandler<State> for CallMessageHandler {
let profile = get_display_profile(event); let profile = get_display_profile(event);
let message = Message::build() let (content, rules) = escape_message(event);
let message: Message;
if rules
.iter()
.any(|rule| rule.action() == &Action::BlockAndPenalty)
{
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?;
message = Message::build()
.call_id(call.id) .call_id(call.id)
.message(event.0.clone()) .message(event.0.clone())
.profile(profile.clone()) .profile(profile.clone())
.is_censored(!rules.is_empty())
.build() .build()
.expect( .expect(
"The message builder was missing either the call ID, the message, or the profile", "The message builder was missing either the call ID, the message, or the profile",
); );
message.insert(&rt.state.db).await?; } else {
let message_sent = rt
.http
.execute_webhook(Id::new(webhook_id), &webhook_token)
.allowed_mentions(None)
.username(&profile.name)
.avatar_url(&profile.avatar_url)
.content(&content)
.wait()
.await
.inspect_err(|e| {
log::error!(
"Failed to send message to webhook for call {} in channel {}: {e:?}",
call.id,
opposite_channel.id
);
})?
.model()
.await?;
let (content, rules) = escape_message(event); message = Message::build()
.call_id(call.id)
.message(event.0.clone())
.profile(profile.clone())
.is_censored(!rules.is_empty())
.propagation_message_id(message_sent.id.get())
.propagation_channel_id(opposite_channel.id)
.build()
.expect(
"The message builder was missing either the call ID, the message, or the profile",
);
}
message.insert(&rt.state.db).await?;
if rules if rules
.iter() .iter()
@@ -142,92 +254,94 @@ impl OnMessageHandler<State> for CallMessageHandler {
user user
}; };
penalize_user(user, message, rules, &rt).await?;
}
Ok(())
}
}
#[derive(Debug)]
pub struct CallMessageEditHandler;
#[async_trait]
impl OnMessageEditHandler<State> for CallMessageEditHandler {
async fn on_message_edit(&self, event: &MessageUpdate, rt: Arc<Runtime<State>>) -> EventResult {
let Some(channel) = Channel::get_by_id(event.channel_id.get(), &rt.state.db).await? 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? {
if !call.is_active() {
return Ok(());
}
call
} else {
return Ok(());
}
} else {
return Ok(());
};
let Some(mut message) =
Message::get_by_call_and_message_id(call.id, event.id.get(), &rt.state.db).await?
else {
return Ok(());
};
let opposite_channel_id = call.other_party_channel_id(event.channel_id.get());
let Some(opposite_channel) = Channel::get_by_id(opposite_channel_id, &rt.state.db).await?
else {
return Ok(());
};
if let Some(propagation_message_id) = message.data.propagation_message_id {
let (content, rules) = escape_message(event);
if rules
.iter()
.any(|rule| rule.action() == &Action::BlockAndPenalty)
{
rt.http rt.http
.create_message(event.channel_id) .create_message(event.channel_id)
.content( .content(
"❌ Your message has been blocked because it broke Payphone's code of conduct. Check your DMs for more information.", "❌ Your message's edit has been blocked because it broke Payphone's code of conduct. Check your DMs for more information.",
) )
.reply(event.id)
.await?; .await?;
rt.http let user = User::get_by_id(event.author.id.get(), &rt.state.db).await?;
.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?;
if let Some(user) = user {
penalize_user(user, message, rules, &rt).await?; penalize_user(user, message, rules, &rt).await?;
} else { }
return Ok(());
}
if let Some((webhook_id, webhook_token)) = opposite_channel
.webhook_id
.zip(opposite_channel.webhook_token)
{
rt.http rt.http
.execute_webhook(Id::new(webhook_id), &webhook_token) .update_webhook_message(
.allowed_mentions(None) Id::new(webhook_id),
.username(&profile.name) &webhook_token,
.avatar_url(&profile.avatar_url) Id::new(i64_to_u64(propagation_message_id)),
.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(())
}
}
async fn penalize_user(
user: User,
message: Message,
broken_rules: Vec<Arc<dyn Rule>>,
rt: &Runtime<State>,
) -> Result<(), Box<dyn Error>> {
let reason = format!(
"You broke the following rules during a call:\n{}",
broken_rules
.iter()
.map(|rule| format!("- {}", rule.name()))
.join("\n")
);
let new_incident = user
.incident(
reason.clone(),
message.data.clone(),
Some(user.id),
&rt.state.db,
) )
.content(Some(&content))
.await?; .await?;
let dm_channel = rt message.data.content = content;
.http message.data.is_edited = true;
.create_private_channel(Id::new(user.id)) message.data.is_censored = !rules.is_empty();
.await?
.model()
.await?;
let embed = if new_incident.is_first_incident { message.insert(&rt.state.db).await?;
embeds::first_warning_embed(&rt.app, &message, broken_rules) }
} else if new_incident.counting_incidents == 0 { }
embeds::repeat_warning_embed(&rt.app, &message, broken_rules)
} else if new_incident.counting_incidents == 1 {
embeds::first_ban_embed(&rt.app, &message, broken_rules)
} else {
embeds::final_ban_embed(&rt.app, &message, broken_rules)
};
rt.http
.create_message(dm_channel.id)
.embeds(&[embed])
.components(&[ActionRow::new(&[
(None, Arc::new(CodeOfConductButton)),
(None, Arc::new(NeedHelpButton)),
])
.build()])
.await?;
Ok(()) Ok(())
} }
}
+7 -7
View File
@@ -88,7 +88,7 @@ use std::{collections::HashMap, sync::Arc};
use linkify::LinkFinder; use linkify::LinkFinder;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use regex::Regex; use regex::Regex;
use twilight_model::gateway::payload::incoming::MessageCreate; use twilight_model::channel::Message;
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
use unidecode::unidecode; use unidecode::unidecode;
@@ -103,7 +103,7 @@ use crate::discord::sanitizing::SanitizeDiscordMarkdown;
/// Returns: /// Returns:
/// [`(String, Vec<Arc<dyn Rule>>)`] - The escaped and censored message, and a vector of the rules /// [`(String, Vec<Arc<dyn Rule>>)`] - The escaped and censored message, and a vector of the rules
/// broken. /// broken.
pub fn escape_message(event: &MessageCreate) -> (String, Vec<Arc<dyn Rule>>) { pub fn escape_message(event: &Message) -> (String, Vec<Arc<dyn Rule>>) {
let mut message = event.content.to_string(); let mut message = event.content.to_string();
for mention in &event.mentions { for mention in &event.mentions {
@@ -274,10 +274,7 @@ type RuleAnalysis = ((Vec<String>, Vec<String>), Arc<dyn Rule>);
/// Returns: /// Returns:
/// [`Vec<RuleAnalysis>`] - The results of the analysis, including all detections (on the original /// [`Vec<RuleAnalysis>`] - The results of the analysis, including all detections (on the original
/// message first, on the normalized message later) and their associated rules. /// message first, on the normalized message later) and their associated rules.
pub fn analyze_message( pub fn analyze_message(original: &str, normalized: &str) -> Vec<RuleAnalysis> {
original: &str,
normalized: &str,
) -> Vec<RuleAnalysis> {
let mut results = Vec::new(); let mut results = Vec::new();
for rule in RULES.iter() { for rule in RULES.iter() {
@@ -568,7 +565,10 @@ impl Rule for LinksRule {
fn detect(&self, original: &str, _normalized: &str) -> (Vec<String>, Vec<String>) { fn detect(&self, original: &str, _normalized: &str) -> (Vec<String>, Vec<String>) {
let mut detections = Vec::new(); let mut detections = Vec::new();
for link in LinkFinder::new().url_must_have_scheme(false).links(original) { for link in LinkFinder::new()
.url_must_have_scheme(false)
.links(original)
{
detections.push(link.as_str().to_string()); detections.push(link.as_str().to_string());
} }
+2 -1
View File
@@ -127,7 +127,7 @@ use crate::{
about::{commands::HelpCommand, components::HelpGoBackButton}, about::{commands::HelpCommand, components::HelpGoBackButton},
calls::{ calls::{
commands::{CallCommand, FriendRequestCommand, HangupCommand, SkipCommand}, commands::{CallCommand, FriendRequestCommand, HangupCommand, SkipCommand},
events::CallMessageHandler, events::{CallMessageEditHandler, CallMessageHandler},
}, },
gateway::events::{ReadyHandler, ReconnectHandler}, gateway::events::{ReadyHandler, ReconnectHandler},
servers::{ servers::{
@@ -233,6 +233,7 @@ async fn start(token: String, database_addr: String, shard: ShardId) {
.on_ready(ReadyHandler) .on_ready(ReadyHandler)
.on_reconnect(ReconnectHandler) .on_reconnect(ReconnectHandler)
.on_message(CallMessageHandler) .on_message(CallMessageHandler)
.on_message_edit(CallMessageEditHandler)
.on_server_create(ServerCreateHandler) .on_server_create(ServerCreateHandler)
.on_server_update(ServerUpdateHandler) .on_server_update(ServerUpdateHandler)
.on_server_delete(ServerDeleteHandler); .on_server_delete(ServerDeleteHandler);
+28
View File
@@ -72,3 +72,31 @@ pub fn ellipse(string: String, max_length: usize) -> String {
format!("{}...", &string[..len]) format!("{}...", &string[..len])
} }
} }
/// 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`].
pub 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`].
pub fn u64_to_i64(u: u64) -> i64 {
i64::from_ne_bytes(u.to_ne_bytes())
}