diff --git a/.dockerignore b/.dockerignore index 61756e5..6fe0808 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,10 +3,4 @@ /target /.pre-commit-config.yaml .env - - -# Added by cargo -# -# already existing elements were commented out - -#/target +buscemi.ron diff --git a/.gitignore b/.gitignore index 61756e5..6fe0808 100644 --- a/.gitignore +++ b/.gitignore @@ -3,10 +3,4 @@ /target /.pre-commit-config.yaml .env - - -# Added by cargo -# -# already existing elements were commented out - -#/target +buscemi.ron diff --git a/Cargo.lock b/Cargo.lock index 45a93a9..16606e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,21 @@ dependencies = [ "libc", ] +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + +[[package]] +name = "anyhow" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224afbd727c3d6e4b90103ece64b8d1b67fbb1973b1046c2281eed3f3803f800" + [[package]] name = "async-trait" version = "0.1.64" @@ -87,9 +102,15 @@ checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535" name = "buscemi" version = "0.1.0" dependencies = [ + "anyhow", "poise", "rand", + "ron", + "serde", "tokio", + "tracing", + "tracing-forest", + "tracing-subscriber", ] [[package]] @@ -123,9 +144,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b0a3d9ed01224b22057780a37bb8c5dbfe1be8ba48678e7bf57ec4b385411f" dependencies = [ "iana-time-zone", + "js-sys", "num-integer", "num-traits", "serde", + "time 0.1.45", + "wasm-bindgen", "winapi", ] @@ -416,7 +440,7 @@ checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", ] [[package]] @@ -595,6 +619,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + [[package]] name = "libc" version = "0.2.139" @@ -629,6 +659,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.5.0" @@ -668,10 +707,20 @@ checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9" dependencies = [ "libc", "log", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.45.0", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "num-integer" version = "0.1.45" @@ -716,6 +765,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "parking_lot" version = "0.12.1" @@ -860,6 +915,15 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax", +] + [[package]] name = "regex-syntax" version = "0.6.28" @@ -923,6 +987,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "ron" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300a51053b1cb55c80b7a9fde4120726ddf25ca241a1cbb926626f62fb136bff" +dependencies = [ + "base64 0.13.1", + "bitflags", + "serde", +] + [[package]] name = "rustls" version = "0.20.8" @@ -1056,7 +1131,7 @@ dependencies = [ "serde", "serde-value", "serde_json", - "time", + "time 0.3.20", "tokio", "tracing", "typemap_rev", @@ -1074,6 +1149,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900fba806f70c630b0a382d0d825e17a0f19fcd059a2ade1ff237bcddf446b31" +dependencies = [ + "lazy_static", +] + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -1160,6 +1244,27 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "time" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi", +] + [[package]] name = "time" version = "0.3.20" @@ -1295,6 +1400,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24eb03ba0eab1fd845050058ce5e616558e8f8d8fca633e6b163fe25c797213a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-forest" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119324027fc01804d9f83aefb7d80fda2e8fbe7c28e0acc59187cbd751a12915" +dependencies = [ + "ansi_term", + "chrono", + "serde", + "smallvec", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "tracing-log" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ddad33d2d10b1ed7eb9d1f518a5674713876e97e5bb9b7345a7984fbb4f922" +dependencies = [ + "lazy_static", + "log", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6176eae26dd70d0c919749377897b54a9276bd7061339665dd68777926b5a70" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1396,6 +1548,22 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "uuid" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1674845326ee10d37ca60470760d4288a6f80f304007d92e5c53bab78c9cfd79" +dependencies = [ + "getrandom", + "serde", +] + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + [[package]] name = "version_check" version = "0.9.4" @@ -1412,6 +1580,12 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index 11f6c9e..054a197 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,12 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +anyhow = "1.0.69" poise = "0.5.2" rand = "0.8.5" +ron = "0.8.0" +serde = { version = "1.0.152", features = ["derive"] } tokio = { version = "1.25.0", features = ["full"] } +tracing = "0.1.37" +tracing-forest = { version = "0.1.5", features = ["full"] } +tracing-subscriber = "0.3.16" diff --git a/flake.nix b/flake.nix index 9eca5fb..2f49dbd 100644 --- a/flake.nix +++ b/flake.nix @@ -58,6 +58,7 @@ rust-analyzer rustfmt clippy + docker-client ; }; }; diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..4bd3d9c --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +# this is a hack to force nixpacks on fly.io to build without musl +[toolchain] +channel = "stable" diff --git a/src/commands.rs b/src/commands.rs index 15b97d0..e17668c 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,15 +1,86 @@ -use std::{collections::HashMap, sync::Mutex}; +use std::collections::HashMap; +use std::path::Path; +use anyhow::{Error, Result}; +use poise::serenity_prelude as serenity; use rand::seq::SliceRandom; +use serde::{Deserialize, Serialize}; +use tokio::fs::{File, OpenOptions}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::Mutex; -/// this is where we keep data for the bot -// can we eventually serialize this to disk for cheap persistence? -#[derive(Default)] -pub struct Data { - facts: Mutex>, +/// a brain stores data for a single guild +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct Brain { + facts: HashMap, } -type Error = Box; -type Context<'a> = poise::Context<'a, Data, Error>; + +/// top level data +#[derive(Debug)] +pub struct Data { + file: Mutex, + brain: Mutex>, +} + +impl Data { + #[tracing::instrument(fields(path = %path.as_ref().display()))] + pub async fn from_path(path: impl AsRef) -> Result { + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(&path) + .await?; + let mut content = String::new(); + file.read_to_string(&mut content).await?; + let brain = if content.is_empty() { + tracing::info!("initializing brain at {}", path.as_ref().display()); + Default::default() + } else { + let brain = ron::from_str(content.as_str())?; + tracing::info!("loaded brain from {}", path.as_ref().display()); + Mutex::new(brain) + }; + let file = Mutex::new(file); + Ok(Data { file, brain }) + } + + #[tracing::instrument] + pub async fn commit(&self) -> Result<()> { + let mut contents = vec![]; + self.brain + .lock() + .await + .serialize(&mut ron::Serializer::new( + &mut contents, + Some(Default::default()), + )?)?; + let mut file = self.file.lock().await; + file.set_len(0).await?; + file.write_all(&contents).await?; + tracing::info!("committed brain to disk"); + Ok(()) + } + + #[tracing::instrument(skip(f))] + async fn with_guild( + &self, + guild_id: serenity::GuildId, + mut f: impl FnMut(&mut Brain) -> T, + ) -> T { + let mut brain = self.brain.lock().await; + if let Some(guild) = brain.get_mut(&guild_id) { + f(guild) + } else { + let mut guild = Default::default(); + let result = f(&mut guild); + brain.insert(guild_id, guild); + result + } + } +} + +pub type Context<'a> = poise::Context<'a, Data, Error>; /// all of the commands supported by `buscemi` pub fn commands() -> Vec> { @@ -23,14 +94,14 @@ async fn help( #[description = "optional command to show help about"] #[autocomplete = "poise::builtins::autocomplete_command"] command: Option, -) -> Result<(), Error> { +) -> Result<()> { poise::builtins::help(ctx, command.as_deref(), Default::default()).await?; Ok(()) } /// check that the bot is working #[poise::command(prefix_command, slash_command)] -async fn ping(ctx: Context<'_>) -> Result<(), Error> { +async fn ping(ctx: Context<'_>) -> Result<()> { let vowel = { let mut rng = rand::thread_rng(); let vowels = ['a', 'e', 'o', 'u', 'y']; @@ -48,7 +119,7 @@ async fn eight_ball( #[rename = "question"] #[description = "a yes or no question"] _question: String, -) -> Result<(), Error> { +) -> Result<()> { let answer = { let mut rng = rand::thread_rng(); let answers = [ @@ -101,17 +172,19 @@ async fn know( #[rest] #[description = "the definition of the thing"] is: String, -) -> Result<(), Error> { +) -> Result<()> { let (that, is) = if that.as_str() == "that" { is.split_once(' ').unwrap() } else { (that.as_str(), is.as_str()) }; let is = is.strip_prefix("is ").unwrap_or(is); - let old = { - let mut facts = ctx.data().facts.lock().unwrap(); - facts.insert(that.to_string(), is.to_string()) - }; + let old = ctx + .data() + .with_guild(ctx.guild_id().unwrap(), |brain| { + brain.facts.insert(that.to_string(), is.to_string()) + }) + .await; let message = if let Some(old) = old { format!("ok, {that} is {is} (changed from {old})") } else { @@ -128,15 +201,17 @@ async fn what( #[rest] #[description = "the thing you want to look up"] is: String, -) -> Result<(), Error> { - let message = { - let facts = ctx.data().facts.lock().unwrap(); - let is = is.strip_prefix("is ").unwrap_or(is.as_str()); - facts.get(is).map_or_else( - || format!("i don't know what {is} is"), - |definition| format!("{is} is {definition}"), - ) - }; +) -> Result<()> { + let message = ctx + .data() + .with_guild(ctx.guild_id().unwrap(), |brain| { + let is = is.strip_prefix("is ").unwrap_or(is.as_str()); + brain.facts.get(is).map_or_else( + || format!("i don't know what {is} is"), + |definition| format!("{is} is {definition}"), + ) + }) + .await; ctx.say(message).await?; Ok(()) } diff --git a/src/main.rs b/src/main.rs index 921ea22..73fe935 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,23 +1,73 @@ mod commands; +use anyhow::{Error, Result}; use poise::serenity_prelude as serenity; +use tracing_forest::{util::EnvFilter, ForestLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, Registry}; + +const DISCORD_TOKEN: &str = "DISCORD_TOKEN"; +const BUSCEMI_DATA: &str = "BUSCEMI_DATA"; +const BUSCEMI_DATA_PATH: &str = "./buscemi.ron"; #[tokio::main] -async fn main() { +async fn main() -> Result<()> { + init_tracing(); + let framework = init_discord(); + tracing::info!("it's beneath me. i'm mr. pink. let's move on."); + framework.run().await?; + Ok(()) +} + +fn init_tracing() { + Registry::default() + .with(EnvFilter::from_default_env()) + .with(ForestLayer::default()) + .init() +} + +// #[tracing::instrument(level = "debug")] +// async fn pre_command(ctx: commands::Context<'_>) { +// // TODO: set up tracing span? +// } + +#[tracing::instrument(level = "debug")] +async fn post_command(ctx: commands::Context<'_>) { + ctx.data() + .commit() + .await + .unwrap_or_else(|e| tracing::error!("failed to commit brain to disk: {e}")) +} + +#[tracing::instrument(level = "debug")] +async fn on_error(error: poise::FrameworkError<'_, commands::Data, Error>) { + if let Err(e) = poise::builtins::on_error(error).await { + tracing::error!("unhandled error: {e}") + } +} + +fn init_discord() -> poise::FrameworkBuilder { let options = poise::FrameworkOptions { commands: commands::commands(), + // pre_command: |ctx| Box::pin(pre_command(ctx)), + post_command: |ctx| Box::pin(post_command(ctx)), + on_error: |error| Box::pin(on_error(error)), ..Default::default() }; - let framework = poise::Framework::builder() + poise::Framework::builder() .options(options) - .token(std::env::var("DISCORD_TOKEN").expect("failed to find DISCORD_TOKEN in env")) + .token(std::env::var(DISCORD_TOKEN).expect("failed to find DISCORD_TOKEN in env")) .intents(serenity::GatewayIntents::non_privileged()) - .setup(|ctx, _ready, framework| { + .setup(|ctx, ready, framework| { Box::pin(async move { - poise::builtins::register_globally(ctx, &framework.options().commands).await?; - Ok(Default::default()) + for guild in &ready.guilds { + poise::builtins::register_in_guild(ctx, &framework.options().commands, guild.id) + .await + .unwrap_or_else(|e| tracing::error!("failed to register commands: {e}")) + } + tracing::debug!("registered commands"); + let data_path = + std::env::var(BUSCEMI_DATA).unwrap_or_else(|_| String::from(BUSCEMI_DATA_PATH)); + commands::Data::from_path(data_path).await }) - }); - println!("it's beneath me. i'm mr. pink. let's move on."); - framework.run().await.unwrap(); + }) }