Compare commits
54 Commits
9dac20b9ef
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
906ddec843
|
|||
|
48e92d1736
|
|||
|
733e8f73ea
|
|||
| 2c0d5c8479 | |||
| 9b479d1236 | |||
| 29f97f82c4 | |||
| dd17de79de | |||
| bb96724454 | |||
| 4dae5bac7a | |||
| 37753fe37c | |||
| 7fe6980867 | |||
| 0ce26fc0e5 | |||
| 62399c2046 | |||
| 612a696829 | |||
| 2bf42e47c5 | |||
| 50230c43b8 | |||
| e4e274a543 | |||
| 6cd7f00028 | |||
| 6a1d8f060f | |||
| 766582c9e8 | |||
| 784ec5e867 | |||
| dbcc155c4c | |||
| 31adbc2027 | |||
| 74bd37a67f | |||
| e609428f95 | |||
| d129913235 | |||
| 066bff4c07 | |||
| 509278c6eb | |||
| f31d06bdf9 | |||
| d93b53267e | |||
| 6ad797eaea | |||
| 4b71e5ef85 | |||
| a99840ffb7 | |||
| 38196e84ec | |||
| 0fdb83a9d3 | |||
| dd6c1723e5 | |||
| 01a55d42ec | |||
| 33a7b15720 | |||
| b457375e69 | |||
| 58212ce240 | |||
| 1b88e6a11d | |||
| d8d2526782 | |||
| 0dd335334d | |||
| 666d13f25b | |||
| dfda319ab4 | |||
| 7885526944 | |||
| 7d3a309d2b | |||
| d2511f7a55 | |||
| 288a784870 | |||
| 1bd8b9b203 | |||
| 3897d1deb2 | |||
|
663586a65c
|
|||
| 7f569629db | |||
| b3936a79b6 |
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
.history/
|
||||||
1517
Cargo.lock
generated
1517
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
34
Cargo.toml
34
Cargo.toml
@@ -4,10 +4,17 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
async-compression = { version = "0.4.41", features = ["brotli", "futures-io"] }
|
||||||
|
async-trait = "0.1.89"
|
||||||
|
bytes = "1.11.1"
|
||||||
|
capnp = "0.25.3"
|
||||||
clap = { version = "4.5.40", features = ["derive", "env"] }
|
clap = { version = "4.5.40", features = ["derive", "env"] }
|
||||||
dashmap = "6.1.0"
|
dashmap = "6.1.0"
|
||||||
|
extension-traits = "2.0.2"
|
||||||
futures = "0.3.32"
|
futures = "0.3.32"
|
||||||
opendal = { git = "https://github.com/apache/opendal", features = [
|
hound = "3.5.1"
|
||||||
|
moka = { version = "0.12.15", features = ["future"] }
|
||||||
|
opendal = { git = "https://github.com/apache/opendal", rev = "ecf840b04afd2be109830b9978ba89759adfee79", features = [
|
||||||
"services-azfile",
|
"services-azfile",
|
||||||
"services-aliyun-drive",
|
"services-aliyun-drive",
|
||||||
"services-alluxio",
|
"services-alluxio",
|
||||||
@@ -44,12 +51,13 @@ opendal = { git = "https://github.com/apache/opendal", features = [
|
|||||||
"services-sled",
|
"services-sled",
|
||||||
"services-webdav",
|
"services-webdav",
|
||||||
] }
|
] }
|
||||||
|
opus2 = "0.4.0"
|
||||||
patricia_tree = "0.10.1"
|
patricia_tree = "0.10.1"
|
||||||
rhai = "1.23.6"
|
rhai = "1.23.6"
|
||||||
rustls = "0.23"
|
rustls = "0.23"
|
||||||
secrecy = { version = "0.10.3", features = ["serde"] }
|
secrecy = { version = "0.10.3", features = ["serde"] }
|
||||||
snafu = { version = "0.8.9", features = ["futures"] }
|
snafu = { version = "0.8.9", features = ["futures"] }
|
||||||
songbird = { version = "0.5.0", default-features = false, features = [
|
songbird = { version = "0.6.0", default-features = false, features = [
|
||||||
"driver",
|
"driver",
|
||||||
"gateway",
|
"gateway",
|
||||||
"receive",
|
"receive",
|
||||||
@@ -57,11 +65,25 @@ songbird = { version = "0.5.0", default-features = false, features = [
|
|||||||
"twilight",
|
"twilight",
|
||||||
"tws",
|
"tws",
|
||||||
] }
|
] }
|
||||||
tokio = { version = "1.46.0", features = ["rt-multi-thread", "macros"] }
|
strum = { version = "0.28.0", features = ["derive"] }
|
||||||
|
time = "0.3.47"
|
||||||
|
tokio = { version = "1.46.0", features = [
|
||||||
|
"rt-multi-thread",
|
||||||
|
"macros",
|
||||||
|
"signal",
|
||||||
|
] }
|
||||||
|
tokio-util = { version = "0.7.18", features = ["io"] }
|
||||||
|
tokio-websockets-0-13 = { package = "tokio-websockets", version = "0.13", features = [
|
||||||
|
"rustls-webpki-roots",
|
||||||
|
] }
|
||||||
|
tokio-websockets-0-11 = { package = "tokio-websockets", version = "0.11", features = [
|
||||||
|
"rustls-webpki-roots",
|
||||||
|
] }
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.19", 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 = { version = "0.17", default-features = false, features = [
|
twilight-http = { version = "0.17", default-features = false, features = [
|
||||||
"rustls-webpki-roots",
|
"rustls-webpki-roots",
|
||||||
@@ -71,7 +93,7 @@ twilight-http = { version = "0.17", default-features = false, features = [
|
|||||||
twilight-model = "0.17"
|
twilight-model = "0.17"
|
||||||
twilight-util = { version = "0.17", features = ["builder"] }
|
twilight-util = { version = "0.17", features = ["builder"] }
|
||||||
typed-builder = "0.23.2"
|
typed-builder = "0.23.2"
|
||||||
|
yoke = "0.8.2"
|
||||||
|
|
||||||
[patch.crates-io]
|
[build-dependencies]
|
||||||
audiopus_sys = { git = "https://github.com/amsam0/audiopus_sys", rev = "3955ddb2b7b87e772e1c7bb93b8726a864f8c8f9" }
|
capnpc = "0.25.3"
|
||||||
songbird = { git = "https://gitea.katniss.top/jacob/songbird", branch = "twilight-0.17" }
|
|
||||||
|
|||||||
39
Dockerfile
Normal file
39
Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
ARG RUST_TAG=1.94.1-alpine3.23
|
||||||
|
FROM rust:${RUST_TAG} AS builder
|
||||||
|
|
||||||
|
ARG BUILD_BASE_VERSION=0.5-r3
|
||||||
|
RUN --mount=type=cache,sharing=locked,target=/var/cache/apk \
|
||||||
|
apk add --update build-base=${BUILD_BASE_VERSION}
|
||||||
|
|
||||||
|
ARG CAPNPROTO_DEV_VERSION=1.2.0-r0
|
||||||
|
RUN --mount=type=cache,sharing=locked,target=/var/cache/apk \
|
||||||
|
apk add --update capnproto-dev=${CAPNPROTO_DEV_VERSION}
|
||||||
|
|
||||||
|
ARG CMAKE_VERSION=4.1.3-r0
|
||||||
|
RUN --mount=type=cache,sharing=locked,target=/var/cache/apk \
|
||||||
|
apk add --update cmake=${CMAKE_VERSION}
|
||||||
|
|
||||||
|
ARG PROTOC_VERSION=31.1-r1
|
||||||
|
RUN --mount=type=cache,sharing=locked,target=/var/cache/apk \
|
||||||
|
apk add --update protoc=${PROTOC_VERSION}
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
# This one would be nice if it worked:
|
||||||
|
# --mount=type=bind,source=.,target=/root/app \
|
||||||
|
# Use this one if there's a hard to track down "read-only filesystem" error:
|
||||||
|
--mount=type=bind,source=.,target=/root/app,rw \
|
||||||
|
|
||||||
|
# --mount=type=bind,source=./target,target=/root/app/target,rw \
|
||||||
|
--mount=type=cache,sharing=private,target=/root/app/target \
|
||||||
|
# --mount=type=cache,sharing=private,target=/root/app/target,uid=1000,gid=1000 \
|
||||||
|
--mount=type=cache,target=/usr/local/cargo/git/db \
|
||||||
|
--mount=type=cache,target=/usr/local/cargo/registry/ \
|
||||||
|
cd /root/app && \
|
||||||
|
cargo build --release && \
|
||||||
|
cp target/release/fomo-reducer /root/program
|
||||||
|
|
||||||
|
FROM scratch AS runner
|
||||||
|
|
||||||
|
COPY --from=builder /root/program /program
|
||||||
|
|
||||||
|
ENTRYPOINT ["/program"]
|
||||||
5
bot.capnp
Normal file
5
bot.capnp
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
@0x993a671d7aa374f3;
|
||||||
|
|
||||||
|
struct Bot {
|
||||||
|
heatScript @0 :Text;
|
||||||
|
}
|
||||||
7
build.rs
Normal file
7
build.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
fn main() {
|
||||||
|
capnpc::CompilerCommand::new()
|
||||||
|
.file("bot.capnp")
|
||||||
|
.file("user.capnp")
|
||||||
|
.run()
|
||||||
|
.expect("couldn't compile capnproto schemas");
|
||||||
|
}
|
||||||
175
src/bot_data.rs
Normal file
175
src/bot_data.rs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
use async_compression::futures::{bufread::BrotliDecoder, write::BrotliEncoder};
|
||||||
|
use capnp::message::TypedBuilder;
|
||||||
|
use futures::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use opendal::Operator;
|
||||||
|
use snafu::{ResultExt as _, Snafu};
|
||||||
|
|
||||||
|
use crate::{OperatorExt, bot_capnp, option_ext::OptionExt as _};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BotDataManager {
|
||||||
|
operator: Operator,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BotDataManager {
|
||||||
|
pub fn new(operator: Operator) -> Self {
|
||||||
|
Self { operator }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PATH: &str = "data.bin.brotli";
|
||||||
|
|
||||||
|
#[derive(Debug, Snafu)]
|
||||||
|
#[snafu(module)]
|
||||||
|
pub enum WithError {
|
||||||
|
/// couldn't read data for this bot from the storage operator
|
||||||
|
ReadError { source: opendal::Error },
|
||||||
|
|
||||||
|
/// couldn't decompress the bot data from storage
|
||||||
|
DecompressionError { source: std::io::Error },
|
||||||
|
|
||||||
|
/// couldn't deserialize the bot data
|
||||||
|
DeserializeError { source: capnp::Error },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BotDataManager {
|
||||||
|
pub async fn with<R>(
|
||||||
|
&self,
|
||||||
|
f: impl FnOnce(bot_capnp::bot::Reader<'_>) -> R,
|
||||||
|
) -> Result<R, WithError> {
|
||||||
|
let compressed_buffer = self
|
||||||
|
.operator
|
||||||
|
.async_reader_if_exists(PATH)
|
||||||
|
.await
|
||||||
|
.context(with_error::ReadSnafu)?;
|
||||||
|
|
||||||
|
let decompressed_reader = compressed_buffer.map(BrotliDecoder::new);
|
||||||
|
let decompressed = decompressed_reader
|
||||||
|
.map_async(|mut reader| async move {
|
||||||
|
let mut vec = Vec::new();
|
||||||
|
reader.read_to_end(&mut vec).await?;
|
||||||
|
Ok(vec)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.transpose()
|
||||||
|
.context(with_error::DecompressionSnafu)?;
|
||||||
|
|
||||||
|
let mut message = TypedBuilder::<bot_capnp::bot::Owned>::new_default();
|
||||||
|
let fallback = message.init_root();
|
||||||
|
|
||||||
|
let mut bot_data = fallback.into_reader();
|
||||||
|
let message_reader;
|
||||||
|
|
||||||
|
if let Some(mut bytes) = decompressed.as_deref() {
|
||||||
|
message_reader = capnp::serialize::read_message_from_flat_slice_no_alloc(
|
||||||
|
&mut bytes,
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.context(with_error::DeserializeSnafu)?;
|
||||||
|
|
||||||
|
bot_data = message_reader
|
||||||
|
.get_root()
|
||||||
|
.context(with_error::DeserializeSnafu)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(f(bot_data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Snafu)]
|
||||||
|
#[snafu(module)]
|
||||||
|
pub enum UpdateError {
|
||||||
|
/// couldn't read data for this bot from the storage operator
|
||||||
|
ReadError { source: opendal::Error },
|
||||||
|
|
||||||
|
/// couldn't decompress the bot data from storage
|
||||||
|
DecompressionError { source: std::io::Error },
|
||||||
|
|
||||||
|
/// couldn't deserialize the bot data
|
||||||
|
DeserializeError { source: capnp::Error },
|
||||||
|
|
||||||
|
/// couldn't serialize the (modified) bot data
|
||||||
|
SerializeError { source: capnp::Error },
|
||||||
|
|
||||||
|
/// couldn't create a writer for this bot data in the storage operator
|
||||||
|
WriterError { source: opendal::Error },
|
||||||
|
|
||||||
|
/// couldn't write (modified) data for this bot to the storage operator
|
||||||
|
WriteError { source: std::io::Error },
|
||||||
|
|
||||||
|
/// couldn't finalize writing (modified) data for this bot to the storage operator
|
||||||
|
FinalizeError { source: std::io::Error },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BotDataManager {
|
||||||
|
pub async fn update<R>(
|
||||||
|
&self,
|
||||||
|
f: impl FnOnce(bot_capnp::bot::Builder<'_>) -> R,
|
||||||
|
) -> Result<R, UpdateError> {
|
||||||
|
let compressed_buffer = self
|
||||||
|
.operator
|
||||||
|
.async_reader_if_exists(PATH)
|
||||||
|
.await
|
||||||
|
.context(update_error::ReadSnafu)?;
|
||||||
|
|
||||||
|
let decompressed_reader = compressed_buffer.map(BrotliDecoder::new);
|
||||||
|
let decompressed = decompressed_reader
|
||||||
|
.map_async(|mut reader| async move {
|
||||||
|
let mut vec = Vec::new();
|
||||||
|
reader.read_to_end(&mut vec).await?;
|
||||||
|
Ok(vec)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.transpose()
|
||||||
|
.context(update_error::DecompressionSnafu)?;
|
||||||
|
|
||||||
|
let mut message = TypedBuilder::<bot_capnp::bot::Owned>::new_default();
|
||||||
|
|
||||||
|
let ret = if let Some(mut bytes) = decompressed.as_deref() {
|
||||||
|
let message_reader = capnp::serialize::read_message_from_flat_slice_no_alloc(
|
||||||
|
&mut bytes,
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.context(update_error::DeserializeSnafu)?;
|
||||||
|
|
||||||
|
let bot_data = message_reader
|
||||||
|
.get_root()
|
||||||
|
.context(update_error::DeserializeSnafu)?;
|
||||||
|
|
||||||
|
message
|
||||||
|
.set_root(bot_data)
|
||||||
|
.context(update_error::DeserializeSnafu)?;
|
||||||
|
|
||||||
|
f(
|
||||||
|
message.get_root().unwrap(), // this is logically impossible
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
f(message.init_root())
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
capnp::serialize::write_message(&mut buffer, message.borrow_inner())
|
||||||
|
.context(update_error::SerializeSnafu)?;
|
||||||
|
|
||||||
|
let compressed_writer = self
|
||||||
|
.operator
|
||||||
|
.writer(PATH)
|
||||||
|
.await
|
||||||
|
.context(update_error::WriterSnafu)?
|
||||||
|
.into_futures_async_write();
|
||||||
|
|
||||||
|
let mut decompressed_writer = BrotliEncoder::new(compressed_writer);
|
||||||
|
|
||||||
|
decompressed_writer
|
||||||
|
.write_all(&buffer)
|
||||||
|
.await
|
||||||
|
.context(update_error::WriteSnafu)?;
|
||||||
|
|
||||||
|
decompressed_writer
|
||||||
|
.close()
|
||||||
|
.await
|
||||||
|
.context(update_error::FinalizeSnafu)?;
|
||||||
|
|
||||||
|
Ok(ret)
|
||||||
|
}
|
||||||
|
}
|
||||||
185
src/command/info.rs
Normal file
185
src/command/info.rs
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
use futures::TryStreamExt;
|
||||||
|
use snafu::{OptionExt, Snafu};
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
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::{EmbedAuthorBuilder, EmbedBuilder, EmbedFieldBuilder},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::command::State;
|
||||||
|
|
||||||
|
const NAME: &str = "info";
|
||||||
|
const DESCRIPTION: &str = "Show various information";
|
||||||
|
|
||||||
|
pub static COMMAND: LazyLock<Command> = 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<UserMarker>,
|
||||||
|
) -> 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 = state
|
||||||
|
.bot_data_manager
|
||||||
|
.with(|bot_data| {
|
||||||
|
let heat_script_option = bot_data.has_heat_script().then(|| {
|
||||||
|
bot_data
|
||||||
|
.get_heat_script()
|
||||||
|
.expect("TODO")
|
||||||
|
.to_string()
|
||||||
|
.expect("TODO")
|
||||||
|
});
|
||||||
|
heat_script_option.map_or("none set yet".into(), |heat_script| {
|
||||||
|
format!("```\n{heat_script}\n```")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("TODO");
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
let mut user_id_stream = state.user_data_manager.list().await.expect("TODO");
|
||||||
|
|
||||||
|
while let Some(user_id) = user_id_stream.try_next().await.expect("TODO") {
|
||||||
|
let (consent, notification_script) = state
|
||||||
|
.user_data_manager
|
||||||
|
.with(user_id, |user_data| {
|
||||||
|
let consent = user_data.get_voice_recording_consent().unwrap();
|
||||||
|
let notification_script = user_data.has_notification_script().then_some(
|
||||||
|
user_data
|
||||||
|
.get_notification_script()
|
||||||
|
.expect("TODO")
|
||||||
|
.to_string()
|
||||||
|
.expect("TODO"),
|
||||||
|
);
|
||||||
|
|
||||||
|
(consent, notification_script)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("TODO");
|
||||||
|
|
||||||
|
let user_mention = format!("<@{user_id}>");
|
||||||
|
|
||||||
|
state
|
||||||
|
.discord_client
|
||||||
|
.interaction(state.discord_application_id)
|
||||||
|
.create_followup(&interaction.token)
|
||||||
|
.embeds(&[EmbedBuilder::new()
|
||||||
|
.author(EmbedAuthorBuilder::new(user_mention))
|
||||||
|
.field(EmbedFieldBuilder::new("Consent", format!("{consent:?}")).build())
|
||||||
|
.field(
|
||||||
|
EmbedFieldBuilder::new(
|
||||||
|
"Notification Script",
|
||||||
|
format!("{notification_script:?}"),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.validate()
|
||||||
|
.unwrap()
|
||||||
|
.build()])
|
||||||
|
.flags(MessageFlags::EPHEMERAL)
|
||||||
|
.await
|
||||||
|
.expect("TODO");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,36 @@
|
|||||||
use std::sync::LazyLock;
|
use crate::{
|
||||||
|
OneToManyUniqueBTreeMap, UserDataManager, VCs, command::State, option_ext::OptionExt as _,
|
||||||
use twilight_model::application::{
|
user_capnp::user::Consent, user_data::RECORD_IF_CONSENT_UNSPECIFIED,
|
||||||
command::{Command, CommandType},
|
};
|
||||||
interaction::{Interaction, application_command::CommandData},
|
use async_trait::async_trait;
|
||||||
|
use futures::FutureExt;
|
||||||
|
use hound::{SampleFormat, WavSpec};
|
||||||
|
use opendal::Operator;
|
||||||
|
use snafu::{OptionExt as _, Snafu};
|
||||||
|
use songbird::{CoreEvent, Event, EventContext, EventHandler};
|
||||||
|
use std::{
|
||||||
|
io::Cursor,
|
||||||
|
sync::{Arc, LazyLock, Mutex},
|
||||||
|
time::Instant,
|
||||||
|
};
|
||||||
|
use time::UtcDateTime;
|
||||||
|
use twilight_model::{
|
||||||
|
application::{
|
||||||
|
command::{Command, CommandType},
|
||||||
|
interaction::Interaction,
|
||||||
|
},
|
||||||
|
channel::message::{Embed, MessageFlags},
|
||||||
|
http::interaction::{InteractionResponse, InteractionResponseType},
|
||||||
|
id::{
|
||||||
|
Id,
|
||||||
|
marker::{ChannelMarker, GuildMarker, UserMarker},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use twilight_util::builder::{
|
||||||
|
InteractionResponseDataBuilder,
|
||||||
|
command::CommandBuilder,
|
||||||
|
embed::{EmbedBuilder, EmbedFieldBuilder, EmbedFooterBuilder},
|
||||||
};
|
};
|
||||||
use twilight_util::builder::command::CommandBuilder;
|
|
||||||
|
|
||||||
use crate::command::State;
|
|
||||||
|
|
||||||
const NAME: &str = "join";
|
const NAME: &str = "join";
|
||||||
const DESCRIPTION: &str = "The bot will join the same VC as you (with intention to record)";
|
const DESCRIPTION: &str = "The bot will join the same VC as you (with intention to record)";
|
||||||
@@ -18,21 +42,321 @@ pub static COMMAND: LazyLock<Command> = LazyLock::new(|| {
|
|||||||
.build()
|
.build()
|
||||||
});
|
});
|
||||||
|
|
||||||
#[tracing::instrument]
|
#[derive(Debug, Snafu)]
|
||||||
pub async fn handle(state: State, interaction: Interaction) {
|
enum GetGuildAndVoiceChannelIdError {
|
||||||
let vcs = state.vcs;
|
/// this command was not used inside a guild (Discord server)
|
||||||
|
NotInGuild,
|
||||||
|
|
||||||
let guild_id = interaction.guild_id.expect("TODO");
|
/// there is no user who invoked this command
|
||||||
// let user_id = data.user.map(|user| user.id).expect("TODO");
|
NoUser,
|
||||||
|
|
||||||
|
/// there are no voice chats in this guild
|
||||||
|
NoVCsInGuild,
|
||||||
|
|
||||||
|
/// the user is not in a voice chat in this guild
|
||||||
|
UserNotInVC,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument]
|
||||||
|
fn get_guild_and_voice_channel_id(
|
||||||
|
interaction: &Interaction,
|
||||||
|
vcs: &VCs,
|
||||||
|
) -> Result<(Id<GuildMarker>, Id<ChannelMarker>), GetGuildAndVoiceChannelIdError> {
|
||||||
|
let guild_id = interaction.guild_id.context(NotInGuildSnafu)?;
|
||||||
|
|
||||||
let user_id = interaction
|
let user_id = interaction
|
||||||
.member
|
.member
|
||||||
.and_then(|member| member.user.map(|user| user.id))
|
.as_ref()
|
||||||
|
.and_then(|member| member.user.as_ref().map(|user| user.id))
|
||||||
|
.context(NoUserSnafu)?;
|
||||||
|
|
||||||
|
let guild_vcs = vcs.get(&guild_id).context(NoVCsInGuildSnafu)?;
|
||||||
|
|
||||||
|
let &voice_channel_id = guild_vcs.get_left_for(&user_id).context(UserNotInVCSnafu)?;
|
||||||
|
|
||||||
|
Ok((guild_id, voice_channel_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_guild_and_vc_error_to_embed(error: GetGuildAndVoiceChannelIdError) -> Embed {
|
||||||
|
match error {
|
||||||
|
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::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::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,
|
||||||
|
|
||||||
|
recordings: Operator,
|
||||||
|
|
||||||
|
guild_id: Id<GuildMarker>,
|
||||||
|
channel_id: Id<ChannelMarker>,
|
||||||
|
|
||||||
|
known_ssrcs: Arc<Mutex<OneToManyUniqueBTreeMap<Id<UserMarker>, u32>>>,
|
||||||
|
|
||||||
|
audio_channels: u16,
|
||||||
|
audio_sample_rate: u32,
|
||||||
|
|
||||||
|
user_data_manager: UserDataManager,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl EventHandler for Handler {
|
||||||
|
async fn act(&self, ctx: &EventContext<'_>) -> Option<Event> {
|
||||||
|
match ctx {
|
||||||
|
EventContext::Track(_items) => {
|
||||||
|
// Not expected to fire
|
||||||
|
}
|
||||||
|
EventContext::SpeakingStateUpdate(speaking) => {
|
||||||
|
tracing::error!(?speaking);
|
||||||
|
|
||||||
|
if let Some(user_id) = speaking.user_id {
|
||||||
|
let user_id = Id::new(user_id.0);
|
||||||
|
|
||||||
|
self.known_ssrcs
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(user_id, speaking.ssrc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EventContext::VoiceTick(voice_tick) => {
|
||||||
|
tracing::error!(?voice_tick);
|
||||||
|
|
||||||
|
for (ssrc, voice_data) in &voice_tick.speaking {
|
||||||
|
let user_id = self.known_ssrcs.lock().unwrap().get_left_for(ssrc).cloned();
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
let now_utc = self.start_utc.checked_add(elapsed).expect("TODO");
|
||||||
|
tracing::error!(?now_utc, "TODO");
|
||||||
|
|
||||||
|
let year = now_utc.year();
|
||||||
|
let month = now_utc.month();
|
||||||
|
let day = now_utc.day();
|
||||||
|
|
||||||
|
let hour = now_utc.hour();
|
||||||
|
let minute = now_utc.minute();
|
||||||
|
let second = now_utc.second();
|
||||||
|
|
||||||
|
let microseconds = now_utc.microsecond();
|
||||||
|
|
||||||
|
let guild_id = self.guild_id;
|
||||||
|
let channel_id = self.channel_id;
|
||||||
|
|
||||||
|
let user = user_id
|
||||||
|
.as_ref()
|
||||||
|
.map_or_else(|| "UNKNOWN".into(), ToString::to_string);
|
||||||
|
|
||||||
|
let path = format!(
|
||||||
|
"{year}/{month}/{day}/{hour}/{minute}/audio-{second}.{microseconds}-{guild_id}-{channel_id}-{user}.wav"
|
||||||
|
);
|
||||||
|
|
||||||
|
let channels = self.audio_channels;
|
||||||
|
let sample_rate = self.audio_sample_rate;
|
||||||
|
|
||||||
|
let wav_spec = WavSpec {
|
||||||
|
channels,
|
||||||
|
sample_rate,
|
||||||
|
bits_per_sample: 16,
|
||||||
|
sample_format: SampleFormat::Int,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
let writer = Cursor::new(&mut buffer);
|
||||||
|
|
||||||
|
let mut wav_writer = hound::WavWriter::new(writer, wav_spec).expect("TODO");
|
||||||
|
|
||||||
|
let mut sample_writer = wav_writer.get_i16_writer(pcm.len() as u32);
|
||||||
|
|
||||||
|
for sample in pcm {
|
||||||
|
sample_writer.write_sample(*sample);
|
||||||
|
}
|
||||||
|
sample_writer.flush().expect("TODO");
|
||||||
|
|
||||||
|
wav_writer.finalize().expect("TODO");
|
||||||
|
|
||||||
|
tracing::info!("going to write the audio shortly");
|
||||||
|
|
||||||
|
let recordings = self.recordings.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
recordings.write(&path, buffer).await.expect("TODO");
|
||||||
|
tracing::info!("successfully wrote the audio!");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EventContext::RtpPacket(_rtp_data) => {}
|
||||||
|
EventContext::RtcpPacket(_rtcp_data) => {}
|
||||||
|
EventContext::ClientDisconnect(_client_disconnect) => {
|
||||||
|
// This is already taken care of elsewhere
|
||||||
|
}
|
||||||
|
EventContext::DriverConnect(_connect_data) => {}
|
||||||
|
EventContext::DriverReconnect(_connect_data) => {}
|
||||||
|
EventContext::DriverDisconnect(_disconnect_data) => {}
|
||||||
|
other => {
|
||||||
|
tracing::warn!(?other, "cannot be handled yet");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state))]
|
||||||
|
pub async fn handle(state: State, interaction: Interaction) {
|
||||||
|
let vcs = state.vcs;
|
||||||
|
|
||||||
|
let (guild_id, voice_channel_id) = match get_guild_and_voice_channel_id(&interaction, &vcs) {
|
||||||
|
Ok((guild_id, voice_channel_id)) => (guild_id, voice_channel_id),
|
||||||
|
Err(error) => {
|
||||||
|
state
|
||||||
|
.discord_client
|
||||||
|
.interaction(state.discord_application_id)
|
||||||
|
.create_response(
|
||||||
|
interaction.id,
|
||||||
|
&interaction.token,
|
||||||
|
&InteractionResponse {
|
||||||
|
kind: InteractionResponseType::ChannelMessageWithSource,
|
||||||
|
data: Some(
|
||||||
|
InteractionResponseDataBuilder::new()
|
||||||
|
.embeds([get_guild_and_vc_error_to_embed(error)])
|
||||||
|
.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::DeferredChannelMessageWithSource,
|
||||||
|
data: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
.expect("TODO");
|
.expect("TODO");
|
||||||
|
|
||||||
let guild_vcs = vcs.get(&guild_id).expect("TODO");
|
let call = state
|
||||||
tracing::error!(?guild_vcs, "TODO");
|
.songbird
|
||||||
|
.join(guild_id, voice_channel_id)
|
||||||
|
.await
|
||||||
|
.expect("TODO");
|
||||||
|
|
||||||
let user_in_vc_data = guild_vcs.get_left_and_data_for(&user_id);
|
tracing::error!(?call, "successfully joined");
|
||||||
tracing::error!(?user_in_vc_data, "TODO");
|
|
||||||
|
let start_instant = Instant::now();
|
||||||
|
let start_utc = UtcDateTime::now();
|
||||||
|
|
||||||
|
let audio_channels = opus2::Channels::from(state.audio_channels) as u16;
|
||||||
|
|
||||||
|
let audio_sample_rate = u32::from(state.audio_sample_rate);
|
||||||
|
|
||||||
|
let handler = Handler {
|
||||||
|
start_instant,
|
||||||
|
start_utc,
|
||||||
|
recordings: state.recording_data,
|
||||||
|
guild_id,
|
||||||
|
channel_id: voice_channel_id,
|
||||||
|
known_ssrcs: Default::default(),
|
||||||
|
|
||||||
|
audio_channels,
|
||||||
|
audio_sample_rate,
|
||||||
|
|
||||||
|
user_data_manager: state.user_data_manager,
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut call = call.lock().await;
|
||||||
|
|
||||||
|
call.add_global_event(CoreEvent::SpeakingStateUpdate.into(), handler.clone());
|
||||||
|
call.add_global_event(CoreEvent::VoiceTick.into(), handler);
|
||||||
|
|
||||||
|
call.mute(true).await.expect("TODO");
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
.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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
use std::sync::LazyLock;
|
use crate::VCs;
|
||||||
|
|
||||||
use twilight_model::application::{
|
|
||||||
command::{Command, CommandType},
|
|
||||||
interaction::Interaction,
|
|
||||||
};
|
|
||||||
use twilight_util::builder::command::CommandBuilder;
|
|
||||||
|
|
||||||
use crate::command::State;
|
use crate::command::State;
|
||||||
|
use snafu::{OptionExt, Snafu};
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
use twilight_model::channel::message::{Embed, MessageFlags};
|
||||||
|
use twilight_model::http::interaction::{InteractionResponse, InteractionResponseType};
|
||||||
|
use twilight_model::id::marker::UserMarker;
|
||||||
|
use twilight_model::{
|
||||||
|
application::{
|
||||||
|
command::{Command, CommandType},
|
||||||
|
interaction::Interaction,
|
||||||
|
},
|
||||||
|
id::{
|
||||||
|
Id,
|
||||||
|
marker::{ChannelMarker, GuildMarker},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use twilight_util::builder::InteractionResponseDataBuilder;
|
||||||
|
use twilight_util::builder::command::CommandBuilder;
|
||||||
|
use twilight_util::builder::embed::EmbedBuilder;
|
||||||
|
|
||||||
const NAME: &str = "leave";
|
const NAME: &str = "leave";
|
||||||
const DESCRIPTION: &str = "The bot will leave the VC it's in (so it won't record anyone anymore)";
|
const DESCRIPTION: &str = "The bot will leave the VC it's in (so it won't record anyone anymore)";
|
||||||
@@ -18,7 +29,146 @@ pub static COMMAND: LazyLock<Command> = LazyLock::new(|| {
|
|||||||
.build()
|
.build()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
#[derive(Debug, Snafu)]
|
||||||
|
pub enum GetGuildAndVoiceChannelIdError {
|
||||||
|
/// this command was not used inside a guild (Discord server)
|
||||||
|
NotInGuild,
|
||||||
|
|
||||||
|
/// there is no user who invoked this command
|
||||||
|
NoUser,
|
||||||
|
|
||||||
|
/// there are no voice chats in this guild
|
||||||
|
NoVCsInGuild,
|
||||||
|
|
||||||
|
/// the bot is not in a voice chat in this guild
|
||||||
|
BotNotInVC,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument]
|
||||||
|
pub fn get_user_and_guild_and_voice_channel_id(
|
||||||
|
bot_user_id: Id<UserMarker>,
|
||||||
|
interaction: &Interaction,
|
||||||
|
vcs: &VCs,
|
||||||
|
) -> Result<(Id<UserMarker>, Id<GuildMarker>, Id<ChannelMarker>), GetGuildAndVoiceChannelIdError> {
|
||||||
|
let user_id = interaction
|
||||||
|
.member
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|member| member.user.as_ref().map(|user| user.id))
|
||||||
|
.context(NoUserSnafu)?;
|
||||||
|
|
||||||
|
let guild_id = interaction.guild_id.context(NotInGuildSnafu)?;
|
||||||
|
|
||||||
|
let guild_vcs = vcs.get(&guild_id).context(NoVCsInGuildSnafu)?;
|
||||||
|
|
||||||
|
let &voice_channel_id = guild_vcs
|
||||||
|
.get_left_for(&bot_user_id)
|
||||||
|
.context(BotNotInVCSnafu)?;
|
||||||
|
|
||||||
|
Ok((user_id, guild_id, voice_channel_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_guild_and_vc_error_to_embed(error: GetGuildAndVoiceChannelIdError) -> Embed {
|
||||||
|
match error {
|
||||||
|
GetGuildAndVoiceChannelIdError::NotInGuild => {
|
||||||
|
EmbedBuilder::new().title("Use this in a server").description("This bot can't tell which VC to leave if the command is used outside of a server (you might've used it in a DM?).").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::NoVCsInGuild => {
|
||||||
|
EmbedBuilder::new().title("No VCs in this server").description("This bot can't leave VC because there aren't any in this server right now (therefore the bot must not be in any).").validate().unwrap().build()
|
||||||
|
},
|
||||||
|
GetGuildAndVoiceChannelIdError::BotNotInVC => {
|
||||||
|
EmbedBuilder::new().title("Not in a VC").description("This bot can't leave VC if it isn't in one in this server.").validate().unwrap().build()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tracing::instrument]
|
#[tracing::instrument]
|
||||||
pub async fn handle(state: State, interaction: Interaction) {
|
pub async fn handle(state: State, interaction: Interaction) {
|
||||||
todo!();
|
let (user_id, guild_id, voice_channel_id) = match get_user_and_guild_and_voice_channel_id(
|
||||||
|
state.discord_user_id,
|
||||||
|
&interaction,
|
||||||
|
&state.vcs,
|
||||||
|
) {
|
||||||
|
Ok((user_id, guild_id, voice_channel_id)) => (user_id, guild_id, voice_channel_id),
|
||||||
|
Err(error) => {
|
||||||
|
state
|
||||||
|
.discord_client
|
||||||
|
.interaction(state.discord_application_id)
|
||||||
|
.create_response(
|
||||||
|
interaction.id,
|
||||||
|
&interaction.token,
|
||||||
|
&InteractionResponse {
|
||||||
|
kind: InteractionResponseType::ChannelMessageWithSource,
|
||||||
|
data: Some(
|
||||||
|
InteractionResponseDataBuilder::new()
|
||||||
|
.embeds([get_guild_and_vc_error_to_embed(error)])
|
||||||
|
.flags(MessageFlags::EPHEMERAL)
|
||||||
|
.build(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("TODO");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if user_id != 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([
|
||||||
|
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(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("TODO");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.songbird.leave(guild_id).await.expect("TODO");
|
||||||
|
|
||||||
|
tracing::error!("TODO: successfully left the call");
|
||||||
|
|
||||||
|
let channel_mention = format!("<#{voice_channel_id}>");
|
||||||
|
|
||||||
|
state
|
||||||
|
.discord_client
|
||||||
|
.interaction(state.discord_application_id)
|
||||||
|
.create_response(interaction.id, &interaction.token,
|
||||||
|
&InteractionResponse {
|
||||||
|
kind: InteractionResponseType::ChannelMessageWithSource,
|
||||||
|
data: Some(
|
||||||
|
InteractionResponseDataBuilder::new()
|
||||||
|
.embeds([
|
||||||
|
EmbedBuilder::new()
|
||||||
|
.title("Left VC")
|
||||||
|
.description(format!(
|
||||||
|
"This bot left {channel_mention} (and is thereby unable to record anymore)."
|
||||||
|
))
|
||||||
|
.validate()
|
||||||
|
.unwrap()
|
||||||
|
.build()
|
||||||
|
])
|
||||||
|
.flags(MessageFlags::EPHEMERAL)
|
||||||
|
.build(),
|
||||||
|
),
|
||||||
|
},)
|
||||||
|
.await
|
||||||
|
.expect("TODO");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,67 @@
|
|||||||
use std::{fmt::Debug, sync::Arc};
|
use std::{fmt::Debug, sync::Arc};
|
||||||
|
|
||||||
use futures::future::BoxFuture;
|
use futures::future::BoxFuture;
|
||||||
|
use opendal::Operator;
|
||||||
use patricia_tree::StringPatriciaMap;
|
use patricia_tree::StringPatriciaMap;
|
||||||
use twilight_model::application::{command::Command, interaction::Interaction};
|
use songbird::{
|
||||||
|
Songbird,
|
||||||
|
driver::{Channels, SampleRate},
|
||||||
|
};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use twilight_model::{
|
||||||
|
application::{command::Command, interaction::Interaction},
|
||||||
|
id::{
|
||||||
|
Id,
|
||||||
|
marker::{ApplicationMarker, CommandMarker, UserMarker},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::VCs;
|
use crate::{BotDataManager, GuildVoiceChannelToTextChannel, UserDataManager, VCs};
|
||||||
|
|
||||||
mod join;
|
pub mod info;
|
||||||
mod leave;
|
pub mod join;
|
||||||
mod opt_out;
|
pub mod leave;
|
||||||
|
pub mod opt_in;
|
||||||
|
pub mod opt_out;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct State {
|
pub struct State {
|
||||||
|
pub audio_channels: Channels,
|
||||||
|
pub audio_sample_rate: SampleRate,
|
||||||
|
pub bot_data_manager: BotDataManager,
|
||||||
|
pub cancellation_token: CancellationToken,
|
||||||
|
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,
|
||||||
|
pub songbird: Arc<Songbird>,
|
||||||
|
pub user_data_manager: UserDataManager,
|
||||||
pub vcs: Arc<VCs>,
|
pub vcs: Arc<VCs>,
|
||||||
}
|
}
|
||||||
|
|
||||||
type Return = ();
|
type Return = ();
|
||||||
type BoxedHandler = Box<dyn Fn(State, Interaction) -> BoxFuture<'static, Return>>;
|
type BoxedHandler = Box<dyn Send + Sync + Fn(State, Interaction) -> BoxFuture<'static, Return>>;
|
||||||
|
|
||||||
fn box_handler<Handler, Fut>(handler: Handler) -> BoxedHandler
|
fn box_handler<Handler, Fut>(handler: Handler) -> BoxedHandler
|
||||||
where
|
where
|
||||||
Fut: Future<Output = Return> + Send + 'static,
|
Fut: Future<Output = Return> + Send + 'static,
|
||||||
Handler: Fn(State, Interaction) -> Fut + 'static,
|
Handler: Send + Sync + Fn(State, Interaction) -> Fut + 'static,
|
||||||
{
|
{
|
||||||
Box::new(move |state, interaction| Box::pin(handler(state, interaction)))
|
Box::new(move |state, interaction| Box::pin(handler(state, interaction)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn all() -> Vec<(&'static Command, BoxedHandler)> {
|
pub fn all() -> Vec<(&'static Command, BoxedHandler)> {
|
||||||
vec![
|
vec![
|
||||||
|
(&info::COMMAND, box_handler(info::handle)),
|
||||||
(&join::COMMAND, box_handler(join::handle)),
|
(&join::COMMAND, box_handler(join::handle)),
|
||||||
(&leave::COMMAND, box_handler(leave::handle)),
|
(&leave::COMMAND, box_handler(leave::handle)),
|
||||||
|
(&opt_in::COMMAND, box_handler(opt_in::handle)),
|
||||||
(&opt_out::COMMAND, box_handler(opt_out::handle)),
|
(&opt_out::COMMAND, box_handler(opt_out::handle)),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -40,15 +72,15 @@ pub struct Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Router {
|
impl Router {
|
||||||
fn add_route<'s, 'a, Fut, Handler>(&'s mut self, name: &'a str, handler: Handler)
|
pub fn add_route<Fut, Handler>(&mut self, name: &str, handler: Handler)
|
||||||
where
|
where
|
||||||
Fut: Future<Output = Return> + Send + 'static,
|
Fut: Future<Output = Return> + Send + 'static,
|
||||||
Handler: Fn(State, Interaction) -> Fut + 'static,
|
Handler: Send + Sync + Fn(State, Interaction) -> Fut + 'static,
|
||||||
{
|
{
|
||||||
self.add_route_already_boxed(name, box_handler(handler));
|
self.add_route_already_boxed(name, box_handler(handler));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_route_already_boxed<'s, 'a>(&'s mut self, name: &'a str, boxed_handler: BoxedHandler) {
|
fn add_route_already_boxed(&mut self, name: &str, boxed_handler: BoxedHandler) {
|
||||||
self.map.insert(name, boxed_handler);
|
self.map.insert(name, boxed_handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
90
src/command/opt_in.rs
Normal file
90
src/command/opt_in.rs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use twilight_model::{
|
||||||
|
application::{
|
||||||
|
command::{Command, CommandType},
|
||||||
|
interaction::Interaction,
|
||||||
|
},
|
||||||
|
channel::message::MessageFlags,
|
||||||
|
http::interaction::{InteractionResponse, InteractionResponseType},
|
||||||
|
};
|
||||||
|
use twilight_util::builder::{InteractionResponseDataBuilder, command::CommandBuilder};
|
||||||
|
|
||||||
|
use crate::{command::State, user_capnp::user::Consent};
|
||||||
|
|
||||||
|
const NAME: &str = "opt-in";
|
||||||
|
const DESCRIPTION: &str = "Opt in to being recorded";
|
||||||
|
|
||||||
|
pub static COMMAND: LazyLock<Command> = 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) {
|
||||||
|
let user_id = interaction
|
||||||
|
.member
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|member| member.user.as_ref().map(|user| user.id));
|
||||||
|
|
||||||
|
let user_id = match user_id {
|
||||||
|
Some(user_id) => user_id,
|
||||||
|
None => {
|
||||||
|
state
|
||||||
|
.discord_client
|
||||||
|
.interaction(state.discord_application_id)
|
||||||
|
.create_response(
|
||||||
|
interaction.id,
|
||||||
|
&interaction.token,
|
||||||
|
&InteractionResponse {
|
||||||
|
kind: InteractionResponseType::ChannelMessageWithSource,
|
||||||
|
data: Some(
|
||||||
|
InteractionResponseDataBuilder::new()
|
||||||
|
.content("TODO")
|
||||||
|
.build(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("TODO");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let previous_consent = state
|
||||||
|
.user_data_manager
|
||||||
|
.update(user_id, |mut user_data| {
|
||||||
|
let previous_consent = user_data
|
||||||
|
.reborrow()
|
||||||
|
.get_voice_recording_consent()
|
||||||
|
.expect("TODO");
|
||||||
|
user_data.set_voice_recording_consent(Consent::Granted);
|
||||||
|
|
||||||
|
previous_consent
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("TODO");
|
||||||
|
|
||||||
|
state
|
||||||
|
.discord_client
|
||||||
|
.interaction(state.discord_application_id)
|
||||||
|
.create_response(
|
||||||
|
interaction.id,
|
||||||
|
&interaction.token,
|
||||||
|
&InteractionResponse {
|
||||||
|
kind: InteractionResponseType::ChannelMessageWithSource,
|
||||||
|
data: Some(
|
||||||
|
InteractionResponseDataBuilder::new()
|
||||||
|
.content(format!(
|
||||||
|
"opted you in, your previous consent was {previous_consent:?}"
|
||||||
|
))
|
||||||
|
.flags(MessageFlags::EPHEMERAL)
|
||||||
|
.build(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("TODO");
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use twilight_model::application::{
|
use twilight_model::{
|
||||||
command::{Command, CommandType},
|
application::{
|
||||||
interaction::Interaction,
|
command::{Command, CommandType},
|
||||||
|
interaction::Interaction,
|
||||||
|
},
|
||||||
|
channel::message::MessageFlags,
|
||||||
|
http::interaction::{InteractionResponse, InteractionResponseType},
|
||||||
};
|
};
|
||||||
use twilight_util::builder::command::CommandBuilder;
|
use twilight_util::builder::{InteractionResponseDataBuilder, command::CommandBuilder};
|
||||||
|
|
||||||
use crate::command::State;
|
use crate::{command::State, user_capnp::user::Consent};
|
||||||
|
|
||||||
const NAME: &str = "opt-out";
|
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<Command> = LazyLock::new(|| {
|
pub static COMMAND: LazyLock<Command> = LazyLock::new(|| {
|
||||||
CommandBuilder::new(NAME, DESCRIPTION, CommandType::ChatInput)
|
CommandBuilder::new(NAME, DESCRIPTION, CommandType::ChatInput)
|
||||||
@@ -20,5 +24,67 @@ pub static COMMAND: LazyLock<Command> = LazyLock::new(|| {
|
|||||||
|
|
||||||
#[tracing::instrument]
|
#[tracing::instrument]
|
||||||
pub async fn handle(state: State, interaction: Interaction) {
|
pub async fn handle(state: State, interaction: Interaction) {
|
||||||
todo!();
|
let user_id = interaction
|
||||||
|
.member
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|member| member.user.as_ref().map(|user| user.id));
|
||||||
|
|
||||||
|
let user_id = match user_id {
|
||||||
|
Some(user_id) => user_id,
|
||||||
|
None => {
|
||||||
|
state
|
||||||
|
.discord_client
|
||||||
|
.interaction(state.discord_application_id)
|
||||||
|
.create_response(
|
||||||
|
interaction.id,
|
||||||
|
&interaction.token,
|
||||||
|
&InteractionResponse {
|
||||||
|
kind: InteractionResponseType::ChannelMessageWithSource,
|
||||||
|
data: Some(
|
||||||
|
InteractionResponseDataBuilder::new()
|
||||||
|
.content("TODO")
|
||||||
|
.build(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("TODO");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let previous_consent = state
|
||||||
|
.user_data_manager
|
||||||
|
.update(user_id, |mut user_data| {
|
||||||
|
let previous_consent = user_data
|
||||||
|
.reborrow()
|
||||||
|
.get_voice_recording_consent()
|
||||||
|
.expect("TODO");
|
||||||
|
user_data.set_voice_recording_consent(Consent::Withheld);
|
||||||
|
|
||||||
|
previous_consent
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("TODO");
|
||||||
|
|
||||||
|
state
|
||||||
|
.discord_client
|
||||||
|
.interaction(state.discord_application_id)
|
||||||
|
.create_response(
|
||||||
|
interaction.id,
|
||||||
|
&interaction.token,
|
||||||
|
&InteractionResponse {
|
||||||
|
kind: InteractionResponseType::ChannelMessageWithSource,
|
||||||
|
data: Some(
|
||||||
|
InteractionResponseDataBuilder::new()
|
||||||
|
.content(format!(
|
||||||
|
"opted you out, your previous consent was {previous_consent:?}"
|
||||||
|
))
|
||||||
|
.flags(MessageFlags::EPHEMERAL)
|
||||||
|
.build(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("TODO");
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/lib.rs
18
src/lib.rs
@@ -1,14 +1,24 @@
|
|||||||
mod command;
|
mod bot_data;
|
||||||
|
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;
|
||||||
|
mod operator_ext;
|
||||||
|
mod option_ext;
|
||||||
|
mod storage;
|
||||||
mod track_vcs;
|
mod track_vcs;
|
||||||
|
mod user_data;
|
||||||
mod vc_user;
|
mod vc_user;
|
||||||
|
capnp::generated_code!(mod bot_capnp);
|
||||||
|
capnp::generated_code!(mod user_capnp);
|
||||||
|
|
||||||
|
pub use bot_data::BotDataManager;
|
||||||
|
pub use command::{Router as CommandRouter, State, all as all_commands};
|
||||||
pub use one_to_many::OneToManyUniqueBTreeMap;
|
pub use one_to_many::OneToManyUniqueBTreeMap;
|
||||||
pub use one_to_many_with_data::OneToManyUniqueBTreeMapWithData;
|
pub use one_to_many_with_data::OneToManyUniqueBTreeMapWithData;
|
||||||
pub use one_to_one::OneToOneBTreeMap;
|
pub use one_to_one::OneToOneBTreeMap;
|
||||||
|
pub use operator_ext::OperatorExt;
|
||||||
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 track_vcs::{GuildVoiceChannelToTextChannel, VCs, initialize_vcs, update_vcs};
|
||||||
|
pub use user_data::UserDataManager;
|
||||||
pub use vc_user::{UserInVCData, VoiceStatus};
|
pub use vc_user::{UserInVCData, VoiceStatus};
|
||||||
|
|||||||
417
src/main.rs
417
src/main.rs
@@ -1,51 +1,107 @@
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use fomo_reducer::{CommandRouter, State, VCs, all_commands, initialize_vcs, update_vcs};
|
use fomo_reducer::{
|
||||||
use opendal::{IntoOperatorUri, Operator, OperatorUri};
|
BotDataManager, CommandRouter, GuildVoiceChannelToTextChannel, State, Storage, UserDataManager,
|
||||||
|
all_commands, command, initialize_vcs, update_vcs,
|
||||||
|
};
|
||||||
use secrecy::{ExposeSecret, SecretString};
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
use snafu::Snafu;
|
use snafu::{OptionExt, ResultExt, Snafu};
|
||||||
use songbird::shards::TwilightMap;
|
use songbird::{
|
||||||
use std::{collections::HashMap, fmt::Debug, str::FromStr, sync::Arc};
|
Config, Songbird,
|
||||||
|
driver::{Channels, DecodeConfig, SampleRate},
|
||||||
|
shards::TwilightMap,
|
||||||
|
};
|
||||||
|
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 _};
|
||||||
use tracing_subscriber::{EnvFilter, fmt::format::FmtSpan};
|
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::{
|
use twilight_model::{
|
||||||
application::interaction::InteractionData,
|
application::interaction::InteractionData,
|
||||||
gateway::payload::incoming::InteractionCreate,
|
gateway::{
|
||||||
id::{Id, marker::UserMarker},
|
payload::{incoming::InteractionCreate, outgoing::UpdatePresence},
|
||||||
|
presence::{ActivityType, MinimalActivity, Status},
|
||||||
|
},
|
||||||
|
id::{
|
||||||
|
Id,
|
||||||
|
marker::{ChannelMarker, GuildMarker, UserMarker},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone, Copy, Debug, strum::Display, EnumString)]
|
||||||
struct OpendalOperator {
|
enum AudioChannels {
|
||||||
uri: OperatorUri,
|
Mono,
|
||||||
operator: Operator,
|
Stereo,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromStr for OpendalOperator {
|
impl From<AudioChannels> for Channels {
|
||||||
type Err = opendal::Error;
|
fn from(value: AudioChannels) -> Self {
|
||||||
|
match value {
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
AudioChannels::Mono => Channels::Mono,
|
||||||
let uri = s.into_operator_uri()?;
|
AudioChannels::Stereo => Channels::Stereo,
|
||||||
let operator = Operator::from_uri(&uri)?;
|
}
|
||||||
|
|
||||||
Ok(Self { uri, operator })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpendalOperator {
|
#[derive(Clone, Copy, Debug, strum::Display, EnumString)]
|
||||||
fn into_inner(self) -> Operator {
|
enum AudioSampleRate {
|
||||||
self.operator
|
#[strum(serialize = "8000Hz")]
|
||||||
|
Hz8000,
|
||||||
|
#[strum(serialize = "12000Hz")]
|
||||||
|
Hz12000,
|
||||||
|
#[strum(serialize = "16000Hz")]
|
||||||
|
Hz16000,
|
||||||
|
#[strum(serialize = "24000Hz")]
|
||||||
|
Hz24000,
|
||||||
|
#[strum(serialize = "48000Hz")]
|
||||||
|
Hz48000,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AudioSampleRate> for SampleRate {
|
||||||
|
fn from(value: AudioSampleRate) -> Self {
|
||||||
|
match value {
|
||||||
|
AudioSampleRate::Hz8000 => SampleRate::Hz8000,
|
||||||
|
AudioSampleRate::Hz12000 => SampleRate::Hz12000,
|
||||||
|
AudioSampleRate::Hz16000 => SampleRate::Hz16000,
|
||||||
|
AudioSampleRate::Hz24000 => SampleRate::Hz24000,
|
||||||
|
AudioSampleRate::Hz48000 => SampleRate::Hz48000,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<OpendalOperator> for Operator {
|
#[derive(Debug, Snafu)]
|
||||||
fn from(wrapper: OpendalOperator) -> Self {
|
enum ParseGuildVCToTextChannelError {
|
||||||
wrapper.into_inner()
|
NoScope,
|
||||||
}
|
|
||||||
|
NoRelation,
|
||||||
|
|
||||||
|
ParseGuildError {
|
||||||
|
source: <Id<GuildMarker> as FromStr>::Err,
|
||||||
|
},
|
||||||
|
|
||||||
|
ParseVoiceChannelError {
|
||||||
|
source: <Id<ChannelMarker> as FromStr>::Err,
|
||||||
|
},
|
||||||
|
|
||||||
|
ParseTextChannelError {
|
||||||
|
source: <Id<ChannelMarker> as FromStr>::Err,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Debug for OpendalOperator {
|
fn parse_guild_vc_to_text_channel(
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
source: &str,
|
||||||
Debug::fmt(&self.uri, f)
|
) -> Result<(Id<GuildMarker>, Id<ChannelMarker>, Id<ChannelMarker>), ParseGuildVCToTextChannelError>
|
||||||
}
|
{
|
||||||
|
let (guild, voice_channel_and_text_channel) = source.split_once(':').context(NoScopeSnafu)?;
|
||||||
|
let (voice_channel, text_channel) = voice_channel_and_text_channel
|
||||||
|
.split_once("->")
|
||||||
|
.context(NoRelationSnafu)?;
|
||||||
|
|
||||||
|
let guild = guild.parse().context(ParseGuildSnafu)?;
|
||||||
|
let voice_channel = voice_channel.parse().context(ParseVoiceChannelSnafu)?;
|
||||||
|
let text_channel = text_channel.parse().context(ParseTextChannelSnafu)?;
|
||||||
|
|
||||||
|
Ok((guild, voice_channel, text_channel))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
@@ -54,15 +110,41 @@ struct AppArgs {
|
|||||||
discord_token: SecretString,
|
discord_token: SecretString,
|
||||||
|
|
||||||
#[arg(long, env)]
|
#[arg(long, env)]
|
||||||
storage: OpendalOperator,
|
discord_bot_owner_user_id: Id<UserMarker>,
|
||||||
|
|
||||||
#[arg(long, env)]
|
#[arg(long, env)]
|
||||||
bot_owner: Id<UserMarker>,
|
discord_nickname: Option<Arc<str>>,
|
||||||
|
|
||||||
|
#[arg(long, env)]
|
||||||
|
discord_status: Option<Arc<str>>,
|
||||||
|
|
||||||
|
#[arg(long, env, value_parser = parse_guild_vc_to_text_channel)]
|
||||||
|
discord_voice_channel_corresponding_text_channel:
|
||||||
|
Vec<(Id<GuildMarker>, Id<ChannelMarker>, Id<ChannelMarker>)>,
|
||||||
|
|
||||||
|
#[arg(long, env, default_value_t = AudioChannels::Mono)]
|
||||||
|
audio_channels: AudioChannels,
|
||||||
|
|
||||||
|
#[arg(long, env, default_value_t = AudioSampleRate::Hz12000)]
|
||||||
|
audio_sample_rate: AudioSampleRate,
|
||||||
|
|
||||||
|
#[arg(long, env)]
|
||||||
|
bot_data: Storage,
|
||||||
|
|
||||||
|
#[arg(long, env)]
|
||||||
|
user_data: Storage,
|
||||||
|
|
||||||
|
#[arg(long, env)]
|
||||||
|
recording_data: Storage,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
struct LoggingArgs {
|
struct LoggingArgs {
|
||||||
#[arg(long = "logging-directives", env = "RUST_LOG", default_value = "info")]
|
#[arg(
|
||||||
|
long = "logging-directives",
|
||||||
|
env = "RUST_LOG",
|
||||||
|
default_value = "warn,fomo_reducer=debug"
|
||||||
|
)]
|
||||||
env_filter: EnvFilter,
|
env_filter: EnvFilter,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +158,10 @@ struct Args {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Snafu)]
|
#[derive(Debug, Snafu)]
|
||||||
enum MainError {}
|
enum MainError {
|
||||||
|
/// the program was cancelled, perhaps by Ctrl-C / SIGINT
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
#[snafu::report]
|
#[snafu::report]
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -98,28 +183,53 @@ async fn main() -> Result<(), MainError> {
|
|||||||
|
|
||||||
let AppArgs {
|
let AppArgs {
|
||||||
discord_token,
|
discord_token,
|
||||||
storage,
|
discord_bot_owner_user_id,
|
||||||
bot_owner,
|
discord_nickname,
|
||||||
|
discord_status,
|
||||||
|
discord_voice_channel_corresponding_text_channel,
|
||||||
|
audio_channels,
|
||||||
|
audio_sample_rate,
|
||||||
|
bot_data,
|
||||||
|
user_data,
|
||||||
|
recording_data,
|
||||||
} = app_args;
|
} = app_args;
|
||||||
|
|
||||||
|
let cancellation_token = CancellationToken::new();
|
||||||
|
|
||||||
rustls::crypto::aws_lc_rs::default_provider()
|
rustls::crypto::aws_lc_rs::default_provider()
|
||||||
.install_default()
|
.install_default()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let shard_id = ShardId::new(0, 1);
|
|
||||||
let intents = Intents::GUILD_VOICE_STATES;
|
|
||||||
let mut shard = Shard::new(shard_id, discord_token.expose_secret().to_owned(), intents);
|
|
||||||
|
|
||||||
let senders = TwilightMap::new(FromIterator::from_iter([(
|
|
||||||
shard.id().number(),
|
|
||||||
shard.sender(),
|
|
||||||
)]));
|
|
||||||
|
|
||||||
let event_types = EventTypeFlags::GUILD_VOICE_STATES | EventTypeFlags::INTERACTION_CREATE;
|
|
||||||
let mut next_event = shard.next_event(event_types);
|
|
||||||
|
|
||||||
let discord_client = twilight_http::Client::new(discord_token.expose_secret().to_owned());
|
let discord_client = twilight_http::Client::new(discord_token.expose_secret().to_owned());
|
||||||
|
|
||||||
|
let guilds = discord_client
|
||||||
|
.current_user_guilds()
|
||||||
|
.limit(200)
|
||||||
|
.await
|
||||||
|
.expect("TODO")
|
||||||
|
.model()
|
||||||
|
.await
|
||||||
|
.expect("TODO");
|
||||||
|
|
||||||
|
JoinSet::from_iter(guilds.into_iter().map(|guild| {
|
||||||
|
discord_client
|
||||||
|
.update_current_member(guild.id)
|
||||||
|
.nick(discord_nickname.as_deref())
|
||||||
|
.into_future()
|
||||||
|
}))
|
||||||
|
.join_all()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let discord_user = discord_client
|
||||||
|
.current_user()
|
||||||
|
.await
|
||||||
|
.expect("couldn't fetch current user") // TODO
|
||||||
|
.model()
|
||||||
|
.await
|
||||||
|
.expect("couldn't deserialize current user"); // TODO
|
||||||
|
|
||||||
|
let discord_user_id = discord_user.id;
|
||||||
|
|
||||||
let current_application = discord_client
|
let current_application = discord_client
|
||||||
.current_user_application()
|
.current_user_application()
|
||||||
.await
|
.await
|
||||||
@@ -130,13 +240,42 @@ async fn main() -> Result<(), MainError> {
|
|||||||
.await
|
.await
|
||||||
.expect("couldn't get current Discord application"); // TODO
|
.expect("couldn't get current Discord application"); // TODO
|
||||||
|
|
||||||
let application_id = current_application.id;
|
let discord_application_id = current_application.id;
|
||||||
|
|
||||||
let interaction_client = discord_client.interaction(application_id);
|
let intents = Intents::GUILD_VOICE_STATES;
|
||||||
|
let config = twilight_gateway::Config::new(discord_token.expose_secret().to_owned(), intents);
|
||||||
|
|
||||||
|
let shards = twilight_gateway::create_recommended(&discord_client, config, |_id, builder| {
|
||||||
|
builder.build()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("TODO");
|
||||||
|
let shards = Vec::from_iter(shards);
|
||||||
|
|
||||||
|
let senders = TwilightMap::new(
|
||||||
|
shards
|
||||||
|
.iter()
|
||||||
|
.map(|shard| (shard.id().number(), shard.sender()))
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let audio_channels = audio_channels.into();
|
||||||
|
let audio_sample_rate = audio_sample_rate.into();
|
||||||
|
|
||||||
|
let senders = Arc::new(senders);
|
||||||
|
let songbird = Songbird::twilight(senders, discord_user_id);
|
||||||
|
songbird.set_config(
|
||||||
|
Config::default().decode_mode(songbird::driver::DecodeMode::Decode(DecodeConfig::new(
|
||||||
|
audio_channels,
|
||||||
|
audio_sample_rate,
|
||||||
|
))),
|
||||||
|
);
|
||||||
|
|
||||||
|
let interaction_client = discord_client.interaction(discord_application_id);
|
||||||
|
|
||||||
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
|
||||||
@@ -151,33 +290,172 @@ async fn main() -> Result<(), MainError> {
|
|||||||
.await
|
.await
|
||||||
.expect("failed to deserialize set commands"); // TODO
|
.expect("failed to deserialize set commands"); // TODO
|
||||||
|
|
||||||
let command_router = CommandRouter::from_iter(commands);
|
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 = Arc::new(command_router);
|
||||||
|
|
||||||
|
let discord_client = Arc::new(discord_client);
|
||||||
|
let songbird = Arc::new(songbird);
|
||||||
let vcs = Arc::new(vcs);
|
let vcs = Arc::new(vcs);
|
||||||
while let Some(event_res) = next_event.await {
|
|
||||||
|
let bot_data = bot_data.into_inner();
|
||||||
|
let recording_data = recording_data.into_inner();
|
||||||
|
let user_data = user_data.into_inner();
|
||||||
|
|
||||||
|
let bot_data_manager = BotDataManager::new(bot_data);
|
||||||
|
let user_data_manager = UserDataManager::new(user_data);
|
||||||
|
|
||||||
|
let discord_voice_channel_corresponding_text_channel = {
|
||||||
|
let mut map = GuildVoiceChannelToTextChannel::default();
|
||||||
|
|
||||||
|
for (guild_id, voice_channel_id, text_channel_id) in
|
||||||
|
discord_voice_channel_corresponding_text_channel
|
||||||
|
{
|
||||||
|
map.entry(guild_id)
|
||||||
|
.or_default()
|
||||||
|
.insert(voice_channel_id, text_channel_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
map
|
||||||
|
};
|
||||||
|
let discord_voice_channel_corresponding_text_channel =
|
||||||
|
Arc::new(discord_voice_channel_corresponding_text_channel);
|
||||||
|
|
||||||
|
let state = State {
|
||||||
|
audio_channels,
|
||||||
|
audio_sample_rate,
|
||||||
|
bot_data_manager,
|
||||||
|
cancellation_token: cancellation_token.clone(),
|
||||||
|
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,
|
||||||
|
songbird,
|
||||||
|
user_data_manager,
|
||||||
|
vcs,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(discord_status) = discord_status {
|
||||||
|
shards.iter().for_each(|shard| {
|
||||||
|
shard.command(
|
||||||
|
&UpdatePresence::new(
|
||||||
|
vec![
|
||||||
|
MinimalActivity {
|
||||||
|
kind: ActivityType::Listening,
|
||||||
|
name: (*discord_status).to_owned(),
|
||||||
|
url: None,
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
Status::Idle,
|
||||||
|
)
|
||||||
|
.expect("TODO"),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let run_shards = JoinSet::from_iter(
|
||||||
|
shards
|
||||||
|
.into_iter()
|
||||||
|
.map(|shard| handle_events(command_router.clone(), state.clone(), shard)),
|
||||||
|
);
|
||||||
|
let run_shards = run_shards.join_all();
|
||||||
|
tokio::pin!(run_shards);
|
||||||
|
|
||||||
|
tokio::spawn({
|
||||||
|
let cancellation_token = cancellation_token.clone();
|
||||||
|
async move {
|
||||||
|
match ctrl_c().await {
|
||||||
|
Ok(()) => cancellation_token.cancel(),
|
||||||
|
Err(error) => tracing::error!(?error, "failed to listen for interrupt signal"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
select! {
|
||||||
|
_ = &mut run_shards => {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
() = cancellation_token.cancelled() => {
|
||||||
|
tracing::warn!("waiting for tasks to gracefully shut down");
|
||||||
|
run_shards.await;
|
||||||
|
|
||||||
|
Err(MainError::Cancelled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(command_router, shard, state))]
|
||||||
|
async fn handle_events(command_router: Arc<CommandRouter>, state: State, mut shard: Shard) {
|
||||||
|
let event_types = EventTypeFlags::GUILD_VOICE_STATES
|
||||||
|
| EventTypeFlags::INTERACTION_CREATE
|
||||||
|
| EventTypeFlags::VOICE_SERVER_UPDATE
|
||||||
|
| EventTypeFlags::VOICE_STATE_UPDATE;
|
||||||
|
|
||||||
|
while let Some(Some(event_res)) = shard
|
||||||
|
.next_event(event_types)
|
||||||
|
.with_cancellation_token(&state.cancellation_token)
|
||||||
|
.await
|
||||||
|
{
|
||||||
match event_res {
|
match event_res {
|
||||||
|
Ok(twilight_model::gateway::event::Event::GatewayClose(frame_option)) => {
|
||||||
|
tracing::warn!(?frame_option);
|
||||||
|
return;
|
||||||
|
}
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
tracing::debug!(?vcs, "before handling");
|
handle_event(command_router.clone(), state.clone(), event).await;
|
||||||
handle_event(&command_router, vcs.clone(), event).await;
|
}
|
||||||
tracing::debug!(?vcs, "after handling");
|
Err(reconnect_error)
|
||||||
|
if matches!(
|
||||||
|
reconnect_error.kind(),
|
||||||
|
&twilight_gateway::error::ReceiveMessageErrorType::Reconnect
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
tracing::error!(?reconnect_error);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::error!(?error);
|
tracing::error!(?error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
next_event = shard.next_event(event_types);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip(command_router, vcs))]
|
#[tracing::instrument(skip(command_router, state))]
|
||||||
async fn handle_event(command_router: &CommandRouter, vcs: Arc<VCs>, event: Event) {
|
async fn handle_event(command_router: Arc<CommandRouter>, state: State, event: Event) {
|
||||||
|
state.songbird.process(&event).await;
|
||||||
|
|
||||||
match event {
|
match event {
|
||||||
Event::VoiceStateUpdate(voice_state_update) => {
|
Event::VoiceStateUpdate(voice_state_update) => {
|
||||||
update_vcs(&voice_state_update, &vcs);
|
update_vcs(&voice_state_update, &state.vcs);
|
||||||
}
|
}
|
||||||
Event::InteractionCreate(interaction_create) => {
|
Event::InteractionCreate(interaction_create) => {
|
||||||
let InteractionCreate(interaction) = *interaction_create;
|
let InteractionCreate(interaction) = *interaction_create;
|
||||||
@@ -187,11 +465,12 @@ async fn handle_event(command_router: &CommandRouter, vcs: Arc<VCs>, event: Even
|
|||||||
tracing::warn!("missing expected interaction data");
|
tracing::warn!("missing expected interaction data");
|
||||||
}
|
}
|
||||||
Some(InteractionData::ApplicationCommand(command_data)) => {
|
Some(InteractionData::ApplicationCommand(command_data)) => {
|
||||||
let command_name = &command_data.name.clone();
|
let command_name = command_data.name.clone();
|
||||||
let state = State { vcs };
|
tokio::spawn(async move {
|
||||||
command_router
|
command_router
|
||||||
.handle(state, command_name, interaction)
|
.handle(state, &command_name, interaction)
|
||||||
.await;
|
.await;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(InteractionData::MessageComponent(component_data)) => {
|
Some(InteractionData::MessageComponent(component_data)) => {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ where
|
|||||||
pub fn remove_left(&mut self, left: &Left) -> Option<(Left, BTreeMap<Right, RightData>)> {
|
pub fn remove_left(&mut self, left: &Left) -> Option<(Left, BTreeMap<Right, RightData>)> {
|
||||||
let (left, rights) = self.left_to_rights.remove_entry(left)?;
|
let (left, rights) = self.left_to_rights.remove_entry(left)?;
|
||||||
|
|
||||||
for (right, _right_data) in &rights {
|
for right in rights.keys() {
|
||||||
self.right_to_left.remove(right);
|
self.right_to_left.remove(right);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
25
src/operator_ext.rs
Normal file
25
src/operator_ext.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
use extension_traits::extension;
|
||||||
|
use opendal::{Buffer, Error, ErrorKind, FuturesAsyncReader, Operator};
|
||||||
|
|
||||||
|
#[extension(pub trait OperatorExt)]
|
||||||
|
impl Operator {
|
||||||
|
async fn read_if_exists(&self, path: &str) -> Result<Option<Buffer>, Error> {
|
||||||
|
match self.read(path).await {
|
||||||
|
Ok(buffer) => Ok(Some(buffer)),
|
||||||
|
Err(error) if matches!(error.kind(), ErrorKind::NotFound) => Ok(None),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn async_reader_if_exists(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<Option<FuturesAsyncReader>, Error> {
|
||||||
|
let reader = self.reader(path).await?;
|
||||||
|
match reader.into_futures_async_read(..).await {
|
||||||
|
Ok(reader) => Ok(Some(reader)),
|
||||||
|
Err(error) if matches!(error.kind(), ErrorKind::NotFound) => Ok(None),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/option_ext.rs
Normal file
11
src/option_ext.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
use extension_traits::extension;
|
||||||
|
|
||||||
|
#[extension(pub trait OptionExt)]
|
||||||
|
impl<S> Option<S> {
|
||||||
|
async fn map_async<M, Fut: Future<Output = M>, F: FnOnce(S) -> Fut>(self, f: F) -> Option<M> {
|
||||||
|
match self {
|
||||||
|
Some(s) => Some(f(s).await),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/storage.rs
Normal file
38
src/storage.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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<Self, Self::Err> {
|
||||||
|
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<Storage> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,7 @@
|
|||||||
type VCsInGuild = OneToManyUniqueBTreeMapWithData<Id<ChannelMarker>, Id<UserMarker>, UserInVCData>;
|
|
||||||
|
|
||||||
pub type VCs = DashMap<Id<GuildMarker>, VCsInGuild>;
|
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use futures::{
|
use futures::{StreamExt, stream::FuturesUnordered};
|
||||||
StreamExt,
|
|
||||||
stream::{self, FuturesUnordered},
|
|
||||||
};
|
|
||||||
use twilight_model::{
|
use twilight_model::{
|
||||||
gateway::payload::incoming::VoiceStateUpdate,
|
gateway::payload::incoming::VoiceStateUpdate,
|
||||||
id::{
|
id::{
|
||||||
@@ -17,7 +10,13 @@ use twilight_model::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{OneToManyUniqueBTreeMapWithData, UserInVCData, VoiceStatus};
|
use crate::{OneToManyUniqueBTreeMapWithData, OneToOneBTreeMap, UserInVCData, VoiceStatus};
|
||||||
|
|
||||||
|
pub type GuildVoiceChannelToTextChannel =
|
||||||
|
BTreeMap<Id<GuildMarker>, OneToOneBTreeMap<Id<ChannelMarker>, Id<ChannelMarker>>>;
|
||||||
|
|
||||||
|
type VCsInGuild = OneToManyUniqueBTreeMapWithData<Id<ChannelMarker>, Id<UserMarker>, UserInVCData>;
|
||||||
|
pub type VCs = DashMap<Id<GuildMarker>, VCsInGuild>;
|
||||||
|
|
||||||
#[tracing::instrument(skip(discord_client), ret)]
|
#[tracing::instrument(skip(discord_client), ret)]
|
||||||
async fn initialize_user_in_vc(
|
async fn initialize_user_in_vc(
|
||||||
@@ -92,6 +91,7 @@ pub async fn initialize_vcs(discord_client: &twilight_http::Client) -> VCs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(vcs))]
|
||||||
pub fn update_vcs(voice_state_update: &VoiceStateUpdate, vcs: &VCs) {
|
pub fn update_vcs(voice_state_update: &VoiceStateUpdate, vcs: &VCs) {
|
||||||
let user_id = voice_state_update.user_id;
|
let user_id = voice_state_update.user_id;
|
||||||
match voice_state_update.guild_id {
|
match voice_state_update.guild_id {
|
||||||
|
|||||||
254
src/user_data.rs
Normal file
254
src/user_data.rs
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use async_compression::futures::{bufread::BrotliDecoder, write::BrotliEncoder};
|
||||||
|
use capnp::message::TypedBuilder;
|
||||||
|
use futures::{AsyncReadExt, AsyncWriteExt, TryStream, TryStreamExt};
|
||||||
|
use opendal::Operator;
|
||||||
|
use snafu::{OptionExt as _, ResultExt as _, Snafu, ensure};
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserDataManager {
|
||||||
|
pub fn new(operator: Operator) -> Self {
|
||||||
|
Self { operator }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(id: Id<UserMarker>) -> String {
|
||||||
|
format!("{id}/data.bin.brotli")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Snafu)]
|
||||||
|
#[snafu(module)]
|
||||||
|
pub enum ParsePathError {
|
||||||
|
/// paths must have a / in them because that's how user data is stored, but this one doesn't have one
|
||||||
|
MissingSlashError,
|
||||||
|
|
||||||
|
/// if this isn't a directory, then the file must be "data.bin.brotli" but it was actually {actual:?}
|
||||||
|
WrongFileError { actual: String },
|
||||||
|
|
||||||
|
/// couldn't parse the directory as a user ID
|
||||||
|
ParseUserIdError {
|
||||||
|
source: <Id<UserMarker> as FromStr>::Err,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(path: &str) -> Result<Id<UserMarker>, ParsePathError> {
|
||||||
|
let (directory, file) = path
|
||||||
|
.rsplit_once("/")
|
||||||
|
.context(parse_path_error::MissingSlashSnafu)?;
|
||||||
|
|
||||||
|
ensure!(
|
||||||
|
file == "" || file == "data.bin.brotli",
|
||||||
|
parse_path_error::WrongFileSnafu {
|
||||||
|
actual: file.to_owned()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let user_id = directory
|
||||||
|
.parse()
|
||||||
|
.context(parse_path_error::ParseUserIdSnafu)?;
|
||||||
|
|
||||||
|
Ok(user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Snafu)]
|
||||||
|
#[snafu(module)]
|
||||||
|
pub enum ListError {
|
||||||
|
/// error creating a lister through the storage operator
|
||||||
|
CreateListerError { source: opendal::Error },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Snafu)]
|
||||||
|
#[snafu(module)]
|
||||||
|
pub enum EntryError {
|
||||||
|
/// failed to get an entry from the storage operator's lister
|
||||||
|
ReceiveEntryError { source: opendal::Error },
|
||||||
|
|
||||||
|
/// failed to parse the entry as an acceptable path
|
||||||
|
ParsePathError { source: ParsePathError },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserDataManager {
|
||||||
|
pub async fn list(
|
||||||
|
&self,
|
||||||
|
) -> Result<impl TryStream<Ok = Id<UserMarker>, Error = EntryError> + Unpin, ListError> {
|
||||||
|
let lister = self
|
||||||
|
.operator
|
||||||
|
.lister("")
|
||||||
|
.await
|
||||||
|
.context(list_error::CreateListerSnafu)?;
|
||||||
|
|
||||||
|
Ok(lister
|
||||||
|
.map_err(|error| EntryError::ReceiveEntryError { source: error })
|
||||||
|
.and_then(|entry| {
|
||||||
|
std::future::ready(parse(entry.path()).context(entry_error::ParsePathSnafu))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Snafu)]
|
||||||
|
#[snafu(module)]
|
||||||
|
pub enum WithError {
|
||||||
|
/// couldn't read data for this user from the storage operator
|
||||||
|
ReadError { source: opendal::Error },
|
||||||
|
|
||||||
|
/// couldn't decompress the user data from storage
|
||||||
|
DecompressionError { source: std::io::Error },
|
||||||
|
|
||||||
|
/// couldn't deserialize the user data
|
||||||
|
DeserializeError { source: capnp::Error },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserDataManager {
|
||||||
|
pub async fn with<R>(
|
||||||
|
&self,
|
||||||
|
id: Id<UserMarker>,
|
||||||
|
f: impl FnOnce(user_capnp::user::Reader<'_>) -> R,
|
||||||
|
) -> Result<R, WithError> {
|
||||||
|
let compressed_buffer = self
|
||||||
|
.operator
|
||||||
|
.async_reader_if_exists(&path(id))
|
||||||
|
.await
|
||||||
|
.context(with_error::ReadSnafu)?;
|
||||||
|
|
||||||
|
let decompressed_reader = compressed_buffer.map(BrotliDecoder::new);
|
||||||
|
let decompressed = decompressed_reader
|
||||||
|
.map_async(|mut reader| async move {
|
||||||
|
let mut vec = Vec::new();
|
||||||
|
reader.read_to_end(&mut vec).await?;
|
||||||
|
Ok(vec)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.transpose()
|
||||||
|
.context(with_error::DecompressionSnafu)?;
|
||||||
|
|
||||||
|
let mut message = TypedBuilder::<user_capnp::user::Owned>::new_default();
|
||||||
|
let fallback = message.init_root();
|
||||||
|
|
||||||
|
let mut user_data = fallback.into_reader();
|
||||||
|
let message_reader;
|
||||||
|
|
||||||
|
if let Some(mut bytes) = decompressed.as_deref() {
|
||||||
|
message_reader = capnp::serialize::read_message_from_flat_slice_no_alloc(
|
||||||
|
&mut bytes,
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.context(with_error::DeserializeSnafu)?;
|
||||||
|
|
||||||
|
user_data = message_reader
|
||||||
|
.get_root()
|
||||||
|
.context(with_error::DeserializeSnafu)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(f(user_data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Snafu)]
|
||||||
|
#[snafu(module)]
|
||||||
|
pub enum UpdateError {
|
||||||
|
/// couldn't read data for this user from the storage operator
|
||||||
|
ReadError { source: opendal::Error },
|
||||||
|
|
||||||
|
/// couldn't decompress the user data from storage
|
||||||
|
DecompressionError { source: std::io::Error },
|
||||||
|
|
||||||
|
/// couldn't deserialize the user data
|
||||||
|
DeserializeError { source: capnp::Error },
|
||||||
|
|
||||||
|
/// couldn't serialize the (modified) user data
|
||||||
|
SerializeError { source: capnp::Error },
|
||||||
|
|
||||||
|
/// couldn't create a writer for this user data in the storage operator
|
||||||
|
WriterError { source: opendal::Error },
|
||||||
|
|
||||||
|
/// couldn't write (modified) data for this user to the storage operator
|
||||||
|
WriteError { source: std::io::Error },
|
||||||
|
|
||||||
|
/// couldn't finalize writing (modified) data for this user to the storage operator
|
||||||
|
FinalizeError { source: std::io::Error },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserDataManager {
|
||||||
|
pub async fn update<R>(
|
||||||
|
&self,
|
||||||
|
id: Id<UserMarker>,
|
||||||
|
f: impl FnOnce(user_capnp::user::Builder<'_>) -> R,
|
||||||
|
) -> Result<R, UpdateError> {
|
||||||
|
let path = path(id);
|
||||||
|
let compressed_buffer = self
|
||||||
|
.operator
|
||||||
|
.async_reader_if_exists(&path)
|
||||||
|
.await
|
||||||
|
.context(update_error::ReadSnafu)?;
|
||||||
|
|
||||||
|
let decompressed_reader = compressed_buffer.map(BrotliDecoder::new);
|
||||||
|
let decompressed = decompressed_reader
|
||||||
|
.map_async(|mut reader| async move {
|
||||||
|
let mut vec = Vec::new();
|
||||||
|
reader.read_to_end(&mut vec).await?;
|
||||||
|
Ok(vec)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.transpose()
|
||||||
|
.context(update_error::DecompressionSnafu)?;
|
||||||
|
|
||||||
|
let mut message = TypedBuilder::<user_capnp::user::Owned>::new_default();
|
||||||
|
|
||||||
|
let ret = if let Some(mut bytes) = decompressed.as_deref() {
|
||||||
|
let message_reader = capnp::serialize::read_message_from_flat_slice_no_alloc(
|
||||||
|
&mut bytes,
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.context(update_error::DeserializeSnafu)?;
|
||||||
|
|
||||||
|
let user_data = message_reader
|
||||||
|
.get_root()
|
||||||
|
.context(update_error::DeserializeSnafu)?;
|
||||||
|
|
||||||
|
message
|
||||||
|
.set_root(user_data)
|
||||||
|
.context(update_error::DeserializeSnafu)?;
|
||||||
|
|
||||||
|
f(
|
||||||
|
message.get_root().unwrap(), // this is logically impossible
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
f(message.init_root())
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
capnp::serialize::write_message(&mut buffer, message.borrow_inner())
|
||||||
|
.context(update_error::SerializeSnafu)?;
|
||||||
|
|
||||||
|
let compressed_writer = self
|
||||||
|
.operator
|
||||||
|
.writer(&path)
|
||||||
|
.await
|
||||||
|
.context(update_error::WriterSnafu)?
|
||||||
|
.into_futures_async_write();
|
||||||
|
|
||||||
|
let mut decompressed_writer = BrotliEncoder::new(compressed_writer);
|
||||||
|
|
||||||
|
decompressed_writer
|
||||||
|
.write_all(&buffer)
|
||||||
|
.await
|
||||||
|
.context(update_error::WriteSnafu)?;
|
||||||
|
|
||||||
|
decompressed_writer
|
||||||
|
.close()
|
||||||
|
.await
|
||||||
|
.context(update_error::FinalizeSnafu)?;
|
||||||
|
|
||||||
|
Ok(ret)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
user.capnp
Normal file
13
user.capnp
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
@0xc3e8b8ea9947b0c5;
|
||||||
|
|
||||||
|
struct User {
|
||||||
|
notificationScript @0 :Text;
|
||||||
|
|
||||||
|
voiceRecordingConsent @1 :Consent = unspecified;
|
||||||
|
|
||||||
|
enum Consent {
|
||||||
|
unspecified @0;
|
||||||
|
granted @1;
|
||||||
|
withheld @2;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user