Compare commits

...

2 Commits

Author SHA1 Message Date
f86c094dda feat: more work on /render 2026-05-26 00:10:52 -04:00
453208ff17 chore: satisfy warnings about async fn in traits 2026-05-26 00:10:28 -04:00
3 changed files with 243 additions and 18 deletions

View File

@@ -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 = [

View File

@@ -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<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<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 = 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!();
}

View File

@@ -3,23 +3,30 @@ use opendal::{Buffer, Error, ErrorKind, FuturesAsyncReader, Operator};
#[extension(pub trait OperatorExt)]
impl Operator {
async fn read_if_exists(&self, path: &str) -> Result<Option<Buffer>, Error> {
match self.read(path).await {
Ok(buffer) => Ok(Some(buffer)),
Err(error) if matches!(error.kind(), ErrorKind::NotFound) => Ok(None),
Err(error) => Err(error),
fn read_if_exists(
&self,
path: &str,
) -> impl Future<Output = Result<Option<Buffer>, Error>> + Send {
async {
match self.read(path).await {
Ok(buffer) => Ok(Some(buffer)),
Err(error) if matches!(error.kind(), ErrorKind::NotFound) => Ok(None),
Err(error) => Err(error),
}
}
}
async fn async_reader_if_exists(
fn async_reader_if_exists(
&self,
path: &str,
) -> Result<Option<FuturesAsyncReader>, Error> {
let reader = self.reader(path).await?;
match reader.into_futures_async_read(..).await {
Ok(reader) => Ok(Some(reader)),
Err(error) if matches!(error.kind(), ErrorKind::NotFound) => Ok(None),
Err(error) => Err(error),
) -> impl Future<Output = Result<Option<FuturesAsyncReader>, Error>> + Send {
async {
let reader = self.reader(path).await?;
match reader.into_futures_async_read(..).await {
Ok(reader) => Ok(Some(reader)),
Err(error) if matches!(error.kind(), ErrorKind::NotFound) => Ok(None),
Err(error) => Err(error),
}
}
}
}