Compare commits

...

7 Commits

5 changed files with 886 additions and 704 deletions

1397
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,17 +4,16 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
async-compression = { version = "0.4.41", features = ["brotli", "futures-io"] } async-compression = { version = "0.4.42", features = ["brotli", "futures-io"] }
async-trait = "0.1.89" async-trait = "0.1.89"
bon = "3.9.1" bon = "3.9.3"
bytes = "1.11.1" bytes = "1.12.0"
capnp = "0.25.3" capnp = "0.25.6"
clap = { version = "4.5.40", features = ["derive", "env"] } clap = { version = "4.6.1", features = ["derive", "env"] }
dashmap = "6.1.0"
extension-traits = "2.0.2" extension-traits = "2.0.2"
futures = "0.3.32" futures = "0.3.32"
hound = "3.5.1" hound = "3.5.1"
humantime = "2.3.0" humantime = "2.4.0"
itertools = "0.14.0" itertools = "0.14.0"
moka = { version = "0.12.15", features = ["future"] } moka = { version = "0.12.15", features = ["future"] }
opendal = { git = "https://github.com/apache/opendal", rev = "ecf840b04afd2be109830b9978ba89759adfee79", features = [ opendal = { git = "https://github.com/apache/opendal", rev = "ecf840b04afd2be109830b9978ba89759adfee79", features = [
@@ -57,7 +56,7 @@ opendal = { git = "https://github.com/apache/opendal", rev = "ecf840b04afd2be109
opus2 = "0.4.0" opus2 = "0.4.0"
patricia_tree = "0.10.1" patricia_tree = "0.10.1"
rayon = "1.12" rayon = "1.12"
rhai = { version = "1.23.6", features = ["sync"] } rhai = { version = "1.25.1", features = ["sync"] }
rustls = "0.23" rustls = "0.23"
secrecy = { version = "0.10.3", features = ["serde"] } secrecy = { version = "0.10.3", features = ["serde"] }
shadow-rs = { version = "2.0.0", default-features = false } shadow-rs = { version = "2.0.0", default-features = false }
@@ -71,8 +70,13 @@ songbird = { version = "0.6.0", default-features = false, features = [
"tws", "tws",
] } ] }
strum = { version = "0.28.0", features = ["derive"] } strum = { version = "0.28.0", features = ["derive"] }
time = { version = "0.3.47", features = ["formatting", "macros", "parsing"] } time = { version = "0.3.53", features = ["formatting", "macros", "parsing"] }
tokio = { version = "1.46.0", features = ["rt-multi-thread", "macros", "signal", "time"] } tokio = { version = "1.52.3", features = [
"rt-multi-thread",
"macros",
"signal",
"time",
] }
tokio-util = { version = "0.7.18", features = ["io"] } tokio-util = { version = "0.7.18", features = ["io"] }
tokio-websockets-0-13 = { package = "tokio-websockets", version = "0.13", features = [ tokio-websockets-0-13 = { package = "tokio-websockets", version = "0.13", features = [
"rustls-webpki-roots", "rustls-webpki-roots",
@@ -80,9 +84,9 @@ tokio-websockets-0-13 = { package = "tokio-websockets", version = "0.13", featur
tokio-websockets-0-11 = { package = "tokio-websockets", version = "0.11", features = [ tokio-websockets-0-11 = { package = "tokio-websockets", version = "0.11", features = [
"rustls-webpki-roots", "rustls-webpki-roots",
] } ] }
tracing = "0.1.41" tracing = "0.1.44"
tracing-appender = "0.2.5" tracing-appender = "0.2.5"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
twilight-gateway = { version = "0.17", default-features = false, features = [ twilight-gateway = { version = "0.17", default-features = false, features = [
"rustls-webpki-roots", "rustls-webpki-roots",
"twilight-http", "twilight-http",

View File

@@ -1,15 +1,15 @@
ARG RUST_TAG=1.94.1-alpine3.23 ARG RUST_TAG=1.99.1-alpine3.24
FROM rust:${RUST_TAG} AS builder FROM rust:${RUST_TAG} AS builder
ARG BUILD_BASE_VERSION=0.5-r3 ARG BUILD_BASE_VERSION=0.5-r4
RUN --mount=type=cache,sharing=locked,target=/var/cache/apk \ RUN --mount=type=cache,sharing=locked,target=/var/cache/apk \
apk add --update build-base=${BUILD_BASE_VERSION} apk add --update build-base=${BUILD_BASE_VERSION}
ARG CAPNPROTO_DEV_VERSION=1.2.0-r0 ARG CAPNPROTO_DEV_VERSION=1.4.0-r0
RUN --mount=type=cache,sharing=locked,target=/var/cache/apk \ RUN --mount=type=cache,sharing=locked,target=/var/cache/apk \
apk add --update capnproto-dev=${CAPNPROTO_DEV_VERSION} apk add --update capnproto-dev=${CAPNPROTO_DEV_VERSION}
ARG CMAKE_VERSION=4.1.3-r0 ARG CMAKE_VERSION=4.2.3-r0
RUN --mount=type=cache,sharing=locked,target=/var/cache/apk \ RUN --mount=type=cache,sharing=locked,target=/var/cache/apk \
apk add --update cmake=${CMAKE_VERSION} apk add --update cmake=${CMAKE_VERSION}
@@ -17,7 +17,7 @@ ARG PROTOC_VERSION=31.1-r1
RUN --mount=type=cache,sharing=locked,target=/var/cache/apk \ RUN --mount=type=cache,sharing=locked,target=/var/cache/apk \
apk add --update protoc=${PROTOC_VERSION} apk add --update protoc=${PROTOC_VERSION}
ARG ZLIB_STATIC_VERSION=1.3.2-r0 ARG ZLIB_STATIC_VERSION=1.3.2-r1
RUN --mount=type=cache,sharing=locked,target=/var/cache/apk \ RUN --mount=type=cache,sharing=locked,target=/var/cache/apk \
apk add --update zlib-static=${ZLIB_STATIC_VERSION} apk add --update zlib-static=${ZLIB_STATIC_VERSION}

View File

@@ -1,4 +1,4 @@
use std::{collections::BTreeMap, num::NonZero, str::Utf8Error, sync::Arc}; use std::{cmp::Reverse, collections::BTreeMap, num::NonZero, str::Utf8Error, sync::Arc};
use futures::{StreamExt as _, stream::FuturesUnordered}; use futures::{StreamExt as _, stream::FuturesUnordered};
use itertools::Itertools as _; use itertools::Itertools as _;
@@ -18,11 +18,33 @@ use crate::{
vc_user::{Camera, Headphone, Microphone, Stream}, vc_user::{Camera, Headphone, Microphone, Stream},
}; };
type Heat = u64; #[derive(Debug, Clone, PartialEq, Eq, Ord)]
type Hot = NonZero<Heat>; struct Interest {
bot_owner_might_be_listening: bool,
total_other_users: usize,
bot_already_here: bool, // switching channels when someone streams/mutes/deafens is commonly seen as annoying
heat: u64,
}
type ChannelHeat = BTreeMap<Id<ChannelMarker>, Heat>; impl Interest {
type HeatMap = OneToManyUniqueBTreeMap<Heat, Id<ChannelMarker>>; const fn key(&self) -> impl PartialOrd {
(
Reverse(self.bot_owner_might_be_listening),
self.total_other_users,
self.bot_already_here,
self.heat,
)
}
}
impl PartialOrd for Interest {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
self.key().partial_cmp(&other.key())
}
}
type ChannelInterest = BTreeMap<Id<ChannelMarker>, Interest>;
type InterestMap = OneToManyUniqueBTreeMap<Interest, Id<ChannelMarker>>;
#[tracing::instrument(skip(state))] #[tracing::instrument(skip(state))]
pub async fn heat_seek(state: State) { pub async fn heat_seek(state: State) {
@@ -36,34 +58,34 @@ pub async fn heat_seek(state: State) {
vcs_in_guild_senders.entry(guild_id).or_insert_with(|| { vcs_in_guild_senders.entry(guild_id).or_insert_with(|| {
let (vcs_in_guild_sender, vcs_in_guild_watcher) = let (vcs_in_guild_sender, vcs_in_guild_watcher) =
watch::channel(Default::default()); watch::channel(Default::default());
let (channel_heat_sender, channel_heat_watcher) = let (channel_interest_sender, channel_interest_watcher) =
watch::channel(Default::default()); watch::channel(Default::default());
let (heat_map_sender, heat_map_watcher) = let (interest_map_sender, interest_map_watcher) =
watch::channel(Default::default()); watch::channel(Default::default());
let (hottest_vc_sender, hottest_vc_watcher) = let (hottest_vc_sender, hottest_vc_watcher) =
watch::channel(Default::default()); watch::channel(Default::default());
tokio::spawn( tokio::spawn(
evaluate_heat() evaluate_interest()
.bot_manager(state.bot_manager.clone()) .bot_manager(state.bot_manager.clone())
.bot_owner_user_id(state.discord_bot_owner_user_id) .bot_owner_user_id(state.discord_bot_owner_user_id)
.bot_user_id(state.discord_user_id) .bot_user_id(state.discord_user_id)
.cancellation_token(state.cancellation_token.clone()) .cancellation_token(state.cancellation_token.clone())
.channel_heat_sender(channel_heat_sender) .channel_interest_sender(channel_interest_sender)
.vcs_in_guild_watcher(vcs_in_guild_watcher) .vcs_in_guild_watcher(vcs_in_guild_watcher)
.call(), .call(),
); );
tokio::spawn( tokio::spawn(
map_heat() map_interest()
.cancellation_token(state.cancellation_token.clone()) .cancellation_token(state.cancellation_token.clone())
.channel_heat_watcher(channel_heat_watcher) .channel_interest_watcher(channel_interest_watcher)
.heat_map_sender(heat_map_sender) .interest_map_sender(interest_map_sender)
.call(), .call(),
); );
tokio::spawn( tokio::spawn(
track_hottest_vc() track_hottest_vc()
.cancellation_token(state.cancellation_token.clone()) .cancellation_token(state.cancellation_token.clone())
.heat_map_watcher(heat_map_watcher) .interest_map_watcher(interest_map_watcher)
.hottest_vc_sender(hottest_vc_sender) .hottest_vc_sender(hottest_vc_sender)
.call(), .call(),
); );
@@ -92,7 +114,7 @@ pub async fn heat_seek(state: State) {
} }
#[derive(Debug, Snafu)] #[derive(Debug, Snafu)]
enum GetHeatError { enum GetInterestError {
/// couldn't retrieve bot data /// couldn't retrieve bot data
WithBotDataError { source: bot_data::WithError }, WithBotDataError { source: bot_data::WithError },
@@ -111,12 +133,22 @@ enum GetHeatError {
#[bon::builder] #[bon::builder]
#[tracing::instrument] #[tracing::instrument]
async fn get_heat( async fn get_interest(
users_in_vc: &BTreeMap<Id<UserMarker>, UserInVCData>, mut users_in_vc: BTreeMap<Id<UserMarker>, UserInVCData>,
bot_user_id: Id<UserMarker>, bot_user_id: Id<UserMarker>,
bot_owner_user_id: Id<UserMarker>, bot_owner_user_id: Id<UserMarker>,
bot_manager: &BotManager, bot_manager: &BotManager,
) -> Result<Heat, GetHeatError> { ) -> Result<Interest, GetInterestError> {
let bot = users_in_vc.remove(&bot_user_id);
let bot_owner = users_in_vc.remove(&bot_owner_user_id);
let bot_owner_might_be_listening =
bot_owner.is_some_and(|user_data| matches!(user_data.headphone, Headphone::Undeafened));
let bot_already_here = bot.is_some();
let total_other_users = users_in_vc.len();
let heat_script = bot_manager let heat_script = bot_manager
.with(|bot_data| { .with(|bot_data| {
bot_data.has_heat_script().then(|| { bot_data.has_heat_script().then(|| {
@@ -152,11 +184,6 @@ async fn get_heat(
let heat = heat.unwrap_or_else(|| { let heat = heat.unwrap_or_else(|| {
tracing::warn!("using default heat scoring algorithm as no script was specified"); tracing::warn!("using default heat scoring algorithm as no script was specified");
let mut users_in_vc = users_in_vc.clone();
let _bot = users_in_vc.remove(&bot_user_id);
let bot_owner = users_in_vc.remove(&bot_owner_user_id);
let mut heat = 0; let mut heat = 0;
for (_user_id, user_in_vc_data) in users_in_vc { for (_user_id, user_in_vc_data) in users_in_vc {
@@ -174,45 +201,45 @@ async fn get_heat(
} }
} }
let bot_owner_might_be_listening =
bot_owner.is_some_and(|user_data| matches!(user_data.headphone, Headphone::Undeafened));
if bot_owner_might_be_listening {
heat = heat.min(999);
}
heat heat
}); });
Ok(heat) let interest = Interest {
bot_already_here,
bot_owner_might_be_listening,
heat,
total_other_users,
};
Ok(interest)
} }
#[bon::builder] #[bon::builder]
#[tracing::instrument(skip(vcs_in_guild_watcher, channel_heat_sender))] #[tracing::instrument(skip(vcs_in_guild_watcher, channel_interest_sender))]
async fn evaluate_heat( async fn evaluate_interest(
bot_manager: BotManager, bot_manager: BotManager,
bot_owner_user_id: Id<UserMarker>, bot_owner_user_id: Id<UserMarker>,
bot_user_id: Id<UserMarker>, bot_user_id: Id<UserMarker>,
cancellation_token: CancellationToken, cancellation_token: CancellationToken,
mut vcs_in_guild_watcher: watch::Receiver<Arc<VCsInGuild>>, mut vcs_in_guild_watcher: watch::Receiver<Arc<VCsInGuild>>,
channel_heat_sender: watch::Sender<ChannelHeat>, channel_interest_sender: watch::Sender<ChannelInterest>,
) { ) {
loop { loop {
let vcs_in_guild = { vcs_in_guild_watcher.borrow().clone() }; let vcs_in_guild = { vcs_in_guild_watcher.borrow().clone() };
let channel_heat_results: BTreeMap<_, _> = { let channel_interest_results: BTreeMap<_, _> = {
FuturesUnordered::from_iter((&*vcs_in_guild).into_iter().map( FuturesUnordered::from_iter((&*vcs_in_guild).into_iter().map(
|(&channel_id, users_in_vc)| { |(&channel_id, users_in_vc)| {
let bot_manager = bot_manager.clone(); let bot_manager = bot_manager.clone();
async move { async move {
( (
channel_id, channel_id,
get_heat() get_interest()
.bot_manager(&bot_manager) .bot_manager(&bot_manager)
.bot_owner_user_id(bot_owner_user_id) .bot_owner_user_id(bot_owner_user_id)
.bot_user_id(bot_user_id) .bot_user_id(bot_user_id)
.users_in_vc(users_in_vc) .users_in_vc(users_in_vc.clone())
.call() .call()
.await, .await,
) )
@@ -223,15 +250,19 @@ async fn evaluate_heat(
.collect() .collect()
.await; .await;
let (channel_heat, get_heat_errors): (ChannelHeat, Vec<_>) = channel_heat_results let (channel_interest, get_interest_errors): (ChannelInterest, Vec<_>) =
.into_iter() channel_interest_results
.map(|(channel_id, heat_result)| heat_result.map(|heat| (channel_id, heat))) .into_iter()
.partition_result(); .map(|(channel_id, heat_result)| heat_result.map(|heat| (channel_id, heat)))
.partition_result();
channel_heat_sender.send_replace(channel_heat); channel_interest_sender.send_replace(channel_interest);
for get_heat_error in get_heat_errors { for get_interest_error in get_interest_errors {
tracing::error!(?get_heat_error, "failed to evaluate heat of channel") tracing::error!(
?get_interest_error,
"failed to evaluate interest of channel"
)
} }
if matches!( if matches!(
vcs_in_guild_watcher vcs_in_guild_watcher
@@ -246,19 +277,19 @@ async fn evaluate_heat(
} }
#[bon::builder] #[bon::builder]
#[tracing::instrument(skip(channel_heat_watcher, heat_map_sender))] #[tracing::instrument(skip(channel_interest_watcher, interest_map_sender))]
async fn map_heat( async fn map_interest(
cancellation_token: CancellationToken, cancellation_token: CancellationToken,
mut channel_heat_watcher: watch::Receiver<ChannelHeat>, mut channel_interest_watcher: watch::Receiver<ChannelInterest>,
heat_map_sender: watch::Sender<HeatMap>, interest_map_sender: watch::Sender<InterestMap>,
) { ) {
loop { loop {
heat_map_sender.send_if_modified(|heat_map| { interest_map_sender.send_if_modified(|interest_map| {
let mut changed = false; let mut changed = false;
for (&channel, &heat) in &*channel_heat_watcher.borrow() { for (&channel, interest) in &*channel_interest_watcher.borrow() {
let existing = heat_map.insert(heat, channel); let existing = interest_map.insert(interest.clone(), channel);
if existing.is_none_or(|(old_heat, old_channel)| { if existing.is_none_or(|(old_interest, old_channel)| {
old_heat != heat || channel != old_channel old_interest != *interest || channel != old_channel
}) { }) {
changed = true; changed = true;
} }
@@ -267,7 +298,7 @@ async fn map_heat(
}); });
if matches!( if matches!(
channel_heat_watcher channel_interest_watcher
.changed() .changed()
.with_cancellation_token(&cancellation_token) .with_cancellation_token(&cancellation_token)
.await, .await,
@@ -279,22 +310,20 @@ async fn map_heat(
} }
#[bon::builder] #[bon::builder]
#[tracing::instrument(skip(heat_map_watcher, hottest_vc_sender))] #[tracing::instrument(skip(interest_map_watcher, hottest_vc_sender))]
async fn track_hottest_vc( async fn track_hottest_vc(
cancellation_token: CancellationToken, cancellation_token: CancellationToken,
mut heat_map_watcher: watch::Receiver<HeatMap>, mut interest_map_watcher: watch::Receiver<InterestMap>,
hottest_vc_sender: watch::Sender<Option<Id<ChannelMarker>>>, hottest_vc_sender: watch::Sender<Option<Id<ChannelMarker>>>,
) { ) {
loop { loop {
let new_hottest_vc_option = { let new_hottest_vc_option = {
heat_map_watcher interest_map_watcher
.borrow() .borrow()
.last_left_and_rights() .last_left_and_rights()
.and_then(|(&heat, hottest_vcs)| { .and_then(|(interest, most_interesting_vcs)| {
let hot_option = Hot::new(heat); (interest.heat > 0).then_some(*most_interesting_vcs.first().unwrap())
hot_option.map(|_| *hottest_vcs.first().unwrap())
}) })
}; };
@@ -305,7 +334,7 @@ async fn track_hottest_vc(
}); });
if matches!( if matches!(
heat_map_watcher interest_map_watcher
.changed() .changed()
.with_cancellation_token(&cancellation_token) .with_cancellation_token(&cancellation_token)
.await, .await,
@@ -368,7 +397,7 @@ async fn follow_hottest_vc(
.embeds(&[ .embeds(&[
EmbedBuilder::new() EmbedBuilder::new()
.title("Joined VC to record") .title("Joined VC to record")
.description(format!("This bot joined {vc_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.")) .description(format!("This bot joined {vc_mention} and records unless you opt out with {opt_out_mention} (yes I know it should require you to opt in with {opt_in_mention} instead, I just haven't done that yet, sorry).\nUse {info_mention} for an explanation of this bot."))
.validate() .validate()
.unwrap() .unwrap()
.build() .build()

View File

@@ -525,7 +525,7 @@ async fn main() -> Result<(), MainError> {
}) })
.unwrap_or(voice_channel_id); .unwrap_or(voice_channel_id);
let _ = discord_client.create_message(text_channel_id).content("back soon (when this bot disconnects but doesn't send this it's not expected to come back online so alerting the bot owner would be appreciated thanks)").await; let _ = discord_client.create_message(text_channel_id).content("back soon (sorry I can't stop this)").await;
} }
} }
}) })