348 lines
12 KiB
Rust
348 lines
12 KiB
Rust
use crate::{
|
|
OneToManyUniqueBTreeMap, UserDataManager, VCs, command::State, option_ext::OptionExt as _,
|
|
user_capnp::user::Consent, user_data::RECORD_IF_CONSENT_UNSPECIFIED,
|
|
};
|
|
use async_trait::async_trait;
|
|
use futures::FutureExt;
|
|
use hound::{SampleFormat, WavSpec};
|
|
use opendal::Operator;
|
|
use snafu::{OptionExt as _, Snafu};
|
|
use songbird::{CoreEvent, Event, EventContext, EventHandler};
|
|
use std::{
|
|
io::Cursor,
|
|
sync::{Arc, LazyLock, Mutex},
|
|
time::Instant,
|
|
};
|
|
use time::UtcDateTime;
|
|
use twilight_model::{
|
|
application::{
|
|
command::{Command, CommandType},
|
|
interaction::Interaction,
|
|
},
|
|
channel::message::{Embed, MessageFlags},
|
|
http::interaction::{InteractionResponse, InteractionResponseType},
|
|
id::{
|
|
Id,
|
|
marker::{ChannelMarker, GuildMarker, UserMarker},
|
|
},
|
|
};
|
|
use twilight_util::builder::{
|
|
InteractionResponseDataBuilder, command::CommandBuilder, embed::EmbedBuilder,
|
|
};
|
|
|
|
const NAME: &str = "join";
|
|
const DESCRIPTION: &str = "The bot will join the same VC as you (with intention to record)";
|
|
|
|
pub static COMMAND: LazyLock<Command> = LazyLock::new(|| {
|
|
CommandBuilder::new(NAME, DESCRIPTION, CommandType::ChatInput)
|
|
.validate()
|
|
.expect("command wasn't correct")
|
|
.build()
|
|
});
|
|
|
|
#[derive(Debug, Snafu)]
|
|
enum GetGuildAndVoiceChannelIdError {
|
|
/// this command was not used inside a guild (Discord server)
|
|
NotInGuild,
|
|
|
|
/// there is no user who invoked this command
|
|
NoUser,
|
|
|
|
/// the user is not in a voice chat in this guild
|
|
UserNotInVC,
|
|
}
|
|
|
|
#[tracing::instrument]
|
|
fn get_guild_and_voice_channel_id(
|
|
interaction: &Interaction,
|
|
vcs: &VCs,
|
|
) -> Result<(Id<GuildMarker>, Id<ChannelMarker>), GetGuildAndVoiceChannelIdError> {
|
|
let guild_id = interaction.guild_id.context(NotInGuildSnafu)?;
|
|
|
|
let user_id = interaction
|
|
.member
|
|
.as_ref()
|
|
.and_then(|member| member.user.as_ref().map(|user| user.id))
|
|
.context(NoUserSnafu)?;
|
|
|
|
let &voice_channel_id = vcs
|
|
.get(&guild_id)
|
|
.context(UserNotInVCSnafu)?
|
|
.get_left_for(&user_id)
|
|
.context(UserNotInVCSnafu)?;
|
|
|
|
Ok((guild_id, voice_channel_id))
|
|
}
|
|
|
|
fn get_guild_and_vc_error_to_embed(error: GetGuildAndVoiceChannelIdError) -> Embed {
|
|
match error {
|
|
GetGuildAndVoiceChannelIdError::NotInGuild => {
|
|
EmbedBuilder::new().title("Use this in a server").description("This bot can't find a VC to join if the command is used outside of a server (you might've used it in a DM?).").validate().unwrap().build()
|
|
},
|
|
GetGuildAndVoiceChannelIdError::NoUser => {
|
|
EmbedBuilder::new().title("Not invoked by a user").description("This command works by joining the same VC as the user, but this bot didn't receive any user data. So did no user invoke it?! (This error should be impossible!)").validate().unwrap().build()
|
|
},
|
|
GetGuildAndVoiceChannelIdError::UserNotInVC => {
|
|
EmbedBuilder::new().title("You're not in a VC").description("This bot can't follow you into VC if you aren't in one in this server.").validate().unwrap().build()
|
|
},
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct Handler {
|
|
start_instant: Instant,
|
|
start_utc: UtcDateTime,
|
|
|
|
recordings: Operator,
|
|
|
|
guild_id: Id<GuildMarker>,
|
|
channel_id: Id<ChannelMarker>,
|
|
|
|
known_ssrcs: Arc<Mutex<OneToManyUniqueBTreeMap<Id<UserMarker>, u32>>>,
|
|
|
|
audio_channels: u16,
|
|
audio_sample_rate: u32,
|
|
|
|
user_data_manager: UserDataManager,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl EventHandler for Handler {
|
|
async fn act(&self, ctx: &EventContext<'_>) -> Option<Event> {
|
|
match ctx {
|
|
EventContext::Track(_items) => {
|
|
// Not expected to fire
|
|
}
|
|
EventContext::SpeakingStateUpdate(speaking) => {
|
|
tracing::error!(?speaking);
|
|
|
|
if let Some(user_id) = speaking.user_id {
|
|
let user_id = Id::new(user_id.0);
|
|
|
|
self.known_ssrcs
|
|
.lock()
|
|
.unwrap()
|
|
.insert(user_id, speaking.ssrc);
|
|
}
|
|
}
|
|
EventContext::VoiceTick(voice_tick) => {
|
|
tracing::error!(?voice_tick);
|
|
|
|
for (ssrc, voice_data) in &voice_tick.speaking {
|
|
let user_id = self.known_ssrcs.lock().unwrap().get_left_for(ssrc).cloned();
|
|
|
|
tracing::info!(?user_id);
|
|
|
|
if let Some(pcm) = &voice_data.decoded_voice {
|
|
let may_record = user_id
|
|
.map_async(|user_id| {
|
|
self.user_data_manager
|
|
.with(user_id, |user_data| {
|
|
user_data.get_voice_recording_consent().unwrap()
|
|
})
|
|
.map(|result| result.expect("TODO"))
|
|
})
|
|
.await
|
|
.map_or(RECORD_IF_CONSENT_UNSPECIFIED, |consent| match consent {
|
|
Consent::Unspecified => RECORD_IF_CONSENT_UNSPECIFIED,
|
|
Consent::Granted => true,
|
|
Consent::Withheld => false,
|
|
});
|
|
|
|
if !may_record {
|
|
tracing::warn!(?user_id, "may not be recorded");
|
|
continue;
|
|
}
|
|
|
|
let elapsed = self.start_instant.elapsed();
|
|
let elapsed = elapsed.try_into().expect("TODO");
|
|
|
|
let now_utc = self.start_utc.checked_add(elapsed).expect("TODO");
|
|
tracing::error!(?now_utc, "TODO");
|
|
|
|
let year = now_utc.year();
|
|
let month = now_utc.month();
|
|
let day = now_utc.day();
|
|
|
|
let hour = now_utc.hour();
|
|
let minute = now_utc.minute();
|
|
let second = now_utc.second();
|
|
|
|
let microseconds = now_utc.microsecond();
|
|
|
|
let guild_id = self.guild_id;
|
|
let channel_id = self.channel_id;
|
|
|
|
let user = user_id
|
|
.as_ref()
|
|
.map_or_else(|| "UNKNOWN".into(), ToString::to_string);
|
|
|
|
let path = format!(
|
|
"{year}/{month}/{day}/{hour}/{minute}/audio-{second}.{microseconds}-{guild_id}-{channel_id}-{user}.wav"
|
|
);
|
|
|
|
let channels = self.audio_channels;
|
|
let sample_rate = self.audio_sample_rate;
|
|
|
|
let wav_spec = WavSpec {
|
|
channels,
|
|
sample_rate,
|
|
bits_per_sample: 16,
|
|
sample_format: SampleFormat::Int,
|
|
};
|
|
|
|
let mut buffer = Vec::new();
|
|
let writer = Cursor::new(&mut buffer);
|
|
|
|
let mut wav_writer = hound::WavWriter::new(writer, wav_spec).expect("TODO");
|
|
|
|
let mut sample_writer = wav_writer.get_i16_writer(pcm.len() as u32);
|
|
|
|
for sample in pcm {
|
|
sample_writer.write_sample(*sample);
|
|
}
|
|
sample_writer.flush().expect("TODO");
|
|
|
|
wav_writer.finalize().expect("TODO");
|
|
|
|
tracing::info!("going to write the audio shortly");
|
|
|
|
let recordings = self.recordings.clone();
|
|
tokio::spawn(async move {
|
|
recordings.write(&path, buffer).await.expect("TODO");
|
|
tracing::info!("successfully wrote the audio!");
|
|
});
|
|
}
|
|
}
|
|
}
|
|
EventContext::RtpPacket(_rtp_data) => {}
|
|
EventContext::RtcpPacket(_rtcp_data) => {}
|
|
EventContext::ClientDisconnect(_client_disconnect) => {
|
|
// This is already taken care of elsewhere
|
|
}
|
|
EventContext::DriverConnect(_connect_data) => {}
|
|
EventContext::DriverReconnect(_connect_data) => {}
|
|
EventContext::DriverDisconnect(_disconnect_data) => {}
|
|
other => {
|
|
tracing::warn!(?other, "cannot be handled yet");
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
}
|
|
|
|
#[tracing::instrument(skip(state))]
|
|
pub async fn handle(state: State, interaction: Interaction) {
|
|
let guild_and_voice_channel_id_res =
|
|
get_guild_and_voice_channel_id(&interaction, &state.vcs_watcher.borrow());
|
|
let (guild_id, voice_channel_id) = match guild_and_voice_channel_id_res {
|
|
Ok((guild_id, voice_channel_id)) => (guild_id, voice_channel_id),
|
|
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([get_guild_and_vc_error_to_embed(error)])
|
|
.flags(MessageFlags::EPHEMERAL)
|
|
.build(),
|
|
),
|
|
},
|
|
)
|
|
.await
|
|
.expect("TODO");
|
|
|
|
return;
|
|
}
|
|
};
|
|
|
|
state
|
|
.discord_client
|
|
.interaction(state.discord_application_id)
|
|
.create_response(
|
|
interaction.id,
|
|
&interaction.token,
|
|
&InteractionResponse {
|
|
kind: InteractionResponseType::DeferredChannelMessageWithSource,
|
|
data: None,
|
|
},
|
|
)
|
|
.await
|
|
.expect("TODO");
|
|
|
|
let call = state
|
|
.songbird
|
|
.join(guild_id, voice_channel_id)
|
|
.await
|
|
.expect("TODO");
|
|
|
|
tracing::error!(?call, "successfully joined");
|
|
|
|
let start_instant = Instant::now();
|
|
let start_utc = UtcDateTime::now();
|
|
|
|
let audio_channels = opus2::Channels::from(state.audio_channels) as u16;
|
|
|
|
let audio_sample_rate = u32::from(state.audio_sample_rate);
|
|
|
|
let handler = Handler {
|
|
start_instant,
|
|
start_utc,
|
|
recordings: state.recording_data,
|
|
guild_id,
|
|
channel_id: voice_channel_id,
|
|
known_ssrcs: Default::default(),
|
|
|
|
audio_channels,
|
|
audio_sample_rate,
|
|
|
|
user_data_manager: state.user_data_manager,
|
|
};
|
|
|
|
{
|
|
let mut call = call.lock().await;
|
|
|
|
call.add_global_event(CoreEvent::SpeakingStateUpdate.into(), handler.clone());
|
|
call.add_global_event(CoreEvent::VoiceTick.into(), handler);
|
|
|
|
call.mute(true).await.expect("TODO");
|
|
}
|
|
|
|
let channel_mention = format!("<#{voice_channel_id}>");
|
|
|
|
let info_mention = format!(
|
|
"</{}:{}>",
|
|
state.discord_info_command_name, state.discord_info_command_id
|
|
);
|
|
let opt_in_mention = format!(
|
|
"</{}:{}>",
|
|
state.discord_opt_in_command_name, state.discord_opt_in_command_id
|
|
);
|
|
let opt_out_mention = format!(
|
|
"</{}:{}>",
|
|
state.discord_opt_out_command_name, state.discord_opt_out_command_id
|
|
);
|
|
|
|
state
|
|
.discord_client
|
|
.interaction(state.discord_application_id)
|
|
.update_response(
|
|
&interaction.token,
|
|
).embeds(Some(&[
|
|
EmbedBuilder::new()
|
|
.title("Joined VC to record")
|
|
.description(format!("This bot joined {channel_mention} and intends to record. You can opt out with {opt_out_mention} or explicitly opt in with {opt_in_mention} (I'd appreciate this one). Please use {info_mention} for more information about this bot."))
|
|
.validate()
|
|
.unwrap()
|
|
.build()
|
|
]))
|
|
.await
|
|
.expect("TODO");
|
|
}
|