Files
payphone/src/groups/calls/commands.rs
T
2026-03-07 19:27:40 -03:00

631 lines
19 KiB
Rust

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<u64>,
}
impl CallCommand {
pub fn new() -> Self {
Self {
channel_lock: HashSet::new(),
}
}
}
#[async_trait]
impl Command<State, Extras> for CallCommand {
fn name(&self) -> &'static str {
"call"
}
fn aliases(&self) -> &'static [&'static str] {
&["c"]
}
fn permissions(&self) -> Vec<Box<dyn Permission<State, Extras> + 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<State, Extras> for SkipCommand {
fn name(&self) -> &'static str {
"skip"
}
fn aliases(&self) -> &'static [&'static str] {
&["s", "next"]
}
fn permissions(&self) -> Vec<Box<dyn Permission<State, Extras> + 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<State, Extras> for HangupCommand {
fn name(&self) -> &'static str {
"hangup"
}
fn aliases(&self) -> &'static [&'static str] {
&["h", "hang"]
}
fn permissions(&self) -> Vec<Box<dyn Permission<State, Extras> + 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<State, Extras> 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<Box<dyn Permission<State, Extras> + 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<State, Extras> 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::<Option<bool>>::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(())
}
}