Finish refactoring

This commit is contained in:
2025-08-10 21:15:53 -03:00
parent f123282699
commit a004fe2308
50 changed files with 2639 additions and 587 deletions
+204
View File
@@ -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<u64>,
}
impl CallCommand {
pub fn new() -> Self {
Self {
channel_lock: HashSet::new(),
}
}
}
#[async_trait]
impl Command<State> 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<State> 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(())
}
}
+228
View File
@@ -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<State> for MessageRedirector {
async fn on_message(&self, event: &MessageCreate, rt: Arc<Runtime<State>>) -> 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<char, Option<char>> = [
('@', 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::<Vec<&str>>()
.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)
}
+2
View File
@@ -0,0 +1,2 @@
pub mod commands;
pub mod events;