416 lines
13 KiB
Rust
416 lines
13 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, 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!();
|
|
}
|