From 788552694403abd2fe00bc4178ba56a316ebd132 Mon Sep 17 00:00:00 2001 From: Jacob Date: Thu, 9 Apr 2026 22:39:02 -0400 Subject: [PATCH] feat: early steps of storage and configuration --- Cargo.lock | 60 +++++++++++--- Cargo.toml | 3 + bot.capnp | 5 ++ build.rs | 7 ++ src/command/debug.rs | 178 +++++++++++++++++++++++++++++++++++++++++ src/command/join.rs | 53 +++++++++++- src/command/leave.rs | 2 +- src/command/mod.rs | 10 ++- src/command/opt_in.rs | 24 ++++++ src/command/opt_out.rs | 2 +- src/lib.rs | 8 +- src/main.rs | 49 +++--------- src/storage.rs | 39 +++++++++ user.capnp | 7 ++ 14 files changed, 393 insertions(+), 54 deletions(-) create mode 100644 bot.capnp create mode 100644 build.rs create mode 100644 src/command/debug.rs create mode 100644 src/command/opt_in.rs create mode 100644 src/storage.rs create mode 100644 user.capnp diff --git a/Cargo.lock b/Cargo.lock index 7c3c571..2c7984c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -199,6 +199,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + [[package]] name = "async-executor" version = "1.14.0" @@ -479,6 +491,17 @@ dependencies = [ "piper", ] +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + [[package]] name = "brotli-decompressor" version = "5.0.0" @@ -768,6 +791,22 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "brotli", + "compression-core", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1652,6 +1691,8 @@ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" name = "fomo-reducer" version = "0.1.0" dependencies = [ + "async-compression", + "async-trait", "capnp", "capnpc", "clap", @@ -1664,6 +1705,7 @@ dependencies = [ "secrecy 0.10.3", "snafu", "songbird", + "time", "tokio", "tokio-util", "tracing", @@ -3487,9 +3529,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-derive" @@ -6563,30 +6605,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", diff --git a/Cargo.toml b/Cargo.toml index b11e71b..b534484 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,8 @@ version = "0.1.0" edition = "2024" [dependencies] +async-compression = { version = "0.4.41", features = ["brotli", "futures-io"] } +async-trait = "0.1.89" capnp = "0.25.3" clap = { version = "4.5.40", features = ["derive", "env"] } dashmap = "6.1.0" @@ -58,6 +60,7 @@ songbird = { version = "0.6.0", default-features = false, features = [ "twilight", "tws", ] } +time = "0.3.47" tokio = { version = "1.46.0", features = ["rt-multi-thread", "macros", "signal"] } tokio-util = "0.7.18" tracing = "0.1.41" diff --git a/bot.capnp b/bot.capnp new file mode 100644 index 0000000..27d295d --- /dev/null +++ b/bot.capnp @@ -0,0 +1,5 @@ +@0x993a671d7aa374f3; + +struct Bot { + heatScript @0 :Text; +} diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..f4c0f44 --- /dev/null +++ b/build.rs @@ -0,0 +1,7 @@ +fn main() { + capnpc::CompilerCommand::new() + .file("bot.capnp") + .file("user.capnp") + .run() + .expect("couldn't compile capnproto schemas"); +} diff --git a/src/command/debug.rs b/src/command/debug.rs new file mode 100644 index 0000000..d6aea4a --- /dev/null +++ b/src/command/debug.rs @@ -0,0 +1,178 @@ +use std::sync::LazyLock; + +use async_compression::futures::bufread::BrotliDecoder; +use capnp::message::ReaderOptions; +use futures::AsyncReadExt; +use opendal::ErrorKind; +use snafu::{OptionExt, Snafu}; +use twilight_model::{ + application::{ + command::{Command, CommandType}, + interaction::Interaction, + }, + channel::message::{Embed, MessageFlags}, + http::interaction::{InteractionResponse, InteractionResponseType}, + id::{Id, marker::UserMarker}, +}; +use twilight_util::builder::{ + InteractionResponseDataBuilder, + command::CommandBuilder, + embed::{EmbedBuilder, EmbedFieldBuilder}, +}; + +use crate::{bot_capnp, command::State}; + +const NAME: &str = "debug"; +const DESCRIPTION: &str = + "(Only the bot owner can use this) Show various information for debugging purposes"; + +pub static COMMAND: LazyLock = LazyLock::new(|| { + CommandBuilder::new(NAME, DESCRIPTION, CommandType::ChatInput) + .validate() + .expect("command wasn't correct") + .build() +}); + +#[derive(Debug, Snafu)] +enum NoPermission { + /// there isn't a user who invoked this command + NoUser, + + /// the user isn't allowed to use this command because they're not the bot owner + NotInvokedByBotOwner, +} + +fn no_permission_to_embed(error: NoPermission) -> Embed { + match error { + NoPermission::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() + }, + NoPermission::NotInvokedByBotOwner => { + EmbedBuilder::new().title("No permission to see debug info").description("Only the owner of this bot is allowed to see its information for debugging purposes.").validate().unwrap().build() + }, + } +} + +fn check_permission( + interaction: &Interaction, + bot_owner_user_id: Id, +) -> Result<(), NoPermission> { + let user_id = interaction + .member + .as_ref() + .and_then(|member| member.user.as_ref().map(|user| user.id)) + .context(NoUserSnafu)?; + + if user_id != bot_owner_user_id { + return Err(NoPermission::NotInvokedByBotOwner); + } + + Ok(()) +} + +#[tracing::instrument] +pub async fn handle(state: State, interaction: Interaction) { + if let Err(no_permission) = check_permission(&interaction, state.discord_bot_owner_user_id) { + state + .discord_client + .interaction(state.discord_application_id) + .create_response( + interaction.id, + &interaction.token, + &InteractionResponse { + kind: InteractionResponseType::ChannelMessageWithSource, + data: Some( + InteractionResponseDataBuilder::new() + .embeds([no_permission_to_embed(no_permission)]) + .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::ChannelMessageWithSource, + data: Some( + InteractionResponseDataBuilder::new() + .flags(MessageFlags::EPHEMERAL) + .content("some debug info is coming your way!") + .build(), + ), + }, + ) + .await + .expect("TODO"); + + let heat_script_description = { + let compressed_result = state + .bot_data + .reader("data.bin.brotli") + .await + .expect("TODO") + .into_futures_async_read(..) + .await; + + let mut buf = Vec::default(); + let mut message = capnp::message::TypedBuilder::::new_default(); + let fallback = message.init_root(); + + let message_reader; + let mut bot_data = fallback.into_reader(); + match compressed_result { + Ok(compressed) => { + let mut decompressed = BrotliDecoder::new(compressed); + decompressed.read_to_end(&mut buf).await.expect("TODO"); + + message_reader = + capnp::serialize_packed::read_message(&*buf, ReaderOptions::default()) + .expect("TODO"); + + bot_data = message_reader + .get_root::() + .expect("TODO"); + } + Err(error) if error.kind() == ErrorKind::NotFound => { + tracing::error!("TODO: proceeding with fallback"); + } + Err(error) => { + tracing::error!(?error, "TODO"); + + return; + } + } + + let heat_script_option = bot_data + .has_heat_script() + .then(|| bot_data.get_heat_script().expect("TODO")); + let heat_script_option = + heat_script_option.map(|heat_script| heat_script.to_str().expect("TODO")); + + heat_script_option.map_or("none set yet".into(), |heat_script| { + format!("```\n{heat_script}\n```") + }) + }; + + state + .discord_client + .interaction(state.discord_application_id) + .create_followup(&interaction.token) + .embeds(&[EmbedBuilder::new() + .field(EmbedFieldBuilder::new("Heat Script", heat_script_description).build()) + .validate() + .unwrap() + .build()]) + .flags(MessageFlags::EPHEMERAL) + .await + .expect("TODO"); +} diff --git a/src/command/join.rs b/src/command/join.rs index 49be9ee..aeea2b7 100644 --- a/src/command/join.rs +++ b/src/command/join.rs @@ -1,6 +1,9 @@ use crate::{VCs, command::State}; +use async_trait::async_trait; use snafu::{OptionExt, Snafu}; -use std::sync::LazyLock; +use songbird::{CoreEvent, Event, EventContext, EventHandler}; +use time::UtcDateTime; +use std::{sync::LazyLock, time::Instant}; use twilight_model::{ application::{ command::{Command, CommandType}, @@ -79,6 +82,45 @@ fn get_guild_and_vc_error_to_embed(error: GetGuildAndVoiceChannelIdError) -> Emb } } + +#[derive(Debug, Clone)] +struct Handler { + start_instant: Instant, + start_utc: UtcDateTime, +} + +#[async_trait] +impl EventHandler for Handler { + async fn act(&self, ctx: &EventContext<'_>) -> Option { + tracing::error!(?ctx, "TODO"); + + let Some(core_event) = ctx.to_core_event() else { + return None; + }; + tracing::error!(?core_event, "TODO"); + + 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"); + + match core_event { + CoreEvent::SpeakingStateUpdate => todo!(), + CoreEvent::VoiceTick => todo!(), + CoreEvent::RtpPacket => todo!(), + CoreEvent::RtcpPacket => todo!(), + CoreEvent::ClientDisconnect => todo!(), + CoreEvent::DriverConnect => todo!(), + CoreEvent::DriverReconnect => todo!(), + CoreEvent::DriverDisconnect => todo!(), + _ => todo!(), + } + + None + } +} + #[tracing::instrument(skip(state))] pub async fn handle(state: State, interaction: Interaction) { let vcs = state.vcs; @@ -131,6 +173,15 @@ pub async fn handle(state: State, interaction: Interaction) { tracing::error!(?call, "successfully joined"); + let start_instant = Instant::now(); + let start_utc = UtcDateTime::now(); + + let handler = Handler { start_instant, start_utc }; + call.lock().await.add_global_event( + CoreEvent::RtpPacket.into(), + handler, + ); + let channel_mention = format!("<#{voice_channel_id}>"); state diff --git a/src/command/leave.rs b/src/command/leave.rs index aa64e86..ee18f1a 100644 --- a/src/command/leave.rs +++ b/src/command/leave.rs @@ -128,7 +128,7 @@ pub async fn handle(state: State, interaction: Interaction) { data: Some( InteractionResponseDataBuilder::new() .embeds([ - EmbedBuilder::new().title("No permission to make this bot leave").description("Only the owner of the bot is allowed to make the bot leave RIGHT NOW. You might be looking for the opt out command.").validate().unwrap().build() + EmbedBuilder::new().title("No permission to make this bot leave").description("Only the owner of this bot is allowed to make the bot leave RIGHT NOW. You might be looking for the opt out command.").validate().unwrap().build() ]) .flags(MessageFlags::EPHEMERAL) .build(), diff --git a/src/command/mod.rs b/src/command/mod.rs index 99f0461..305061b 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -1,6 +1,7 @@ use std::{fmt::Debug, sync::Arc}; use futures::future::BoxFuture; +use opendal::Operator; use patricia_tree::StringPatriciaMap; use songbird::Songbird; use tokio_util::sync::CancellationToken; @@ -14,18 +15,23 @@ use twilight_model::{ use crate::VCs; +mod debug; mod join; mod leave; +mod opt_in; mod opt_out; #[derive(Debug, Clone)] pub struct State { + pub bot_data: Operator, pub cancellation_token: CancellationToken, pub discord_application_id: Id, pub discord_bot_owner_user_id: Id, pub discord_client: Arc, pub discord_user_id: Id, + pub recording_data: Operator, pub songbird: Arc, + pub user_data: Operator, pub vcs: Arc, } @@ -42,8 +48,10 @@ where pub fn all() -> Vec<(&'static Command, BoxedHandler)> { vec![ + (&debug::COMMAND, box_handler(debug::handle)), (&join::COMMAND, box_handler(join::handle)), (&leave::COMMAND, box_handler(leave::handle)), + (&opt_in::COMMAND, box_handler(opt_in::handle)), (&opt_out::COMMAND, box_handler(opt_out::handle)), ] } @@ -54,7 +62,7 @@ pub struct Router { } impl Router { - fn add_route(&mut self, name: &str, handler: Handler) + pub fn add_route(&mut self, name: &str, handler: Handler) where Fut: Future + Send + 'static, Handler: Send + Sync + Fn(State, Interaction) -> Fut + 'static, diff --git a/src/command/opt_in.rs b/src/command/opt_in.rs new file mode 100644 index 0000000..7378f5d --- /dev/null +++ b/src/command/opt_in.rs @@ -0,0 +1,24 @@ +use std::sync::LazyLock; + +use twilight_model::application::{ + command::{Command, CommandType}, + interaction::Interaction, +}; +use twilight_util::builder::command::CommandBuilder; + +use crate::command::State; + +const NAME: &str = "opt-in"; +const DESCRIPTION: &str = "Opt in to being recorded"; + +pub static COMMAND: LazyLock = LazyLock::new(|| { + CommandBuilder::new(NAME, DESCRIPTION, CommandType::ChatInput) + .validate() + .expect("command wasn't correct") + .build() +}); + +#[tracing::instrument] +pub async fn handle(state: State, interaction: Interaction) { + todo!(); +} diff --git a/src/command/opt_out.rs b/src/command/opt_out.rs index 489caf3..7651201 100644 --- a/src/command/opt_out.rs +++ b/src/command/opt_out.rs @@ -9,7 +9,7 @@ use twilight_util::builder::command::CommandBuilder; use crate::command::State; const NAME: &str = "opt-out"; -const DESCRIPTION: &str = "Opt out of being recorded (duration option TODO)"; +const DESCRIPTION: &str = "Opt out of being recorded"; pub static COMMAND: LazyLock = LazyLock::new(|| { CommandBuilder::new(NAME, DESCRIPTION, CommandType::ChatInput) diff --git a/src/lib.rs b/src/lib.rs index df4dcda..ff05ddd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,13 +2,17 @@ mod command; mod one_to_many; mod one_to_many_with_data; mod one_to_one; +mod storage; mod track_vcs; mod vc_user; +pub use command::{Router as CommandRouter, State, all as all_commands}; pub use one_to_many::OneToManyUniqueBTreeMap; pub use one_to_many_with_data::OneToManyUniqueBTreeMapWithData; pub use one_to_one::OneToOneBTreeMap; - -pub use command::{Router as CommandRouter, State, all as all_commands}; +pub use storage::Storage; pub use track_vcs::{VCs, initialize_vcs, update_vcs}; pub use vc_user::{UserInVCData, VoiceStatus}; + +capnp::generated_code!(pub mod user_capnp); +capnp::generated_code!(pub mod bot_capnp); diff --git a/src/main.rs b/src/main.rs index 0f5148e..5f9b154 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,55 +1,19 @@ use clap::Parser; -use fomo_reducer::{CommandRouter, State, all_commands, initialize_vcs, update_vcs}; -use opendal::{IntoOperatorUri, Operator, OperatorUri}; +use fomo_reducer::{CommandRouter, State, Storage, all_commands, initialize_vcs, update_vcs}; use secrecy::{ExposeSecret, SecretString}; use snafu::Snafu; use songbird::{Songbird, shards::TwilightMap}; -use std::{fmt::Debug, str::FromStr, sync::Arc}; +use std::{fmt::Debug, sync::Arc}; use tokio::{select, signal::ctrl_c, task::JoinSet}; use tokio_util::{sync::CancellationToken, time::FutureExt as _}; use tracing_subscriber::{EnvFilter, fmt::format::FmtSpan}; -use twilight_gateway::{Event, EventTypeFlags, Intents, Shard, ShardId, StreamExt}; +use twilight_gateway::{Event, EventTypeFlags, Intents, Shard, StreamExt}; use twilight_model::{ application::interaction::InteractionData, gateway::payload::incoming::InteractionCreate, id::{Id, marker::UserMarker}, }; -#[derive(Clone)] -struct Storage { - uri: OperatorUri, - operator: Operator, -} - -impl FromStr for Storage { - type Err = opendal::Error; - - fn from_str(s: &str) -> Result { - let uri = s.into_operator_uri()?; - let operator = Operator::from_uri(&uri)?; - - Ok(Self { uri, operator }) - } -} - -impl Storage { - fn into_inner(self) -> Operator { - self.operator - } -} - -impl From for Operator { - fn from(wrapper: Storage) -> Self { - wrapper.into_inner() - } -} - -impl Debug for Storage { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - Debug::fmt(&self.uri, f) - } -} - #[derive(Debug, Parser)] struct AppArgs { #[arg(long, env)] @@ -198,13 +162,20 @@ async fn main() -> Result<(), MainError> { let songbird = Arc::new(songbird); let vcs = Arc::new(vcs); + let bot_data = bot_data.into_inner(); + let recording_data = recording_data.into_inner(); + let user_data = user_data.into_inner(); + let state = State { + bot_data, cancellation_token: cancellation_token.clone(), discord_application_id, discord_bot_owner_user_id: bot_owner, discord_client, discord_user_id, + recording_data, songbird, + user_data, vcs, }; diff --git a/src/storage.rs b/src/storage.rs new file mode 100644 index 0000000..e33cf78 --- /dev/null +++ b/src/storage.rs @@ -0,0 +1,39 @@ +use std::{fmt::Debug, str::FromStr}; + +use opendal::{IntoOperatorUri, Operator, OperatorUri}; + + +#[derive(Clone)] +pub struct Storage { + uri: OperatorUri, + operator: Operator, +} + +impl FromStr for Storage { + type Err = opendal::Error; + + fn from_str(s: &str) -> Result { + let uri = s.into_operator_uri()?; + let operator = Operator::from_uri(&uri)?; + + Ok(Self { uri, operator }) + } +} + +impl Storage { + pub fn into_inner(self) -> Operator { + self.operator + } +} + +impl From for Operator { + fn from(wrapper: Storage) -> Self { + wrapper.into_inner() + } +} + +impl Debug for Storage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Debug::fmt(&self.uri, f) + } +} diff --git a/user.capnp b/user.capnp new file mode 100644 index 0000000..24c8c8f --- /dev/null +++ b/user.capnp @@ -0,0 +1,7 @@ +@0xc3e8b8ea9947b0c5; + +struct User { + notificationScript @0 :Text; + + voiceRecordingConsent @1 :Bool; +}