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 = 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, Id), 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, channel_id: Id, known_ssrcs: Arc, 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 { 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"); }