diff --git a/Cargo.toml b/Cargo.toml index 05418b8..134ed10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,7 +69,7 @@ songbird = { version = "0.6.0", default-features = false, features = [ "tws", ] } strum = { version = "0.28.0", features = ["derive"] } -time = "0.3.47" +time = { version = "0.3.47", features = ["formatting", "parsing"] } tokio = { version = "1.46.0", features = ["rt-multi-thread", "macros", "signal", "time"] } tokio-util = { version = "0.7.18", features = ["io"] } tokio-websockets-0-13 = { package = "tokio-websockets", version = "0.13", features = [ diff --git a/src/command/render.rs b/src/command/render.rs index af2df8f..005b2fa 100644 --- a/src/command/render.rs +++ b/src/command/render.rs @@ -1,9 +1,18 @@ -use std::sync::LazyLock; -use twilight_model::application::{ - command::{Command, CommandType}, - interaction::Interaction, +use snafu::{OptionExt as _, Report, ResultExt as _, Snafu}; +use std::{collections::BTreeMap, sync::LazyLock}; +use time::{UtcDateTime, format_description::well_known::Rfc3339}; +use twilight_model::{ + application::{ + command::{Command, CommandOption, CommandOptionType, CommandType}, + interaction::{Interaction, InteractionData, application_command::CommandOptionValue}, + }, + channel::{ChannelType, message::{Embed, MessageFlags}}, + http::interaction::{InteractionResponse, InteractionResponseType}, + id::{Id, marker::ChannelMarker}, +}; +use twilight_util::builder::{ + InteractionResponseDataBuilder, command::CommandBuilder, embed::EmbedBuilder, }; -use twilight_util::builder::command::CommandBuilder; use crate::command::State; @@ -11,13 +20,162 @@ const NAME: &str = "render"; const DESCRIPTION: &str = "(Only the bot owner can use this) Make an audio file from the specified range of VC"; +const OPTION_VOICE_CHANNEL: &str = "voice-channel"; +const OPTION_START: &str = "start"; +const OPTION_END: &str = "end"; + +const DATE_FORMAT: Rfc3339 = Rfc3339; + pub static COMMAND: LazyLock = LazyLock::new(|| { CommandBuilder::new(NAME, DESCRIPTION, CommandType::ChatInput) + .option(CommandOption { + autocomplete: None, + channel_types: Some(vec![ChannelType::GuildVoice]), + choices: None, + description: "Which voice channel to render a recording from".into(), + description_localizations: None, + kind: CommandOptionType::Channel, + max_length: None, + max_value: None, + min_length: None, + min_value: None, + name: OPTION_VOICE_CHANNEL.into(), + name_localizations: None, + options: None, + required: Some(true), + }) + .option(CommandOption { + autocomplete: None, + channel_types: None, + choices: None, + description: "What UTC datetime to start from".into(), + description_localizations: None, + kind: CommandOptionType::String, + max_length: None, + max_value: None, + min_length: None, + min_value: None, + name: OPTION_START.into(), + name_localizations: None, + options: None, + required: Some(true), + }) + .option(CommandOption { + autocomplete: None, + channel_types: None, + choices: None, + description: "What UTC datetime to end at".into(), + description_localizations: None, + kind: CommandOptionType::String, + max_length: None, + max_value: None, + min_length: None, + min_value: None, + name: OPTION_END.into(), + name_localizations: None, + options: None, + required: Some(true), + }) .validate() .expect("command wasn't correct") .build() }); +struct Options { + voice_channel: Id, + start: UtcDateTime, + end: UtcDateTime, +} + +#[derive(Debug, Snafu)] +enum ParseOptionsError { + /// could not get any interaction data + NoInteractionData, + + /// this wasn't a command invocation + NotCommandInvocation, + + /// a voice channel wasn't selected + NoVoiceChannel, + + /// a start time wasn't specified + NoStart, + + /// an end time wasn't specified + NoEnd, + + /// voice channel was {actual:?} instead of a channel ID + VoiceChannelInvalidType { actual: CommandOptionValue }, + + /// start was {actual:?} instead of a string + StartInvalidType { actual: CommandOptionValue }, + + /// end was {actual:?} instead of a string + EndInvalidType { actual: CommandOptionValue }, + + /// could not parse `start` as a date in RFC3339 format + StartInvalidDate { source: time::error::Parse }, + + /// could not parse `start` as a date in RFC3339 format + EndInvalidDate { source: time::error::Parse }, +} + +impl From for Embed { + fn from(error: ParseOptionsError) -> Self { + EmbedBuilder::new().title("Error parsing options").description(Report::from_error(error).to_string()).validate().unwrap().build() + } +} + +fn parse_options(interaction: &Interaction) -> Result { + let interaction_data = interaction.data.as_ref().context(NoInteractionDataSnafu)?; + + let InteractionData::ApplicationCommand(command_data) = interaction_data else { + return Err(ParseOptionsError::NotCommandInvocation); + }; + + let mut options: BTreeMap<&str, &CommandOptionValue> = command_data + .options + .iter() + .map(|command_data_option| { + ( + command_data_option.name.as_str(), + &command_data_option.value, + ) + }) + .collect(); + + let voice_channel = options + .remove(OPTION_VOICE_CHANNEL) + .context(NoVoiceChannelSnafu)?; + let start = options.remove(OPTION_START).context(NoStartSnafu)?; + let end = options.remove(OPTION_END).context(NoEndSnafu)?; + + let &CommandOptionValue::Channel(voice_channel) = voice_channel else { + return Err(ParseOptionsError::VoiceChannelInvalidType { + actual: voice_channel.clone(), + }); + }; + let &CommandOptionValue::String(ref start) = start else { + return Err(ParseOptionsError::StartInvalidType { + actual: start.clone(), + }); + }; + let &CommandOptionValue::String(ref end) = end else { + return Err(ParseOptionsError::StartInvalidType { + actual: end.clone(), + }); + }; + + let start = UtcDateTime::parse(start, &DATE_FORMAT).context(StartInvalidDateSnafu)?; + let end = UtcDateTime::parse(end, &DATE_FORMAT).context(EndInvalidDateSnafu)?; + + Ok(Options { + voice_channel, + start, + end, + }) +} + #[tracing::instrument] pub async fn handle(state: State, interaction: Interaction) { let bot_owner_user_id = state.discord_bot_owner_user_id; @@ -29,5 +187,65 @@ pub async fn handle(state: State, interaction: Interaction) { .map(|user_id| user_id == bot_owner_user_id) .unwrap_or(false); + if !is_bot_owner { + state + .discord_client + .interaction(state.discord_application_id) + .create_response( + interaction.id, + &interaction.token, + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some( + InteractionResponseDataBuilder::new() + .embeds([EmbedBuilder::new() + .title("No permission") + .description("Only the bot owner can use this command.") + .validate() + .unwrap() + .build()]) + .flags(MessageFlags::EPHEMERAL) + .build(), + ), + }, + ) + .await + .expect("TODO"); + + return; + } + + let Options { + voice_channel, + start, + end, + } = match parse_options(&interaction) { + Ok(options) => options, + 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"); + + return; + } + }; + + tracing::info!(?voice_channel, ?start, ?end); + todo!(); }