From 72d9bbe15220c21909dec8e30fb80729a24cec72 Mon Sep 17 00:00:00 2001 From: Nathan Perry Date: Tue, 6 Aug 2024 10:45:06 -0400 Subject: first pass convert to poise --- Cargo.lock | 397 +++++++++++++++++++++++++++- Cargo.toml | 15 ++ src/bot.rs | 169 ++++++------ src/commands/game.rs | 572 ++++++++++++++++++++++++++++++++++++++++ src/commands/help.rs | 31 --- src/commands/meme/create.rs | 104 ++++---- src/commands/meme/delete.rs | 29 +-- src/commands/meme/history.rs | 157 +++++------ src/commands/meme/invoke.rs | 149 +++++------ src/commands/meme/mod.rs | 75 +++--- src/commands/mod.rs | 94 ++++--- src/commands/playback.rs | 153 +++++------ src/commands/roll.rs | 111 ++++---- src/commands/sound_levels.rs | 79 +----- src/commands/today/mod.rs | 32 +-- src/game.rs | 606 ------------------------------------------- src/lib.rs | 31 +++ src/main.rs | 34 +-- src/util.rs | 188 -------------- src/util/mod.rs | 232 +++++++++++++++++ src/util/rest_vec.rs | 84 ++++++ 21 files changed, 1831 insertions(+), 1511 deletions(-) create mode 100644 src/commands/game.rs delete mode 100644 src/commands/help.rs delete mode 100644 src/game.rs create mode 100644 src/lib.rs delete mode 100644 src/util.rs create mode 100644 src/util/mod.rs create mode 100644 src/util/rest_vec.rs diff --git a/Cargo.lock b/Cargo.lock index a4e7e87..3496cef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,6 +51,55 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" + +[[package]] +name = "anstyle-parse" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", +] + [[package]] name = "anyhow" version = "1.0.83" @@ -139,6 +188,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -166,6 +221,12 @@ version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +[[package]] +name = "bytecount" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ce89b21cab1437276d2650d57e971f9d548a2d9037cc231abdc0562b97498ce" + [[package]] name = "bytemuck" version = "1.15.0" @@ -184,6 +245,37 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" +[[package]] +name = "camino" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0ec6b951b160caa93cc0c7b209e5a3bff7aae9062213451ac99493cd844c239" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24b1f0365a6c6bb4020cd05806fd0d33c44d38046b8bd7f0e40814b9763cabfc" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", +] + [[package]] name = "cc" version = "1.0.97" @@ -206,6 +298,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-targets 0.52.5", ] @@ -221,6 +314,46 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clap" +version = "4.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbb260a053428790f3de475e304ff84cdbc4face759ea7a3e64c1edd938a7fc" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64b17d7ea74e9f833c7dbf2cbe4fb12ff26783eda4782a8975b72f895c9b4d99" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.61", +] + +[[package]] +name = "clap_lex" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" + [[package]] name = "cmake" version = "0.1.50" @@ -230,6 +363,12 @@ dependencies = [ "cc", ] +[[package]] +name = "colorchoice" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" + [[package]] name = "colored" version = "1.9.4" @@ -243,9 +382,9 @@ dependencies = [ [[package]] name = "command_attr" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f08c85a02e066b7b4f7dcb60eee6ae0793ef7d6452a3547d1f19665df070a9" +checksum = "88da8d7e9fe6f30d8e3fcf72d0f84102b49de70fece952633e8439e89bdc7631" dependencies = [ "proc-macro2", "quote", @@ -327,6 +466,41 @@ dependencies = [ "zeroize", ] +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.61", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.61", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -564,6 +738,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "error-chain" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" +dependencies = [ + "version_check", +] + [[package]] name = "extended" version = "0.1.0" @@ -791,6 +974,12 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + [[package]] name = "h2" version = "0.3.26" @@ -816,6 +1005,12 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.3.9" @@ -950,6 +1145,12 @@ dependencies = [ "cc", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.5.0" @@ -996,6 +1197,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + [[package]] name = "isolang" version = "2.4.0" @@ -1141,6 +1348,21 @@ dependencies = [ "unicase", ] +[[package]] +name = "mini-moka" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c325dfab65f261f386debee8b0969da215b3fa0037e74c8a1234db7ba986d803" +dependencies = [ + "crossbeam-channel", + "crossbeam-utils", + "dashmap", + "skeptic", + "smallvec", + "tagptr", + "triomphe", +] + [[package]] name = "miniz_oxide" version = "0.7.2" @@ -1539,6 +1761,35 @@ dependencies = [ "pnet_base", ] +[[package]] +name = "poise" +version = "0.6.1" +source = "git+https://pub.npry.dev/poise#f72f91ad0c11f403e9ceee5b4581a348c38b3bb6" +dependencies = [ + "async-trait", + "derivative", + "futures-util", + "indexmap", + "parking_lot", + "poise_macros", + "regex", + "serenity", + "tokio", + "tracing", + "trim-in-place", +] + +[[package]] +name = "poise_macros" +version = "0.6.1" +source = "git+https://pub.npry.dev/poise#f72f91ad0c11f403e9ceee5b4581a348c38b3bb6" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.61", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -1610,6 +1861,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" +dependencies = [ + "bitflags 2.5.0", + "memchr", + "unicase", +] + [[package]] name = "quote" version = "1.0.36" @@ -2003,6 +2265,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.23" @@ -2077,6 +2348,15 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +dependencies = [ + "serde", +] + [[package]] name = "serde" version = "1.0.201" @@ -2107,6 +2387,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_cow" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7bbbec7196bfde255ab54b65e34087c0849629280028238e67ee25d6a4b7da" +dependencies = [ + "serde", +] + [[package]] name = "serde_derive" version = "1.0.201" @@ -2154,15 +2443,16 @@ dependencies = [ [[package]] name = "serenity" -version = "0.12.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64da29158bb55d70677cacd4f4f8eab1acef005fb830d9c3bea411b090e96a9" +checksum = "880a04106592d0a8f5bdacb1d935889bfbccb4a14f7074984d9cd857235d34ac" dependencies = [ "arrayvec", "async-trait", - "base64 0.21.7", + "base64 0.22.1", "bitflags 2.5.0", "bytes", + "chrono", "command_attr", "dashmap", "flate2", @@ -2175,6 +2465,7 @@ dependencies = [ "reqwest", "secrecy", "serde", + "serde_cow", "serde_json", "static_assertions", "time", @@ -2182,6 +2473,7 @@ dependencies = [ "tokio-tungstenite 0.21.0", "tracing", "typemap_rev", + "typesize", "url", "uwl", ] @@ -2258,6 +2550,21 @@ version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" +[[package]] +name = "skeptic" +version = "0.13.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8" +dependencies = [ + "bytecount", + "cargo_metadata", + "error-chain", + "glob", + "pulldown-cmark", + "tempfile", + "walkdir", +] + [[package]] name = "slab" version = "0.4.9" @@ -2389,6 +2696,12 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.5.0" @@ -2639,6 +2952,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tap" version = "1.0.1" @@ -2693,6 +3012,7 @@ version = "0.2.0" dependencies = [ "anyhow", "chrono", + "clap", "deadpool-postgres", "diesel", "diesel-async", @@ -2708,6 +3028,7 @@ dependencies = [ "log", "pest", "pest_derive", + "poise", "rand", "regex", "reqwest", @@ -3021,6 +3342,18 @@ dependencies = [ "strength_reduce", ] +[[package]] +name = "trim-in-place" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc" + +[[package]] +name = "triomphe" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6631e42e10b40c0690bf92f404ebcfe6e1fdb480391d15f17cc8e96eeed5369" + [[package]] name = "try-lock" version = "0.2.5" @@ -3133,6 +3466,35 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "typesize" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb704842c709bc76f63e99e704cb208beeccca2abbabd0d9aec02e48ca1cee0f" +dependencies = [ + "chrono", + "dashmap", + "hashbrown", + "mini-moka", + "parking_lot", + "secrecy", + "serde_json", + "time", + "typesize-derive", + "url", +] + +[[package]] +name = "typesize-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "905e88c2a4cc27686bd57e495121d451f027e441388a67f773be729ad4be1ea8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.61", +] + [[package]] name = "ucd-trie" version = "0.1.6" @@ -3218,6 +3580,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.8.0" @@ -3251,6 +3619,16 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3423,6 +3801,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index fa9aac5..f3f28e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,11 @@ name = "thulani" version = "0.2.0" authors = ["Nathan Perry "] edition = "2021" +default-run = "thulani" + +[[bin]] +name = "batch_delmeme" +required-features = ["db"] [features] default = ["db", "games"] @@ -32,6 +37,8 @@ timeago = "0.4" statrs = "0.16" fnv = "1.0" +clap = { version = "4.5", features = ["derive"] } + pest = "2.7" pest_derive = "2.7" @@ -45,6 +52,8 @@ tokio = { version = "1.37", features = ["full"] } songbird = { version = "0.4", features = ["builtin-queue"] } symphonia = { version = "0.5", features = ["all"] } +poise = { git = "https://pub.npry.dev/poise" } + diesel = { version = "2.1", features = ["chrono"], optional = true } diesel-async = { version = "0.4", optional = true, features = ["deadpool", "postgres"] } diesel_async_migrations = { version = "0.12", optional = true } @@ -56,3 +65,9 @@ version = "0.12" default-features = false features = ["builder", "cache", "client", "framework", "gateway", "http", "model", "utils", "voice", "standard_framework", "rustls_backend"] +[profile.release] +codegen-units = 1 +lto = "fat" +overflow-checks = false +debug-assertions = false +opt-level = 3 diff --git a/src/bot.rs b/src/bot.rs index db37754..c8b01ab 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,9 +1,9 @@ use std::{ + collections::HashSet, fs::File, future::Future, path::PathBuf, pin::Pin, - result::Result as StdResult, str::FromStr, sync::Arc, }; @@ -21,22 +21,16 @@ use log::{ trace, warn, }; +use poise::{ + BoxFuture, + FrameworkError, +}; use serenity::{ all::{ GuildId, ReactionType, }, - async_trait, - framework::{ - standard::{ - BucketBuilder, - CommandError, - Configuration, - }, - StandardFramework, - }, model::{ - channel::Message, event::ResumedEvent, gateway::Ready, id::{ @@ -56,11 +50,13 @@ use songbird::{ use tokio::sync::Mutex; use crate::{ - commands::register_commands, + commands, config::CONFIG, util, util::OAUTH_URL, Error, + PoiseContext, + PoiseData, Result, }; @@ -123,7 +119,7 @@ impl EventHandler for Handler { struct SongbirdHandler(Arc>); -#[async_trait] +#[serenity::async_trait] impl songbird::events::EventHandler for SongbirdHandler { async fn act(&self, _ctx: &EventContext<'_>) -> Option { let mut call = self.0.lock().await; @@ -161,112 +157,125 @@ lazy_static! { let result = restrict_ids.unwrap_or_default().into_iter().collect::>(); - info!("restricted ids: {:?}", result); + info!("restricted ids: {result:?}"); result }; } -async fn framework() -> StandardFramework { - let builder = BucketBuilder::default().delay(1).limit(20).time_span(60); +fn on_err(err: FrameworkError) -> BoxFuture<()> { + Box::pin(async move { + error!("error encountered: {err:?}"); - let framework = StandardFramework::new() - .before(before_handle) - .after(after_handle) - .bucket("Standard", builder) - .await; + if let Some(ctx) = err.ctx() { + if let Err(e) = util::react(ctx, ReactionType::Unicode("❌".to_owned())).await { + error!("reacting to failed message: {e}"); + } - let config = Configuration::default() - .allow_dm(false) - .with_whitespace(true) - .prefixes(ALL_PREFIXES.iter().map(|x| x.to_string())) - .ignore_bots(true) - .on_mention(None) - .owners(vec![CONFIG.discord.owner()].into_iter().collect()) - .case_insensitivity(true); + if let Err(e) = util::reply(ctx, "BANIC").await { + error!("sending BANIC: {e}"); + } + } + }) +} - framework.configure(config); +async fn framework() -> poise::Framework { + let additional_prefixes = + ALL_PREFIXES.iter().skip(1).map(|x| poise::Prefix::Literal(x.to_owned())).collect(); + + let framework = poise::Framework::builder() + .options(poise::FrameworkOptions { + pre_command: before_handle, + post_command: after_handle, + on_error: on_err, + + command_check: Some(check), + + prefix_options: poise::PrefixFrameworkOptions { + prefix: ALL_PREFIXES.get(0).map(|&x| x.to_owned()), + additional_prefixes, + case_insensitive_commands: true, + mention_as_prefix: false, + ignore_bots: true, + ..Default::default() + }, - register_commands(framework) -} + commands: commands::commands(), + owners: HashSet::from_iter([CONFIG.discord.owner()]), + initialize_owners: false, + skip_checks_for_owners: true, -fn before_handle<'fut>( - ctx: &'fut Context, - message: &'fut Message, - cmd: &'fut str, -) -> Pin + Send + 'fut>> { - debug!("got command '{}' from user '{}' ({})", cmd, message.author.name, message.author.id); + ..Default::default() + }) + .setup(|_ctx, _ready, _framework| Box::pin(async move { Ok(()) })) + .build(); + framework +} + +fn check(ctx: PoiseContext) -> BoxFuture> { Box::pin(async move { - if !message.guild_id.map_or(false, |x| x == CONFIG.discord.guild()) { - info!("rejecting command '{}' from user '{}': wrong guild", cmd, message.author.name); - return false; + if !ctx.guild_id().map_or(false, |x| x == CONFIG.discord.guild()) { + info!( + "rejecting command '{}' from user '{}': wrong guild", + ctx.command().name, + ctx.author().name + ); + return Ok(false); } - if message.author.id == CONFIG.discord.owner() { - return true; + if ctx.author().id == CONFIG.discord.owner() { + return Ok(true); } - let restricted_prefix = - RESTRICTED_PREFIXES.iter().any(|prefix| message.content.starts_with(prefix)); + let restricted_prefix = RESTRICTED_PREFIXES.iter().any(|&prefix| ctx.prefix() == prefix); if !restricted_prefix { - return true; + return Ok(true); } const PERMITTED_WEEKDAY: chrono::Weekday = chrono::Weekday::Tue; - let user_is_restricted = RESTRICT_IDS.contains(&message.author.id.get()); + let user_is_restricted = RESTRICT_IDS.contains(&ctx.author().id.get()); let restrictions_flipped = chrono::Local::now().weekday() == PERMITTED_WEEKDAY; if user_is_restricted == restrictions_flipped { - return true; + return Ok(true); } let reason = if !restrictions_flipped { "restricted prefix".to_owned() } else { - format!("it is {:?}", PERMITTED_WEEKDAY) + format!("it is {PERMITTED_WEEKDAY:?}") }; - info!("rejecting command '{}' from user '{}': {}", cmd, message.author.name, reason); + info!( + "rejecting command '{}' from user '{}': {}", + ctx.command().name, + ctx.author().name, + reason + ); - match util::send_result(ctx, message.channel_id, "no", message.tts).await { - Err(e) => error!("sending restricted prefix response: {}", e), - Ok(msg_id) => { - let mut mp = MESSAGE_WATCH.lock().await; - mp.insert(message.id, msg_id); - }, - } + util::reply(ctx, "no").await?; - false + Ok(false) }) } -fn after_handle<'fut>( - ctx: &'fut Context, - msg: &'fut Message, - cmd: &'fut str, - err: StdResult<(), CommandError>, -) -> Pin + Send + 'fut>> { - Box::pin(async move { - match err { - Ok(()) => { - trace!("command '{}' completed successfully", cmd); - }, +fn before_handle<'fut>(ctx: PoiseContext<'fut>) -> Pin + Send + 'fut>> { + debug!( + "got command '{}' from user '{}' ({})", + ctx.command().name, + ctx.author().name, + ctx.author().id + ); - Err(e) => { - if let Err(e) = msg.react(&ctx, ReactionType::Unicode("❌".to_owned())).await { - error!("reacting to failed message: {}", e); - } - - if let Err(e) = util::send(ctx, msg.channel_id, "BANIC", msg.tts).await { - error!("sending BANIC: {}", e); - } + Box::pin(async {}) +} - error!("error encountered handling command '{}': {:?}", cmd, e); - }, - } +fn after_handle(ctx: PoiseContext) -> BoxFuture<()> { + Box::pin(async move { + trace!("command '{}' completed successfully", ctx.command().name); }) } diff --git a/src/commands/game.rs b/src/commands/game.rs new file mode 100644 index 0000000..72633b5 --- /dev/null +++ b/src/commands/game.rs @@ -0,0 +1,572 @@ +use std::{ + fs, + iter, + path::PathBuf, + result::Result as StdResult, + str::{ + self, + FromStr, + }, +}; + +use anyhow::anyhow; +use fnv::{ + FnvHashMap, + FnvHashSet, +}; +use itertools::Itertools; +use lazy_static::lazy_static; +use log::{ + debug, + error, + info, +}; +use serde::Deserialize; +use serenity::model::{ + guild::Guild, + id::UserId, +}; +use tap::Pipe; +use url::Url; + +use crate::{ + bot::HttpKey, + util, + PoiseContext, + Result, + CONFIG, +}; + +lazy_static! { + static ref SPREADSHEET_URL: Url = Url::parse(&format!( + "https://sheets.googleapis.com/v4/spreadsheets/{}/values:batchGet", + &CONFIG.sheets.spreadsheet, + )) + .expect("parsing spreadsheet url"); +} + +#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +struct UserInfo { + name: String, + + #[serde(flatten)] + profile: ProfileInfo, +} + +#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct ProfileInfo { + #[serde(rename = "steam")] + steam_id: Option, + + #[serde(rename = "discord")] + discord_user_id: u64, +} + +lazy_static! { + static ref USER_MAP_STR: String = { + let default_path = PathBuf::from_str("user_id_mapping").unwrap(); + let mapping_path = CONFIG.user_id_mapping.as_ref().unwrap_or(&default_path); + + fs::read_to_string(mapping_path).unwrap_or("{}".to_owned()) + }; + static ref USER_INFO_MAP: FnvHashMap = { + let v: Vec = serde_json::from_str(&USER_MAP_STR).unwrap(); + + let result = v + .into_iter() + .map(|ui| { + let UserInfo { + name, + profile, + } = ui; + + (name, profile) + }) + .collect::>(); + + info!( + "loaded user info for {} users ({:#?})", + result.len(), + result.keys().collect::>() + ); + + result + }; + static ref DISCORD_MAP: FnvHashMap = { + USER_INFO_MAP + .clone() + .into_iter() + .map(|(name, profile)| (UserId::new(profile.discord_user_id), name)) + .collect::>() + }; + static ref STEAM_MAP: FnvHashMap = { + USER_INFO_MAP + .clone() + .into_iter() + .filter_map(|(_, profile)| { + profile.steam_id.map(|sid| (UserId::new(profile.discord_user_id), sid)) + }) + .collect::>() + }; + static ref ALPHABET: Vec = (0..26).map(|x| (x + b'a') as char).collect(); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd)] +enum GameStatus { + Installed, + NotInstalled, + NotOwned, + Unknown, +} + +impl FromStr for GameStatus { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + use std::char; + + if s.starts_with('y') { + Ok(GameStatus::Installed) + } else if s.starts_with("n/i") { + Ok(GameStatus::NotInstalled) + } else if s.starts_with('n') { + Ok(GameStatus::NotOwned) + } else if s.chars().all(char::is_whitespace) { + Ok(GameStatus::Unknown) + } else { + Err(anyhow!(format!("unexpected status '{}'", s))) + } + } +} + +pub fn commands() -> Vec> { + vec![installedgame(), ownedgame(), game(), updategaem()] +} + +#[poise::command(prefix_command, guild_only, category = "gaem", aliases("installedgaem"))] +pub async fn installedgame(ctx: PoiseContext<'_>, args: util::RestVec) -> anyhow::Result<()> { + _game(ctx, args.into_inner(), GameStatus::Installed).await +} + +#[poise::command(prefix_command, guild_only, category = "gaem", aliases("ownedgaem"))] +pub async fn ownedgame(ctx: PoiseContext<'_>, args: util::RestVec) -> anyhow::Result<()> { + _game(ctx, args.into_inner(), GameStatus::NotInstalled).await +} + +#[derive(Copy, Clone, Debug, thiserror::Error, PartialEq, Eq, Hash)] +pub enum UserLookupError { + #[error("too many possible options ({}) for query", _0)] + Ambiguous(usize), + + #[error("user wasn't found in the guild")] + NotFound, +} + +pub fn get_user_id>(g: &Guild, s: S) -> StdResult { + let s = s.as_ref().trim_start_matches('@').to_lowercase(); + + if let Some(info) = USER_INFO_MAP.get(&s) { + return Ok(UserId::new(info.discord_user_id)); + } + + let nicks = g.members_nick_containing(&s, false, false); + + { + let exact_match = nicks.iter().find(|(m, _)| m.display_name().to_lowercase() == s); + + if let Some((m, _)) = exact_match { + return Ok(m.user.id); + } + } + + let usernames = g.members_username_containing(&s, false, false); + + { + let exact_match = usernames.iter().find(|(m, _)| m.user.name.to_lowercase() == s); + + if let Some((m, _)) = exact_match { + return Ok(m.user.id); + } + } + + let opts = nicks + .into_iter() + .chain(usernames) + .map(|(member, _)| member.user.id) + .collect::>(); + + match opts.len() { + 0 => Err(UserLookupError::NotFound), + 1 => Ok(opts.into_iter().next().unwrap()), + x => Err(UserLookupError::Ambiguous(x)), + } +} + +#[poise::command(prefix_command, guild_only, category = "gaem", aliases("gaem"))] +async fn game(ctx: PoiseContext<'_>, args: util::RestVec) -> anyhow::Result<()> { + _game(ctx, args.into_inner(), GameStatus::Installed).await +} + +async fn _game( + ctx: PoiseContext<'_>, + user_args: Vec, + min_status: GameStatus, +) -> anyhow::Result<()> { + use serenity::futures::StreamExt; + + let users = { + let guild = ctx + .channel_id() + .to_channel(&ctx) + .await? + .guild() + .ok_or(anyhow!("couldn't find guild"))?; + + let mut users = user_args + .into_iter() + .pipe(serenity::futures::stream::iter) + .filter_map(|u| { + let guild = &guild; + async move { + use std::borrow::Borrow; + + let possible = { + let Ok(guild) = + guild.guild(&ctx).ok_or_else(|| anyhow!("couldn't find guild")) + else { + error!("failed retrieving guild"); + return None; + }; + + get_user_id(guild.borrow(), &u) + }; + + debug!("parsed userid {:?}", possible); + + match possible { + Ok(x) => Some(x), + Err(UserLookupError::NotFound) => { + let _ = util::reply(ctx, format!("didn't recognize {u}")).await; + + None + }, + Err(UserLookupError::Ambiguous(x)) => { + let _ = + util::reply(ctx, format!("too many matches ({x}) for {u}")).await; + None + }, + } + } + }) + .filter_map(|uid| async move { + let res = DISCORD_MAP.get(&uid).map(|s| s.to_lowercase()); + + if res.is_none() { + info!("user {uid} is not recognized"); + } + + res + }) + .collect::>() + .await; + + if users.is_empty() { + let guild = guild.guild(&ctx).ok_or_else(|| anyhow!("couldn't find guild"))?; + + let pairs = guild + .voice_states + .iter() + .filter_map(|(uid, voice)| voice.channel_id.map(|cid| (*uid, cid))) + .collect::>(); + + let channel = + pairs.get(&ctx.author().id).cloned().unwrap_or(CONFIG.discord.voice_channel()); + + users = pairs + .iter() + .filter_map(|(uid, cid)| { + if *cid == channel { + DISCORD_MAP.get(uid).map(|s| s.to_lowercase()) + } else { + None + } + }) + .collect::>(); + } + + users + }; + + let inferred = users.is_empty(); + + if inferred && users.len() < 2 || !inferred && users.is_empty() { + info!("too few known users to make game comparison"); + util::reply(ctx, "yer too lonely").await?; + return Ok(()); + } + + let client = { + let data = ctx.serenity_context().data.read().await; + data.get::().unwrap().clone() + }; + let data = load_spreadsheet(&client).await?; + + let user_indexes = (0..data.len()) + .filter_map(|i| { + let user = data[i][0].to_lowercase(); + + if users.contains(&user) { + Some((user, i)) + } else { + None + } + }) + .collect::>(); + + let data_ref = &data; + let user_games = user_indexes + .iter() + .map(|(user, col)| { + let empty_hash_set: FnvHashSet<_> = vec![].into_iter().collect(); + + let mut game_map = vec![ + (GameStatus::Installed, empty_hash_set.clone()), + (GameStatus::NotInstalled, empty_hash_set.clone()), + (GameStatus::NotOwned, empty_hash_set.clone()), + (GameStatus::Unknown, empty_hash_set), + ] + .into_iter() + .collect::>(); + + (1..data[*col].len()).for_each(|i| { + let status = + &data_ref[*col][i].parse::().unwrap_or(GameStatus::Unknown); + let game = &data_ref[0][i]; + + game_map.get_mut(status).unwrap().insert(game); + }); + + (user, game_map) + }) + .collect::>(); + + let statuses = vec![ + GameStatus::Installed, + GameStatus::NotOwned, + GameStatus::NotInstalled, + GameStatus::Unknown, + ] + .into_iter() + .filter(|s| s <= &min_status) + .collect::>(); + + let mut games_in_common = { + let game_map = user_games.values().next().unwrap(); + + statuses.iter().fold(iter::empty().collect::>(), |acc, s| { + acc.union(&game_map[s]).cloned().collect() + }) + }; + + for (_user, game_map) in user_games.iter() { + let relevant_games = + statuses.iter().fold(iter::empty().collect::>(), |acc, s| { + acc.union(&game_map[s]).cloned().collect() + }); + + games_in_common = games_in_common.intersection(&relevant_games).cloned().collect(); + } + + let mut games_formatted = + games_in_common.iter().sorted_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())).join("\n"); + + if games_formatted.is_empty() { + games_formatted = "**LITERALLY NOTHING**".to_owned(); + } + + util::reply(ctx, games_formatted).await?; + + Ok(()) +} + +async fn load_spreadsheet(client: &reqwest::Client) -> Result>> { + let mut u = SPREADSHEET_URL.clone(); + + u.query_pairs_mut() + .append_pair("ranges", &format!("a1:{}", &CONFIG.sheets.max_column)) + .append_pair("valueRenderOption", "FORMATTED_VALUE") + .append_pair("majorDimension", "COLUMNS") + .append_pair("key", &CONFIG.sheets.api_key); + + let req = reqwest::Request::new(reqwest::Method::GET, u); + let resp = client.execute(req).await?; + + #[derive(Deserialize)] + struct Resp { + #[serde(rename = "valueRanges")] + value_ranges: Vec, + } + + #[derive(Deserialize)] + struct Inner { + values: Vec>, + } + + let resp = resp.json::().await?; + + Ok(resp.value_ranges.into_iter().next().unwrap().values) +} + +#[poise::command( + slash_command, + prefix_command, + guild_only, + category = "gaem", + aliases("updategame") +)] +pub async fn updategaem(ctx: PoiseContext<'_>, user: Option) -> anyhow::Result<()> { + use regex::Regex; + use std::borrow::Borrow; + + let client = { + let data = ctx.serenity_context().data.read().await; + data.get::().unwrap().clone() + }; + + let user = match user { + None => ctx.author().id, + Some(user) => { + let guild = ctx + .channel_id() + .to_channel(&ctx) + .await? + .guild() + .ok_or(anyhow!("couldn't find guild"))?; + + let guild = guild.guild(&ctx).ok_or(anyhow!("couldn't find guild"))?; + + get_user_id(guild.borrow(), user).map_err(anyhow::Error::from)? + }, + }; + + debug!("parsed userid {:?}", user); + + let username = match DISCORD_MAP.get(&user) { + Some(s) => s, + None => { + util::reply(ctx, "WHO THE FUCK ARE YE").await?; + return Ok(()); + }, + }; + + let steam_id = match STEAM_MAP.get(&user) { + Some(u) => u, + None => { + util::reply(ctx, "WHO ARE YE ON STEAM").await?; + return Ok(()); + }, + }; + + let spreadsheet = load_spreadsheet(&client).await?; + + let user_column = (0..spreadsheet.len()) + .find(|x| spreadsheet[*x][0].to_lowercase() == username.to_lowercase()); + + let user_column = match user_column { + Some(c) => &spreadsheet[c][1..], + None => { + util::reply(ctx, "YER NOT IN THE SPREADSHEET").await?; + return Ok(()); + }, + }; + + lazy_static! { + static ref APPID_REGEX: Regex = Regex::new(r#"(?i)^\s*app\s*id\s*$"#).unwrap(); + } + + let appid_column = (0..spreadsheet.len()).find(|x| APPID_REGEX.is_match(&spreadsheet[*x][0])); + + let appid_column = match appid_column { + Some(c) => &spreadsheet[c][1..], + None => { + error!("didn't find an appid column in the spreadsheet"); + util::reply(ctx, "SPREADSHEET BROKE").await?; + return Ok(()); + }, + }; + + let missing_appids = (0..user_column.len()) + .filter_map(|x| user_column[x].parse::().ok().map(|s| (x, s))) + .filter(|(_, s)| *s == GameStatus::Unknown || *s == GameStatus::NotOwned) + .filter_map(|(x, _)| { + appid_column.get(x).and_then(|s| s.parse::().ok().map(|appid| (appid, x))) + }); + + let mut u = Url::parse("https://api.steampowered.com/IPlayerService/GetOwnedGames/v1")?; + + u.query_pairs_mut() + .append_pair("key", &CONFIG.steam_api_key) + .append_pair("include_played_free_games", "1") + .append_pair("steamid", &steam_id.to_string()); + + #[derive(Deserialize, Clone, Debug, PartialEq, Eq, Hash)] + struct SteamResp { + response: SteamInner, + } + + #[derive(Deserialize, Clone, Debug, PartialEq, Eq, Hash)] + struct SteamInner { + games: Vec, + } + + #[derive(Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash)] + struct SteamGameEntry { + #[serde(rename = "appid")] + app_id: u64, + + #[serde(rename = "playtime_forever")] + play_time: u64, + } + + let client = { + let data = ctx.serenity_context().data.read().await; + data.get::().unwrap().clone() + }; + + let games_owned = client + .get(u) + .send() + .await? + .json::() + .await? + .response + .games + .into_iter() + .map(|ge| ge.app_id) + .collect::>(); + + let found_games = missing_appids + .filter_map(|(ai, x)| { + if games_owned.contains(&ai) { + Some(&spreadsheet[0][x + 1]) + } else { + None + } + }) + .join("\n"); + + if !found_games.is_empty() { + let n_missing = found_games.chars().filter(|x| *x == '\n').count() + 1; + util::reply( + ctx, + format!( + "{n_missing} games owned on steam that are missing from the list:\n{found_games}" + ), + ) + .await?; + } else { + util::reply(ctx, "up to date").await?; + } + + Ok(()) +} diff --git a/src/commands/help.rs b/src/commands/help.rs deleted file mode 100644 index 0d84b2d..0000000 --- a/src/commands/help.rs +++ /dev/null @@ -1,31 +0,0 @@ -use std::collections::HashSet; - -use serenity::{ - framework::standard::{ - help_commands, - macros::help, - Args, - CommandGroup, - CommandResult, - HelpOptions, - }, - model::{ - channel::Message, - id::UserId, - }, - prelude::*, -}; - -#[help] -pub async fn help( - ctx: &Context, - msg: &Message, - args: Args, - opts: &'static HelpOptions, - groups: &[&'static CommandGroup], - owners: HashSet, -) -> CommandResult { - help_commands::with_embeds(ctx, msg, args, opts, groups, owners).await?; - - Ok(()) -} diff --git a/src/commands/meme/create.rs b/src/commands/meme/create.rs index 9aff370..cad9bfc 100644 --- a/src/commands/meme/create.rs +++ b/src/commands/meme/create.rs @@ -2,25 +2,12 @@ use std::process::Stdio; use anyhow::anyhow; use diesel::result::Error as DieselError; -use lazy_static::lazy_static; use log::{ debug, error, warn, }; -use serenity::{ - all::ReactionType, - framework::standard::{ - macros::command, - Args, - CommandError, - CommandResult, - Delimiter, - }, - futures::TryFutureExt, - model::channel::Message, - prelude::*, -}; +use serenity::all::ReactionType; use tap::Pipe; use tokio::{ io::AsyncReadExt, @@ -37,20 +24,16 @@ use crate::{ }, parse_times, util, + PoiseContext, FFMPEG_COMMAND, }; -lazy_static! { - static ref DELIMS: Vec = vec![' '.into(), '\n'.into(), '\t'.into()]; -} - -#[command] -pub async fn addmeme(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - let mut args = Args::new(args.rest(), DELIMS.as_ref()); - - let title = args.single_quoted::()?; - let text = args.rest().to_owned(); - +#[poise::command(slash_command, prefix_command, guild_only, category = "memes")] +pub async fn addmeme( + ctx: PoiseContext<'_>, + title: String, + #[rest] text: String, +) -> anyhow::Result<()> { let text = if text.is_empty() { None } else { @@ -59,20 +42,21 @@ pub async fn addmeme(ctx: &Context, msg: &Message, args: Args) -> CommandResult let mut conn = connection().await?; - let image = msg.attachments.first(); + let image = util::msg(ctx).and_then(|msg| msg.attachments.first()); if image.is_none() && text.is_none() { warn!("tried to create non-audio meme with no image or text"); - return util::send(ctx, msg.channel_id, "hahAA it's empty xdddd", msg.tts) - .map_err(CommandError::from) - .await; + + util::reply(ctx, "hahAA it's empty xdddd").await?; + return Ok(()); } let mut image_id = None; if let Some(att) = image { let data = att.download().await?; - image_id = Some(Image::create(&mut conn, &att.filename, data, msg.author.id.get()).await?); + image_id = + Some(Image::create(&mut conn, &att.filename, data, ctx.author().id.get()).await?); }; let save_result = NewMeme { @@ -82,24 +66,25 @@ pub async fn addmeme(ctx: &Context, msg: &Message, args: Args) -> CommandResult audio_id: None, metadata_id: 0, } - .save(&mut conn, msg.author.id.get()) + .save(&mut conn, ctx.author().id.get()) .await .map(|_| {}); use diesel::result::DatabaseErrorKind; match save_result { Ok(_) => { - msg.react(&ctx, ReactionType::Unicode("👌".to_string())).await?; + util::react(ctx, ReactionType::Unicode("👌".to_string())).await?; }, Err(e) => { if let Some(DieselError::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) = e.downcast_ref::() { error!("tried to create meme that already exists"); - msg.react(&ctx, ReactionType::Unicode("❌".to_owned())).await?; - return util::send(ctx, msg.channel_id, "that meme already exists", msg.tts) - .map_err(CommandError::from) - .await; + + util::react(ctx, ReactionType::Unicode("❌".to_owned())).await?; + util::reply(ctx, "that meme already exists").await?; + + return Ok(()); } return Err(e.into()); @@ -109,19 +94,19 @@ pub async fn addmeme(ctx: &Context, msg: &Message, args: Args) -> CommandResult Ok(()) } -#[command] -pub async fn addaudiomeme(ctx: &Context, msg: &Message, args: Args) -> CommandResult { +#[poise::command(slash_command, prefix_command, guild_only, category = "memes")] +pub async fn addaudiomeme( + ctx: PoiseContext<'_>, + title: String, + audio_str: String, + #[rest] rest: String, +) -> anyhow::Result<()> { debug!("running addaudiomeme"); - let mut args = Args::new(args.rest(), DELIMS.as_ref()); - - let title = args.single_quoted::()?; - let audio_str = args.single_quoted::()?; - let elems = audio_str.split_whitespace().collect::>(); if elems.is_empty() { - util::send(ctx, msg.channel_id, "are you stupid", msg.tts).await?; + util::reply(ctx, "are you stupid").await?; return Err(anyhow!("no audio link was provided").into()); } @@ -168,23 +153,23 @@ pub async fn addaudiomeme(ctx: &Context, msg: &Message, args: Args) -> CommandRe let mut audio_reader = ffmpeg_command.stdout.unwrap(); - let text = args.rest().to_owned(); - let text = if text.is_empty() { + let text = if rest.is_empty() { None } else { - Some(text) + Some(rest) }; let mut conn = connection().await?; - let image_att = msg.attachments.first().ok_or(anyhow!("no attachment")); + let image_att = + util::msg(ctx).and_then(|x| x.attachments.first()).ok_or(anyhow!("no attachment")); let mut image_id = None; if let Ok(att) = image_att { let data = att.download().await?; image_id = - Image::create(&mut conn, &att.filename, data, msg.author.id.get()).await?.pipe(Some); + Image::create(&mut conn, &att.filename, data, ctx.author().id.get()).await?.pipe(Some); } let mut audio_data = Vec::new(); @@ -193,12 +178,12 @@ pub async fn addaudiomeme(ctx: &Context, msg: &Message, args: Args) -> CommandRe if bytes == 0 { debug!("read 0 bytes from audio reader"); - return util::send(ctx, msg.channel_id, "🔇🔇🔇🔕🔕🔕🔕🔕🔇🔕🔕🔇🔕🔕📣📢📣📢📣", msg.tts) - .map_err(CommandError::from) - .await; + + util::reply(ctx, "🔇🔇🔇🔕🔕🔕🔕🔕🔇🔕🔕🔇🔕🔕📣📢📣📢📣").await?; + return Ok(()); } - let audio_id = Audio::create(&mut conn, audio_data, msg.author.id.get()).await?; + let audio_id = Audio::create(&mut conn, audio_data, ctx.author().id.get()).await?; let save_result = NewMeme { title, @@ -207,24 +192,25 @@ pub async fn addaudiomeme(ctx: &Context, msg: &Message, args: Args) -> CommandRe audio_id: Some(audio_id), metadata_id: 0, } - .save(&mut conn, msg.author.id.get()) + .save(&mut conn, ctx.author().id.get()) .await .map(|_| {}); use diesel::result::DatabaseErrorKind; match save_result { Ok(_) => { - msg.react(&ctx, ReactionType::Unicode("👌".to_owned())).await?; + util::react(ctx, ReactionType::Unicode("👌".to_owned())).await?; }, Err(e) => { if let Some(DieselError::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) = e.downcast_ref::() { error!("tried to create meme that already exists"); - msg.react(&ctx, ReactionType::Unicode("❌".to_owned())).await?; - return util::send(ctx, msg.channel_id, "that meme already exists", msg.tts) - .map_err(CommandError::from) - .await; + + util::react(ctx, ReactionType::Unicode("❌".to_owned())).await?; + util::reply(ctx, "that meme already exists").await?; + + return Ok(()); } return Err(e.into()); diff --git a/src/commands/meme/delete.rs b/src/commands/meme/delete.rs index 6af1b6b..25ddf0d 100644 --- a/src/commands/meme/delete.rs +++ b/src/commands/meme/delete.rs @@ -3,42 +3,33 @@ use diesel::{ NotFound, }; use log::info; -use serenity::{ - all::ReactionType, - framework::standard::{ - macros::command, - Args, - CommandResult, - }, - model::channel::Message, - prelude::*, -}; +use serenity::all::ReactionType; use crate::{ db::{ connection, delete_meme, }, + msg, util, + PoiseContext, }; -#[command] -#[aliases("delmem")] -pub async fn delmeme(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { - let title = args.single_quoted::()?; - +#[poise::command(slash_command, prefix_command, guild_only, category = "memes", aliases("delmem"))] +pub async fn delmeme(ctx: PoiseContext<'_>, title: String) -> anyhow::Result<()> { let mut conn = connection().await?; - match delete_meme(&mut conn, &title, msg.author.id.get()).await { + match delete_meme(&mut conn, &title, ctx.author().id.get()).await { Ok(_) => { - msg.react(ctx, ReactionType::Unicode("💀".to_owned())).await?; + util::react(ctx, ReactionType::Unicode("💀".to_owned())).await?; Ok(()) }, Err(e) => { if let Some(NotFound) = e.downcast_ref::() { - msg.react(&ctx, ReactionType::Unicode("❓".to_owned())).await?; info!("attempted to delete nonexistent meme: '{}'", title); - util::send(ctx, msg.channel_id, "nice try", msg.tts).await?; + + util::react(ctx, ReactionType::Unicode("❓".to_owned())).await?; + util::reply(ctx, "nice try").await?; return Ok(()); } diff --git a/src/commands/meme/history.rs b/src/commands/meme/history.rs index edc75cd..cfd78df 100644 --- a/src/commands/meme/history.rs +++ b/src/commands/meme/history.rs @@ -1,4 +1,3 @@ -use anyhow::anyhow; use diesel::{ result::Error as DieselError, NotFound, @@ -11,18 +10,10 @@ use log::{ info, }; use serenity::{ - framework::standard::{ - macros::command, - Args, - CommandError, - CommandResult, - }, futures::{ StreamExt, - TryFutureExt, TryStreamExt, }, - model::channel::Message, prelude::*, }; use tap::Pipe; @@ -32,6 +23,7 @@ use timeago::{ }; use crate::{ + commands::game::get_user_id, db::{ self, connection, @@ -40,6 +32,7 @@ use crate::{ Metadata, }, util, + PoiseContext, CONFIG, }; @@ -55,9 +48,14 @@ lazy_static! { static CLEAN_DATE_FORMAT: &str = "%b %-e %Y"; -#[command] -#[aliases("what", "hwaet", "hwæt")] -pub async fn wat(ctx: &Context, msg: &Message, _: Args) -> CommandResult { +#[poise::command( + slash_command, + prefix_command, + guild_only, + category = "memes", + aliases("what", "hwaet", "hwæt") +)] +pub async fn wat(ctx: PoiseContext<'_>) -> anyhow::Result<()> { let mut conn = connection().await?; let record = match InvocationRecord::last(&mut conn).await { @@ -65,12 +63,12 @@ pub async fn wat(ctx: &Context, msg: &Message, _: Args) -> CommandResult { Err(e) => { if let Some(NotFound) = e.downcast_ref::() { info!("found no memes in history"); - return util::send(ctx, msg.channel_id, "no one has ever memed before", msg.tts) - .map_err(CommandError::from) - .await; + + util::reply(ctx, "no one has ever memed before").await?; + return Ok(()); } - util::send(ctx, msg.channel_id, "BAD MEME BAD MEME", msg.tts).await?; + util::reply(ctx, "BAD MEME BAD MEME").await?; return Err(e.into()); }, }; @@ -82,44 +80,41 @@ pub async fn wat(ctx: &Context, msg: &Message, _: Args) -> CommandResult { let metadata = Metadata::find(&mut conn, meme.metadata_id).await?; let author = CONFIG.discord.guild().member(&ctx, metadata.created_by as u64).await?; - util::send( + util::reply( ctx, - msg.channel_id, &format!( "that was \"{}\" by {} ({})", meme.title, author.mention(), metadata.created.date().format(CLEAN_DATE_FORMAT) ), - msg.tts, ) - .await? + .await?; }, Err(e) => { if let Some(NotFound) = e.downcast_ref::() { info!("last meme not found in database"); - return util::send(ctx, msg.channel_id, "heuueueeeeh?", msg.tts) - .await - .map_err(CommandError::from); + + util::reply(ctx, "heuueueeeeh?").await?; + return Ok(()); } - util::send(ctx, msg.channel_id, "do i look like i know what a jpeg is", msg.tts) - .await?; + util::reply(ctx, "do i look like i know what a jpeg is").await?; return Err(e.into()); }, }; - meme.map(|_| {}).map_err(CommandError::from) + let _meme = meme?; + Ok(()) } -#[command] -#[aliases("hist")] -pub async fn history(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { - let n = args.single_quoted::().unwrap_or(CONFIG.default_hist); - +#[poise::command(slash_command, prefix_command, guild_only, category = "memes", aliases("hist"))] +pub async fn history(ctx: PoiseContext<'_>, n: Option) -> anyhow::Result<()> { + let n = n.unwrap_or(CONFIG.default_hist); + if n > CONFIG.max_hist { debug!("user requested more than MAX_HIST ({}) items from history", CONFIG.max_hist); - util::send(ctx, msg.channel_id, "YER PUSHIN ME OVER THE FUCKIN LINE", true).await?; + util::reply(ctx, "YER PUSHIN ME OVER THE FUCKIN LINE").await?; } let n = n.min(CONFIG.max_hist); @@ -131,9 +126,9 @@ pub async fn history(ctx: &Context, msg: &Message, mut args: Args) -> CommandRes if records.is_empty() { info!("no memes in history"); - return util::send(ctx, msg.channel_id, "i don't remember anything :(", msg.tts) - .map_err(CommandError::from) - .await; + util::reply(ctx, "i don't remember anything :(").await?; + + return Ok(()); } info!("reporting meme history (len {})", n); @@ -198,19 +193,19 @@ pub async fn history(ctx: &Context, msg: &Message, mut args: Args) -> CommandRes }, }; - Result::<_, CommandError>::Ok(result) + anyhow::Ok(result) }) .try_collect::>() .await?; let resp = resp.join("\n"); + util::reply(ctx, resp).await?; - util::send(ctx, msg.channel_id, &resp, false).await.map_err(CommandError::from) + Ok(()) } -#[command] -#[aliases("stat")] -pub async fn stats(ctx: &Context, msg: &Message, _: Args) -> CommandResult { +#[poise::command(slash_command, prefix_command, guild_only, category = "memes", aliases("stat"))] +pub async fn stats(ctx: PoiseContext<'_>) -> anyhow::Result<()> { use db; use serenity::model::{ id::UserId, @@ -277,11 +272,12 @@ and *{}* was the most-memed overall ({})"#, stats.most_popular_meme_overall_count, ); - util::send(ctx, msg.channel_id, s, msg.tts).map_err(CommandError::from).await + util::reply(ctx, s).await?; + Ok(()) } -#[command] -pub async fn memers(ctx: &Context, msg: &Message, _args: Args) -> CommandResult { +#[poise::command(slash_command, prefix_command, guild_only, category = "memes")] +pub async fn memers(ctx: PoiseContext<'_>) -> anyhow::Result<()> { use serenity::model::id::UserId; let s = db::memers() @@ -302,25 +298,25 @@ pub async fn memers(ctx: &Context, msg: &Message, _args: Args) -> CommandResult info.most_used_meme_count, ); - Result::<_, CommandError>::Ok(res) + anyhow::Ok(res) }) .try_collect::>() .await? .into_iter() .join("\n"); - util::send(ctx, msg.channel_id, &s, msg.tts).map_err(CommandError::from).await + util::reply(ctx, s).await?; + + Ok(()) } -#[command] -pub async fn query(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { +#[poise::command(prefix_command, guild_only, category = "memes")] +pub async fn query(ctx: PoiseContext<'_>, rest: util::RestVec) -> anyhow::Result<()> { use regex::Regex; use serenity::model::id::UserId; - use std::borrow::Borrow; use crate::{ db, - game::get_user_id, CONFIG, }; @@ -329,42 +325,31 @@ pub async fn query(ctx: &Context, msg: &Message, mut args: Args) -> CommandResul static ref AGE_REGEX: Regex = Regex::new(r"(?i)(?:age|order)=(.*)").unwrap(); } - let creator: Option = { - let guild = - msg.channel_id.to_channel(&ctx).await?.guild().ok_or(anyhow!("couldn't find guild"))?; - - let guild = guild.guild(&ctx).ok_or(anyhow!("couldn't find guild"))?; - - let creator = args.quoted().current().map(|s| CREATOR_REGEX.is_match(s)).unwrap_or(false); - if creator { - args.single_quoted::() - .ok() - .and_then(|s| { - CREATOR_REGEX.captures(&s).and_then(|c| c.get(1)).map(|x| x.as_str().to_owned()) - }) - .and_then(|s| get_user_id(guild.borrow(), s).ok().map(UserId::get)) - } else { - None - } + let mut rest = rest.into_inner(); + + let creator: Option = try { + let fst = rest.first()?; + let captures = CREATOR_REGEX.captures(fst)?; + let creator = captures.get(1)?.as_str().to_owned(); + + let guild = ctx.guild()?; + let user_id = get_user_id(&guild, creator).ok()?.get(); + rest.pop(); + + user_id }; - let order = { - let order = args.quoted().current().map(|s| AGE_REGEX.is_match(s)).unwrap_or(false); - - if order { - args.single_quoted::() - .ok() - .and_then(|s| { - AGE_REGEX.captures(&s).and_then(|c| c.get(1)).map(|x| x.as_str().to_owned()) - }) - .map(|s: String| s.contains("new")) - .unwrap_or(true) - } else { - true - } + let order: Option = try { + let fst = rest.first()?; + let captures = AGE_REGEX.captures(fst)?; + let order = captures.get(1)?.as_str().to_owned(); + + order }; - let iter = db::query_meme(args.rest(), creator, order).await?.into_iter(); + let order = order.is_some_and(|o| o.contains("new")); + + let iter = db::query_meme(rest.join(" "), creator, order).await?.into_iter(); let result = iter .pipe(serenity::futures::stream::iter) @@ -380,7 +365,7 @@ pub async fn query(ctx: &Context, msg: &Message, mut args: Args) -> CommandResul meme.content.map_or(0, |s| s.len()), meme.image_id.map_or("NO", |_s| "YES"), meme.audio_id.map_or("NO", |_s| "YES"), - )) as Result + )) as anyhow::Result }) .try_collect::>() .await; @@ -401,10 +386,10 @@ pub async fn query(ctx: &Context, msg: &Message, mut args: Args) -> CommandResul if result.is_empty() { info!("no memes matched query"); - return util::send(ctx, msg.channel_id, "no match".to_owned(), msg.tts) - .map_err(CommandError::from) - .await; + util::reply(ctx, "no match").await?; + return Ok(()); } - util::send(ctx, msg.channel_id, &result, msg.tts).map_err(CommandError::from).await + util::reply(ctx, result).await?; + Ok(()) } diff --git a/src/commands/meme/invoke.rs b/src/commands/meme/invoke.rs index 1400452..e399e82 100644 --- a/src/commands/meme/invoke.rs +++ b/src/commands/meme/invoke.rs @@ -2,19 +2,7 @@ use diesel::{ result::Error as DieselError, NotFound, }; -use itertools::Itertools; use log::info; -use serenity::{ - framework::standard::{ - macros::command, - Args, - CommandError, - CommandResult, - }, - futures::TryFutureExt, - model::channel::Message, - prelude::*, -}; use crate::{ commands::meme::send_meme, @@ -25,42 +13,49 @@ use crate::{ InvocationRecord, }, util, + PoiseContext, }; -#[command] -#[aliases("mem")] -pub async fn meme(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - _meme(ctx, msg, args, AudioPlayback::Optional).await +#[poise::command(slash_command, prefix_command, guild_only, category = "memes", aliases("mem"))] +pub async fn meme(ctx: PoiseContext<'_>, #[rest] rest: String) -> anyhow::Result<()> { + _meme(ctx, rest, AudioPlayback::Optional).await } -#[command] -pub async fn omen(ctx: &Context, msg: &Message, _args: Args) -> CommandResult { - let args = Args::new("", &[]); - _meme(ctx, msg, args, AudioPlayback::Optional).await +#[poise::command(slash_command, prefix_command, guild_only, category = "memes")] +pub async fn omen(ctx: PoiseContext<'_>) -> anyhow::Result<()> { + _meme(ctx, "", AudioPlayback::Optional).await } -#[command] -pub async fn silentomen(ctx: &Context, msg: &Message, _args: Args) -> CommandResult { - let args = Args::new("", &[]); - _meme(ctx, msg, args, AudioPlayback::Prohibited).await +#[poise::command(slash_command, prefix_command, guild_only, category = "memes")] +pub async fn silentomen(ctx: PoiseContext<'_>) -> anyhow::Result<()> { + _meme(ctx, "", AudioPlayback::Prohibited).await } -#[command] -pub async fn audioomen(ctx: &Context, msg: &Message, _args: Args) -> CommandResult { - let args = Args::new("", &[]); - _meme(ctx, msg, args, AudioPlayback::Required).await +#[poise::command(slash_command, prefix_command, guild_only, category = "memes")] +pub async fn audioomen(ctx: PoiseContext<'_>) -> anyhow::Result<()> { + _meme(ctx, "", AudioPlayback::Required).await } -#[command] -#[aliases("audiomeme", "audiomem")] -pub async fn audio_meme(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - _meme(ctx, msg, args, AudioPlayback::Required).await +#[poise::command( + slash_command, + prefix_command, + guild_only, + category = "memes", + aliases("audiomeme", "audiomem") +)] +pub async fn audio_meme(ctx: PoiseContext<'_>, #[rest] rest: String) -> anyhow::Result<()> { + _meme(ctx, rest, AudioPlayback::Required).await } -#[command] -#[aliases("silentmeme", "silentmem")] -pub async fn silent_meme(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - _meme(ctx, msg, args, AudioPlayback::Prohibited).await +#[poise::command( + slash_command, + prefix_command, + guild_only, + category = "memes", + aliases("silentmeme", "silentmem") +)] +pub async fn silent_meme(ctx: PoiseContext<'_>, #[rest] rest: String) -> anyhow::Result<()> { + _meme(ctx, rest, AudioPlayback::Prohibited).await } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -71,21 +66,20 @@ enum AudioPlayback { } async fn _meme( - ctx: &Context, - msg: &Message, - args: Args, + ctx: PoiseContext<'_>, + args: impl AsRef, audio_playback: AudioPlayback, -) -> CommandResult { +) -> anyhow::Result<()> { + let args = args.as_ref().trim(); + if args.is_empty() || audio_playback != AudioPlayback::Optional { - return rand_meme(ctx, msg, audio_playback).await; + return rand_meme(ctx, audio_playback).await; } - let search = args.raw().join(" "); - let mut conn = connection().await?; - let mem = match find_meme(&mut conn, search).await { + let mem = match find_meme(&mut conn, args).await { Ok(x) => { - InvocationRecord::create(&mut conn, msg.author.id.get(), msg.id.get(), x.id, false) + InvocationRecord::create(&mut conn, ctx.author().id.get(), ctx.id(), x.id, false) .await?; x @@ -93,27 +87,23 @@ async fn _meme( Err(e) => { return if let Some(NotFound) = e.downcast_ref::() { info!("requested meme not found in database"); - util::send(ctx, msg.channel_id, "c'mon baby, guesstimate", msg.tts) - .await - .map_err(CommandError::from) + + util::reply(ctx, "c'mon baby, guesstimate").await?; + Ok(()) } else { - util::send(ctx, msg.channel_id, "what in ryan's name", msg.tts).await?; + util::reply(ctx, "what in ryan's name").await?; Err(e.into()) }; }, }; - send_meme(ctx, &mem, &mut conn, msg).await + send_meme(ctx, &mem, &mut conn).await } -async fn rand_meme( - ctx: &Context, - message: &Message, - audio_playback: AudioPlayback, -) -> CommandResult { +async fn rand_meme(ctx: PoiseContext<'_>, audio_playback: AudioPlayback) -> anyhow::Result<()> { let mut conn = connection().await?; - let should_audio = util::users_listening(ctx).await?; + let should_audio = util::users_listening(ctx.serenity_context()).await?; let mem = match audio_playback { AudioPlayback::Required => db::rand_audio_meme(&mut conn).await, @@ -123,56 +113,53 @@ async fn rand_meme( match mem { Ok(mem) => { - InvocationRecord::create( - &mut conn, - message.author.id.get(), - message.id.get(), - mem.id, - true, - ) - .await?; - send_meme(ctx, &mem, &mut conn, message).await?; + InvocationRecord::create(&mut conn, ctx.author().id.get(), ctx.id(), mem.id, true) + .await?; + send_meme(ctx, &mem, &mut conn).await?; Ok(()) }, Err(e) => { if let Some(NotFound) = e.downcast_ref::() { info!("random meme not found"); - return util::send(ctx, message.channel_id, "i don't know any :(", message.tts) - .map_err(CommandError::from) - .await; + + util::reply(ctx, "i don't know any :(").await?; + return Ok(()); } - util::send(ctx, message.channel_id, "HELP", message.tts).await?; + util::reply(ctx, "HELP").await?; Err(e.into()) }, } } -#[command] -#[aliases("rarememe", "raremem")] -pub async fn rare_meme(ctx: &Context, msg: &Message, _args: Args) -> CommandResult { - let should_audio = util::users_listening(ctx).await?; +#[poise::command( + slash_command, + prefix_command, + guild_only, + category = "memes", + aliases("raremem", "rarememe") +)] +pub async fn rare_meme(ctx: PoiseContext<'_>) -> anyhow::Result<()> { + let should_audio = util::users_listening(ctx.serenity_context()).await?; let mut conn = connection().await?; let meme = db::rare_meme(&mut conn, should_audio).await; match meme { Ok(meme) => { - InvocationRecord::create(&mut conn, msg.author.id.get(), msg.id.get(), meme.id, true) + InvocationRecord::create(&mut conn, ctx.author().id.get(), ctx.id(), meme.id, true) .await?; - send_meme(ctx, &meme, &mut conn, msg).await + send_meme(ctx, &meme, &mut conn).await }, Err(e) => { if let Some(NotFound) = e.downcast_ref::() { info!("rare meme not found"); - return util::send(ctx, msg.channel_id, "i don't know any :(", msg.tts) - .map_err(CommandError::from) - .await; + util::reply(ctx, "i don't know any :(").await?; + + return Ok(()); } - util::send(ctx, msg.channel_id, "THE MEME MARKET IS IN FREEFALL", msg.tts) - .map_err(CommandError::from) - .await?; + util::reply(ctx, "THE MEME MARKET IS IN FREEFALL").await?; Err(e.into()) }, diff --git a/src/commands/meme/mod.rs b/src/commands/meme/mod.rs index cfe02ee..0108219 100644 --- a/src/commands/meme/mod.rs +++ b/src/commands/meme/mod.rs @@ -8,12 +8,6 @@ use serenity::{ CreateAttachment, CreateMessage, }, - framework::standard::{ - macros::group, - CommandResult, - }, - model::channel::Message, - prelude::*, }; use songbird::input::{ core::{ @@ -26,53 +20,54 @@ use songbird::input::{ Input, }; +pub use self::{ + create::*, + delete::*, + history::*, + invoke::*, +}; use crate::{ - commands::songbird, + commands::playback::songbird, db::{ Audio, Meme, }, + msg, + util, + PoiseContext, CONFIG, }; -pub use self::{ - create::*, - delete::*, - history::*, - invoke::*, -}; - mod create; mod delete; mod history; mod invoke; -#[group] -#[commands( - meme, - audio_meme, - silent_meme, - omen, - audioomen, - silentomen, - addmeme, - addaudiomeme, - delmeme, - wat, - stats, - history, - rare_meme, - memers, - query -)] -struct Memes; +pub fn commands() -> Vec> { + vec![ + meme(), + silent_meme(), + audio_meme(), + rare_meme(), + omen(), + silentomen(), + audioomen(), + addmeme(), + addaudiomeme(), + delmeme(), + history(), + stats(), + memers(), + wat(), + query(), + ] +} async fn send_meme( - ctx: &Context, + ctx: PoiseContext<'_>, t: &Meme, conn: &mut AsyncPgConnection, - msg: &Message, -) -> CommandResult { +) -> anyhow::Result<()> { let should_tts = t.content.as_ref().map(|t| !t.is_empty()).unwrap_or(false) && random::() % 25 == 0; @@ -95,12 +90,12 @@ async fn send_meme( let image = image?; let att = CreateAttachment::bytes(image.data.as_slice(), &image.filename); - msg.channel_id.send_files(ctx, vec![att], cmsg).await?; + ctx.channel_id().send_files(ctx, vec![att], cmsg).await?; }, None => { if t.content.is_some() { - msg.channel_id.send_message(ctx, cmsg).await?; + ctx.channel_id().send_message(ctx, cmsg).await?; } }, }; @@ -108,7 +103,7 @@ async fn send_meme( if let Some(audio) = audio { let audio = audio?; - let (_sb, call) = songbird(ctx, msg).await?; + let (_sb, call) = songbird(ctx).await?; let mut call = call.lock().await; if call.current_channel().is_none() { @@ -117,7 +112,7 @@ async fn send_meme( call.enqueue_input(Input::Lazy(Box::new(audio))).await; - msg.react(ctx, ReactionType::Unicode("📣".to_owned())).await?; + util::react(ctx, ReactionType::Unicode("📣".to_owned())).await?; } Ok(()) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 69c9185..ba87adb 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,18 +1,14 @@ -use crate::util; -use log::info; -use serenity::framework::{ - standard::macros::group, - StandardFramework, -}; +use poise::builtins::PrettyHelpConfiguration; -#[cfg(feature = "db")] -pub use self::meme::*; -pub use self::{ - playback::*, - roll::ROLL_COMMAND, - today::TODAY_COMMAND, +use crate::{ + commands::playback::_play, + util, + PoiseContext, }; +#[cfg(feature = "games")] +pub mod game; + #[cfg(feature = "db")] pub(crate) mod meme; pub(crate) mod playback; @@ -20,40 +16,52 @@ pub(crate) mod roll; pub(crate) mod sound_levels; pub(crate) mod today; -mod help; +#[cfg(feature = "db")] +pub use self::meme::*; -#[group] -#[only_in(guild)] -#[commands(roll, today)] -struct General; +pub fn commands() -> Vec> { + let mut commands = vec![ + playback::play(), + playback::pause(), + playback::resume(), + playback::die(), + playback::list(), + sound_levels::mute(), + sound_levels::unmute(), + roll::roll(), + help(), + ]; -pub fn register_commands(f: StandardFramework) -> StandardFramework { - let result = f.group(&PLAYBACK_GROUP).group(&GENERAL_GROUP); + #[cfg(feature = "games")] + commands.extend(game::commands()); #[cfg(feature = "db")] - let result = result.group(&MEMES_GROUP); + commands.extend(meme::commands()); - #[cfg(feature = "games")] - let result = result.group(&crate::game::GAME_GROUP); - - result.help(&help::HELP).unrecognised_command(|ctx, msg, unrec| { - Box::pin(async move { - let url = match msg.content.split_whitespace().nth(1) { - Some(x) if x.starts_with("http") => x, - _ => { - info!("bad command formatting: '{}'", unrec); - let _ = util::send( - ctx, - msg.channel_id, - "format your commands right. fuck you.", - msg.tts, - ) - .await; - return; - }, - }; - - let _ = _play(ctx, msg, url).await; - }) - }) + commands +} + +#[poise::command(slash_command, prefix_command, aliases("halp"))] +pub async fn help(ctx: PoiseContext<'_>, command: Option) -> anyhow::Result<()> { + poise::builtins::pretty_help( + ctx, + command.as_ref().map(|x| x.as_str()), + PrettyHelpConfiguration { + ..Default::default() + }, + ) + .await?; + + Ok(()) +} + +pub async fn unrecognized(ctx: PoiseContext<'_>, u: url::Url) -> anyhow::Result<()> { + if !u.scheme().starts_with("http") { + util::reply(ctx, "format your commands right. fuck you.").await?; + return Ok(()); + } + + let _ = _play(ctx, &u).await?; + + Ok(()) } diff --git a/src/commands/playback.rs b/src/commands/playback.rs index 1c3ab95..98ae613 100644 --- a/src/commands/playback.rs +++ b/src/commands/playback.rs @@ -1,78 +1,46 @@ +use std::sync::Arc; + use log::{ debug, - error, info, warn, }; -use serenity::{ - framework::standard::{ - macros::{ - command, - group, - }, - Args, - CommandError, - CommandResult, - }, - model::channel::Message, - prelude::*, -}; +use serenity::prelude::*; use songbird::{ input::YoutubeDl, Call, Songbird, }; -use std::sync::Arc; -use tap::Conv; use crate::{ bot::HttpKey, - commands::sound_levels::*, util, + PoiseContext, CONFIG, }; -#[group] -#[commands(skip, pause, resume, list, die, mute, unmute, play)] -#[only_in(guild)] -struct Playback; - -pub async fn songbird( - ctx: &Context, - msg: &Message, -) -> Result<(Arc, Arc>), CommandError> { - let Some(gid) = msg.guild_id else { +pub async fn songbird(ctx: PoiseContext<'_>) -> anyhow::Result<(Arc, Arc>)> { + let Some(gid) = ctx.guild_id() else { return Err(anyhow::anyhow!("no guild id").into()); }; - let sb = songbird::get(ctx).await.expect("acquiring songbird handle"); + let sb = songbird::get(ctx.serenity_context()).await.expect("acquiring songbird handle"); let call = sb.get_or_insert(gid); Ok((sb, call)) } -pub async fn _play(ctx: &Context, msg: &Message, url: &str) -> CommandResult { - use url::{ - Host, - Url, - }; +pub async fn _play(ctx: PoiseContext<'_>, url: &url::Url) -> anyhow::Result<()> { + use url::Host; debug!("playing '{}'", url); - if !url.starts_with("http") { + if !url.scheme().starts_with("http") { warn!("got bad url argument to play: {}", url); - util::send(ctx, msg.channel_id, "bAD LiNk", msg.tts).await?; + + util::reply(ctx, "bAD LiNk").await?; return Ok(()); } - let url = match Url::parse(url) { - Err(e) => { - error!("bad url: {}", e); - util::send(ctx, msg.channel_id, "INVALID URL", msg.tts).await?; - return Ok(()); - }, - Ok(u) => u, - }; - let host = url.host().and_then(|u| match u { Host::Domain(h) => Some(h.to_owned()), _ => None, @@ -81,16 +49,16 @@ pub async fn _play(ctx: &Context, msg: &Message, url: &str) -> CommandResult { if host.map(|h| h.to_lowercase().contains("imgur")).unwrap_or(false) { info!("detected imgur link"); - if msg.author.id.get() == 106160362109272064 { - util::send(ctx, msg.channel_id, "fuck you conway", true).await?; + if ctx.author().id == 106160362109272064 { + util::reply(ctx, "fuck you conway").await?; } else { - util::send(ctx, msg.channel_id, "IMGUR IS BAD, YOU TRASH CAN MAN", msg.tts).await?; + util::reply(ctx, "IMGUR IS BAD, YOU TRASH CAN MAN").await?; } return Ok(()); } - let (_sb, call) = songbird(ctx, msg).await?; + let (_sb, call) = songbird(ctx).await?; let mut call = call.lock().await; if call.current_channel().is_none() { @@ -98,38 +66,31 @@ pub async fn _play(ctx: &Context, msg: &Message, url: &str) -> CommandResult { } let client = { - let data = ctx.data.read().await; + let data = ctx.serenity_context().data.read().await; data.get::().unwrap().clone() }; - let input = YoutubeDl::new_ytdl_like("yt-dlp", client.clone(), url.conv::()); + let input = YoutubeDl::new_ytdl_like("yt-dlp", client.clone(), url.to_string()); call.enqueue_input(input.into()).await; Ok(()) } -#[command] -pub async fn play(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { - if args.is_empty() { - return _resume(ctx, msg).await; - } - - let url = match args.single::() { - Ok(url) => url, - Err(e) => { - error!("unable to parse url from args: {}", e); - return util::send(ctx, msg.channel_id, "BAD LINK", msg.tts) - .await - .map_err(CommandError::from); - }, +#[poise::command(slash_command, prefix_command, guild_only, category = "playback")] +pub async fn play( + ctx: PoiseContext<'_>, + #[description = "link to play (if absent, resumes playback)"] u: Option, +) -> anyhow::Result<()> { + let Some(u) = u else { + return _resume(ctx).await; }; - _play(ctx, msg, &url).await + _play(ctx, &u).await } -#[command] -pub async fn pause(ctx: &Context, msg: &Message, _: Args) -> CommandResult { - let (_sb, call) = songbird(ctx, msg).await?; +#[poise::command(slash_command, prefix_command, guild_only, category = "playback")] +pub async fn pause(ctx: PoiseContext<'_>) -> anyhow::Result<()> { + let (_sb, call) = songbird(ctx).await?; let call = call.lock().await; call.queue().pause()?; @@ -137,14 +98,19 @@ pub async fn pause(ctx: &Context, msg: &Message, _: Args) -> CommandResult { Ok(()) } -#[command] -#[aliases("continue")] -pub async fn resume(ctx: &Context, msg: &Message, _: Args) -> CommandResult { - _resume(ctx, msg).await +#[poise::command( + slash_command, + prefix_command, + guild_only, + aliases("continue"), + category = "playback" +)] +pub async fn resume(ctx: PoiseContext<'_>) -> anyhow::Result<()> { + _resume(ctx).await } -async fn _resume(ctx: &Context, msg: &Message) -> CommandResult { - let (_sb, call) = songbird(ctx, msg).await?; +async fn _resume(ctx: PoiseContext<'_>) -> anyhow::Result<()> { + let (_sb, call) = songbird(ctx).await?; let call = call.lock().await; call.queue().resume()?; @@ -152,10 +118,9 @@ async fn _resume(ctx: &Context, msg: &Message) -> CommandResult { Ok(()) } -#[command] -#[aliases("next")] -pub async fn skip(ctx: &Context, msg: &Message, _args: Args) -> CommandResult { - let (_sb, call) = songbird(ctx, msg).await?; +#[poise::command(slash_command, prefix_command, guild_only, category = "playback", aliases("next"))] +pub async fn skip(ctx: PoiseContext<'_>) -> anyhow::Result<()> { + let (_sb, call) = songbird(ctx).await?; let call = call.lock().await; call.queue().skip()?; @@ -163,10 +128,15 @@ pub async fn skip(ctx: &Context, msg: &Message, _args: Args) -> CommandResult { Ok(()) } -#[command] -#[aliases("sudoku", "fuckoff", "stop")] -pub async fn die(ctx: &Context, msg: &Message, _: Args) -> CommandResult { - let (_sb, call) = songbird(ctx, msg).await?; +#[poise::command( + slash_command, + prefix_command, + guild_only, + category = "playback", + aliases("sudoku", "fuckoff", "stop") +)] +pub async fn die(ctx: PoiseContext<'_>) -> anyhow::Result<()> { + let (_sb, call) = songbird(ctx).await?; let mut call = call.lock().await; call.queue().stop(); @@ -176,21 +146,26 @@ pub async fn die(ctx: &Context, msg: &Message, _: Args) -> CommandResult { Ok(()) } -#[command] -#[aliases("queue")] -pub async fn list(ctx: &Context, msg: &Message, _: Args) -> CommandResult { - let (_sb, call) = songbird(ctx, msg).await?; +#[poise::command( + slash_command, + prefix_command, + guild_only, + category = "playback", + aliases("queue") +)] +pub async fn list(ctx: PoiseContext<'_>) -> anyhow::Result<()> { + let (_sb, call) = songbird(ctx).await?; let call = call.lock().await; let queue = call.queue(); - util::send(ctx, msg.channel_id, "(command fix work-in-progress)", msg.tts).await?; + util::reply(ctx, "(command fix work-in-progress)").await?; for track in queue.current_queue().into_iter() { let info = track.get_info().await?; - util::send(ctx, msg.channel_id, format!("track playing for {:?}", info.play_time), msg.tts) - .await?; + let fmt = format!("track playing for {:?}", info.play_time); + util::reply(ctx, fmt).await?; } Ok(()) diff --git a/src/commands/roll.rs b/src/commands/roll.rs index 45e3ba8..6cd084c 100644 --- a/src/commands/roll.rs +++ b/src/commands/roll.rs @@ -1,31 +1,18 @@ use std::result::Result as StdResult; +use lazy_static::lazy_static; use log::{ debug, error, }; use rand::prelude::*; -use serenity::{ - framework::standard::{ - macros::command, - Args, - }, - model::channel::Message, - prelude::*, -}; use thiserror::Error; -use lazy_static::lazy_static; -use serenity::{ - framework::standard::{ - CommandError, - CommandResult, - }, - futures::TryFutureExt, +use crate::{ + util, + PoiseContext, }; -use crate::util; - #[derive(pest_derive::Parser)] #[grammar = "commands/calc.pest"] struct Calc; @@ -49,27 +36,26 @@ impl Calc { Pair, Pairs, }, - prec_climber::PrecClimber, + pratt_parser::PrattParser, Parser, }; use self::Rule::*; lazy_static! { - static ref CLIMBER: PrecClimber = { - use pest::prec_climber::{ + static ref CLIMBER: PrattParser = { + use pest::pratt_parser::{ Assoc::*, - Operator, + Op as Operator, }; - PrecClimber::new(vec![ - Operator::new(add, Left) - | Operator::new(sub, Left) - | Operator::new(modulo, Left), - Operator::new(mul, Left) | Operator::new(div, Left), - Operator::new(dice, Left), - Operator::new(pow, Right), - ]) + PrattParser::new() + .op(Operator::infix(add, Left) + | Operator::infix(sub, Left) + | Operator::infix(modulo, Left)) + .op(Operator::infix(mul, Left) | Operator::infix(div, Left)) + .op(Operator::infix(dice, Left)) + .op(Operator::infix(pow, Right)) }; } @@ -160,29 +146,33 @@ impl Calc { } fn eval_expr(p: Pairs) -> StdResult { - CLIMBER.climb(p, eval_single_pair, |lhs, op, rhs| { - let lhs = lhs?; - let rhs = rhs?; - - let result = match op.as_rule() { - add => lhs + rhs, - sub => lhs - rhs, - mul => lhs * rhs, - div => lhs / rhs, - pow => lhs.powf(rhs), - dice => { - let dice_count = lhs as usize; - let dice_faces = rhs as usize; - - let mut rng = thread_rng(); - (0..dice_count).map(|_| rng.gen_range(1..(dice_faces + 1))).sum::() - as f64 - }, - _ => unreachable!(), - }; + CLIMBER + .map_primary(eval_single_pair) + .map_infix(|lhs, op, rhs| { + let lhs = lhs?; + let rhs = rhs?; + + let result = match op.as_rule() { + add => lhs + rhs, + sub => lhs - rhs, + mul => lhs * rhs, + div => lhs / rhs, + pow => lhs.powf(rhs), + dice => { + let dice_count = lhs as usize; + let dice_faces = rhs as usize; + + let mut rng = thread_rng(); + (0..dice_count) + .map(|_| rng.gen_range(1..(dice_faces + 1))) + .sum::() as f64 + }, + _ => unreachable!(), + }; - Ok(result) - }) + Ok(result) + }) + .parse(p) } eval_expr(result) @@ -212,21 +202,18 @@ mod test { } } -#[command] -#[aliases("calc", "calculate")] -pub async fn roll(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - match Calc::eval(args.rest()) { +#[poise::command(slash_command, prefix_command, guild_only, aliases("calc", "calculate"))] +pub async fn roll(ctx: PoiseContext<'_>, #[rest] rest: String) -> anyhow::Result<()> { + match Calc::eval(&rest) { Ok(result) => { debug!("got calc result '{}'", result); - util::send(ctx, msg.channel_id, &format!("{}", result), msg.tts) - .map_err(CommandError::from) - .await + util::reply(ctx, result.to_string()).await?; }, Err(e) => { - error!("error encountered reading calc '{}': {}", args.rest(), e); - util::send(ctx, msg.channel_id, "I COULDN'T READ THAT YOU FUCK", msg.tts) - .map_err(CommandError::from) - .await + error!("error encountered reading calc '{}': {}", rest, e); + util::reply(ctx, "I COULDN'T READ THAT YOU FUCK").await?; }, } + + Ok(()) } diff --git a/src/commands/sound_levels.rs b/src/commands/sound_levels.rs index 8c75b37..9a6cfc6 100644 --- a/src/commands/sound_levels.rs +++ b/src/commands/sound_levels.rs @@ -1,21 +1,14 @@ -use serenity::{ - framework::standard::{ - macros::command, - Args, - CommandResult, - }, - model::channel::Message, - prelude::*, +use crate::{ + commands::playback::songbird, + PoiseContext, }; -use crate::commands::songbird; - pub const DEFAULT_VOLUME: f32 = 0.20; const MAX_VOLUME: f32 = 5.0; -#[command] -pub async fn mute(ctx: &Context, msg: &Message, _: Args) -> CommandResult { - let (_sb, call) = songbird(ctx, msg).await?; +#[poise::command(slash_command, prefix_command, guild_only)] +pub async fn mute(ctx: PoiseContext<'_>) -> anyhow::Result<()> { + let (_sb, call) = songbird(ctx).await?; let mut call = call.lock().await; call.mute(true).await?; @@ -23,66 +16,12 @@ pub async fn mute(ctx: &Context, msg: &Message, _: Args) -> CommandResult { Ok(()) } -#[command] -pub async fn unmute(ctx: &Context, msg: &Message, _: Args) -> CommandResult { - let (_sb, call) = songbird(ctx, msg).await?; +#[poise::command(slash_command, prefix_command, guild_only)] +pub async fn unmute(ctx: PoiseContext<'_>) -> anyhow::Result<()> { + let (_sb, call) = songbird(ctx).await?; let mut call = call.lock().await; call.mute(true).await?; Ok(()) } - -// #[command] -// pub async fn volume(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { -// if args.len() == 0 { -// let vol = { -// let queue_lock = ctx.data.write().await.get::().cloned().unwrap(); -// let play_queue = queue_lock.read().unwrap(); -// (play_queue.volume / DEFAULT_VOLUME * 100.0) as usize -// }; -// -// trace!("reporting volume {}", vol); -// -// return util::send(ctx, msg.channel_id, &format!("volume: {}%", vol), msg.tts) -// .map_err(CommandError::from) -// .await; -// } -// -// let vol: usize = match args.single::() { -// Ok(vol) if vol.is_nan() => { -// warn!("reporting NaN volume"); -// return util::send(ctx, msg.channel_id, "you're a fuck", msg.tts) -// .map_err(CommandError::from) -// .await; -// }, -// Ok(vol) => vol as usize, -// Err(e) => { -// error!("parsing volume arg: {}", e); -// return util::send(ctx, msg.channel_id, "???????", msg.tts) -// .map_err(CommandError::from) -// .await; -// }, -// }; -// -// let mut vol: f32 = (vol as f32) / 100.0; // force aliasing to reasonable values -// let adjusted_text = if vol > MAX_VOLUME { -// format!(" ({:.0}% max)", MAX_VOLUME * 100.0) -// } else { -// "".to_owned() -// }; -// -// vol = vol.clamp(0.0, MAX_VOLUME); -// -// let queue_lock = ctx.data.write().await.get::().cloned().unwrap(); -// -// { -// let mut play_queue = queue_lock.write().unwrap(); -// play_queue.volume = vol * DEFAULT_VOLUME; -// info!("volume updated to {}", vol); -// } -// -// util::send(ctx, msg.channel_id, format!("volume adjusted{}", adjusted_text), msg.tts).await?; -// -// Ok(()) -// } diff --git a/src/commands/today/mod.rs b/src/commands/today/mod.rs index 7f1dca7..c1a02d5 100644 --- a/src/commands/today/mod.rs +++ b/src/commands/today/mod.rs @@ -5,22 +5,14 @@ use rand::{ seq::SliceRandom, thread_rng, }; -use serenity::{ - framework::standard::{ - macros::command, - Args, - CommandResult, - }, - model::channel::Message, - prelude::*, -}; use songbird::input::YoutubeDl; use tap::Conv; use crate::{ bot::HttpKey, - commands::songbird, + commands::playback::songbird, util, + PoiseContext, CONFIG, }; @@ -66,16 +58,16 @@ lazy_static! { ]; } -#[command] -pub async fn today(ctx: &Context, msg: &Message, _args: Args) -> CommandResult { +#[poise::command(slash_command, prefix_command, guild_only)] +pub async fn today(ctx: PoiseContext<'_>, #[rest] _rest: String) -> anyhow::Result<()> { let today = { #[allow(unused_mut)] let mut result = chrono::Local::now().naive_local(); #[cfg(debug_assertions)] { - let dt = _args.parse::().or_else(|_| { - _args.parse::().map(|date| { + let dt = _rest.parse::().or_else(|_| { + _rest.parse::().map(|date| { let time = chrono::NaiveTime::from_hms_opt(12, 0, 0).unwrap(); date.and_time(time) }) @@ -83,11 +75,11 @@ pub async fn today(ctx: &Context, msg: &Message, _args: Args) -> CommandResult { match dt { Ok(dt) => { - log::debug!("overriding with datetime: {}", dt); + debug!("overriding with datetime: {dt}"); result = dt; }, Err(e) => { - log::debug!("parsing datetime: {:?}", e); + debug!("parsing datetime: {e:?}"); }, }; } @@ -102,7 +94,7 @@ pub async fn today(ctx: &Context, msg: &Message, _args: Args) -> CommandResult { let play_args = options.choose(&mut thread_rng()); if let Some(play_args) = play_args { - let (_sb, call) = songbird(ctx, msg).await?; + let (_sb, call) = songbird(ctx).await?; let mut call = call.lock().await; if call.current_channel().is_none() { @@ -110,7 +102,7 @@ pub async fn today(ctx: &Context, msg: &Message, _args: Args) -> CommandResult { } let client = { - let data = ctx.data.read().await; + let data = ctx.serenity_context().data.read().await; data.get::().unwrap().clone() }; @@ -130,8 +122,8 @@ pub async fn today(ctx: &Context, msg: &Message, _args: Args) -> CommandResult { }); q.resume()?; } else { - util::send(ctx, msg.channel_id, "no", false).await?; - util::send(ctx, msg.channel_id, ":angry:", false).await?; + util::reply(ctx, "no").await?; + util::reply(ctx, ":angry:").await?; } Ok(()) diff --git a/src/game.rs b/src/game.rs deleted file mode 100644 index 4011a5d..0000000 --- a/src/game.rs +++ /dev/null @@ -1,606 +0,0 @@ -use std::{ - convert::Infallible, - fs, - iter, - path::PathBuf, - result::Result as StdResult, - str::{ - self, - FromStr, - }, -}; - -use anyhow::{ - anyhow, - Error, -}; -use fnv::{ - FnvHashMap, - FnvHashSet, -}; -use itertools::Itertools; -use lazy_static::lazy_static; -use log::{ - debug, - error, - info, -}; -use serde::Deserialize; -use serenity::{ - framework::standard::{ - macros::{ - command, - group, - }, - ArgError, - Args, - CommandError, - CommandResult, - }, - futures::TryFutureExt, - model::{ - channel::Message, - guild::Guild, - id::UserId, - }, - prelude::*, -}; -use tap::Pipe; -use url::Url; - -use crate::{ - bot::HttpKey, - util, - Result, - CONFIG, -}; - -#[group] -#[commands(game, installedgame, ownedgame, updategaem)] -pub struct Game; - -lazy_static! { - static ref SPREADSHEET_URL: Url = Url::parse(&format!( - "https://sheets.googleapis.com/v4/spreadsheets/{}/values:batchGet", - &CONFIG.sheets.spreadsheet, - )) - .expect("parsing spreadsheet url"); -} - -#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Hash)] -struct UserInfo { - name: String, - - #[serde(flatten)] - profile: ProfileInfo, -} - -#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] -struct ProfileInfo { - #[serde(rename = "steam")] - steam_id: Option, - - #[serde(rename = "discord")] - discord_user_id: u64, -} - -lazy_static! { - static ref USER_MAP_STR: String = { - let default_path = PathBuf::from_str("user_id_mapping").unwrap(); - let mapping_path = CONFIG.user_id_mapping.as_ref().unwrap_or(&default_path); - - fs::read_to_string(mapping_path).unwrap_or("{}".to_owned()) - }; - static ref USER_INFO_MAP: FnvHashMap = { - let v: Vec = serde_json::from_str(&USER_MAP_STR).unwrap(); - - let result = v - .into_iter() - .map(|ui| { - let UserInfo { - name, - profile, - } = ui; - - (name, profile) - }) - .collect::>(); - - info!( - "loaded user info for {} users ({:#?})", - result.len(), - result.keys().collect::>() - ); - - result - }; - static ref DISCORD_MAP: FnvHashMap = { - USER_INFO_MAP - .clone() - .into_iter() - .map(|(name, profile)| (UserId::new(profile.discord_user_id), name)) - .collect::>() - }; - static ref STEAM_MAP: FnvHashMap = { - USER_INFO_MAP - .clone() - .into_iter() - .filter_map(|(_, profile)| { - profile.steam_id.map(|sid| (UserId::new(profile.discord_user_id), sid)) - }) - .collect::>() - }; - static ref ALPHABET: Vec = (0..26).map(|x| (x + b'a') as char).collect(); -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd)] -enum GameStatus { - Installed, - NotInstalled, - NotOwned, - Unknown, -} - -impl FromStr for GameStatus { - type Err = Error; - - fn from_str(s: &str) -> Result { - use std::char; - - if s.starts_with('y') { - Ok(GameStatus::Installed) - } else if s.starts_with("n/i") { - Ok(GameStatus::NotInstalled) - } else if s.starts_with('n') { - Ok(GameStatus::NotOwned) - } else if s.chars().all(char::is_whitespace) { - Ok(GameStatus::Unknown) - } else { - Err(anyhow!(format!("unexpected status '{}'", s))) - } - } -} - -#[command] -#[aliases("installedgaem")] -pub async fn installedgame(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - _game(ctx, msg, args, GameStatus::Installed).await -} - -#[command] -#[aliases("ownedgaem")] -pub async fn ownedgame(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - _game(ctx, msg, args, GameStatus::NotInstalled).await -} - -#[derive(Copy, Clone, Debug, thiserror::Error, PartialEq, Eq, Hash)] -pub enum UserLookupError { - #[error("too many possible options ({}) for query", _0)] - Ambiguous(usize), - - #[error("user wasn't found in the guild")] - NotFound, -} - -pub fn get_user_id>(g: &Guild, s: S) -> StdResult { - let s = s.as_ref().trim_start_matches('@').to_lowercase(); - - if let Some(info) = USER_INFO_MAP.get(&s) { - return Ok(UserId::new(info.discord_user_id)); - } - - let nicks = g.members_nick_containing(&s, false, false); - - { - let exact_match = nicks.iter().find(|(m, _)| m.display_name().to_lowercase() == s); - - if let Some((m, _)) = exact_match { - return Ok(m.user.id); - } - } - - let usernames = g.members_username_containing(&s, false, false); - - { - let exact_match = usernames.iter().find(|(m, _)| m.user.name.to_lowercase() == s); - - if let Some((m, _)) = exact_match { - return Ok(m.user.id); - } - } - - let opts = nicks - .into_iter() - .chain(usernames) - .map(|(member, _)| member.user.id) - .collect::>(); - - match opts.len() { - 0 => Err(UserLookupError::NotFound), - 1 => Ok(opts.into_iter().next().unwrap()), - x => Err(UserLookupError::Ambiguous(x)), - } -} - -#[command] -#[aliases("gaem")] -async fn game(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - _game(ctx, msg, args, GameStatus::Installed).await -} - -async fn _game( - ctx: &Context, - msg: &Message, - mut args: Args, - min_status: GameStatus, -) -> CommandResult { - let users = { - let guild = - msg.channel_id.to_channel(&ctx).await?.guild().ok_or(anyhow!("couldn't find guild"))?; - - let user_args: Vec = if args.rest().is_empty() { - Vec::new() - } else { - args.quoted().iter::().collect::, ArgError>>()? - }; - - use serenity::futures::StreamExt; - - let mut users = user_args - .into_iter() - .pipe(serenity::futures::stream::iter) - .filter_map(|u| { - let guild = &guild; - async move { - use std::borrow::Borrow; - - let possible = { - let Ok(guild) = - guild.guild(&ctx).ok_or_else(|| anyhow!("couldn't find guild")) - else { - error!("failed retrieving guild"); - return None; - }; - - get_user_id(guild.borrow(), &u) - }; - - debug!("parsed userid {:?}", possible); - - match possible { - Err(UserLookupError::NotFound) => { - let _ = util::send( - ctx, - msg.channel_id, - &format!("didn't recognize {}", &u), - msg.tts, - ) - .await; - None - }, - Ok(x) => Some(x), - Err(UserLookupError::Ambiguous(x)) => { - let _ = util::send( - ctx, - msg.channel_id, - &format!("too many matches ({}) for {}", x, &u), - msg.tts, - ) - .await; - None - }, - } - } - }) - .filter_map(|uid| async move { - let res = DISCORD_MAP.get(&uid).map(|s| s.to_lowercase()); - - if res.is_none() { - info!("user {} is not recognized", uid); - } - - res - }) - .collect::>() - .await; - - if users.is_empty() { - let guild = guild.guild(&ctx).ok_or_else(|| anyhow!("couldn't find guild"))?; - - let pairs = guild - .voice_states - .iter() - .filter_map(|(uid, voice)| voice.channel_id.map(|cid| (*uid, cid))) - .collect::>(); - - let channel = - pairs.get(&msg.author.id).cloned().unwrap_or(CONFIG.discord.voice_channel()); - - users = pairs - .iter() - .filter_map(|(uid, cid)| { - if *cid == channel { - DISCORD_MAP.get(uid).map(|s| s.to_lowercase()) - } else { - None - } - }) - .collect::>(); - } - - users - }; - - let inferred = users.is_empty(); - - if inferred && users.len() < 2 || !inferred && users.is_empty() { - info!("too few known users to make game comparison"); - util::send(ctx, msg.channel_id, "yer too lonely", msg.tts).await?; - return Ok(()); - } - - let client = { - let data = ctx.data.read().await; - data.get::().unwrap().clone() - }; - let data = load_spreadsheet(&client).await?; - - let user_indexes = (0..data.len()) - .filter_map(|i| { - let user = data[i][0].to_lowercase(); - - if users.contains(&user) { - Some((user, i)) - } else { - None - } - }) - .collect::>(); - - let data_ref = &data; - let user_games = user_indexes - .iter() - .map(|(user, col)| { - let empty_hash_set: FnvHashSet<_> = vec![].into_iter().collect(); - - let mut game_map = vec![ - (GameStatus::Installed, empty_hash_set.clone()), - (GameStatus::NotInstalled, empty_hash_set.clone()), - (GameStatus::NotOwned, empty_hash_set.clone()), - (GameStatus::Unknown, empty_hash_set), - ] - .into_iter() - .collect::>(); - - (1..data[*col].len()).for_each(|i| { - let status = - &data_ref[*col][i].parse::().unwrap_or(GameStatus::Unknown); - let game = &data_ref[0][i]; - - game_map.get_mut(status).unwrap().insert(game); - }); - - (user, game_map) - }) - .collect::>(); - - let statuses = vec![ - GameStatus::Installed, - GameStatus::NotOwned, - GameStatus::NotInstalled, - GameStatus::Unknown, - ] - .into_iter() - .filter(|s| s <= &min_status) - .collect::>(); - - let mut games_in_common = { - let game_map = user_games.values().next().unwrap(); - - statuses.iter().fold(iter::empty().collect::>(), |acc, s| { - acc.union(&game_map[s]).cloned().collect() - }) - }; - - for (_user, game_map) in user_games.iter() { - let relevant_games = - statuses.iter().fold(iter::empty().collect::>(), |acc, s| { - acc.union(&game_map[s]).cloned().collect() - }); - - games_in_common = games_in_common.intersection(&relevant_games).cloned().collect(); - } - - let mut games_formatted = - games_in_common.iter().sorted_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())).join("\n"); - - if games_formatted.is_empty() { - games_formatted = "**LITERALLY NOTHING**".to_owned(); - } - - util::send(ctx, msg.channel_id, &games_formatted, msg.tts).await?; - - Ok(()) -} - -async fn load_spreadsheet(client: &reqwest::Client) -> Result>> { - let mut u = SPREADSHEET_URL.clone(); - - u.query_pairs_mut() - .append_pair("ranges", &format!("a1:{}", &CONFIG.sheets.max_column)) - .append_pair("valueRenderOption", "FORMATTED_VALUE") - .append_pair("majorDimension", "COLUMNS") - .append_pair("key", &CONFIG.sheets.api_key); - - let req = reqwest::Request::new(reqwest::Method::GET, u); - let resp = client.execute(req).await?; - - #[derive(Deserialize)] - struct Resp { - #[serde(rename = "valueRanges")] - value_ranges: Vec, - } - - #[derive(Deserialize)] - struct Inner { - values: Vec>, - } - - let resp = resp.json::().await?; - - Ok(resp.value_ranges.into_iter().next().unwrap().values) -} - -#[command] -#[aliases("updategame")] -pub async fn updategaem(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { - use regex::Regex; - - let client = { - let data = ctx.data.read().await; - data.get::().unwrap().clone() - }; - - let arg_user = args.single_quoted::(); - - let user = if arg_user.is_err() { - msg.author.id - } else { - use std::borrow::Borrow; - - let guild = - msg.channel_id.to_channel(&ctx).await?.guild().ok_or(anyhow!("couldn't find guild"))?; - - let guild = guild.guild(&ctx).ok_or(anyhow!("couldn't find guild"))?; - - get_user_id(guild.borrow(), arg_user.unwrap()).map_err(Error::from)? - }; - - debug!("parsed userid {:?}", user); - - let username = match DISCORD_MAP.get(&user) { - Some(s) => s, - None => { - return util::send(ctx, msg.channel_id, "WHO THE FUCK ARE YE", msg.tts) - .map_err(CommandError::from) - .await; - }, - }; - - let steam_id = match STEAM_MAP.get(&user) { - Some(u) => u, - None => { - return util::send(ctx, msg.channel_id, "WHO ARE YE ON STEAM", msg.tts) - .map_err(CommandError::from) - .await; - }, - }; - - let spreadsheet = load_spreadsheet(&client).await?; - - let user_column = (0..spreadsheet.len()) - .find(|x| spreadsheet[*x][0].to_lowercase() == username.to_lowercase()); - - let user_column = match user_column { - Some(c) => &spreadsheet[c][1..], - None => { - return util::send(ctx, msg.channel_id, "YER NOT IN THE SPREADSHEET", msg.tts) - .map_err(CommandError::from) - .await; - }, - }; - - lazy_static! { - static ref APPID_REGEX: Regex = Regex::new(r#"(?i)^\s*app\s*id\s*$"#).unwrap(); - } - - let appid_column = (0..spreadsheet.len()).find(|x| APPID_REGEX.is_match(&spreadsheet[*x][0])); - - let appid_column = match appid_column { - Some(c) => &spreadsheet[c][1..], - None => { - error!("didn't find an appid column in the spreadsheet"); - return util::send(ctx, msg.channel_id, "SPREADSHEET BROKE", msg.tts) - .map_err(CommandError::from) - .await; - }, - }; - - let missing_appids = (0..user_column.len()) - .filter_map(|x| user_column[x].parse::().ok().map(|s| (x, s))) - .filter(|(_, s)| *s == GameStatus::Unknown || *s == GameStatus::NotOwned) - .filter_map(|(x, _)| { - appid_column.get(x).and_then(|s| s.parse::().ok().map(|appid| (appid, x))) - }); - - let mut u = Url::parse("https://api.steampowered.com/IPlayerService/GetOwnedGames/v1")?; - - u.query_pairs_mut() - .append_pair("key", &CONFIG.steam_api_key) - .append_pair("include_played_free_games", "1") - .append_pair("steamid", &steam_id.to_string()); - - #[derive(Deserialize, Clone, Debug, PartialEq, Eq, Hash)] - struct SteamResp { - response: SteamInner, - } - - #[derive(Deserialize, Clone, Debug, PartialEq, Eq, Hash)] - struct SteamInner { - games: Vec, - } - - #[derive(Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash)] - struct SteamGameEntry { - #[serde(rename = "appid")] - app_id: u64, - - #[serde(rename = "playtime_forever")] - play_time: u64, - } - - let client = { - let data = ctx.data.read().await; - data.get::().unwrap().clone() - }; - - let games_owned = client - .get(u) - .send() - .await? - .json::() - .await? - .response - .games - .into_iter() - .map(|ge| ge.app_id) - .collect::>(); - - let found_games = missing_appids - .filter_map(|(ai, x)| { - if games_owned.contains(&ai) { - Some(&spreadsheet[0][x + 1]) - } else { - None - } - }) - .join("\n"); - - if !found_games.is_empty() { - util::send( - ctx, - msg.channel_id, - &format!( - "{} games owned on steam that are missing from the list:\n{}", - found_games.chars().filter(|x| *x == '\n').count() + 1, - found_games - ), - msg.tts, - ) - .await?; - } else { - util::send(ctx, msg.channel_id, "up to date", msg.tts).await?; - } - - Ok(()) -} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..4ed3e44 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,31 @@ +#![feature(try_blocks)] + +#[cfg(feature = "db")] +pub mod db; + +#[cfg(not(feature = "games"))] +pub mod game { + use serenity::framework::StandardFramework; + + #[inline] + fn register(f: StandardFramework) -> StandardFramework { + return f; + } +} + +pub mod bot; +pub mod commands; +pub mod config; +pub mod log_setup; +pub mod util; + +pub use crate::{ + config::*, + util::*, +}; + +pub type Error = anyhow::Error; +pub type Result = anyhow::Result; + +pub type PoiseData = (); +pub type PoiseContext<'a> = poise::Context<'a, PoiseData, anyhow::Error>; diff --git a/src/main.rs b/src/main.rs index be2fc90..93d4d2b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,36 +15,6 @@ use log::{ info, }; -pub use self::{ - config::*, - util::*, -}; - -#[cfg(feature = "db")] -mod db; - -#[cfg(feature = "games")] -mod game; - -#[cfg(not(feature = "games"))] -mod game { - use serenity::framework::StandardFramework; - - #[inline] - fn register(f: StandardFramework) -> StandardFramework { - return f; - } -} - -mod bot; -mod commands; -mod config; -mod log_setup; -mod util; - -pub type Error = anyhow::Error; -pub type Result = anyhow::Result; - const BACKOFF_FACTOR: f64 = 2.0; const MAX_BACKOFFS: usize = 3; const BACKOFF_INIT: f64 = 100.0; @@ -53,7 +23,7 @@ const MIN_RUN_DURATION: Duration = Duration::from_secs(120); #[tokio::main] async fn main() { - log_setup::init(false).expect("initializing logging"); + thulani::log_setup::init(false).expect("initializing logging"); let mut backoff_count: usize = 0; @@ -61,7 +31,7 @@ async fn main() { let start = Instant::now(); info!("starting bot"); - match bot::run().await { + match thulani::bot::run().await { Err(e) => { error!("error encountered running client: {:?}", e); }, diff --git a/src/util.rs b/src/util.rs deleted file mode 100644 index e88e7b0..0000000 --- a/src/util.rs +++ /dev/null @@ -1,188 +0,0 @@ -use chrono::Duration; -use serenity::{ - client::Context, - model::{ - id::{ - ChannelId, - MessageId, - }, - permissions::Permissions, - }, -}; -use std::process::Stdio; - -use lazy_static::lazy_static; -use log::debug; -use regex::{ - Match, - Regex, -}; -use serenity::all::{ - CreateMessage, - Message, -}; -use url::Url; - -use crate::{ - commands::songbird, - Result, - CONFIG, -}; - -pub async fn currently_playing(ctx: &Context, msg: &Message) -> bool { - let (_sb, call) = songbird(ctx, msg).await.expect("no songbird"); - - let call = call.lock().await; - call.queue().current().is_some() -} - -pub async fn users_listening(ctx: &Context) -> Result { - let channel = CONFIG.discord.voice_channel().to_channel(&ctx).await?; - - let res = channel - .guild() - .and_then(|ch| ch.guild(&ctx)) - .map(|g| { - g.voice_states - .iter() - .any(|(_, state)| state.channel_id == Some(CONFIG.discord.voice_channel())) - }) - .unwrap_or(false); - - Ok(res) -} - -#[inline] -pub async fn send( - ctx: &Context, - channel: ChannelId, - text: impl AsRef, - tts: bool, -) -> Result<()> { - send_result(ctx, channel, text, tts).await.map(|_| ()) -} - -pub async fn send_result( - ctx: &Context, - channel: ChannelId, - text: impl AsRef, - tts: bool, -) -> Result { - let text = text.as_ref(); - debug!("sending message {:?} to channel {:?} (tts: {})", text, channel, tts); - - let result = channel.send_message(ctx, CreateMessage::default().content(text).tts(tts)).await?; - Ok(result.id) -} - -lazy_static! { - static ref REQUIRED_PERMS: Permissions = Permissions::EMBED_LINKS - | Permissions::ADD_REACTIONS - | Permissions::SEND_MESSAGES - | Permissions::SEND_TTS_MESSAGES - | Permissions::MENTION_EVERYONE - | Permissions::USE_EXTERNAL_EMOJIS - | Permissions::CONNECT - | Permissions::SPEAK - | Permissions::CHANGE_NICKNAME - | Permissions::USE_VAD - | Permissions::ATTACH_FILES; -} - -lazy_static! { - pub static ref OAUTH_URL: Url = Url::parse(&format!( - "https://discordapp.com/api/oauth2/authorize?scope=bot&permissions={}&client_id={}", - REQUIRED_PERMS.bits(), - CONFIG.discord.auth.client_id, - )) - .unwrap(); -} - -pub async fn ytdl_url(uri: &str) -> Result { - use serde_json::Value; - use tokio::process::Command; - - lazy_static! { - static ref YTDL_COMMAND: String = { - let result = CONFIG.ytdl.clone().unwrap_or("youtube-dl".to_owned()); - log::debug!("got ytdl: {}", result); - - result - }; - } - - let args = [ - "-f", - "webm[abr>0]/bestaudio/best", - "--no-playlist", - "--print-json", - "--skip-download", - uri, - ]; - - debug!("downloading info for uri: {uri}"); - - let mut command = Command::new(&*YTDL_COMMAND); - command.args(args).stdin(Stdio::null()); - - debug!("running command: {command:?}"); - - let out = command.output().await?; - - if !out.status.success() { - return Err(anyhow::anyhow!("running ytdl: {out:?}")); - } - - let value = serde_json::from_reader(&out.stdout[..])?; - let mut obj = match value { - Value::Object(obj) => obj, - other => return Err(anyhow::anyhow!("ytdl output not object: {other:?}")), - }; - - match obj.remove("url") { - Some(v) => match v { - Value::String(uri) => Ok(uri), - other => Err(anyhow::anyhow!("url not string: {other:?}")), - }, - None => Err(anyhow::anyhow!("no url")), - } -} -pub fn parse_times>(s: A) -> (Option, Option) { - lazy_static! { - static ref START_REGEX: Regex = - Regex::new(r"(?:start|begin(?:ning)?)\s*=?\s*(?:(?P\d+)h\s?)?(?:(?P\d+)m\s?)?(?:(?P\d+)s?)?").unwrap(); - - static ref DUR_REGEX: Regex = - Regex::new(r"dur(?:ation)?\s*=?\s*(?:(?P\d+)h\s?)?(?:(?P\d+)m\s?)?(?:(?P\d+)s?)?").unwrap(); - - static ref END_REGEX: Regex = - Regex::new(r"(?:end|term(?:inate|ination)?)\s*=?\s*(?:(?P\d+)h\s?)?(?:(?P\d+)m\s?)?(?:(?P\d+)s?)?").unwrap(); - } - - fn parse_match(m: Option) -> u64 { - m.and_then(|s| s.as_str().parse::().ok()).unwrap_or(0) - } - - fn parse_captures>(r: &Regex, s: B) -> Option { - r.captures(s.as_ref()).map(|capt| { - let hours = parse_match(capt.name("hours")); - let minutes = parse_match(capt.name("minutes")); - let seconds = parse_match(capt.name("seconds")); - - let result = Duration::hours(hours as i64) - + Duration::minutes(minutes as i64) - + Duration::seconds(seconds as i64); - - assert!(result >= Duration::zero()); - - result - }) - } - - let start_time = parse_captures(&START_REGEX, &s); - let dur = parse_captures(&DUR_REGEX, &s); - let end_time = parse_captures(&END_REGEX, s) - .or_else(|| start_time.and_then(|start| dur.map(|d| start + d))); - - (start_time, end_time) -} diff --git a/src/util/mod.rs b/src/util/mod.rs new file mode 100644 index 0000000..a0105ac --- /dev/null +++ b/src/util/mod.rs @@ -0,0 +1,232 @@ +use std::process::Stdio; + +use chrono::Duration; +use lazy_static::lazy_static; +use log::debug; +use poise::CreateReply; +use regex::{ + Match, + Regex, +}; +use serenity::{ + all::{ + CreateMessage, + Message, + Reaction, + ReactionType, + }, + client::Context, + model::{ + id::{ + ChannelId, + MessageId, + }, + permissions::Permissions, + }, +}; +use url::Url; + +use crate::{ + commands::playback::songbird, + PoiseContext, + Result, + CONFIG, +}; + +mod rest_vec; + +pub use rest_vec::RestVec; + +pub async fn currently_playing(ctx: PoiseContext<'_>) -> bool { + let (_sb, call) = songbird(ctx).await.expect("no songbird"); + + let call = call.lock().await; + call.queue().current().is_some() +} + +pub async fn users_listening(ctx: &Context) -> Result { + let channel = CONFIG.discord.voice_channel().to_channel(&ctx).await?; + + let res = channel + .guild() + .and_then(|ch| ch.guild(&ctx)) + .map(|g| { + g.voice_states + .iter() + .any(|(_, state)| state.channel_id == Some(CONFIG.discord.voice_channel())) + }) + .unwrap_or(false); + + Ok(res) +} + +#[inline] +pub fn msg(ctx: PoiseContext<'_>) -> Option<&Message> { + match ctx { + PoiseContext::Prefix(poise::PrefixContext { + msg, + .. + }) => Some(msg), + _ => None, + } +} + +#[inline] +pub fn tts(ctx: PoiseContext<'_>) -> Option { + msg(ctx).map(|msg| msg.tts) +} + +#[inline] +pub fn unwrap_tts(ctx: PoiseContext<'_>) -> bool { + tts(ctx).unwrap_or(false) +} + +#[inline] +pub async fn send( + ctx: &Context, + channel: ChannelId, + text: impl AsRef, + tts: bool, +) -> Result<()> { + send_result(ctx, channel, text, tts).await.map(|_| ()) +} + +#[inline] +pub async fn reply(ctx: PoiseContext<'_>, text: impl AsRef) -> Result { + let handle = + poise::send_reply(ctx, CreateReply::default().tts(unwrap_tts(ctx)).content(text.as_ref())) + .await?; + + Ok(handle) +} + +#[inline] +pub async fn react(ctx: PoiseContext<'_>, react: ReactionType) -> Result { + let react = msg(ctx).ok_or_else(|| anyhow::anyhow!("elp"))?.react(ctx, react).await?; + Ok(react) +} + +pub async fn send_result( + ctx: &Context, + channel: ChannelId, + text: impl AsRef, + tts: bool, +) -> Result { + let text = text.as_ref(); + debug!("sending message {:?} to channel {:?} (tts: {})", text, channel, tts); + + let result = channel.send_message(ctx, CreateMessage::default().content(text).tts(tts)).await?; + Ok(result.id) +} + +lazy_static! { + static ref REQUIRED_PERMS: Permissions = Permissions::EMBED_LINKS + | Permissions::ADD_REACTIONS + | Permissions::SEND_MESSAGES + | Permissions::SEND_TTS_MESSAGES + | Permissions::MENTION_EVERYONE + | Permissions::USE_EXTERNAL_EMOJIS + | Permissions::CONNECT + | Permissions::SPEAK + | Permissions::CHANGE_NICKNAME + | Permissions::USE_VAD + | Permissions::ATTACH_FILES; +} + +lazy_static! { + pub static ref OAUTH_URL: Url = Url::parse(&format!( + "https://discordapp.com/api/oauth2/authorize?scope=bot&permissions={}&client_id={}", + REQUIRED_PERMS.bits(), + CONFIG.discord.auth.client_id, + )) + .unwrap(); +} + +pub async fn ytdl_url(uri: &str) -> Result { + use serde_json::Value; + use tokio::process::Command; + + lazy_static! { + static ref YTDL_COMMAND: String = { + let result = CONFIG.ytdl.clone().unwrap_or("youtube-dl".to_owned()); + debug!("got ytdl: {}", result); + + result + }; + } + + let args = [ + "-f", + "webm[abr>0]/bestaudio/best", + "--no-playlist", + "--print-json", + "--skip-download", + uri, + ]; + + debug!("downloading info for uri: {uri}"); + + let mut command = Command::new(&*YTDL_COMMAND); + command.args(args).stdin(Stdio::null()); + + debug!("running command: {command:?}"); + + let out = command.output().await?; + + if !out.status.success() { + return Err(anyhow::anyhow!("running ytdl: {out:?}")); + } + + let value = serde_json::from_reader(&out.stdout[..])?; + let mut obj = match value { + Value::Object(obj) => obj, + other => return Err(anyhow::anyhow!("ytdl output not object: {other:?}")), + }; + + match obj.remove("url") { + Some(v) => match v { + Value::String(uri) => Ok(uri), + other => Err(anyhow::anyhow!("url not string: {other:?}")), + }, + None => Err(anyhow::anyhow!("no url")), + } +} +pub fn parse_times>(s: A) -> (Option, Option) { + lazy_static! { + static ref START_REGEX: Regex = + Regex::new(r"(?:start|begin(?:ning)?)\s*=?\s*(?:(?P\d+)h\s?)?(?:(?P\d+)m\s?)?(?:(?P\d+)s?)?").unwrap(); + + static ref DUR_REGEX: Regex = + Regex::new(r"dur(?:ation)?\s*=?\s*(?:(?P\d+)h\s?)?(?:(?P\d+)m\s?)?(?:(?P\d+)s?)?").unwrap(); + + static ref END_REGEX: Regex = + Regex::new(r"(?:end|term(?:inate|ination)?)\s*=?\s*(?:(?P\d+)h\s?)?(?:(?P\d+)m\s?)?(?:(?P\d+)s?)?").unwrap(); + } + + fn parse_match(m: Option) -> u64 { + m.and_then(|s| s.as_str().parse::().ok()).unwrap_or(0) + } + + fn parse_captures>(r: &Regex, s: B) -> Option { + r.captures(s.as_ref()).map(|capt| { + let hours = parse_match(capt.name("hours")); + let minutes = parse_match(capt.name("minutes")); + let seconds = parse_match(capt.name("seconds")); + + let result = Duration::hours(hours as i64) + + Duration::minutes(minutes as i64) + + Duration::seconds(seconds as i64); + + assert!(result >= Duration::zero()); + + result + }) + } + + let start_time = parse_captures(&START_REGEX, &s); + let dur = parse_captures(&DUR_REGEX, &s); + let end_time = parse_captures(&END_REGEX, s) + .or_else(|| start_time.and_then(|start| dur.map(|d| start + d))); + + (start_time, end_time) +} diff --git a/src/util/rest_vec.rs b/src/util/rest_vec.rs new file mode 100644 index 0000000..82889cd --- /dev/null +++ b/src/util/rest_vec.rs @@ -0,0 +1,84 @@ +use serenity::all::{ + Context, + Message, +}; +use std::error::Error; + +/// Pop a whitespace-separated word from the front of the arguments. Supports quotes and quote +/// escaping. +/// +/// Leading whitespace will be trimmed; trailing whitespace is not consumed. +// From https://github.com/serenity-rs/poise/blob/current/src/prefix_argument/mod.rs +fn pop_string(args: &str) -> Result<(&str, String), poise::TooFewArguments> { + // TODO: consider changing the behavior to parse quotes literally if they're in the middle + // of the string: + // - `"hello world"` => `hello world` + // - `"hello "world"` => `"hello "world` + // - `"hello" world"` => `hello` + + let args = args.trim_start(); + if args.is_empty() { + return Err(poise::TooFewArguments::default()); + } + + let mut output = String::new(); + let mut inside_string = false; + let mut escaping = false; + + let mut chars = args.chars(); + // .clone().next() is poor man's .peek(), but we can't do peekable because then we can't + // call as_str on the Chars iterator + while let Some(c) = chars.clone().next() { + if escaping { + output.push(c); + escaping = false; + } else if !inside_string && c.is_whitespace() { + break; + } else if c == '"' { + inside_string = !inside_string; + } else if c == '\\' { + escaping = true; + } else { + output.push(c); + } + + chars.next(); + } + + Ok((chars.as_str(), output)) +} + +pub struct RestVec(Vec); + +impl RestVec { + #[inline] + pub fn into_inner(self) -> Vec { + self.0 + } +} + +impl From for Vec { + #[inline] + fn from(value: RestVec) -> Self { + value.0 + } +} + +#[poise::async_trait] +impl<'a> poise::PopArgument<'a> for RestVec { + async fn pop_from( + mut args: &'a str, + attachment_index: usize, + _ctx: &Context, + _msg: &Message, + ) -> Result<(&'a str, usize, Self), (Box, Option)> { + let mut v = vec![]; + + while let Ok((remaining, s)) = pop_string(args) { + args = remaining; + v.push(s); + } + + Ok(("", attachment_index, Self(v))) + } +} -- cgit v1.3.1