feat: improve transparency of the join command

This commit is contained in:
2026-04-07 00:24:27 -04:00
parent 663586a65c
commit 3897d1deb2
3 changed files with 225 additions and 109 deletions

View File

@@ -1,12 +1,23 @@
use std::sync::LazyLock;
use twilight_model::application::{
command::{Command, CommandType},
interaction::{Interaction, application_command::CommandData},
use snafu::{OptionExt, Snafu};
use twilight_model::{
application::{
command::{Command, CommandType},
interaction::Interaction,
},
channel::message::{Embed, MessageFlags},
http::interaction::{InteractionResponse, InteractionResponseType},
id::{
Id,
marker::{ChannelMarker, GuildMarker},
},
};
use twilight_util::builder::{
InteractionResponseDataBuilder, command::CommandBuilder, embed::EmbedBuilder,
};
use twilight_util::builder::command::CommandBuilder;
use crate::command::State;
use crate::{VCs, command::State};
const NAME: &str = "join";
const DESCRIPTION: &str = "The bot will join the same VC as you (with intention to record)";
@@ -18,24 +29,103 @@ pub static COMMAND: LazyLock<Command> = LazyLock::new(|| {
.build()
});
#[derive(Debug, Snafu)]
enum GetGuildAndChannelIdError {
/// this command was not used inside a guild (Discord server)
NotInGuild,
/// there is no user who invoked this command
NoUser,
/// there are no voice chats in this guild
NoVCsInGuild,
/// the user is not in a voice chat in this guild
UserNotInVC,
}
impl From<GetGuildAndChannelIdError> for Embed {
fn from(error: GetGuildAndChannelIdError) -> Embed {
match error {
GetGuildAndChannelIdError::NotInGuild => {
EmbedBuilder::new().title("Use this in a server").description("This bot can't find a VC to join if the command is used outside of a server (you might've used it in a DM?).").validate().unwrap().build()
}
GetGuildAndChannelIdError::NoUser => EmbedBuilder::new().title("Not invoked by a user").description("This command works by joining the same VC as the user, but this bot didn't receive any user data. So did no user invoke it?! (This error should be impossible!)").validate().unwrap().build(),
GetGuildAndChannelIdError::NoVCsInGuild => {
EmbedBuilder::new().title("No VCs in this server").description("This bot can't find a VC to join because there aren't any in this server right now.").validate().unwrap().build()
},
GetGuildAndChannelIdError::UserNotInVC => {
EmbedBuilder::new().title("You're not in a VC").description("This bot can't follow you into VC if you aren't in one in this server.").validate().unwrap().build()
},
}
}
}
#[tracing::instrument]
fn get_guild_and_channel_id(
interaction: &Interaction,
vcs: &VCs,
) -> Result<(Id<GuildMarker>, Id<ChannelMarker>), GetGuildAndChannelIdError> {
let guild_id = interaction.guild_id.context(NotInGuildSnafu)?;
let user_id = interaction
.member
.as_ref()
.and_then(|member| member.user.as_ref().map(|user| user.id))
.context(NoUserSnafu)?;
let guild_vcs = vcs.get(&guild_id).context(NoVCsInGuildSnafu)?;
let &channel_id = guild_vcs.get_left_for(&user_id).context(UserNotInVCSnafu)?;
Ok((guild_id, channel_id))
}
#[tracing::instrument]
pub async fn handle(state: State, interaction: Interaction) {
let vcs = state.vcs;
let guild_id = interaction.guild_id.expect("TODO");
// let user_id = data.user.map(|user| user.id).expect("TODO");
let (guild_id, channel_id) = match get_guild_and_channel_id(&interaction, &vcs) {
Ok((guild_id, channel_id)) => (guild_id, channel_id),
Err(error) => {
state
.discord_client
.interaction(state.discord_application_id)
.create_response(
interaction.id,
&interaction.token,
&InteractionResponse {
kind: InteractionResponseType::ChannelMessageWithSource,
data: Some(
InteractionResponseDataBuilder::new()
.embeds([error.into()])
.flags(MessageFlags::EPHEMERAL)
.build(),
),
},
)
.await
.expect("TODO");
let user_id = interaction
.member
.and_then(|member| member.user.map(|user| user.id))
return;
}
};
state
.discord_client
.interaction(state.discord_application_id)
.create_response(
interaction.id,
&interaction.token,
&InteractionResponse {
kind: InteractionResponseType::DeferredChannelMessageWithSource,
data: None,
},
)
.await
.expect("TODO");
let guild_vcs = vcs.get(&guild_id).expect("TODO");
tracing::error!(?guild_vcs, "TODO");
let (&channel_id, user_in_vc_data) = guild_vcs.get_left_and_data_for(&user_id).expect("TODO");
tracing::error!(?user_in_vc_data, "TODO");
let call = tokio::spawn({
let songbird = state.songbird.clone();
async move { songbird.join(guild_id, channel_id).await }
@@ -44,5 +134,20 @@ pub async fn handle(state: State, interaction: Interaction) {
.unwrap()
.expect("TODO");
let channel_mention = format!("<#{channel_id}>");
state
.discord_client
.interaction(state.discord_application_id)
.update_response(
&interaction.token,
).embeds(Some(&[
EmbedBuilder::new().title("Joined VC with intent to record").description(&format!("This bot joined {channel_mention} and intends to start recording after a responsible disclosure and consent.")).validate().unwrap().build()
]))
.await
.expect("TODO");
tracing::error!(?call, "TODO");
let call_guard = call.lock().await;
}

View File

@@ -1,88 +1,93 @@
use std::{fmt::Debug, sync::Arc};
use futures::future::BoxFuture;
use patricia_tree::StringPatriciaMap;
use songbird::Songbird;
use twilight_model::application::{command::Command, interaction::Interaction};
use crate::VCs;
mod join;
mod leave;
mod opt_out;
#[derive(Debug, Clone)]
pub struct State {
pub vcs: Arc<VCs>,
pub songbird: Arc<Songbird>,
}
type Return = ();
type BoxedHandler = Box<dyn Fn(State, Interaction) -> BoxFuture<'static, Return>>;
fn box_handler<Handler, Fut>(handler: Handler) -> BoxedHandler
where
Fut: Future<Output = Return> + Send + 'static,
Handler: Fn(State, Interaction) -> Fut + 'static,
{
Box::new(move |state, interaction| Box::pin(handler(state, interaction)))
}
pub fn all() -> Vec<(&'static Command, BoxedHandler)> {
vec![
(&join::COMMAND, box_handler(join::handle)),
(&leave::COMMAND, box_handler(leave::handle)),
(&opt_out::COMMAND, box_handler(opt_out::handle)),
]
}
#[derive(Default)]
pub struct Router {
map: StringPatriciaMap<BoxedHandler>,
}
impl Router {
fn add_route<'s, 'a, Fut, Handler>(&'s mut self, name: &'a str, handler: Handler)
where
Fut: Future<Output = Return> + Send + 'static,
Handler: Fn(State, Interaction) -> Fut + 'static,
{
self.add_route_already_boxed(name, box_handler(handler));
}
fn add_route_already_boxed<'s, 'a>(&'s mut self, name: &'a str, boxed_handler: BoxedHandler) {
self.map.insert(name, boxed_handler);
}
pub async fn handle(
&self,
state: State,
command_name: &str,
interaction: Interaction,
) -> Option<Return> {
let handler = self.map.get(command_name)?;
Some(handler(state, interaction).await)
}
}
impl<'a> FromIterator<(&'a Command, BoxedHandler)> for Router {
#[inline]
fn from_iter<T: IntoIterator<Item = (&'a Command, BoxedHandler)>>(iter: T) -> Self {
let mut this = Self::default();
this.extend(iter);
this
}
}
impl<'a> Extend<(&'a Command, BoxedHandler)> for Router {
#[inline]
fn extend<T: IntoIterator<Item = (&'a Command, BoxedHandler)>>(&mut self, iter: T) {
for (command, boxed_handler) in iter {
let name = &command.name;
self.add_route_already_boxed(name, boxed_handler);
}
}
}
use std::{fmt::Debug, sync::Arc};
use futures::future::BoxFuture;
use patricia_tree::StringPatriciaMap;
use songbird::Songbird;
use twilight_model::{
application::{command::Command, interaction::Interaction},
id::{Id, marker::ApplicationMarker},
};
use crate::VCs;
mod join;
mod leave;
mod opt_out;
#[derive(Debug, Clone)]
pub struct State {
pub discord_client: Arc<twilight_http::Client>,
pub discord_application_id: Id<ApplicationMarker>,
pub songbird: Arc<Songbird>,
pub vcs: Arc<VCs>,
}
type Return = ();
type BoxedHandler = Box<dyn Fn(State, Interaction) -> BoxFuture<'static, Return>>;
fn box_handler<Handler, Fut>(handler: Handler) -> BoxedHandler
where
Fut: Future<Output = Return> + Send + 'static,
Handler: Fn(State, Interaction) -> Fut + 'static,
{
Box::new(move |state, interaction| Box::pin(handler(state, interaction)))
}
pub fn all() -> Vec<(&'static Command, BoxedHandler)> {
vec![
(&join::COMMAND, box_handler(join::handle)),
(&leave::COMMAND, box_handler(leave::handle)),
(&opt_out::COMMAND, box_handler(opt_out::handle)),
]
}
#[derive(Default)]
pub struct Router {
map: StringPatriciaMap<BoxedHandler>,
}
impl Router {
fn add_route<'s, 'a, Fut, Handler>(&'s mut self, name: &'a str, handler: Handler)
where
Fut: Future<Output = Return> + Send + 'static,
Handler: Fn(State, Interaction) -> Fut + 'static,
{
self.add_route_already_boxed(name, box_handler(handler));
}
fn add_route_already_boxed<'s, 'a>(&'s mut self, name: &'a str, boxed_handler: BoxedHandler) {
self.map.insert(name, boxed_handler);
}
pub async fn handle(
&self,
state: State,
command_name: &str,
interaction: Interaction,
) -> Option<Return> {
let handler = self.map.get(command_name)?;
Some(handler(state, interaction).await)
}
}
impl<'a> FromIterator<(&'a Command, BoxedHandler)> for Router {
#[inline]
fn from_iter<T: IntoIterator<Item = (&'a Command, BoxedHandler)>>(iter: T) -> Self {
let mut this = Self::default();
this.extend(iter);
this
}
}
impl<'a> Extend<(&'a Command, BoxedHandler)> for Router {
#[inline]
fn extend<T: IntoIterator<Item = (&'a Command, BoxedHandler)>>(&mut self, iter: T) {
for (command, boxed_handler) in iter {
let name = &command.name;
self.add_route_already_boxed(name, boxed_handler);
}
}
}