feat: advertise opt in and opt out commands
This commit is contained in:
@@ -1,354 +1,362 @@
|
||||
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, EmbedFieldBuilder, EmbedFooterBuilder},
|
||||
};
|
||||
|
||||
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,
|
||||
|
||||
/// there are no voice chats in this guild
|
||||
NoVCsInGuild,
|
||||
|
||||
/// 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 guild_vcs = vcs.get(&guild_id).context(NoVCsInGuildSnafu)?;
|
||||
|
||||
let &voice_channel_id = guild_vcs.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::NoVCsInGuild => {
|
||||
EmbedBuilder::new().title("No VCs in this server").description("This bot can't find a VC to join because there aren't any in this server right now.").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 vcs = state.vcs;
|
||||
|
||||
let (guild_id, voice_channel_id) = match get_guild_and_voice_channel_id(&interaction, &vcs) {
|
||||
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 bot_owner_mention = format!("<@{}>", state.discord_bot_owner_user_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. Here are some pledges backed by faith (because there is no way to verify them yourself) in {bot_owner_mention}:"))
|
||||
.field(
|
||||
EmbedFieldBuilder::new("Recordings are never shared", "Audio recordings are only stored on my home server and desktop computer and will never be uploaded to services or hardware that is owned by another person: not even curated clips, and not even to people who were in the recording. When transcription to text is implemented, this will only be run on my personally owned devices and not on any internet or cloud offering.").build()
|
||||
)
|
||||
.field(
|
||||
EmbedFieldBuilder::new("You won't be \"audited\"", "I will not reference things said in past recordings with the goal of \"making a point\", nor pull them up on the spot (even by the request of the person who said it). Ideally, these are just peace of mind for me that I'm not missing out by not being in a Discord call all the time and can take my life back, so using them in an unhealthy way isn't in my interest.").build()
|
||||
)
|
||||
.field(
|
||||
EmbedFieldBuilder::new("Code is publicly available", "The latest source code is at https://gitea.katniss.top/jacob/fomo-reducer so that I don't have to write guarantees about the technology here (e.g. what data is acquired, how it's used or stored) and you can just check it yourself.").build()
|
||||
)
|
||||
.footer(
|
||||
EmbedFooterBuilder::new("Thanks for your patience and understanding as I have bad and unusual mental health and it's crazy that I need this. This - especially if I learn if I can record streams or webcams so I don't miss out on those experiences either - should be the end of abrasion and force about how we spend our time. Again, thank you, I appreciate it.")
|
||||
)
|
||||
.validate()
|
||||
.unwrap()
|
||||
.build()
|
||||
]))
|
||||
.await
|
||||
.expect("TODO");
|
||||
}
|
||||
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, EmbedFieldBuilder, EmbedFooterBuilder},
|
||||
};
|
||||
|
||||
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,
|
||||
|
||||
/// there are no voice chats in this guild
|
||||
NoVCsInGuild,
|
||||
|
||||
/// 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 guild_vcs = vcs.get(&guild_id).context(NoVCsInGuildSnafu)?;
|
||||
|
||||
let &voice_channel_id = guild_vcs.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::NoVCsInGuild => {
|
||||
EmbedBuilder::new().title("No VCs in this server").description("This bot can't find a VC to join because there aren't any in this server right now.").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 vcs = state.vcs;
|
||||
|
||||
let (guild_id, voice_channel_id) = match get_guild_and_voice_channel_id(&interaction, &vcs) {
|
||||
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 bot_owner_mention = format!("<@{}>", state.discord_bot_owner_user_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). Here are some pledges backed by faith (because there is no way to verify them yourself) in {bot_owner_mention}:"))
|
||||
.field(
|
||||
EmbedFieldBuilder::new("Recordings are never shared", "Audio recordings are only stored on my home server and desktop computer and will never be uploaded to services or hardware that is owned by another person: not even curated clips, and not even to people who were in the recording. When transcription to text is implemented, this will only be run on my personally owned devices and not on any internet or cloud offering.").build()
|
||||
)
|
||||
.field(
|
||||
EmbedFieldBuilder::new("You won't be \"audited\"", "I will not reference things said in past recordings with the goal of \"making a point\", nor pull them up on the spot (even by the request of the person who said it). Ideally, these are just peace of mind for me that I'm not missing out by not being in a Discord call all the time and can take my life back, so using them in an unhealthy way isn't in my interest.").build()
|
||||
)
|
||||
.field(
|
||||
EmbedFieldBuilder::new("Code is publicly available", "The latest source code is at https://gitea.katniss.top/jacob/fomo-reducer so that I don't have to write guarantees about the technology here (e.g. what data is acquired, how it's used or stored) and you can just check it yourself.").build()
|
||||
)
|
||||
.footer(
|
||||
EmbedFooterBuilder::new("Thanks for your patience and understanding as I have bad and unusual mental health and it's crazy that I need this. This - especially if I learn if I can record streams or webcams so I don't miss out on those experiences either - should be the end of abrasion and force about how we spend our time. Again, thank you, I appreciate it.")
|
||||
)
|
||||
.validate()
|
||||
.unwrap()
|
||||
.build()
|
||||
]))
|
||||
.await
|
||||
.expect("TODO");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user