Files
fomo-reducer/src/command/render.rs

390 lines
12 KiB
Rust

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<Command> = 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<ChannelMarker>,
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<ParseOptionsError> 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<Options, ParseOptionsError> {
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))]
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 sample_rate = u32::from(state.audio_sample_rate);
let total_samples = (duration.whole_seconds() as u32 * sample_rate)
+ (duration.subsec_microseconds() as u32 * 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);
while let Some(recording) = recordings.try_next().await.expect("TODO") {
tracing::debug!(?recording);
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 origin = (after_start.whole_seconds() as u32 * sample_rate)
+ (after_start.subsec_microseconds() as u32 * sample_rate / 1_000_000);
let origin = origin as usize;
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,
i,
total_samples,
"out of range"
);
}
}
}
let render = Render {
start,
end,
guild_id,
voice_channel_id,
};
let render_data = RenderData {
channels: state.audio_channels.into(),
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!();
}