use futures::TryStreamExt as _; use snafu::{OptionExt as _, Report, ResultExt as _, Snafu}; use std::{collections::BTreeMap, sync::LazyLock}; use time::{Date, 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 crate::{ command::State, recording_data::{Clip, Recording, RecordingData}, render_data::{Render, RenderData}, }; 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: 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_id = 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_id) = voice_channel_id else { return Err(ParseOptionsError::VoiceChannelInvalidType { actual: voice_channel_id.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_id, start, end, }) } #[tracing::instrument(skip(state, interaction))] pub async fn handle(state: State, interaction: Interaction) { let Some(guild_id) = interaction.guild_id else { 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("Not in a server") .description( "This command can only work when used in a Discord server.", ) .validate() .unwrap() .build()]) .flags(MessageFlags::EPHEMERAL) .build(), ), }, ) .await .expect("TODO"); return; }; let bot_owner_user_id = state.discord_bot_owner_user_id; let is_bot_owner = interaction .member .as_ref() .and_then(|member| member.user.as_ref().map(|user| user.id)) .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_id, 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; } }; let duration = end - start; tracing::info!(?voice_channel_id, ?start, ?end, ?duration); let channels = state.audio_channels.into(); let sample_rate = state.audio_sample_rate.into(); let total_samples = (duration.whole_seconds() as u64 * sample_rate) + (duration.subsec_microseconds() as u64 * sample_rate / 1_000_000); let mut composite = vec![0; total_samples as usize]; let mut recordings = state .recording_manager .between_in_vc(start, end, guild_id, voice_channel_id); let mut recordings_used = 0; while let Some(recording) = recordings.try_next().await.expect("TODO") { let recording_data = state .recording_manager .read(&recording) .await .expect("TODO"); let RecordingData { channels, sample_rate, samples, } = recording_data; let Recording { year, month, day, hour, minute, clip, } = recording; let Clip { second, microsecond, guild, voice_channel, user, } = clip; let date = Date::from_calendar_date(year, month, day).unwrap(); let time = Time::from_hms_micro(hour, minute, second, microsecond).unwrap(); let datetime = UtcDateTime::new(date, time); let after_start = datetime - start; let progress_by_time = after_start / duration; let origin = (after_start.whole_seconds() as u64 * sample_rate) + (after_start.subsec_microseconds() as u64 * sample_rate / 1_000_000); let origin = origin as usize; let progress_by_sample = (origin as f64) / (total_samples as f64); let samples_length = samples.len(); let extent = origin + samples_length; tracing::debug!( progress_by_time, progress_by_sample, ?after_start, ?duration, origin, total_samples, samples_length, extent ); for (i, sample) in samples.into_iter().enumerate() { if let Some(composite_sample) = composite.get_mut(origin + i) { *composite_sample += sample; } else { tracing::error!( ?start, ?end, ?year, ?month, ?day, ?hour, ?minute, ?second, ?microsecond, origin, samples_length, extent, i, total_samples, "out of range" ); } } recordings_used += 1; } tracing::debug!(?recordings_used); let render = Render { start, end, guild_id, voice_channel_id, }; let render_data = RenderData { channels, sample_rate, samples: composite, }; let render_result = state.render_manager.write(&render, render_data).await; tracing::info!(?render_result); render_result.expect("TODO"); tracing::info!(%render, "written"); todo!(); }