use async_trait::async_trait; use deadpool_redis::redis::AsyncTypedCommands; use scc::HashSet; use time::UtcDateTime; use twilight_model::id::Id; use crate::database::models::{Call, Channel, Participant}; use crate::discord::sanitizing::SanitizeDiscordMarkdown; use crate::extras::Extras; use crate::framework::commands::arguments::{Arg, Argument, ParseArguments}; use crate::framework::commands::context::CommandContext; use crate::framework::commands::permissions::Permission; use crate::framework::commands::{Command, CommandError}; use crate::groups::calls::masks::{get_mask_display_name, get_mask_emoji}; use crate::groups::calls::permissions::{ IsCallParticipantPermission, NonNewAccountPermission, NotBannedPermission, }; use crate::groups::calls::strings::{ get_string_call_answered, get_string_call_hangup_other_party, get_string_call_hangup_this_party, get_string_call_no_answer, get_string_call_searching, get_string_tips, }; use crate::state::State; use crate::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"] } fn permissions(&self) -> Vec + Send + Sync + 'static>> { vec![ Box::new(NotBannedPermission), Box::new(NonNewAccountPermission), ] } async fn execute( &self, ctx: CommandContext<'_, State, Extras>, _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:?}"); })?; 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?; 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 ?; return Ok(()); } self.channel_lock.insert(ctx.event.channel_id.get()).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()); })?; self.channel_lock.remove(&ctx.event.channel_id.get()); if let Some(call) = call { ctx.send() .content(format!( "**TIP:** {}\n☎️ {}\n☎️ {}", get_string_tips(ctx.prefix), get_string_call_searching(ctx.prefix), get_string_call_answered(ctx.prefix) )) .await .bye(); ctx.rt .http .create_message(Id::new(call.other_party_channel_id(channel.id))) .content(&format!("☎️ {}", get_string_call_answered(ctx.prefix))) .await .bye(); let mut redis = ctx.rt.state.redis.get().await?; let timestamp = UtcDateTime::now().unix_timestamp(); redis .zadd( "active-calls", format!("{}:{}", call.id, channel.id), timestamp, ) .await?; redis .zadd( "active-calls", format!("{}:{}", call.id, call.other_party_channel_id(channel.id)), timestamp, ) .await?; } else { ctx.send() .content(format!( "**TIP:** {}\n☎️ {}", get_string_tips(ctx.prefix), get_string_call_searching(ctx.prefix) )) .await?; } Ok(()) } } #[derive(Debug)] pub struct SkipCommand; #[async_trait] impl Command for SkipCommand { fn name(&self) -> &'static str { "skip" } fn aliases(&self) -> &'static [&'static str] { &["s", "next"] } fn permissions(&self) -> Vec + Send + Sync + 'static>> { vec![ Box::new(NonNewAccountPermission), Box::new(NotBannedPermission), Box::new(IsCallParticipantPermission), ] } async fn execute( &self, ctx: CommandContext<'_, State, Extras>, _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:?}"); })?; if channel.call_id.is_none() { ctx.send() .content(format!( "❌ You're not on a call! Use `{}call` to start one.", ctx.prefix )) .await?; return Ok(()); } if channel.is_searching { ctx.send() .content(format!( "☎️ You're already searching for a call! Use `{}hangup` to cancel the search.", ctx.prefix )) .await?; return Ok(()); } let call = Call::get_by_id(channel.call_id.unwrap(), &ctx.rt.state.db) .await .inspect_err(|e| log::error!("Failed to get call: {e:?}"))? .ok_or(CommandError::DatabaseInconsistentState)?; call.hangup(&ctx.rt.state.db) .await .inspect_err(|e| log::error!("Failed to hang up call: {e:?}"))?; let other_channel_id = call.other_party_channel_id(ctx.event.channel_id.get()); ctx.rt .http .create_message(Id::new(other_channel_id)) .content(&format!( "☎️ {}", get_string_call_hangup_other_party(ctx.prefix) )) .await .inspect_err(|e| { log::error!("Failed to send message to other party: {e:?}"); })?; let mut redis = ctx.rt.state.redis.get().await?; let channel_keys = [ format!("{}:{}", call.id, call.channel_ids[0]), format!("{}:{}", call.id, call.channel_ids[1]), ]; redis .zrem("active-calls", &channel_keys) .await .inspect_err(|e| log::error!("Failed to remove inactive call from redis: {e}")) .ok(); let call = Call::start(&channel, &ctx.rt.state.db) .await .inspect_err(|e| { log::error!("Failed to start (skipped) call: {e:?}"); })?; if let Some(call) = call { ctx.send() .content(format!( "**TIP:** {}\n☎️ Skipped! Searching for a call...\n☎️ {}", get_string_tips(ctx.prefix), get_string_call_answered(ctx.prefix) )) .await .bye(); ctx.rt .http .create_message(Id::new(call.other_party_channel_id(channel.id))) .content(&format!("☎️ {}", get_string_call_answered(ctx.prefix))) .await .bye(); let mut redis = ctx.rt.state.redis.get().await?; let timestamp = UtcDateTime::now().unix_timestamp(); redis .zadd( "active-calls", format!("{}:{}", call.id, channel.id), timestamp, ) .await?; redis .zadd( "active-calls", format!("{}:{}", call.id, call.other_party_channel_id(channel.id)), timestamp, ) .await?; } else { ctx.send() .content(format!( "**TIP:** {}\n☎️ Skipped! Searching for a call...", get_string_tips(ctx.prefix) )) .await?; } 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"] } fn permissions(&self) -> Vec + Send + Sync + 'static>> { vec![ Box::new(NonNewAccountPermission), Box::new(NotBannedPermission), Box::new(IsCallParticipantPermission), ] } async fn execute( &self, ctx: CommandContext<'_, State, Extras>, _args: &str, ) -> Result<(), CommandError> { let Some(mut 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:?}"))? else { ctx.send() .content(format!( "❌ You're not on a call! Use `{}call` to start one.", ctx.prefix )) .await?; return Ok(()); }; if channel.is_searching { channel .cancel_search(&ctx.rt.state.db) .await .inspect_err(|e| { log::error!("Failed to cancel search: {e:?}"); })?; ctx.send() .content(format!("☎️ {}", get_string_call_no_answer(ctx.prefix))) .await?; return Ok(()); } if let Some(call_id) = channel.call_id { let mut redis = ctx.rt.state.redis.get().await?; let Some(call) = Call::get_by_id(call_id, &ctx.rt.state.db) .await .inspect_err(|e| log::error!("Failed to get call: {e:?}"))? else { ctx.send().content("❌ The call could not be found.\n-# This is not supposed to happen. If the issue persists, contact a moderator in the official server.").await?; channel.call_id = None; channel.upsert(&ctx.rt.state.db).await.bye(); redis .zrem("active-calls", format!("{}:{}", call_id, channel.id)) .await .bye(); return Err(CommandError::DatabaseInconsistentState); }; let channel_keys = [ format!("{call_id}:{}", call.channel_ids[0]), format!("{call_id}:{}", call.channel_ids[1]), ]; redis .zrem("active-calls", &channel_keys) .await .inspect_err(|e| log::error!("Failed to remove inactive call from redis: {e}")) .ok(); call.hangup(&ctx.rt.state.db) .await .inspect_err(|e| log::error!("Failed to hang up call: {e:?}"))?; ctx.send() .content(format!( "☎️ {}", get_string_call_hangup_this_party(ctx.prefix) )) .await .bye(); ctx.rt .http .create_message(Id::new(call.other_party_channel_id(channel.id))) .content(&format!( "☎️ {}", get_string_call_hangup_other_party(ctx.prefix) )) .await?; } else { ctx.send() .content(format!( "❌ You're not on a call! Use `{}call` to start one.", ctx.prefix )) .await?; return Ok(()); } Ok(()) } } #[derive(Debug)] pub struct FriendRequestCommand; #[async_trait] impl Command for FriendRequestCommand { fn name(&self) -> &'static str { "friendreq" } fn aliases(&self) -> &'static [&'static str] { &["f", "fr", "friendrequest"] } fn description(&self) -> &'static str { "Shows your username to the other party and asks them to send you a friend request on Discord." } fn permissions(&self) -> Vec + Send + Sync + 'static>> { vec![ Box::new(NonNewAccountPermission), Box::new(NotBannedPermission), Box::new(IsCallParticipantPermission), ] } async fn execute( &self, ctx: CommandContext<'_, State, Extras>, _args: &str, ) -> Result<(), CommandError> { let 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:?}"))?; let Some(channel) = channel else { ctx.send() .content(format!( "❌ You're not on a call! Use `{}call` to start one.", ctx.prefix )) .await?; return Ok(()); }; let call = if let Some(call_id) = channel.call_id { Call::get_by_id(call_id, &ctx.rt.state.db) .await .inspect_err(|e| log::error!("Failed to get call: {e:?}"))? } else { ctx.send() .content(format!( "❌ You're not on a call! Use `{}call` to start one.", ctx.prefix )) .await?; return Ok(()); }; let Some(call) = call else { ctx.send() .content("❌ Oops! We could not find that call.") .await?; return Err(CommandError::DatabaseInconsistentState); }; let other_channel_id = call.other_party_channel_id(channel.id); let Some(participant) = Participant::get_for_user_and_channel_in_call( ctx.event.author.id.get(), ctx.event.channel_id.get(), call.id, &ctx.rt.state.db, ) .await? else { ctx.send() .content("❌ You haven't even said hi! Who are you trying to add?") .await?; return Ok(()); }; ctx .rt .http .create_message( Id::new(other_channel_id) ) .content( &format!( "📬 **{}** wants you to send them a friend request on Discord! Their username is `{}`.", participant.display_name().sanitize(false), ctx.event.author.name.sanitize(false) ) ) .await ?; ctx.send() .content("📬 Asked the other party to send you a friend request!") .await?; Ok(()) } } #[derive(Debug)] pub struct MaskCommand; #[async_trait] impl Command for MaskCommand { fn name(&self) -> &'static str { "mask" } fn aliases(&self) -> &'static [&'static str] { &["m"] } fn description(&self) -> &'static str { "Hides your profile from the other party by replacing your profile with a random animal. This command's preferences persist across calls.\n\nNote: Using this command without `on/off` will display your current mask status, without making any changes to it." } fn arguments(&self) -> &'static [Argument] { &[Argument { name: "on/off", is_optional: true, is_greedy: false, }] } async fn execute( &self, mut ctx: CommandContext<'_, State, Extras>, args: &str, ) -> Result<(), CommandError> { let Arg(toggle) = match Arg::>::parse(args, &ctx).await { Ok(v) => v, Err(_) => { ctx.send() .content(format!( "❓ Are you sure that's correct? Use `{}mask on` and `{}mask off`, or just `{}mask` to toggle it.", ctx.prefix, ctx.prefix, ctx.prefix )) .await?; return Ok(()); } }; let mask_verb: &'static str; // If toggle is Some, update user mask preference if let Some(toggle) = toggle { ctx.extras.user.is_masked = toggle; ctx.extras.user.insert(&ctx.rt.state.db).await?; mask_verb = "was"; } else { mask_verb = "is"; } let mask_status = if ctx.extras.user.is_masked { "on" } else { "off" }; let channel = Channel::get_by_id(ctx.event.channel_id.get(), &ctx.rt.state.db).await?; let mut mask_message = format!( "🎭 Your mask {mask_verb} set to `{}`. You can toggle your mask on or off using `{}mask on` and `{}mask off`.", mask_status, ctx.prefix, ctx.prefix ); if let Some(channel) = channel && let Some(call_id) = channel.call_id { if let Some(mut participant) = Participant::get_for_user_and_channel_in_call( ctx.extras.user.id, ctx.event.channel_id.get(), call_id, &ctx.rt.state.db, ) .await? { participant.is_masked = ctx.extras.user.is_masked; participant.upsert(&ctx.rt.state.db).await?; if ctx.extras.user.is_masked { mask_message = format!( "🎭 Your mask {mask_verb} set to `on`. You're currently being displayed as **{} {}**. You can toggle your mask on or off using `{}mask on` and `{}mask off`.", get_mask_emoji(&participant.user_mask).expect("User's mask did not exist"), get_mask_display_name(&participant.user_mask), ctx.prefix, ctx.prefix ); } } } ctx.send().content(mask_message).await?; Ok(()) } }