Compare commits
2 Commits
09afa83348
...
76c20ff280
| Author | SHA1 | Date | |
|---|---|---|---|
|
76c20ff280
|
|||
|
45cac40234
|
1397
Cargo.lock
generated
1397
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
28
Cargo.toml
28
Cargo.toml
@@ -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",
|
||||||
|
|||||||
147
src/heat_seek.rs
147
src/heat_seek.rs
@@ -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<_>) =
|
||||||
|
channel_interest_results
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(channel_id, heat_result)| heat_result.map(|heat| (channel_id, heat)))
|
.map(|(channel_id, heat_result)| heat_result.map(|heat| (channel_id, heat)))
|
||||||
.partition_result();
|
.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()
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user