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,8 +1,12 @@
use crate::{OneToManyUniqueBTreeMap, VCs, command::State};
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, Snafu};
use snafu::{OptionExt as _, Snafu};
use songbird::{CoreEvent, Event, EventContext, EventHandler};
use std::{
io::Cursor,
@@ -104,6 +108,8 @@ struct Handler {
audio_channels: u16,
audio_sample_rate: u32,
user_data_manager: UserDataManager,
}
#[async_trait]
@@ -134,6 +140,26 @@ impl EventHandler for Handler {
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");
@@ -281,6 +307,8 @@ pub async fn handle(state: State, interaction: Interaction) {
audio_channels,
audio_sample_rate,
user_data_manager: state.user_data_manager,
};
{
@@ -293,9 +321,17 @@ pub async fn handle(state: State, interaction: Interaction) {
}
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)
@@ -304,7 +340,7 @@ pub async fn handle(state: State, interaction: Interaction) {
).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}:"))
.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()
)

View File

@@ -12,17 +12,17 @@ use twilight_model::{
application::{command::Command, interaction::Interaction},
id::{
Id,
marker::{ApplicationMarker, UserMarker},
marker::{ApplicationMarker, CommandMarker, UserMarker},
},
};
use crate::{GuildVoiceChannelToTextChannel, UserDataManager, VCs};
mod debug;
mod join;
mod leave;
mod opt_in;
mod opt_out;
pub mod debug;
pub mod join;
pub mod leave;
pub mod opt_in;
pub mod opt_out;
#[derive(Debug, Clone)]
pub struct State {
@@ -33,6 +33,10 @@ pub struct State {
pub discord_application_id: Id<ApplicationMarker>,
pub discord_bot_owner_user_id: Id<UserMarker>,
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_voice_channel_corresponding_text_channel: Arc<GuildVoiceChannelToTextChannel>,
pub recording_data: Operator,

View File

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

View File

@@ -1,7 +1,7 @@
use clap::Parser;
use fomo_reducer::{
CommandRouter, GuildVoiceChannelToTextChannel, State, Storage, UserDataManager, all_commands,
initialize_vcs, update_vcs,
command, initialize_vcs, update_vcs,
};
use secrecy::{ExposeSecret, SecretString};
use snafu::{OptionExt, ResultExt, Snafu};
@@ -10,7 +10,7 @@ use songbird::{
driver::{Channels, DecodeConfig, SampleRate},
shards::TwilightMap,
};
use std::{fmt::Debug, str::FromStr, sync::Arc};
use std::{collections::BTreeMap, fmt::Debug, str::FromStr, sync::Arc};
use strum::EnumString;
use tokio::{select, signal::ctrl_c, task::JoinSet};
use tokio_util::{sync::CancellationToken, time::FutureExt as _};
@@ -275,7 +275,7 @@ async fn main() -> Result<(), MainError> {
let commands = all_commands();
let _returned_commands = interaction_client
let returned_commands = interaction_client
.set_global_commands(
Vec::from_iter(
commands
@@ -290,6 +290,25 @@ async fn main() -> Result<(), MainError> {
.await
.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 command_router = CommandRouter::from_iter(commands);
@@ -329,6 +348,10 @@ async fn main() -> Result<(), MainError> {
discord_application_id,
discord_bot_owner_user_id,
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_voice_channel_corresponding_text_channel,
recording_data,

View File

@@ -1,7 +1,7 @@
use std::str::FromStr;
use async_compression::futures::{bufread::BrotliDecoder, write::BrotliEncoder};
use capnp::message::{TypedBuilder, TypedReader};
use capnp::message::TypedBuilder;
use futures::{AsyncReadExt, AsyncWriteExt, TryStream, TryStreamExt};
use opendal::Operator;
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};
pub const RECORD_IF_CONSENT_UNSPECIFIED: bool = true;
#[derive(Debug, Clone)]
pub struct UserDataManager {
operator: Operator,
@@ -242,7 +244,10 @@ impl UserDataManager {
.await
.context(update_error::WriteSnafu)?;
decompressed_writer.close().await.context(update_error::FinalizeSnafu)?;
decompressed_writer
.close()
.await
.context(update_error::FinalizeSnafu)?;
Ok(ret)
}