Compare commits

..

2 Commits

Author SHA1 Message Date
2c0d5c8479 feat: advertise opt in and opt out commands 2026-04-22 12:11:36 -04:00
9b479d1236 feat: respect consent to be recorded 2026-04-22 11:57:57 -04:00
5 changed files with 406 additions and 338 deletions

View File

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

View File

@@ -12,17 +12,17 @@ use twilight_model::{
application::{command::Command, interaction::Interaction}, application::{command::Command, interaction::Interaction},
id::{ id::{
Id, Id,
marker::{ApplicationMarker, UserMarker}, marker::{ApplicationMarker, CommandMarker, UserMarker},
}, },
}; };
use crate::{GuildVoiceChannelToTextChannel, UserDataManager, VCs}; use crate::{GuildVoiceChannelToTextChannel, UserDataManager, VCs};
mod debug; pub mod debug;
mod join; pub mod join;
mod leave; pub mod leave;
mod opt_in; pub mod opt_in;
mod opt_out; pub mod opt_out;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct State { pub struct State {
@@ -33,6 +33,10 @@ pub struct State {
pub discord_application_id: Id<ApplicationMarker>, pub discord_application_id: Id<ApplicationMarker>,
pub discord_bot_owner_user_id: Id<UserMarker>, pub discord_bot_owner_user_id: Id<UserMarker>,
pub discord_client: Arc<twilight_http::Client>, pub discord_client: Arc<twilight_http::Client>,
pub discord_opt_in_command_id: Id<CommandMarker>,
pub discord_opt_in_command_name: Arc<str>,
pub discord_opt_out_command_id: Id<CommandMarker>,
pub discord_opt_out_command_name: Arc<str>,
pub discord_user_id: Id<UserMarker>, pub discord_user_id: Id<UserMarker>,
pub discord_voice_channel_corresponding_text_channel: Arc<GuildVoiceChannelToTextChannel>, pub discord_voice_channel_corresponding_text_channel: Arc<GuildVoiceChannelToTextChannel>,
pub recording_data: Operator, pub recording_data: Operator,

View File

@@ -1,4 +1,4 @@
mod command; pub mod command;
mod one_to_many; mod one_to_many;
mod one_to_many_with_data; mod one_to_many_with_data;
mod one_to_one; mod one_to_one;

View File

@@ -1,7 +1,7 @@
use clap::Parser; use clap::Parser;
use fomo_reducer::{ use fomo_reducer::{
CommandRouter, GuildVoiceChannelToTextChannel, State, Storage, UserDataManager, all_commands, CommandRouter, GuildVoiceChannelToTextChannel, State, Storage, UserDataManager, all_commands,
initialize_vcs, update_vcs, command, initialize_vcs, update_vcs,
}; };
use secrecy::{ExposeSecret, SecretString}; use secrecy::{ExposeSecret, SecretString};
use snafu::{OptionExt, ResultExt, Snafu}; use snafu::{OptionExt, ResultExt, Snafu};
@@ -10,7 +10,7 @@ use songbird::{
driver::{Channels, DecodeConfig, SampleRate}, driver::{Channels, DecodeConfig, SampleRate},
shards::TwilightMap, shards::TwilightMap,
}; };
use std::{fmt::Debug, str::FromStr, sync::Arc}; use std::{collections::BTreeMap, fmt::Debug, str::FromStr, sync::Arc};
use strum::EnumString; use strum::EnumString;
use tokio::{select, signal::ctrl_c, task::JoinSet}; use tokio::{select, signal::ctrl_c, task::JoinSet};
use tokio_util::{sync::CancellationToken, time::FutureExt as _}; use tokio_util::{sync::CancellationToken, time::FutureExt as _};
@@ -275,7 +275,7 @@ async fn main() -> Result<(), MainError> {
let commands = all_commands(); let commands = all_commands();
let _returned_commands = interaction_client let returned_commands = interaction_client
.set_global_commands( .set_global_commands(
Vec::from_iter( Vec::from_iter(
commands commands
@@ -290,6 +290,25 @@ async fn main() -> Result<(), MainError> {
.await .await
.expect("failed to deserialize set commands"); // TODO .expect("failed to deserialize set commands"); // TODO
let mut discord_command_name_to_returned_command = BTreeMap::from_iter(
returned_commands
.into_iter()
.map(|command| (command.name.clone(), command)),
);
let discord_opt_in_command = discord_command_name_to_returned_command
.remove(&command::opt_in::COMMAND.name)
.expect("TODO");
let discord_opt_out_command = discord_command_name_to_returned_command
.remove(&command::opt_out::COMMAND.name)
.expect("TODO");
let discord_opt_in_command_id = discord_opt_in_command.id.expect("TODO");
let discord_opt_out_command_id = discord_opt_out_command.id.expect("TODO");
let discord_opt_in_command_name = discord_opt_in_command.name.into();
let discord_opt_out_command_name = discord_opt_out_command.name.into();
let vcs = initialize_vcs(&discord_client).await; let vcs = initialize_vcs(&discord_client).await;
let command_router = CommandRouter::from_iter(commands); let command_router = CommandRouter::from_iter(commands);
@@ -329,6 +348,10 @@ async fn main() -> Result<(), MainError> {
discord_application_id, discord_application_id,
discord_bot_owner_user_id, discord_bot_owner_user_id,
discord_client, discord_client,
discord_opt_in_command_id,
discord_opt_in_command_name,
discord_opt_out_command_id,
discord_opt_out_command_name,
discord_user_id, discord_user_id,
discord_voice_channel_corresponding_text_channel, discord_voice_channel_corresponding_text_channel,
recording_data, recording_data,

View File

@@ -1,7 +1,7 @@
use std::str::FromStr; use std::str::FromStr;
use async_compression::futures::{bufread::BrotliDecoder, write::BrotliEncoder}; use async_compression::futures::{bufread::BrotliDecoder, write::BrotliEncoder};
use capnp::message::{TypedBuilder, TypedReader}; use capnp::message::TypedBuilder;
use futures::{AsyncReadExt, AsyncWriteExt, TryStream, TryStreamExt}; use futures::{AsyncReadExt, AsyncWriteExt, TryStream, TryStreamExt};
use opendal::Operator; use opendal::Operator;
use snafu::{OptionExt as _, ResultExt as _, Snafu, ensure}; use snafu::{OptionExt as _, ResultExt as _, Snafu, ensure};
@@ -9,6 +9,8 @@ use twilight_model::id::{Id, marker::UserMarker};
use crate::{OperatorExt, option_ext::OptionExt as _, user_capnp}; use crate::{OperatorExt, option_ext::OptionExt as _, user_capnp};
pub const RECORD_IF_CONSENT_UNSPECIFIED: bool = true;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct UserDataManager { pub struct UserDataManager {
operator: Operator, operator: Operator,
@@ -242,7 +244,10 @@ impl UserDataManager {
.await .await
.context(update_error::WriteSnafu)?; .context(update_error::WriteSnafu)?;
decompressed_writer.close().await.context(update_error::FinalizeSnafu)?; decompressed_writer
.close()
.await
.context(update_error::FinalizeSnafu)?;
Ok(ret) Ok(ret)
} }