From 4c9a44ad5a2bd24faa6841451ac921af21bd75f5 Mon Sep 17 00:00:00 2001 From: Lennart Hase <1834270+motorlatitude@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:48:16 +0100 Subject: [PATCH 01/15] =?UTF-8?q?=F0=9F=8E=A8=20=20Abstracted=20plugins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Abstracted plugin logic to tidy up codebase - Add error handling in a new error module --- Cargo.lock | 570 ++------- Cargo.toml | 3 +- src/error.rs | 30 + src/main.rs | 825 ++----------- src/plugin.rs | 132 ++ src/plugin/error.rs | 78 ++ src/plugin/plugin_context.rs | 148 +++ src/plugin/plugin_info.rs | 9 + src/plugins/coin_toss.rs | 71 ++ src/plugins/debug.rs | 251 ++++ src/plugins/dice.rs | 99 ++ src/plugins/jokes.rs | 252 ++++ src/plugins/mod.rs | 9 +- src/plugins/patches.rs | 495 ++++++++ src/plugins/patches/mod.rs | 4 - src/plugins/patches/patches_plugin.rs | 178 --- src/plugins/patches/platforms/platform.rs | 13 +- src/plugins/ping.rs | 69 ++ src/plugins/points.rs | 410 +++++++ src/plugins/time.rs | 78 ++ src/storage.rs | 5 +- src/storage/database.rs | 1343 ++++++++++++--------- src/storage/error.rs | 70 ++ src/storage/errors.rs | 65 - 24 files changed, 3232 insertions(+), 1975 deletions(-) create mode 100644 src/error.rs create mode 100644 src/plugin.rs create mode 100644 src/plugin/error.rs create mode 100644 src/plugin/plugin_context.rs create mode 100644 src/plugin/plugin_info.rs create mode 100644 src/plugins/coin_toss.rs create mode 100644 src/plugins/debug.rs create mode 100644 src/plugins/dice.rs create mode 100644 src/plugins/jokes.rs create mode 100644 src/plugins/patches.rs delete mode 100644 src/plugins/patches/mod.rs delete mode 100644 src/plugins/patches/patches_plugin.rs create mode 100644 src/plugins/ping.rs create mode 100644 src/plugins/points.rs create mode 100644 src/plugins/time.rs create mode 100644 src/storage/error.rs delete mode 100644 src/storage/errors.rs diff --git a/Cargo.lock b/Cargo.lock index 1d1aac0..6bd0237 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,13 +43,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.83" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -86,24 +86,12 @@ dependencies = [ "fs_extra", ] -[[package]] -name = "base64" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.6.0" @@ -219,6 +207,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -302,7 +299,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -350,10 +347,12 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case", "proc-macro2", "quote", "rustc_version", - "syn 2.0.115", + "syn", + "unicode-xid", ] [[package]] @@ -374,7 +373,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -425,16 +424,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" -[[package]] -name = "errno" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "fallible-iterator" version = "0.3.0" @@ -573,7 +562,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -665,25 +654,6 @@ dependencies = [ "wasip3", ] -[[package]] -name = "h2" -version = "0.3.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "h2" version = "0.4.6" @@ -695,7 +665,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.1.0", + "http", "indexmap", "slab", "tokio", @@ -736,12 +706,6 @@ dependencies = [ "hashbrown 0.16.1", ] -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -758,17 +722,6 @@ dependencies = [ "markup5ever", ] -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - [[package]] name = "http" version = "1.1.0" @@ -780,17 +733,6 @@ dependencies = [ "itoa", ] -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - [[package]] name = "http-body" version = "1.0.1" @@ -798,7 +740,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.1.0", + "http", ] [[package]] @@ -809,8 +751,8 @@ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", "futures-util", - "http 1.1.0", - "http-body 1.0.1", + "http", + "http-body", "pin-project-lite", ] @@ -820,36 +762,6 @@ version = "1.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "0.14.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c08302e8fa335b151b788c775ff56e7a03ae64ff85c548ee820fecb70356e85" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2 0.3.26", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.7", - "tokio", - "tower-service", - "tracing", - "want", -] - [[package]] name = "hyper" version = "1.8.1" @@ -860,9 +772,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.6", - "http 1.1.0", - "http-body 1.0.1", + "h2", + "http", + "http-body", "httparse", "itoa", "pin-project-lite", @@ -879,8 +791,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" dependencies = [ "futures-util", - "http 1.1.0", - "hyper 1.8.1", + "http", + "hyper", "hyper-util", "rustls 0.23.36", "rustls-pki-types", @@ -890,38 +802,25 @@ dependencies = [ "webpki-roots", ] -[[package]] -name = "hyper-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" -dependencies = [ - "bytes", - "hyper 0.14.31", - "native-tls", - "tokio", - "tokio-native-tls", -] - [[package]] name = "hyper-util" version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-util", - "http 1.1.0", - "http-body 1.0.1", - "hyper 1.8.1", + "http", + "http-body", + "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", - "system-configuration 0.7.0", + "socket2", + "system-configuration", "tokio", "tower-service", "tracing", @@ -1066,7 +965,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -1201,12 +1100,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" - [[package]] name = "litemap" version = "0.7.5" @@ -1292,11 +1185,12 @@ dependencies = [ name = "motorbot" version = "0.2.4" dependencies = [ + "async-trait", "chrono", "clokwerk", + "derive_more", "dotenv", "futures", - "oai_rs", "openssl", "rand 0.10.1", "regex", @@ -1311,23 +1205,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "native-tls" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe 0.1.5", - "openssl-sys", - "schannel", - "security-framework 2.11.1", - "security-framework-sys", - "tempfile", -] - [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -1358,20 +1235,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "oai_rs" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7004f6f1c96db43bed09dab1fc55cbbcbd0840a971568242f0e61eb367fd6688" -dependencies = [ - "dotenv", - "reqwest 0.11.27", - "serde", - "serde_json", - "strum", - "strum_macros", -] - [[package]] name = "once_cell" version = "1.20.2" @@ -1384,7 +1247,7 @@ version = "0.10.77" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f" dependencies = [ - "bitflags 2.6.0", + "bitflags", "cfg-if", "foreign-types", "libc", @@ -1401,15 +1264,9 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] -[[package]] -name = "openssl-probe" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" - [[package]] name = "openssl-probe" version = "0.2.1" @@ -1508,7 +1365,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -1566,7 +1423,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.115", + "syn", ] [[package]] @@ -1591,7 +1448,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.36", - "socket2 0.6.3", + "socket2", "thiserror 2.0.18", "tokio", "tracing", @@ -1629,7 +1486,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2", "tracing", "windows-sys 0.60.2", ] @@ -1732,7 +1589,7 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" dependencies = [ - "bitflags 2.6.0", + "bitflags", ] [[package]] @@ -1764,60 +1621,20 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" -[[package]] -name = "reqwest" -version = "0.11.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" -dependencies = [ - "base64 0.21.7", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2 0.3.26", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.31", - "hyper-tls", - "ipnet", - "js-sys", - "log", - "mime", - "native-tls", - "once_cell", - "percent-encoding", - "pin-project-lite", - "rustls-pemfile 1.0.4", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper 0.1.2", - "system-configuration 0.5.1", - "tokio", - "tokio-native-tls", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "winreg", -] - [[package]] name = "reqwest" version = "0.12.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-core", "futures-util", - "http 1.1.0", - "http-body 1.0.1", + "http", + "http-body", "http-body-util", - "hyper 1.8.1", + "hyper", "hyper-rustls", "hyper-util", "ipnet", @@ -1830,12 +1647,12 @@ dependencies = [ "pin-project-lite", "quinn", "rustls 0.23.36", - "rustls-pemfile 2.2.0", + "rustls-pemfile", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 1.0.1", + "sync_wrapper", "tokio", "tokio-rustls 0.26.0", "tokio-util", @@ -1856,15 +1673,15 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "encoding_rs", "futures-core", - "h2 0.4.6", - "http 1.1.0", - "http-body 1.0.1", + "h2", + "http", + "http-body", "http-body-util", - "hyper 1.8.1", + "hyper", "hyper-rustls", "hyper-util", "js-sys", @@ -1878,7 +1695,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", - "sync_wrapper 1.0.1", + "sync_wrapper", "tokio", "tokio-rustls 0.26.0", "tower", @@ -1921,7 +1738,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ - "bitflags 2.6.0", + "bitflags", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -1945,19 +1762,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "0.38.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" -dependencies = [ - "bitflags 2.6.0", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.52.0", -] - [[package]] name = "rustls" version = "0.22.4" @@ -1993,19 +1797,10 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe 0.2.1", + "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.5.1", -] - -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.7", + "security-framework", ] [[package]] @@ -2042,7 +1837,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki 0.103.9", - "security-framework 3.5.1", + "security-framework", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -2138,26 +1933,13 @@ dependencies = [ "zeroize", ] -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.6.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - [[package]] name = "security-framework" version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ - "bitflags 2.6.0", + "bitflags", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -2180,7 +1962,7 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 2.6.0", + "bitflags", "cssparser", "derive_more", "log", @@ -2235,7 +2017,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -2271,8 +2053,8 @@ checksum = "9bde37f42765dfdc34e2a039e0c84afbf79a3101c1941763b0beb816c2f17541" dependencies = [ "arrayvec", "async-trait", - "base64 0.22.1", - "bitflags 2.6.0", + "base64", + "bitflags", "bytes", "dashmap", "flate2", @@ -2350,16 +2132,6 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" -[[package]] -name = "socket2" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.3" @@ -2418,42 +2190,12 @@ dependencies = [ "quote", ] -[[package]] -name = "strum" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" - -[[package]] -name = "strum_macros" -version = "0.24.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "quote", - "rustversion", - "syn 1.0.109", -] - [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.115" @@ -2465,12 +2207,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - [[package]] name = "sync_wrapper" version = "1.0.1" @@ -2488,18 +2224,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", -] - -[[package]] -name = "system-configuration" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "system-configuration-sys 0.5.0", + "syn", ] [[package]] @@ -2508,19 +2233,9 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.6.0", + "bitflags", "core-foundation 0.9.4", - "system-configuration-sys 0.6.0", -] - -[[package]] -name = "system-configuration-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" -dependencies = [ - "core-foundation-sys", - "libc", + "system-configuration-sys", ] [[package]] @@ -2533,19 +2248,6 @@ dependencies = [ "libc", ] -[[package]] -name = "tempfile" -version = "3.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" -dependencies = [ - "cfg-if", - "fastrand", - "once_cell", - "rustix", - "windows-sys 0.59.0", -] - [[package]] name = "tendril" version = "0.5.0" @@ -2582,7 +2284,7 @@ checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -2593,7 +2295,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -2672,7 +2374,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "socket2 0.6.3", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -2685,17 +2387,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", -] - -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", + "syn", ] [[package]] @@ -2758,7 +2450,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper 1.0.1", + "sync_wrapper", "tokio", "tower-layer", "tower-service", @@ -2770,11 +2462,11 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.6.0", + "bitflags", "bytes", "futures-util", - "http 1.1.0", - "http-body 1.0.1", + "http", + "http-body", "iri-string", "pin-project-lite", "tower", @@ -2814,7 +2506,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -2867,7 +2559,7 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http 1.1.0", + "http", "httparse", "log", "rand 0.8.5", @@ -2906,6 +2598,12 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + [[package]] name = "unicode-width" version = "0.2.2" @@ -3068,7 +2766,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.115", + "syn", "wasm-bindgen-shared", ] @@ -3122,7 +2820,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.6.0", + "bitflags", "hashbrown 0.15.5", "indexmap", "semver", @@ -3246,15 +2944,6 @@ dependencies = [ "windows-targets 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -3306,21 +2995,6 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -3360,12 +3034,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3384,12 +3052,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3408,12 +3070,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3444,12 +3100,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3468,12 +3118,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3492,12 +3136,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3516,12 +3154,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3534,16 +3166,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -3560,7 +3182,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "wit-parser", ] @@ -3570,7 +3192,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags 2.6.0", + "bitflags", ] [[package]] @@ -3580,10 +3202,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "indexmap", "prettyplease", - "syn 2.0.115", + "syn", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3599,7 +3221,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.115", + "syn", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -3611,7 +3233,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.6.0", + "bitflags", "indexmap", "log", "serde", @@ -3673,7 +3295,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", "synstructure", ] @@ -3704,7 +3326,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -3715,7 +3337,7 @@ checksum = "6352c01d0edd5db859a63e2605f4ea3183ddbd15e2c4a9e7d32184df75e4f154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -3735,7 +3357,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", "synstructure", ] @@ -3764,7 +3386,7 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5659775..26a8f3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,10 +19,11 @@ serde_json = "1.0.149" tracing = "0.1.44" tracing-subscriber = "0.3.23" rand = "0.10.1" -oai_rs = "0.1.3" scraper = "0.26.0" rusqlite = { version = "0.39.0", features = ["bundled"] } openssl = { version = "0.10.77", features = ["vendored"] } # Required for cross-compilation +async-trait = "0.1.89" +derive_more = { version = "2.1.1", features = ["display", "from"] } # Reduce Docker Image Size diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..57ceb92 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,30 @@ +use derive_more::{Display, From}; + +use crate::{plugin, storage}; + +pub type Result = std::result::Result; + +#[derive(Debug, From, Display)] +#[non_exhaustive] +pub enum Error { + // -- Internal + /// An error related to storage operations, such as database interactions. + #[from] + Storage(storage::StorageError), + /// An error related to plugin operations. + #[from] + Plugin(plugin::PluginError), + + // -- External + /// An error related to the Serenity library, such as issues with the Discord API. + #[from] + Serenity(serenity::Error), +} + +impl From for Error { + fn from(e: rusqlite::Error) -> Self { + Self::Storage(storage::StorageError::from(e)) + } +} + +impl std::error::Error for Error {} diff --git a/src/main.rs b/src/main.rs index f8bc126..0b30934 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,97 +1,57 @@ use dotenv::dotenv; -use reqwest; -use reqwest::header::{HeaderMap, HeaderValue}; -use serde::Deserialize; -use serde_json; -use serenity::all::{ - ActivityData, Command, CommandDataOptionValue, CommandOptionType, CreateCommand, - CreateCommandOption, CreateInteractionResponse, CreateInteractionResponseFollowup, - CreateInteractionResponseMessage, CreateMessage, Interaction, -}; -use serenity::model::prelude::ChannelId; -use serenity::model::prelude::GuildId; -use std::convert::TryFrom; -use std::env; -use std::time::Duration; -use std::u64; -use tracing::{debug, error, info, warn, Level}; -use tracing_subscriber::FmtSubscriber; - -use oai_rs::{completions, images, models}; -use clokwerk::{AsyncScheduler, Job, TimeUnits}; +use serenity::all::{ActivityData, Interaction}; +use std::{env, vec}; +use tracing::{debug, error, info, Level}; +use tracing_subscriber::FmtSubscriber; use serenity::async_trait; use serenity::model::channel::Message; use serenity::model::channel::Reaction; use serenity::model::gateway::Ready; -use serenity::model::prelude::ReactionType; use serenity::model::user::OnlineStatus; use serenity::prelude::*; use serenity::utils::MessageBuilder; -mod storage; -use storage::{Database, GuildConfig, GuildConfigKey}; - -mod plugins; -use crate::plugins::patches::game_data::GameData; -use crate::plugins::patches::patches_plugin; -use crate::plugins::patches::platforms::platform::Platform; +mod error; -struct Handler; +pub use self::error::{Error, Result}; -#[derive(Deserialize, Debug)] -struct Response { - body: Vec, - //success: bool -} - -#[derive(Deserialize, Debug)] -struct Joke { - _id: serde_json::Value, - punchline: serde_json::Value, - setup: serde_json::Value, -} - -pub enum MotorbotChannels { - General = 130734377066954752, - BotEvents = 432351112616738837, - PatchNotes = 438307738250903553, - Jokes = 1040719087585742980, -} +mod plugins; +mod storage; -pub enum MotorBotGuilds { - MotorBot = 130734377066954752, +use crate::plugin::plugin_context::{PluginEvent, PluginEventLevel}; +use crate::plugin::PluginContext; +use crate::plugins::jokes::JokesPlugin; +use crate::plugins::patches::PatchesPlugin; + +use crate::plugins::coin_toss::CoinTossPlugin; +use crate::plugins::debug::DebugPlugin; +use crate::plugins::dice::DicePlugin; +use crate::plugins::ping::PingPlugin; +use crate::plugins::points::PointsPlugin; +use crate::plugins::time::TimePlugin; + +mod plugin; +use plugin::MotorbotPlugin; +struct Handler { + plugins: Vec>, } #[async_trait] impl EventHandler for Handler { async fn message(&self, ctx: Context, msg: Message) { debug!("Message"); - if msg.content == "!ping" { - if let Err(why) = msg.channel_id.say(&ctx.http, "Pong!").await { - error!("Error sending message: {:?}", why); - } - } else if msg.content == "!whoami" { - let response = MessageBuilder::new() - .push("```") - .push(format!("id : {:?}\n", msg.author.id.get())) - .push(format!("name : {:?}\n", msg.author.name)) - .push(format!("discriminator : {:?}\n", msg.author.discriminator)) - .push(format!( - "avatar : {:?}\n", - msg.author.avatar.as_ref().unwrap() - )) - .push(format!( - "flags : {:?}\n", - msg.author.public_flags.unwrap() - )) - .push(format!("accent colour : {:?}\n", msg.author.accent_colour)) - .push(format!("banner : {:?}\n", msg.author.banner)) - .push("```") - .build(); - if let Err(why) = msg.channel_id.say(&ctx.http, response).await { - error!("Error sending message: {:?}", why); + + let plugin_ctx = PluginContext::new(ctx.clone(), &self.plugins).await; + for plugin in self.plugins.iter() { + match plugin.on_message(&plugin_ctx, &msg).await { + Ok(()) => {} + Err(e) => error!( + "[{}] Error occurred while handling message: {}", + &plugin.info().name, + e + ), } } @@ -104,190 +64,34 @@ impl EventHandler for Handler { error!("Error sending message: {:?}", why); } } - - let channel_ids: Vec = vec![ - MotorbotChannels::General as u64, - MotorbotChannels::PatchNotes as u64, - MotorbotChannels::Jokes as u64, - ]; - - if msg.attachments.len() > 0 || msg.content.contains("http") { - if channel_ids.contains(&msg.channel_id.get()) && !msg.author.id.eq(&169554882674556930) - { - if let Err(why) = msg - .react( - &ctx, - ReactionType::try_from("<:upvote:429449534389616641>").unwrap(), - ) - .await - { - error!("Failed to react to message {:?}", why); - } - if let Err(why) = msg - .react( - &ctx, - ReactionType::try_from("<:downvote:429449638454493187>").unwrap(), - ) - .await - { - error!("Failed to react to message {:?}", why); - } - } - } } async fn reaction_add(&self, ctx: Context, reaction: Reaction) { info!("Reaction Added"); - let channel_ids: Vec = vec![ - MotorbotChannels::General as u64, - MotorbotChannels::PatchNotes as u64, - MotorbotChannels::Jokes as u64, - ]; - - if channel_ids.contains(&reaction.channel_id.get()) - && reaction.emoji == ReactionType::try_from("<:upvote:429449534389616641>").unwrap() - && !reaction.user_id.unwrap().eq(&169554882674556930) - { - let user = reaction.user_id.unwrap(); // The user id of who upvoted - let message_id = reaction.message_id; // The message id of the message that was upvoted - let message_user_id = ctx - .http - .get_message(reaction.channel_id, message_id) - .await - .unwrap() - .author - .id; // The user id of the message that was upvoted - - info!( - "User {:?} upvoted message from user: {:?}", - user, message_user_id - ); - - // Update Database - let mut db = Database::open() - .await - .expect("Failed to connect to database"); - let user_score = db - .user_score(&message_user_id.get()) - .await - .expect("Failed to fetch user score"); - let new_score = user_score.score + 1; - if let Err(why) = db.set_user_score(&message_user_id.get(), new_score).await { - error!("Failed to set user score {:?}", why); - } - if let Err(why) = db.close().await { - error!("Failed to close database connection {:?}", why); - } - } else if channel_ids.contains(&reaction.channel_id.get()) - && reaction.emoji == ReactionType::try_from("<:downvote:429449638454493187>").unwrap() - && !reaction.user_id.unwrap().eq(&169554882674556930) - { - let user = reaction.user_id.unwrap(); // The user id of who upvoted - let message_id = reaction.message_id; // The message id of the message that was upvoted - let message_user_id = ctx - .http - .get_message(reaction.channel_id, message_id) - .await - .unwrap() - .author - .id; // The user id of the message that was downvoted - info!( - "User {:?} downvoted message from user: {:?}", - user, message_user_id - ); - - // Update Database - let mut db = Database::open() - .await - .expect("Failed to connect to database"); - let user_score = db - .user_score(&message_user_id.get()) - .await - .expect("Failed to fetch user score"); - let new_score = user_score.score - 1; - if let Err(why) = db.set_user_score(&message_user_id.get(), new_score).await { - error!("Failed to set user score {:?}", why); - } - if let Err(why) = db.close().await { - error!("Failed to close database connection {:?}", why); + let plugin_ctx = PluginContext::new(ctx.clone(), &self.plugins).await; + for plugin in self.plugins.iter() { + match plugin.on_reaction_add(&plugin_ctx, &reaction).await { + Ok(()) => {} + Err(e) => error!( + "[{}] Error occurred while handling reaction add: {}", + &plugin.info().name, + e + ), } } } async fn reaction_remove(&self, ctx: Context, reaction: Reaction) { info!("Reaction Removed"); - let channel_ids: Vec = vec![ - MotorbotChannels::General as u64, - MotorbotChannels::PatchNotes as u64, - MotorbotChannels::Jokes as u64, - ]; - - if channel_ids.contains(&reaction.channel_id.get()) - && reaction.emoji == ReactionType::try_from("<:upvote:429449534389616641>").unwrap() - && !reaction.user_id.unwrap().eq(&169554882674556930) - { - let user = reaction.user_id.unwrap(); // The user id of who upvoted - let message_id = reaction.message_id; // The message id of the message that was upvoted - let message_user_id = ctx - .http - .get_message(reaction.channel_id, message_id) - .await - .unwrap() - .author - .id; // The user id of the message that was the upvote was removed from - - info!( - "User {:?} upvoted message from user: {:?}", - user, message_user_id - ); - - // Update Database - let mut db = Database::open() - .await - .expect("Failed to connect to database"); - let user_score = db - .user_score(&message_user_id.get()) - .await - .expect("Failed to fetch user score"); - let new_score = user_score.score - 1; - if let Err(why) = db.set_user_score(&message_user_id.get(), new_score).await { - error!("Failed to set user score {:?}", why); - } - if let Err(why) = db.close().await { - error!("Failed to close database connection {:?}", why); - } - } else if channel_ids.contains(&reaction.channel_id.get()) - && reaction.emoji == ReactionType::try_from("<:downvote:429449638454493187>").unwrap() - && !reaction.user_id.unwrap().eq(&169554882674556930) - { - let user = reaction.user_id.unwrap(); // The user id of who upvoted - let message_id = reaction.message_id; // The message id of the message that was upvoted - let message_user_id = ctx - .http - .get_message(reaction.channel_id, message_id) - .await - .unwrap() - .author - .id; // The user id of the message that was downvoted was removed from - info!( - "User {:?} downvoted message from user: {:?}", - user, message_user_id - ); - - // Update Database - let mut db = Database::open() - .await - .expect("Failed to connect to database"); - let user_score = db - .user_score(&message_user_id.get()) - .await - .expect("Failed to fetch user score"); - let new_score = user_score.score + 1; - if let Err(why) = db.set_user_score(&message_user_id.get(), new_score).await { - error!("Failed to set user score {:?}", why); - } - if let Err(why) = db.close().await { - error!("Failed to close database connection {:?}", why); + let plugin_ctx = PluginContext::new(ctx.clone(), &self.plugins).await; + for plugin in self.plugins.iter() { + match plugin.on_reaction_remove(&plugin_ctx, &reaction).await { + Ok(()) => {} + Err(e) => error!( + "[{}] Error occurred while handling reaction remove: {}", + &plugin.info().name, + e + ), } } } @@ -295,462 +99,65 @@ impl EventHandler for Handler { // Ready Event async fn ready(&self, ctx: Context, ready: Ready) { info!("{} is connected!", ready.user.name); - ctx.set_presence(Some(ActivityData::custom("πŸ˜Άβ€πŸŒ«οΈ")), OnlineStatus::Online); + ctx.set_presence( + Some(ActivityData::custom("πŸ˜Άβ€πŸŒ«οΈ")), + OnlineStatus::Online, + ); - // let avatar = serenity::utils::read_image("./motorbot.png"); - // match avatar { - // Ok(av) => { - // info!("Avatar read successfully"); - // let mut user = ctx.cache.current_user(); - // let avatar_edit = user.edit(&ctx.http, |p| p.avatar(Some(&av))).await; - // match avatar_edit { - // Ok(_) => { - // info!("Avatar updated successfully"); - // } - // Err(why) => { - // error!("Error updating avatar: {:?}", why); - // } - // } - // } - // Err(why) => { - // error!("Error reading avatar: {:?}", why); - // } - // } - - let channel_id = ChannelId::new(MotorbotChannels::BotEvents as u64); + let plugin_ctx = PluginContext::new(ctx.clone(), &self.plugins).await; const VERSION: &str = env!("CARGO_PKG_VERSION"); - if let Err(why) = channel_id - .say(&ctx.http, format!("MotorBot v{} reporting for duty!", VERSION)) - .await - { - error!("Error sending message: {:?}", why); - } - - let loaded_patch_plugin = patches_plugin::PatchesPlugin::new(ctx.clone()).await; - - // Register Slash Commands - let commands_list = vec![ - CreateCommand::new("time").description("Returns server time for MotorBot"), - CreateCommand::new("roll").description("Roll the dice, what will you get?"), - CreateCommand::new("headsortails").description("Heads or tails?"), - CreateCommand::new("score") - .description("Get a user's score") - .add_option( CreateCommandOption::new(CommandOptionType::User, "user", "The user's score to look up").required(false)), - CreateCommand::new("ai") - .description("Allows you to interact with OpenAI") - .add_option(CreateCommandOption::new(CommandOptionType::String, "endpoint", "The type of interaction to use for OpenAI either `completions` or `images`").required(true)) - .add_option(CreateCommandOption::new(CommandOptionType::String, "prompt", "The prompt to use for OpenAI").required(true)), - CreateCommand::new("patches") - .description("Get the latest patch notes for monitored games") - .add_option(CreateCommandOption::new(CommandOptionType::SubCommand, "list", "List all monitored games")) - .add_option(CreateCommandOption::new(CommandOptionType::SubCommand, "channel", "Set the channel for patch notes to be posted in") - .add_sub_option(CreateCommandOption::new(CommandOptionType::Channel, "channel", "The channel to post patch notes in").required(true)) - ) - .add_option(CreateCommandOption::new(CommandOptionType::SubCommand, "update", "Manually trigger an update to check for new patch notes")) - .add_option(CreateCommandOption::new(CommandOptionType::SubCommand, "add", "Add a game to monitor") - .add_sub_option(CreateCommandOption::new(CommandOptionType::String, "id", "The game id to monitor (this should be either a Steam or Riot game ID)").required(true)) - .add_sub_option(CreateCommandOption::new(CommandOptionType::String, "platform", "The platform the game is on (e.g. steam, riot)").required(true)) - .add_sub_option(CreateCommandOption::new(CommandOptionType::String, "name", "The friendly name of the game").required(true)) - .add_sub_option(CreateCommandOption::new(CommandOptionType::String, "thumbnail", "The URL for the game's logo image").required(true)) - .add_sub_option(CreateCommandOption::new(CommandOptionType::String, "color", "The color associated with the news item (hex color code)").required(true)) - ) - .add_option(CreateCommandOption::new(CommandOptionType::SubCommand, "remove", "Remove a game from monitoring") - .add_sub_option(CreateCommandOption::new(CommandOptionType::String, "id", "The game id to stop monitoring").required(true)) - ) - ]; - - let mut guild_ids = Vec::new(); - ctx.http.get_guilds(None, None).await.unwrap().iter().for_each(|guild| { - guild_ids.push(guild.id); - }); - info!("Registering slash commands for guilds: {:?}", guild_ids); - for guild_id in guild_ids { - let _commands = GuildId::set_commands(guild_id, &ctx.http, commands_list.clone()).await; - } - - let _ = Command::create_global_command( - &ctx.http, - CreateCommand::new("ping").description("A simple ping command"), - ) - .await; - - let mut scheduler = AsyncScheduler::with_tz(chrono::Local); - // Add some tasks to it - let inner_ctx = loaded_patch_plugin.clone(); - scheduler.every(30.minutes()).run(move || { - let inner_inner_ctx = inner_ctx.clone(); - async move { - inner_inner_ctx.update().await; - } - }); - scheduler.every(1.day()).at("10:30 am").run(move || { - let ctx = ctx.clone(); - async move { - let channel_id = ChannelId::new(MotorbotChannels::Jokes as u64); - let client = reqwest::Client::new(); - - let mut headers = HeaderMap::new(); - let rapid_api_key: String = - env::var("RAPID_API_KEY").expect("Expected a token in the environment"); - headers.insert( - "X-RapidAPI-Key", - HeaderValue::from_str(&rapid_api_key).unwrap(), - ); - headers.insert( - "X-RapidAPI-Host", - HeaderValue::from_str("dad-jokes.p.rapidapi.com").unwrap(), - ); - - let http_response = client - .get("https://dad-jokes.p.rapidapi.com/random/joke") - .headers(headers) - .send() - .await - .expect("Failed to request joke") - .json::() - .await - .expect("Failed to parse joke"); - - let _ = channel_id - .send_message( - &ctx.http, - CreateMessage::new() - .content(format!( - "{}\n\n||{}||", - http_response.body[0].setup.as_str().unwrap(), - http_response.body[0].punchline.as_str().unwrap() - )) - .reactions([ - ReactionType::try_from("<:upvote:429449534389616641>").unwrap(), - ReactionType::try_from("<:downvote:429449638454493187>").unwrap(), - ]), - ) - .await; - } - }); - - tokio::spawn(async move { - loop { - scheduler.run_pending().await; - tokio::time::sleep(Duration::from_millis(100)).await; + plugin_ctx + .send_event( + PluginEvent::new( + PluginEventLevel::Info, + "Bot Started", + ).description(&format!( + "<:lightning:1494777585874370600> MotorBot v{} reporting for duty!", + VERSION + )), + ) + .await; + for plugin in self.plugins.iter() { + match plugin.on_ready(&plugin_ctx).await { + Ok(()) => {} + Err(e) => error!( + "[{}] Error occurred while handling ready event: {}", + &plugin.info().name, + e + ), } - }); + } } // Handle Slash Command Trigger async fn interaction_create(&self, ctx: Context, interaction: Interaction) { - if let Interaction::Command(command) = interaction { - info!("Received command interaction: {:#?}", command.data.name); - - let content = match command.data.name.as_str() { - "ping" => "Pong!".to_string(), - "time" => MessageBuilder::new() - .push(":alarm_clock: The current time for MotorBot is ") - .push(format!("{:?}", chrono::Local::now())) - .build(), - "roll" => { - let roll = rand::random_range(1..100); - MessageBuilder::new() - .push("You rolled a ") - .push_bold_safe(format!("{}", roll)) - .build() - } - "headsortails" => { - let roll = rand::random_range(1..100); - if roll >= 50 { - MessageBuilder::new().push(":coin: Heads").build() - } else { - MessageBuilder::new().push(":coin: Tails").build() - } - } - "score" => { - let mut user_id = command.user.id; - let mut username = command.user.tag(); - - let options = command.data.options.get(0); - info!("Options: {:?}", options); - if !options.is_none() { - let option = options.expect("Expected User Id").value.clone(); - if let CommandDataOptionValue::User(user) = option { - user_id = user; - } - username = ctx - .http - .get_user(user_id) - .await - .expect("Failed to fetch user") - .tag(); - } - - let mut db = Database::open() - .await - .expect("Failed to connect to database"); - let user_score = db - .user_score(&user_id.get()) - .await - .expect("Failed to fetch user score"); - if let Err(why) = db.close().await { - error!("Failed to close database connection {:?}", why); - } - format!("{}'s score is {}", username, user_score.score) - } - "ai" => { - "Generating response...".to_string() - } - "patches" => { - let subcommand = command.data.options.get(0); - if let Some(subcommand) = subcommand { - match subcommand.name.as_str() { - "channel" => { - if let CommandDataOptionValue::SubCommand(subcommand_options) = subcommand.value.clone() { - info!("Subcommand options: {:?}", subcommand_options); - let channel_id = subcommand_options - .get(0) - .expect("Expected channel") - .value - .as_channel_id() - .unwrap().get(); - let guild_id = command.guild_id.unwrap().get(); - let mut db = Database::open() - .await - .expect("Failed to connect to database"); - let config_option = GuildConfig::from((GuildConfigKey::PatchNotesChannel, channel_id.to_string())); - let result = db.set_guild_config(guild_id, config_option).await; - match result { - Ok(_) => format!("Patch notes channel set to <#{}>", channel_id), - Err(e) => { - error!("Failed to set patch notes channel: {:?}", e); - "Failed to set patch notes channel".to_string() - } - } - } else { - "No subcommand options provided".to_string() - } - }, - "list" => { - let mut db = Database::open() - .await - .expect("Failed to connect to database"); - let game_ids = db.game_ids_for_guild(command.guild_id.unwrap().get()).await; - let games_to_monitor = match game_ids { - Ok(ids) => ids, - Err(e) => { - error!("Failed to fetch game ids: {:?}", e); - return; - } - }; - let mut response = String::new(); - let mut count = 1; - for game_id in games_to_monitor { - let game_data = GameData::from_id(&game_id).await; - let guild_data = game_data.guild_data.iter().find(|g| g.guild == command.guild_id.unwrap().get()); - let game_name = match guild_data { - Some(guild_data) => &guild_data.name, - None => "Unknown Name", - }; - response.push_str(&format!( - "{}. {} ({})\n", - count, game_name, game_data.id - )); - count += 1; - } - if response.is_empty() { - "No games are currently being monitored".to_string() - } else { - response - } - } - "update" => { - let _ = patches_plugin::PatchesPlugin::new(ctx.clone()).await; - "Update complete".to_string() - } - "add" => { - if let CommandDataOptionValue::SubCommand(subcommand_options) = subcommand.value.clone() { - info!("Subcommand options: {:?}", subcommand_options); - let id = subcommand_options - .get(0) - .expect("Expected game id") - .value - .as_str() - .unwrap(); - let platform = subcommand_options - .get(1) - .expect("Expected platform") - .value - .as_str() - .unwrap(); - if !Platform::is_valid_platform(platform) { - format!("Invalid platform provided. Valid platforms are: steam, riot") - } else { - let name = subcommand_options - .get(2) - .expect("Expected name") - .value - .as_str() - .unwrap(); - let thumbnail = subcommand_options - .get(3) - .expect("Expected thumbnail") - .value - .as_str() - .unwrap(); - if !thumbnail.starts_with("http") { - format!("Invalid thumbnail URL provided. URL should start with http") - } else { - let raw_color = subcommand_options - .get(4) - .expect("Expected color") - .value - .as_str() - .unwrap(); - if !raw_color.starts_with("#") || raw_color.len() != 7 { - format!("Invalid color provided. Color should be a hex code starting with # and followed by 6 characters (e.g. #FF0000)") - } else { - let color = raw_color[1..].to_uppercase(); - let guild = command.guild_id.unwrap().get(); - let mut db = Database::open() - .await - .expect("Failed to connect to database"); - let result = db.add_game(id, guild, Platform::from(platform), name, thumbnail, &color).await; - match result { - Ok(_) => format!("Game {} added to monitoring list", name), - Err(e) => { - error!("Failed to add game: {:?}", e); - "Failed to add game".to_string() - } - } - } - } - } - } else { - "No subcommand options provided".to_string() - } - }, - "remove" => { - if let CommandDataOptionValue::SubCommand(subcommand_options) = subcommand.value.clone() { - info!("Subcommand options: {:?}", subcommand_options); - let id = subcommand_options - .get(0) - .expect("Expected game id") - .value - .as_str() - .unwrap(); - let guild = command.guild_id.unwrap().get(); - let mut db = Database::open() - .await - .expect("Failed to connect to database"); - let result = db.remove_game(id, guild).await; - match result { - Ok(_) => format!("Game `{}` removed from monitoring list", id), - Err(e) => { - error!("Failed to remove game: {:?}", e); - "Failed to remove game".to_string() - } - } - } else { - "No subcommand options provided".to_string() - } - }, - _ => "Unknown subcommand".to_string(), - } - } else { - "No subcommand provided".to_string() - } - } - _ => "not implemented :(".to_string(), - }; - - if command.data.name.as_str().ne("ai") { - if let Err(why) = command - .create_response( - &ctx.http, - CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new().content(content), - ), - ) - .await - { - warn!("Cannot respond to slash command: {}", why); - } - } else { - // Send deferred message whilst dealing with AI - if let Err(why) = command - .create_response( - &ctx.http, - CreateInteractionResponse::Defer(CreateInteractionResponseMessage::new()), - ) - .await - { - warn!("Cannot respond to slash command: {}", why); - } - - let endpoint: String = command.data.options[0].value.as_str().unwrap().to_string(); - let prompt: String = command.data.options[1].value.as_str().unwrap().to_string(); - - info!("Endpoint: {} | Prompt: {}", endpoint, prompt); - - if endpoint.eq("completions") { - info!("Prompt: {}", prompt); - let completions = - completions::build(models::CompletionModels::TEXT_DAVINCI_003) - .prompt(&prompt) - .max_tokens(50) - .user("MotorBot") - .complete() - .await; - match completions { - Ok(completions) => { - let message = completions.choices[0].text.as_str().to_string(); - info!("Message: {}", message); - if let Err(why) = command - .create_followup( - ctx.http, - CreateInteractionResponseFollowup::new().content(message), - ) - .await - { - warn!("Cannot respond to slash command: {}", why); - } - } - Err(why) => { - warn!("Error: {:?}", why); - } - } - } else if endpoint.eq("images") { - let images = images::build() - .generate(prompt) - .size("256x256") - .user("MotorBot") - .done() - .await; - match images { - Ok(images) => { - let message = images.data[0].url.as_str().to_string(); - info!("Message: {}", message); - if let Err(why) = command - .create_followup( - ctx.http, - CreateInteractionResponseFollowup::new().content(message), - ) - .await - { - warn!("Cannot respond to slash command: {}", why); - } - } - Err(why) => { - warn!("Error: {:?}", why); - } - } - } else { - if let Err(why) = command - .create_followup( - ctx.http, - CreateInteractionResponseFollowup::new().content("Unknown Endpoint"), + let plugin_ctx = PluginContext::new(ctx.clone(), &self.plugins).await; + let plugin_interaction = interaction.clone(); + for plugin in self.plugins.iter() { + match plugin + .on_interaction_create(&plugin_ctx, &plugin_interaction) + .await + { + Ok(()) => {} + Err(e) => { + error!( + "[{}] Error occurred while handling interaction create: {}", + &plugin.info().name, + e + ); + plugin_ctx + .send_event( + PluginEvent::new( + PluginEventLevel::Error, + "Internal Error", + ) + .description(&format!( + "*{}*\n{}", + plugin.info().name, + e + )), ) - .await - { - warn!("Cannot respond to slash command: {}", why); - } + .await; } } } @@ -771,19 +178,33 @@ async fn main() -> std::io::Result<()> { _ => Level::INFO, }; - let subscriber = FmtSubscriber::builder() - .with_max_level(level) - .finish(); - tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); - let token = env::var("DISCORD_TOKEN").expect("Expected a token in the environment"); + let subscriber = FmtSubscriber::builder().with_max_level(level).finish(); + tracing::subscriber::set_global_default(subscriber) + .expect("setting default subscriber failed"); + let token = + env::var("DISCORD_TOKEN").expect("Expected a token in the environment"); let intents = GatewayIntents::MESSAGE_CONTENT | GatewayIntents::DIRECT_MESSAGES | GatewayIntents::GUILD_MESSAGES | GatewayIntents::GUILD_MESSAGE_REACTIONS | GatewayIntents::DIRECT_MESSAGE_REACTIONS; + let plugins: Vec> = vec![ + Box::new(PingPlugin::new()) as Box, + Box::new(TimePlugin::new()) as Box, + Box::new(DicePlugin::new()) as Box, + Box::new(CoinTossPlugin::new()) + as Box, + Box::new(DebugPlugin::new()) as Box, + Box::new(PointsPlugin::new()) as Box, + Box::new(JokesPlugin::new()) as Box, + Box::new(PatchesPlugin::new()) as Box, + ]; + + let handler = Handler { plugins }; + let mut client = Client::builder(&token, intents) - .event_handler(Handler) + .event_handler(handler) .await .expect("Err creating client"); const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/src/plugin.rs b/src/plugin.rs new file mode 100644 index 0000000..edf75d2 --- /dev/null +++ b/src/plugin.rs @@ -0,0 +1,132 @@ +use async_trait::async_trait; +use serenity::all::{Interaction, Message, Reaction}; + +pub mod error; +pub mod plugin_context; +pub mod plugin_info; + +// Re-exporting for easier access +pub use error::Error as PluginError; +pub use plugin_context::PluginContext; + +pub use crate::Result; + +#[allow(unused)] +pub use plugin_info::PluginInfo; + +/// Default trait definition for plugins in the MotorBot system +/// +/// This trait defines the basic interface for all plugins in the MotorBot +/// system. +#[async_trait] +pub trait MotorbotPlugin { + /// Creates a new instance of the plugin. + /// + /// This method is used to create a new instance of the plugin. It should be + /// implemented by all plugins to allow for dynamic loading and initialization. + /// + /// Please note that at this point MotorBot may not yet have fully + /// initialized, so plugins should avoid making any assumptions about the + /// state of the system in this method. For example, plugins should not + /// attempt to access the database or send messages to Discord in this method, + /// as these systems may not yet be ready. + fn new() -> Self + where + Self: Sized; + + /// Returns the plugin's information, including its name, description, and + /// version. + fn info(&self) -> PluginInfo; + + /// Called when motorbot is ready + /// + /// This method is called when motorbot is ready to start processing events. + /// It can be used to perform any necessary initialization or setup for + /// plugins. + async fn on_ready(&self, _ctx: &PluginContext) -> Result<()> { + // Default implementation does nothing + Ok(()) + } + + /// Called when a message is created + /// + /// This method is called when a new message is created in any channel that + /// the bot has access to. It can be used to perform any functionality that + /// requires processing messages. + async fn on_message( + &self, + _ctx: &PluginContext, + _message: &Message, + ) -> Result<()> { + // Default implementation does nothing + Ok(()) + } + + /// Called when a reaction is added to a message + /// + /// This method is called when a reaction is added to any message that the bot + /// has access to. It can be used to perform any functionality that requires + /// processing reactions. + async fn on_reaction_add( + &self, + _ctx: &PluginContext, + _reaction: &Reaction, + ) -> Result<()> { + // Default implementation does nothing + Ok(()) + } + + /// Called when a reaction is removed from a message + /// + /// This method is called when a reaction is removed from any message that the + /// bot has access to. It can be used to perform any functionality that + /// requires processing reaction removals. + async fn on_reaction_remove( + &self, + _ctx: &PluginContext, + _reaction: &Reaction, + ) -> Result<()> { + // Default implementation does nothing + Ok(()) + } + + /// Called when an interaction is created + async fn on_interaction_create( + &self, + _ctx: &PluginContext, + _interaction: &Interaction, + ) -> Result<()> { + // Default implementation does nothing + Ok(()) + } +} + +/// Macro to simplify getting options from command interactions +/// +/// # Example +/// To get an option named channel with a value of the Channel type, you can use +/// the macro like this: +/// ```rust +/// let options = command.data.options(); +/// let channel_id = option!( +/// options, +/// "channel", +/// ResolvedValue::Channel +/// ).ok_or(PluginError::MissingChannelId)?.id.get(); +/// ``` +macro_rules! option { + ($cmd:ident, $name:expr, $t:path) => {{ + let option = $cmd.iter().find(|opt| opt.name == $name); + if let Some(option) = option { + if let $t(value) = option.value { + Some(value) + } else { + None + } + } else { + None + } + }}; +} + +pub(crate) use option; diff --git a/src/plugin/error.rs b/src/plugin/error.rs new file mode 100644 index 0000000..c9d09ec --- /dev/null +++ b/src/plugin/error.rs @@ -0,0 +1,78 @@ +use derive_more::{Display, From}; + +/// Custom error type for plugin operations. +#[derive(Debug, Display, From)] +#[non_exhaustive] +pub enum Error { + /// An error indicating that a plugin failed to send a message or response + /// due to an issue with the Serenity context or API. + #[display("Failed to send response: {}", err)] + FailedToRespond { + /// The error that occurred during sending a message or response + err: serenity::Error, + }, + + /// Plugin expected a UserId but got None + #[display("Expected a UserId but got None")] + MissingUserId, + + /// Plugin expected a ChannelId but got None + #[display("Expected a ChannelId but got None")] + MissingChannelId, + + /// Plugin expected something to happen in a guild context but it was used + /// somewhere else (e.g. in a DM) + ExpectedGuild, + + /// A channel was expected but the provided option was either not a channel + /// or was missing entirely. + #[display( + "Expected a channel option but got an invalid value or none at all" + )] + InvalidChannel, + + /// A user was expected but the provided option was either not a user + /// or was missing entirely. + #[display( + "Expected a user option but got an invalid value or none at all" + )] + InvalidUser, + + /// An error indicating that a subcommand option was either not a subcommand + /// or was missing entirely when one was expected. + #[display( + "Expected a subcommand option but got an invalid value or none at all" + )] + InvalidSubCommand, + + /// An error indicating that a game ID provided to the Patches plugin was + /// invalid, such as not being a valid Steam game ID or not being found in + /// the database when expected. + #[display("Invalid game ID provided")] + InvalidGameId, + + /// An error indicating that a game platform provided to the Patches plugin + /// was invalid, such as not being a recognized platform like "steam" or + /// "riot". + #[display("Invalid game platform provided")] + InvalidGamePlatform, + + /// An error indicating that a game name provided to the Patches plugin was + /// invalid, such as being empty or not matching any known game names in the + /// database when expected. + #[display("Invalid game name provided")] + InvalidGameName, + + /// An error indicating that a game thumbnail URL provided to the Patches + /// plugin was invalid, such as not being a valid URL or not pointing to an + /// image when expected. + #[display("Invalid game thumbnail URL provided")] + InvalidGameThumbnail, + + /// An error indicating that a game color provided to the Patches plugin was + /// invalid, such as not being a valid hex color code when expected. + #[display("Invalid game color provided")] + InvalidGameColor, +} + +impl std::error::Error for Error {} diff --git a/src/plugin/plugin_context.rs b/src/plugin/plugin_context.rs new file mode 100644 index 0000000..b3f4405 --- /dev/null +++ b/src/plugin/plugin_context.rs @@ -0,0 +1,148 @@ +use std::fmt::Display; + +use serenity::all::{ChannelId, Context, CreateEmbed, CreateMessage}; +use tracing::error; + +use crate::{ + plugin::MotorbotPlugin, + storage::{Database, GuildConfigKey, GuildConfigValue}, +}; + +/// The context passed to plugins, containing the Serenity context and +/// motorbot specific information such as the list of loaded plugins. +#[derive(Clone)] +pub struct PluginContext<'a> { + /// The Serenity context, which provides access to Discord API + pub ctx: Context, + /// A reference to the list of loaded plugins, allowing plugins to access + /// information about other plugins if needed. + pub plugins: &'a Vec>, +} + +/// An event generated by a plugin, containing information about the event +/// and its severity level. +pub struct PluginEvent { + pub level: PluginEventLevel, + pub name: String, + pub description: Option, +} + +/// The level of a plugin event, indicating its severity or importance. +#[derive(Debug, Default)] +#[allow(unused)] +pub enum PluginEventLevel { + /// A debug event, used for detailed information that may be useful for + /// diagnosing issues or understanding the internal workings of the plugin. + #[default] + Debug, + /// An informational event, used for general information about the plugin's + /// operation or important milestones. + Info, + /// A warning event, used for potential issues or situations that may + /// require attention but are not necessarily errors. + Warning, + /// An error event, used for significant issues or errors that may impact the + /// plugin's functionality or require immediate attention. + Error, +} + +impl PluginEvent { + pub fn new(level: PluginEventLevel, name: &str) -> Self { + Self { + name: name.to_string(), + description: None, + level, + } + } + + pub fn description(mut self, description: &str) -> Self { + self.description = Some(description.to_string()); + self + } +} + +impl Display for PluginEventLevel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Debug => write!(f, "DEBUG"), + Self::Info => write!(f, "INFO"), + Self::Warning => write!(f, "WARNING"), + Self::Error => write!(f, "ERROR"), + } + } +} + +impl<'a> PluginContext<'a> { + pub async fn new( + ctx: Context, + plugins: &'a Vec>, + ) -> Self { + Self { ctx, plugins } + } + + /// Send a message to the configured bot events channel + /// + /// This is a convenience method for plugins to send messages to the bot + /// events channel without needing to know the channel ID or access the + /// Serenity context directly. + /// + /// Plugins can use this method to log important events or information to + /// the bot events channel for easier monitoring and debugging. + pub async fn send_event(&self, event: PluginEvent) { + match Database::open().await { + Err(why) => { + error!("Error opening database: {:?}", why); + } + Ok(mut db) => { + let guilds = self.ctx.cache.guilds(); + let mut event_channel_ids = Vec::new(); + for guild_id in guilds { + let channel_id: Option = match db + .get_guild_config( + guild_id.get(), + GuildConfigKey::EventsChannel, + ) + .await + { + Ok(Some(config_value)) => match config_value.value { + GuildConfigValue::ChannelId(id) => Some(id), + _ => None, + }, + Ok(None) => None, + Err(why) => { + error!("Error fetching events channel from database: {:?}", why); + None + } + }; + if let Some(channel_id) = channel_id { + event_channel_ids.push(channel_id); + } + } + let level_color = match event.level { + PluginEventLevel::Debug => 0xAAAAAA, + PluginEventLevel::Info => 0x55B8F9, + PluginEventLevel::Warning => 0xFAE577, + PluginEventLevel::Error => 0xFF0000, + }; + let mut embed_builder = CreateEmbed::default() + .title(format!("[{}] {}", event.level, event.name)) + .color(level_color); + if let Some(description) = event.description { + embed_builder = embed_builder.description(description); + } + let builder = CreateMessage::new().embed(embed_builder); + for channel_id in event_channel_ids { + if let Err(why) = ChannelId::new(channel_id) + .send_message(&self.ctx.http, builder.clone()) + .await + { + error!( + "Error sending message to channel {}: {:?}", + channel_id, why + ); + } + } + } + } + } +} diff --git a/src/plugin/plugin_info.rs b/src/plugin/plugin_info.rs new file mode 100644 index 0000000..fc70cb9 --- /dev/null +++ b/src/plugin/plugin_info.rs @@ -0,0 +1,9 @@ +/// Information about a plugin, including its name, description, and version. +pub struct PluginInfo { + /// The name of the plugin. + pub name: String, + /// A brief description of the plugin's functionality. + pub description: String, + /// The version of the plugin. + pub version: String, +} diff --git a/src/plugins/coin_toss.rs b/src/plugins/coin_toss.rs new file mode 100644 index 0000000..e8e2392 --- /dev/null +++ b/src/plugins/coin_toss.rs @@ -0,0 +1,71 @@ +use async_trait::async_trait; +use serenity::all::{ + Command, CreateCommand, CreateInteractionResponse, + CreateInteractionResponseMessage, Interaction, +}; +use tracing::info; + +use crate::plugin::PluginError; +use crate::{Error, Result}; + +use crate::plugin::{MotorbotPlugin, PluginContext, PluginInfo}; + +pub struct CoinTossPlugin; + +/// A simple plugin that responds with a random coin toss result when a user sends +/// a "heads-or-tails" slash command. This plugin serves as a basic example of how to +/// create a plugin for the MotorBot system. +#[async_trait] +impl MotorbotPlugin for CoinTossPlugin { + fn new() -> Self + where + Self: Sized, + { + CoinTossPlugin + } + + fn info(&self) -> PluginInfo { + PluginInfo { + name: "Coin Toss".to_string(), + description: "A simple coin toss command".to_string(), + version: "0.1.0".to_string(), + } + } + + async fn on_ready(&self, p_ctx: &PluginContext) -> Result<()> { + info!("Coin Toss Plugin is ready!"); + + let _ = Command::create_global_command( + &p_ctx.ctx.http, + CreateCommand::new("heads-or-tails") + .description("Heads or tails? Let's find out!"), + ) + .await; + Ok(()) + } + + async fn on_interaction_create( + &self, + p_ctx: &PluginContext, + interaction: &Interaction, + ) -> Result<()> { + if let Interaction::Command(command) = interaction { + if command.data.name == "heads-or-tails" { + let coin_toss = if rand::random() { "Heads" } else { "Tails" }; + command + .create_response( + &p_ctx.ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(format!(":coin: **{}**", coin_toss)), + ), + ) + .await + .map_err(|e| { + Error::Plugin(PluginError::FailedToRespond { err: e }) + })?; + } + } + Ok(()) + } +} diff --git a/src/plugins/debug.rs b/src/plugins/debug.rs new file mode 100644 index 0000000..67e0ad9 --- /dev/null +++ b/src/plugins/debug.rs @@ -0,0 +1,251 @@ +use async_trait::async_trait; +use serenity::all::{ + Command, CommandOptionType, CreateCommand, CreateCommandOption, + CreateInteractionResponse, CreateInteractionResponseMessage, Interaction, + MessageBuilder, ResolvedValue, +}; +use tracing::info; + +use crate::plugin::PluginError; +use crate::{Error, Result}; + +use crate::{ + plugin::{MotorbotPlugin, PluginContext, PluginInfo}, + storage::{Database, GuildConfig, GuildConfigKey, GuildConfigValue}, +}; + +pub struct DebugPlugin; + +/// A simple plugin that responds with a random dice roll when a user sends +/// a "roll" slash command. This plugin serves as a basic example of how to +/// create a plugin for the MotorBot system. +#[async_trait] +impl MotorbotPlugin for DebugPlugin { + fn new() -> Self + where + Self: Sized, + { + DebugPlugin + } + + fn info(&self) -> PluginInfo { + PluginInfo { + name: "Debug".to_string(), + description: "MotorBot debug command".to_string(), + version: "0.1.0".to_string(), + } + } + + async fn on_ready(&self, p_ctx: &PluginContext) -> Result<()> { + info!("Debug Plugin is ready!"); + + Command::create_global_command( + &p_ctx.ctx.http, + CreateCommand::new("debug") + .description("Debug command for MotorBot") + .add_option(CreateCommandOption::new( + CommandOptionType::SubCommand, + "whoami", + "Get information about the user who invoked the command", + )) + .add_option(CreateCommandOption::new( + CommandOptionType::SubCommand, + "version", + "Get the bot's version", + )) + .add_option(CreateCommandOption::new( + CommandOptionType::SubCommand, + "plugins", + "Get a list of all loaded plugins", + )) + .add_option( + CreateCommandOption::new( + CommandOptionType::SubCommand, + "events", + "Event log configuration", + ) + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::Channel, + "channel", + "Channel to send events to", + ) + .required(true), + ), + ), + ) + .await?; + Ok(()) + } + + async fn on_interaction_create( + &self, + p_ctx: &PluginContext, + interaction: &Interaction, + ) -> Result<()> { + let Interaction::Command(command) = interaction else { + // Not a command interaction, so we can skip processing + return Ok(()); + }; + if command.data.name != "debug" { + // Not the "debug" command, so we can skip processing + return Ok(()); + } + + let options = command.data.options(); + let first_option = + options.first().ok_or(PluginError::InvalidSubCommand)?; + match first_option.name { + "whoami" => { + let avatar = match command.user.avatar.as_ref() { + Some(avatar) => format!( + "https://cdn.discordapp.com/avatars/{}/{}.png", + command.user.id.get(), + avatar + ), + None => "No avatar".to_string(), + }; + let public_flags = + command.user.public_flags.unwrap_or_default(); + let response = MessageBuilder::new() + .push("```") + .push(format!( + "id : {:?}\n", + command.user.id.get() + )) + .push(format!("name : {:?}\n", command.user.name)) + .push(format!( + "global name : {:?}\n", + command.user.global_name + )) + .push(format!( + "discriminator : {:?}\n", + command.user.discriminator + )) + .push(format!("avatar : {:?}\n", avatar)) + .push(format!("flags : {:?}\n", public_flags)) + .push(format!( + "accent colour : {:?}\n", + command.user.accent_colour + )) + .push(format!( + "banner : {:?}\n", + command.user.banner + )) + .push(format!("bot : {:?}\n", command.user.bot)) + .push(format!( + "system : {:?}\n", + command.user.system + )) + .push("```") + .build(); + command + .create_response( + &p_ctx.ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(response), + ), + ) + .await + .map_err(|err| PluginError::FailedToRespond { err })?; + } + "plugins" => { + let plugins = p_ctx.plugins; + let plugin_list = plugins + .iter() + .map(|p| { + format!( + "🧩 `v{}` **{}**: {}", + p.info().version, + p.info().name, + p.info().description + ) + }) + .collect::>() + .join("\n"); + command + .create_response( + &p_ctx.ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new().content( + format!("Loaded plugins:\n{}", plugin_list), + ), + ), + ) + .await + .map_err(|err| PluginError::FailedToRespond { err })?; + } + "version" => { + command + .create_response( + &p_ctx.ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new().content( + format!( + "MotorBot version: v{}", + env!("CARGO_PKG_VERSION") + ), + ), + ), + ) + .await + .map_err(|err| PluginError::FailedToRespond { err })?; + } + "events" => { + if let ResolvedValue::SubCommand(subcommand) = + &first_option.value + { + let channel_option = subcommand + .iter() + .find(|opt| opt.name == "channel") + .ok_or(PluginError::MissingChannelId)?; + + let ResolvedValue::Channel(channel_id) = + channel_option.value + else { + return Err(Error::Plugin( + PluginError::MissingChannelId, + )); + }; + let mut db = Database::open().await?; + let config = GuildConfig::from(( + GuildConfigKey::EventsChannel, + GuildConfigValue::ChannelId(channel_id.id.get()), + )); + let guild_id = + command.guild_id.ok_or(PluginError::ExpectedGuild)?; + db.set_guild_config(guild_id.get(), config).await?; + + command + .create_response( + &p_ctx.ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(format!( + "Events channel set to <#{}>", + channel_id.id.get() + )), + ), + ) + .await + .map_err(|err| PluginError::FailedToRespond { err })?; + } + } + _ => { + command + .create_response( + &p_ctx.ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("<:warn:1495130104613965994> **You've chosen... poorly.**\nPlease select a valid subcommand: `whoami`, `version`, `plugins`, or `events`."), + ), + ) + .await + .map_err(|err| PluginError::FailedToRespond { err })?; + } + } + + Ok(()) + } +} diff --git a/src/plugins/dice.rs b/src/plugins/dice.rs new file mode 100644 index 0000000..aa73285 --- /dev/null +++ b/src/plugins/dice.rs @@ -0,0 +1,99 @@ +use async_trait::async_trait; +use serenity::all::{ + Command, CommandOptionType, CreateCommand, CreateCommandOption, + CreateInteractionResponse, CreateInteractionResponseMessage, Interaction, + ResolvedValue, +}; +use tracing::info; + +use crate::plugin::PluginError; +use crate::Result; + +use crate::plugin::{MotorbotPlugin, PluginContext, PluginInfo}; + +pub struct DicePlugin; + +/// A simple plugin that responds with a random dice roll when a user sends +/// a "roll" slash command. This plugin serves as a basic example of how to +/// create a plugin for the MotorBot system. +#[async_trait] +impl MotorbotPlugin for DicePlugin { + fn new() -> Self + where + Self: Sized, + { + DicePlugin + } + + fn info(&self) -> PluginInfo { + PluginInfo { + name: "Dice".to_string(), + description: "Dice roll command".to_string(), + version: "0.1.0".to_string(), + } + } + + async fn on_ready(&self, p_ctx: &PluginContext) -> Result<()> { + info!("DicePlugin is ready!"); + + Command::create_global_command( + &p_ctx.ctx.http, + CreateCommand::new("roll") + .description("Roll the dice, what will you get?") + .add_option( + CreateCommandOption::new( + CommandOptionType::Integer, + "sides", + "Number of sides on the dice", + ) + .required(false), + ), + ) + .await?; + + Ok(()) + } + + async fn on_interaction_create( + &self, + p_ctx: &PluginContext, + interaction: &Interaction, + ) -> Result<()> { + let Interaction::Command(command) = interaction else { + // Not a command interaction, ignore + return Ok(()); + }; + if command.data.name != "roll" { + // Not the "roll" command, so we can skip processing + return Ok(()); + } + + let options = &command.data.options(); + let sides = options + .iter() + .find_map(|opt| { + if opt.name == "sides" { + if let ResolvedValue::Integer(value) = opt.value { + return Some(value as u64); + } + } + None + }) + .unwrap_or(6); // Default to a 6-sided die if no option provided + let dice_roll = rand::random_range(1..=sides); + command + .create_response( + &p_ctx.ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new().content(format!( + ":game_die: You rolled a **{}**", + dice_roll + )), + ), + ) + .await + .map_err(|err| PluginError::FailedToRespond { err })?; + + Ok(()) + } +} diff --git a/src/plugins/jokes.rs b/src/plugins/jokes.rs new file mode 100644 index 0000000..87c6218 --- /dev/null +++ b/src/plugins/jokes.rs @@ -0,0 +1,252 @@ +use std::{env, time::Duration}; + +use async_trait::async_trait; +use clokwerk::{AsyncScheduler, Job, TimeUnits}; +use reqwest::header::{HeaderMap, HeaderValue}; +use serde::Deserialize; +use serenity::all::{ + ChannelId, CommandOptionType, CreateCommand, CreateCommandOption, + CreateInteractionResponse, CreateInteractionResponseMessage, CreateMessage, + GuildId, Interaction, ResolvedValue, +}; +use tracing::{error, info}; + +use crate::{ + plugin::{option, MotorbotPlugin, PluginContext, PluginError, PluginInfo}, + storage::{Database, GuildConfig, GuildConfigKey, GuildConfigValue}, + Error, Result, +}; + +#[derive(Deserialize, Debug)] +struct Response { + body: Vec, + //success: bool +} + +#[derive(Deserialize, Debug)] +struct Joke { + _id: serde_json::Value, + punchline: serde_json::Value, + setup: serde_json::Value, +} + +pub struct JokesPlugin; + +impl JokesPlugin { + async fn joke( + http: std::sync::Arc, + guilds: Vec, + ) { + let mut db = match Database::open().await { + Ok(db) => db, + Err(why) => { + error!("Failed to open database connection: {:?}", why); + return; + } + }; + let mut channel_ids = Vec::new(); + for guild in &guilds { + let guild_id = guild.get(); + let jokes_channel_id = match db + .get_guild_config(guild_id, GuildConfigKey::JokesChannel) + .await + { + Ok(Some(config)) => match config.value { + GuildConfigValue::ChannelId(id) => Some(id), + _ => None, + }, + Ok(None) => None, + Err(why) => { + error!("Failed to get jokes channel from database for guild {}: {:?}", guild_id, why); + None + } + }; + if let Some(jokes_channel_id) = jokes_channel_id { + channel_ids.push(ChannelId::new(jokes_channel_id)); + } + } + let client = reqwest::Client::new(); + + let mut headers = HeaderMap::new(); + let rapid_api_key: String = env::var("RAPID_API_KEY") + .expect("Expected a token in the environment"); + headers.insert( + "X-RapidAPI-Key", + HeaderValue::from_str(&rapid_api_key).unwrap(), + ); + headers.insert( + "X-RapidAPI-Host", + HeaderValue::from_str("dad-jokes.p.rapidapi.com").unwrap(), + ); + + let http_response = client + .get("https://dad-jokes.p.rapidapi.com/random/joke") + .headers(headers) + .send() + .await + .expect("Failed to request joke") + .json::() + .await + .expect("Failed to parse joke"); + + for channel_id in channel_ids { + let pixel_animal_emojis = [ + "<:goat:1494789527921889371>", + "<:bunny:1494789525740851393>", + "<:cat:1494789524415451266>", + "<:frog:1494789522779537501>", + "<:dog:1494789521089237076>", + ]; + let random_emoji = pixel_animal_emojis + [rand::random_range(0..pixel_animal_emojis.len())]; + let _ = channel_id + .send_message( + &http, + CreateMessage::new().content(format!( + "## {} Joke for {}\n{}\n\n||{}||", + random_emoji, + chrono::Local::now().format("%d %B %Y"), + http_response.body[0].setup.as_str().unwrap(), + http_response.body[0].punchline.as_str().unwrap() + )), + ) + .await; + } + } +} + +/// A plugin that shows a random joke at a set time every day +#[async_trait] +impl MotorbotPlugin for JokesPlugin { + fn new() -> Self + where + Self: Sized, + { + JokesPlugin + } + + fn info(&self) -> PluginInfo { + PluginInfo { + name: "Jokes".to_string(), + description: "Shows a random joke at a set time every day" + .to_string(), + version: "0.1.0".to_string(), + } + } + + async fn on_ready(&self, p_ctx: &PluginContext) -> Result<()> { + info!("Jokes Plugin is ready!"); + + // Register jokes command for all guilds + let guilds = p_ctx.ctx.cache.guilds(); + for guild in &guilds { + GuildId::create_command( + *guild, + &p_ctx.ctx.http, + CreateCommand::new("jokes") + .description("Jokes command for MotorBot") + .add_option( + CreateCommandOption::new( + CommandOptionType::SubCommand, + "channel", + "Set a channel for daily jokes", + ) + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::Channel, + "channel", + "The channel to post jokes in", + ) + .required(true), + ), + ), + ) + .await?; + } + + let mut scheduler = AsyncScheduler::with_tz(chrono::Local); + let http = p_ctx.ctx.http.clone(); + scheduler.every(1.day()).at("10:30 am").run(move || { + let http = http.clone(); + let guilds = guilds.clone(); + async move { + JokesPlugin::joke(http, guilds).await; + } + }); + + tokio::spawn(async move { + loop { + scheduler.run_pending().await; + tokio::time::sleep(Duration::from_millis(100)).await; + } + }); + + Ok(()) + } + + async fn on_interaction_create( + &self, + p_ctx: &PluginContext, + interaction: &Interaction, + ) -> Result<()> { + let Interaction::Command(command) = interaction else { + // Not a command interaction, so we can skip processing + return Ok(()); + }; + if command.data.name != "jokes" { + // Not the "jokes" command, so we can skip processing + return Ok(()); + } + + let options = command.data.options(); + let first_option = + options.first().ok_or(PluginError::InvalidSubCommand)?; + match first_option.name { + "channel" => { + let ResolvedValue::SubCommand(subcommand_options) = + first_option.value.clone() + else { + return Err(Error::Plugin(PluginError::InvalidSubCommand)); + }; + let channel = option!( + subcommand_options, + "channel", + ResolvedValue::Channel + ) + .ok_or(PluginError::MissingChannelId)?; + let guild_id = + command.guild_id.ok_or(PluginError::ExpectedGuild)?; + let mut db = Database::open().await?; + let new_value = GuildConfig::from(( + GuildConfigKey::JokesChannel, + GuildConfigValue::ChannelId(channel.id.get()), + )); + db.set_guild_config(guild_id.get(), new_value).await?; + command.create_response( + &p_ctx.ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(format!("<:dog:1494789521089237076> Jokes will be sent to <#{}> at the scheduled time", channel.id.get())) + ) + ) + .await + .map_err(|err| PluginError::FailedToRespond { err })?; + db.close().await?; + } + _ => { + command + .create_response( + &p_ctx.ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("<:warn:1495130104613965994> **You've chosen... poorly.**\nPlease select a valid subcommand: `channel`."), + ), + ) + .await + .map_err(|err| PluginError::FailedToRespond { err })?; + } + } + + Ok(()) + } +} diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index 7304dc5..8ba98dc 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -1 +1,8 @@ -pub mod patches; \ No newline at end of file +pub mod coin_toss; +pub mod debug; +pub mod dice; +pub mod jokes; +pub mod patches; +pub mod ping; +pub mod points; +pub mod time; diff --git a/src/plugins/patches.rs b/src/plugins/patches.rs new file mode 100644 index 0000000..ec6607d --- /dev/null +++ b/src/plugins/patches.rs @@ -0,0 +1,495 @@ +use std::time::Duration; + +use crate::{ + plugin::{option, MotorbotPlugin, PluginContext, PluginError, PluginInfo}, + plugins::patches::{ + game_data::{GameData, GuildGameData}, + patch_notes::PatchNotes, + platforms::platform::Platform, + }, + storage::{Database, GuildConfig, GuildConfigKey, GuildConfigValue}, + Error, Result, +}; +use async_trait::async_trait; +use clokwerk::{AsyncScheduler, TimeUnits}; +use serenity::all::{ + ActivityData, ChannelId, Colour, CommandOptionType, Context, + CreateActionRow, CreateButton, CreateCommand, CreateCommandOption, + CreateEmbed, CreateEmbedAuthor, CreateEmbedFooter, + CreateInteractionResponse, CreateInteractionResponseMessage, CreateMessage, + GuildId, Interaction, OnlineStatus, ResolvedValue, Timestamp, +}; +use tracing::{error, info, warn}; + +pub mod game_data; +pub mod patch_notes; +pub mod platforms; + +#[derive(Clone)] +pub struct PatchesPlugin { + ctx: Option, +} + +impl PatchesPlugin { + /// Sets the context for the plugin, allowing it to access the Serenity + /// context. This will be called on ready, and the context will be passed to + /// the plugin for use in its operations. + pub fn set_context(&mut self, ctx: Context) { + self.ctx = Some(ctx); + } + + /// Looks for new patch notes for games + pub async fn update(&self) { + match &self.ctx { + Some(ctx) => { + info!("Updating sources..."); + ctx.set_presence( + Some(ActivityData::custom("🧭 Exploring...")), + OnlineStatus::DoNotDisturb, + ); + let mut db = Database::open() + .await + .expect("Failed to connect to database"); + let game_ids = db.game_ids().await; + let games_to_monitor = match game_ids { + Ok(ids) => ids, + Err(e) => { + error!("Failed to fetch game ids: {:?}", e); + return; + } + }; + // Get latest patch notes for each game_id and check latest patch notes + // against db if patch notes are different, post patch notes to channel + for game_id in games_to_monitor { + // Data from DB + let game_data = GameData::from_id(&game_id).await; + // Patch notes from Platform + let patch_notes = PatchNotes::fetch_for_platform( + game_data.platform, + &game_id, + ) + .await; + // Compare gid + let game_news_items = match &game_data.news_items { + Some(items) => items, + None => { + warn!( + "No news items found for game_id {}, skipping", + game_id + ); + continue; + } + }; + for guild_data in &game_data.guild_data { + if !game_news_items.contains(&patch_notes.gid) { + // Send patch notes + info!( + "[+] {} ({}) [{}]", + &guild_data.name, game_id, guild_data.guild + ); + self.send_patch_notes( + &mut db, + &patch_notes, + &game_id, + guild_data, + ) + .await; + } else { + info!( + "[β€’] {} ({}) [{}]", + guild_data.name, game_id, guild_data.guild + ); + } + } + } + info!("Update complete"); + ctx.set_presence( + Some(ActivityData::custom("πŸ˜Άβ€πŸŒ«οΈ")), + OnlineStatus::Online, + ); + } + None => { + warn!( + "No context set for PatchesPlugin, cannot update sources" + ); + } + } + } + + /// Sends patch notes to a channel + /// + /// ## Arguments + /// - `db` - A `DBClient` struct containing the database client + /// - `platform_data` - A `PatchNotes` struct containing the patch notes + /// - `game_id` - A string slice containing the game ID + /// - `game_data` - A [GuildGameData] struct containing the game data + async fn send_patch_notes( + &self, + db: &mut Database, + platform_data: &PatchNotes, + game_id: &str, + game_data: &GuildGameData, + ) { + match &self.ctx { + Some(ctx) => { + if !platform_data.success { + warn!("Patch notes failed to fetch for {}", game_id); + return; + } + + let channel_id: Option = match db + .get_guild_config( + game_data.guild, + GuildConfigKey::PatchNotesChannel, + ) + .await + { + Ok(Some(config)) => match config.value { + GuildConfigValue::ChannelId(id) => { + Some(ChannelId::new(id)) + } + _ => None, + }, + Ok(None) => None, + Err(e) => { + error!( + "Failed to fetch patch notes channel for guild {}, error: {:?}", + game_data.guild, e + ); + None + } + }; + + let mut action_row = vec![CreateActionRow::Buttons(vec![ + CreateButton::new_link(&platform_data.url) + .label("Patch Notes"), + ])]; + // Clear the action row if the url is empty + if platform_data.url.is_empty() { + action_row.clear(); + } + if let Some(channel_id) = channel_id { + if let Err(why) = channel_id + .send_message( + &ctx.http, + CreateMessage::new() + .content("") + .embed( + CreateEmbed::new() + .title(&platform_data.title) + .description(&platform_data.content) + //.thumbnail(&game_data.thumbnail) + .color(Colour::new( + u32::from_str_radix( + &game_data.color, + 16, + ) + .unwrap_or(0), + )) + .image(&platform_data.image) + .url(&platform_data.url) + .author( + CreateEmbedAuthor::new( + &game_data.name, + ) + .icon_url(&game_data.thumbnail), + ) + .timestamp(Timestamp::now()) + .footer(CreateEmbedFooter::new( + "MotorBot - Patch Plugin", + )), + ) + .components(action_row), + ) + .await + { + error!("Error sending message: {:?}", why); + } else { + match db + .add_news_item(game_id, &platform_data.gid) + .await + { + Ok(_) => (), + Err(e) => { + error!("Failed to update game news id: {:?}", e) + } + } + } + } else { + warn!( + "No channel configured for guild {}, cannot send patch notes", + game_data.guild + ); + } + } + None => { + warn!( + "No context set for PatchesPlugin, cannot send patch notes" + ); + } + } + } +} + +/// A plugin that provides information about the latest patches for various +/// games. +#[async_trait] +impl MotorbotPlugin for PatchesPlugin { + fn new() -> Self + where + Self: Sized, + { + PatchesPlugin { ctx: None } + } + + fn info(&self) -> PluginInfo { + PluginInfo { + name: "Patches".to_string(), + description: "Provides information about the latest patches for various games".to_string(), + version: "0.1.0".to_string(), + } + } + + async fn on_ready(&self, p_ctx: &PluginContext) -> Result<()> { + info!("Patches Plugin is ready!"); + + for guild in p_ctx.ctx.cache.guilds() { + GuildId::create_command( + guild, + &p_ctx.ctx.http, + CreateCommand::new("patches") + .description("Get the latest patch notes for monitored games") + .add_option(CreateCommandOption::new(CommandOptionType::SubCommand, "list", "List all monitored games")) + .add_option(CreateCommandOption::new(CommandOptionType::SubCommand, "channel", "Set the channel for patch notes to be posted in") + .add_sub_option(CreateCommandOption::new(CommandOptionType::Channel, "channel", "The channel to post patch notes in").required(true)) + ) + .add_option(CreateCommandOption::new(CommandOptionType::SubCommand, "update", "Manually trigger an update to check for new patch notes")) + .add_option(CreateCommandOption::new(CommandOptionType::SubCommand, "add", "Add a game to monitor") + .add_sub_option(CreateCommandOption::new(CommandOptionType::String, "id", "The game id to monitor (this should be either a Steam or Riot game ID)").required(true)) + .add_sub_option(CreateCommandOption::new(CommandOptionType::String, "platform", "The platform the game is on (e.g. steam, riot)").required(true)) + .add_sub_option(CreateCommandOption::new(CommandOptionType::String, "name", "The friendly name of the game").required(true)) + .add_sub_option(CreateCommandOption::new(CommandOptionType::String, "thumbnail", "The URL for the game's logo image").required(true)) + .add_sub_option(CreateCommandOption::new(CommandOptionType::String, "color", "The color associated with the news item (hex color code)").required(true)) + ) + .add_option(CreateCommandOption::new(CommandOptionType::SubCommand, "remove", "Remove a game from monitoring") + .add_sub_option(CreateCommandOption::new(CommandOptionType::String, "id", "The game id to stop monitoring").required(true)) + ) + ).await?; + } + + // Start the update loop + let mut scheduler = AsyncScheduler::with_tz(chrono::Local); + let mut plugin = self.clone(); + plugin.set_context(p_ctx.ctx.clone()); + scheduler.every(30.minutes()).run(move || { + let plugin = plugin.clone(); + async move { + plugin.update().await; + } + }); + + tokio::spawn(async move { + loop { + scheduler.run_pending().await; + tokio::time::sleep(Duration::from_millis(100)).await; + } + }); + + Ok(()) + } + + async fn on_interaction_create( + &self, + p_ctx: &PluginContext, + interaction: &Interaction, + ) -> Result<()> { + let Interaction::Command(command) = interaction else { + // Not a command interaction, so we can skip processing + return Ok(()); + }; + if command.data.name != "patches" { + // Not the "patches" command, so we can skip processing + return Ok(()); + } + let mut db = Database::open().await?; + + let guild_id = command.guild_id.ok_or(PluginError::ExpectedGuild)?; + + let options = command.data.options(); + let first_option = + options.first().ok_or(PluginError::InvalidSubCommand)?; + + let response = match first_option.name { + "channel" => { + if let ResolvedValue::SubCommand(subcommand_options) = + first_option.value.clone() + { + info!("Subcommand options: {:?}", subcommand_options); + let channel = option!( + subcommand_options, + "channel", + ResolvedValue::Channel + ) + .ok_or(PluginError::MissingChannelId)?; + + let config_option = GuildConfig::from(( + GuildConfigKey::PatchNotesChannel, + GuildConfigValue::ChannelId(channel.id.get()), + )); + db.set_guild_config(guild_id.get(), config_option).await?; + format!( + "Patch notes channel set to <#{}>", + channel.id.get() + ) + } else { + "No subcommand options provided".to_string() + } + } + "list" => { + let game_ids = db + .game_ids_for_guild(guild_id.get()) + .await?; + let mut response = String::new(); + let mut count = 1; + for game_id in game_ids { + let game_data = GameData::from_id(&game_id).await; + let guild_data = game_data + .guild_data + .iter() + .find(|g| g.guild == guild_id.get()); + let game_name = match guild_data { + Some(guild_data) => &guild_data.name, + None => "Unknown Name", + }; + response.push_str(&format!( + "{}. {} ({})\n", + count, game_name, game_data.id + )); + count += 1; + } + if response.is_empty() { + "No games are currently being monitored".to_string() + } else { + response + } + } + "update" => { + let mut plugin = self.clone(); + plugin.set_context(p_ctx.ctx.clone()); + let _ = plugin.update().await; + "Update complete".to_string() + } + "add" => { + if let ResolvedValue::SubCommand(subcommand_options) = + first_option.value.clone() + { + info!("Subcommand options: {:?}", subcommand_options); + let id = option!( + subcommand_options, + "id", + ResolvedValue::String + ) + .ok_or(PluginError::InvalidGameId)?; + + let platform = option!( + subcommand_options, + "platform", + ResolvedValue::String + ) + .ok_or(PluginError::InvalidGamePlatform)?; + if !Platform::is_valid_platform(platform) { + return Err(Error::Plugin( + PluginError::InvalidGamePlatform, + )); + } else { + let name = option!( + subcommand_options, + "name", + ResolvedValue::String + ).ok_or(PluginError::InvalidGameName)?; + let thumbnail = option!( + subcommand_options, + "thumbnail", + ResolvedValue::String + ).ok_or(PluginError::InvalidGameThumbnail)?; + + if !thumbnail.starts_with("http") { + return Err(Error::Plugin( + PluginError::InvalidGameThumbnail, + )); + } else { + let raw_color = option!( + subcommand_options, + "color", + ResolvedValue::String + ).ok_or(PluginError::InvalidGameColor)?; + + if !raw_color.starts_with("#") + || raw_color.len() != 7 + { + return Err(Error::Plugin( + PluginError::InvalidGameColor, + )); + } else { + let color = raw_color[1..].to_uppercase(); + db.add_game( + id, + guild_id.get(), + Platform::from(platform), + name, + thumbnail, + &color, + ) + .await?; + format!("{} added to monitoring list", name) + .to_string() + } + } + } + } else { + Err(Error::Plugin( + PluginError::InvalidSubCommand, + ))? + } + } + "remove" => { + if let ResolvedValue::SubCommand(subcommand_options) = + first_option.value.clone() + { + info!("Subcommand options: {:?}", subcommand_options); + let id = option!( + subcommand_options, + "id", + ResolvedValue::String + ) + .ok_or(PluginError::InvalidGameId)?; + let guild = command + .guild_id + .ok_or(PluginError::ExpectedGuild)? + .get(); + + db.remove_game(id, guild).await?; + format!("Game `{}` removed from monitoring list", id) + } else { + Err(Error::Plugin( + PluginError::InvalidSubCommand, + ))? + } + } + _ => "<:warn:1495130104613965994> **You've chosen... poorly.**\nPlease select a valid subcommand: `channel`, `add`, `update`, `list` or `remove`".to_string(), + }; + + // Send response + command + .create_response( + &p_ctx.ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new().content(response), + ), + ) + .await + .map_err(|err| PluginError::FailedToRespond { err })?; + + Ok(()) + } +} diff --git a/src/plugins/patches/mod.rs b/src/plugins/patches/mod.rs deleted file mode 100644 index 75919b7..0000000 --- a/src/plugins/patches/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod game_data; -pub mod patch_notes; -pub mod patches_plugin; -pub mod platforms; diff --git a/src/plugins/patches/patches_plugin.rs b/src/plugins/patches/patches_plugin.rs deleted file mode 100644 index d78d4aa..0000000 --- a/src/plugins/patches/patches_plugin.rs +++ /dev/null @@ -1,178 +0,0 @@ -use crate::{ - MotorbotChannels, plugins::patches::game_data::{GameData, GuildGameData}, storage::{Database, GuildConfigKey, database::GuildConfigValue} -}; -use serenity::{ - all::{ - ActivityData, Colour, CreateActionRow, CreateButton, CreateEmbed, CreateEmbedAuthor, - CreateEmbedFooter, CreateMessage, Timestamp, - }, - model::{prelude::ChannelId, user::OnlineStatus}, - prelude::*, -}; -use tracing::{error, info, warn}; - -use super::patch_notes::PatchNotes; - -#[derive(Clone)] -pub struct PatchesPlugin { - ctx: Context, -} - -impl PatchesPlugin { - pub async fn new(ctx: Context) -> Self { - info!("Starting Patches Plugin"); - let channel_id = ChannelId::new(MotorbotChannels::BotEvents as u64); - if let Err(why) = channel_id.say(&ctx.http, "Launching Patches Plugin").await { - error!("Error sending message: {:?}", why); - } - let new_plugin = Self { ctx }; - new_plugin.start().await; - new_plugin - } - - /// Start the plugin - /// - /// # Example - /// ``` - /// let patches_plugin = PatchesPlugin::new(); // <- Create a new instance of the plugin will automatically start it - /// ``` - pub async fn start(&self) { - self.update().await; - } - - /// Looks for new patch notes for games - pub async fn update(&self) { - info!("Updating sources..."); - self.ctx.set_presence( - Some(ActivityData::playing("πŸ”")), - OnlineStatus::DoNotDisturb, - ); - let mut db = Database::open() - .await - .expect("Failed to connect to database"); - let game_ids = db.game_ids().await; - let games_to_monitor = match game_ids { - Ok(ids) => ids, - Err(e) => { - error!("Failed to fetch game ids: {:?}", e); - return; - } - }; - // Get latest patch notes for each game_id and check latest patch notes - // against db if patch notes are different, post patch notes to channel - for game_id in games_to_monitor { - // Data from DB - let game_data = GameData::from_id(&game_id).await; - // Patch notes from Platform - let patch_notes = PatchNotes::fetch_for_platform(game_data.platform, &game_id).await; - // Compare gid - let game_news_items = match &game_data.news_items { - Some(items) => items, - None => { - warn!("No news items found for game_id {}, skipping", game_id); - continue; - } - }; - for guild_data in &game_data.guild_data { - if !game_news_items.contains(&patch_notes.gid) { - // Send patch notes - info!("[⬦] {} ({}) [{}]", &guild_data.name, game_id, guild_data.guild); - self.send_patch_notes(&mut db, &patch_notes, &game_id, guild_data).await; - } else { - info!("[βœ”] {} ({}) [{}]", guild_data.name, game_id, guild_data.guild); - } - } - } - info!("Update complete"); - self.ctx - .set_presence(Some(ActivityData::custom("πŸ˜Άβ€πŸŒ«οΈ")), OnlineStatus::Online); - } - - /// Sends patch notes to a channel - /// - /// ## Arguments - /// - `db` - A `DBClient` struct containing the database client - /// - `platform_data` - A `PatchNotes` struct containing the patch notes - /// - `game_id` - A string slice containing the game ID - /// - `game_data` - A [GuildGameData] struct containing the game data - async fn send_patch_notes( - &self, - db: &mut Database, - platform_data: &PatchNotes, - game_id: &str, - game_data: &GuildGameData, - ) { - if platform_data.success == false { - warn!("Patch notes failed to fetch for {}", game_id); - return; - } - - let channel_id = match db - .get_guild_config(game_data.guild, GuildConfigKey::PatchNotesChannel) - .await - { - Ok(Some(config)) => match config.value { - GuildConfigValue::ChannelId(id) => ChannelId::new(id), - }, - Ok(None) => { - warn!( - "No patch notes channel configured for guild {}, skipping", - game_data.guild - ); - return; - } - Err(e) => { - error!( - "Failed to fetch patch notes channel for guild {}, error: {:?}", - game_data.guild, e - ); - return; - } - }; - - let mut action_row = vec![CreateActionRow::Buttons(vec![CreateButton::new_link( - &platform_data.url, - ) - .label("Patch Notes")])]; - // Clear the action row if the url is empty - if platform_data.url.is_empty() { - action_row.clear(); - } - if let Err(why) = channel_id - .send_message( - &self.ctx.http, - CreateMessage::new() - .content("") - .embed( - CreateEmbed::new() - .title(&platform_data.title) - .description(&platform_data.content) - //.thumbnail(&game_data.thumbnail) - .color(Colour::new( - u32::from_str_radix(&game_data.color, 16).unwrap_or(0), - )) - .image(&platform_data.image) - .url(&platform_data.url) - .author( - CreateEmbedAuthor::new(&game_data.name) - .icon_url(&game_data.thumbnail), - ) - .timestamp(Timestamp::now()) - .footer(CreateEmbedFooter::new("MotorBot - Patch Plugin")), - ) - .components(action_row), - ) - .await - { - error!("Error sending message: {:?}", why); - } else { - match db - .add_news_item(&game_id, &platform_data.gid) - .await - { - Ok(_) => (), - Err(e) => error!("Failed to update game news id: {:?}", e), - } - } - } -} diff --git a/src/plugins/patches/platforms/platform.rs b/src/plugins/patches/platforms/platform.rs index cede4b1..645887c 100644 --- a/src/plugins/patches/platforms/platform.rs +++ b/src/plugins/patches/platforms/platform.rs @@ -1,9 +1,14 @@ use std::{fmt, str::FromStr}; -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Default)] pub enum Platform { + /// Steam platform Steam, + /// Riot platform Riot, + /// An unknown platform, used as a fallback for invalid or unrecognized + /// platform strings + #[default] Unknown, } @@ -51,9 +56,3 @@ impl FromStr for Platform { } } } - -impl Default for Platform { - fn default() -> Self { - Platform::Unknown - } -} diff --git a/src/plugins/ping.rs b/src/plugins/ping.rs new file mode 100644 index 0000000..55cf01a --- /dev/null +++ b/src/plugins/ping.rs @@ -0,0 +1,69 @@ +use async_trait::async_trait; +use serenity::all::{ + Command, CreateCommand, CreateInteractionResponse, + CreateInteractionResponseMessage, Interaction, +}; +use tracing::info; + +use crate::plugin::PluginError; +use crate::{Error, Result}; + +use crate::plugin::{MotorbotPlugin, PluginContext, PluginInfo}; + +pub struct PingPlugin; + +/// A simple plugin that responds with "Pong!" when a user sends a "ping" +/// slash command. This plugin serves as a basic example of how to create a +/// plugin for the MotorBot system. +#[async_trait] +impl MotorbotPlugin for PingPlugin { + fn new() -> Self + where + Self: Sized, + { + PingPlugin + } + + fn info(&self) -> PluginInfo { + PluginInfo { + name: "Ping".to_string(), + description: "A simple ping command".to_string(), + version: "0.1.0".to_string(), + } + } + + async fn on_ready(&self, p_ctx: &PluginContext) -> Result<()> { + info!("Ping Plugin is ready!"); + + let _ = Command::create_global_command( + &p_ctx.ctx.http, + CreateCommand::new("ping").description("A simple ping command"), + ) + .await; + Ok(()) + } + + async fn on_interaction_create( + &self, + p_ctx: &PluginContext, + interaction: &Interaction, + ) -> Result<()> { + if let Interaction::Command(command) = interaction { + if command.data.name == "ping" { + command + .create_response( + &p_ctx.ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("Pong!"), + ), + ) + .await + .map_err(|e| { + Error::Plugin(PluginError::FailedToRespond { err: e }) + })?; + } + } + Ok(()) + } +} diff --git a/src/plugins/points.rs b/src/plugins/points.rs new file mode 100644 index 0000000..299d91a --- /dev/null +++ b/src/plugins/points.rs @@ -0,0 +1,410 @@ +use async_trait::async_trait; +use serenity::all::{ + CommandOptionType, CreateCommand, CreateCommandOption, + CreateInteractionResponse, CreateInteractionResponseMessage, GuildId, + Interaction, Message, Reaction, ReactionType, ResolvedValue, +}; +use tracing::info; + +use crate::plugin::PluginError; +use crate::{Error, Result}; + +use crate::{ + plugin::{MotorbotPlugin, PluginContext, PluginInfo}, + storage::{Database, GuildConfig, GuildConfigKey, GuildConfigValue}, +}; + +pub struct PointsPlugin; + +impl PointsPlugin { + /// Adds points to a user. + /// + /// This is a helper function that can be called from + /// various places in the plugin where points need to be added, such as when a + /// user receives an upvote reaction on a message. + async fn add_points(&self, user_id: &u64, points: i32) -> Result<()> { + let mut db = Database::open().await?; + let current_user_score = db.user_score(user_id).await?; + db.set_user_score(user_id, current_user_score.score + points) + .await?; + Ok(()) + } + + /// Subtracts points from a user. + /// + /// This is a helper function that can be called + /// from various places in the plugin where points need to be subtracted, such + /// as when a user receives a downvote reaction on a message. + async fn subtract_points(&self, user_id: &u64, points: i32) -> Result<()> { + let mut db = Database::open().await?; + let current_user_score = db.user_score(user_id).await?; + db.set_user_score(user_id, current_user_score.score - points) + .await?; + Ok(()) + } +} + +/// A plugin that creates a user points system. Users can check their own points +/// or someone else's points using the "points" slash command. Additionally, +/// certain channels can be configured to add vote reactions to messages, +/// allowing for an upvote/downvote system that contributes to user points. +#[async_trait] +impl MotorbotPlugin for PointsPlugin { + fn new() -> Self + where + Self: Sized, + { + PointsPlugin + } + + fn info(&self) -> PluginInfo { + PluginInfo { + name: "Points".to_string(), + description: "A user points system with upvote/downvote reactions" + .to_string(), + version: "0.1.0".to_string(), + } + } + + async fn on_ready(&self, p_ctx: &PluginContext) -> Result<()> { + info!("Points Plugin is ready!"); + + for guild in p_ctx.ctx.cache.guilds() { + let _ = GuildId::create_command( + guild, + &p_ctx.ctx.http, + CreateCommand::new("points").description("Check your or someone else's points") + .add_option(CreateCommandOption::new(CommandOptionType::User, "user", "The user's score to look up").required(false)) + .add_option(CreateCommandOption::new(CommandOptionType::SubCommand, "channel", "Set a channel that should allow users to vote on messages") + .add_sub_option(CreateCommandOption::new(CommandOptionType::Channel, "channel", "The channel to add vote reactions to").required(true)) + ), + ).await; + } + Ok(()) + } + + async fn on_message( + &self, + p_ctx: &PluginContext, + message: &Message, + ) -> Result<()> { + let Some(msg_guild_id) = message.guild_id else { + // Message is not in a guild, so we can skip processing + return Ok(()); + }; + + let mut db = Database::open().await?; + let Some(stored_channel_ids) = db + .get_guild_config( + msg_guild_id.get(), + GuildConfigKey::PointsChannels, + ) + .await? + else { + // No channels configured for this guild, so we can skip processing + return Ok(()); + }; + + if let GuildConfigValue::ChannelIds(channel_ids) = + stored_channel_ids.value() + { + if (!message.attachments.is_empty() + || message.content.contains("http")) + && channel_ids.contains(&message.channel_id.get()) + && !message.author.id.eq(&169554882674556930) + { + message + .react( + &p_ctx.ctx.http, + ReactionType::try_from("<:upvote:1494772872500088913>") + .unwrap(), + ) + .await + .map_err(|err| PluginError::FailedToRespond { err })?; + message + .react( + &p_ctx.ctx.http, + ReactionType::try_from( + "<:downvote:1494772826945617940>", + ) + .unwrap(), + ) + .await + .map_err(|err| PluginError::FailedToRespond { err })?; + } + } + Ok(()) + } + + async fn on_reaction_add( + &self, + p_ctx: &PluginContext, + reaction: &Reaction, + ) -> Result<()> { + let Some(guild_id) = reaction.guild_id else { + // Reaction is not in a guild, so we can skip processing + return Ok(()); + }; + + let reaction_user_id = + reaction.user_id.ok_or(PluginError::MissingUserId)?; + let bot_user_id = p_ctx.ctx.cache.current_user().id.get(); + + let mut db = Database::open().await?; + let Some(stored_channel_ids) = db + .get_guild_config(guild_id.get(), GuildConfigKey::PointsChannels) + .await? + else { + // No channels configured for this guild, so we can skip processing + return Ok(()); + }; + + if let GuildConfigValue::ChannelIds(channel_ids) = + stored_channel_ids.value() + { + if channel_ids.contains(&reaction.channel_id.get()) + && !reaction_user_id.eq(&bot_user_id) + { + let message = p_ctx + .ctx + .http + .get_message(reaction.channel_id, reaction.message_id) + .await?; + let user_id = message.author.id; + match reaction.emoji { + ReactionType::Custom { id, .. } + if id == 1494772872500088913 => + { + // Add points to the user who sent the message + self.add_points(&user_id.get(), 1).await?; + } + ReactionType::Custom { id, .. } + if id == 1494772826945617940 => + { + // Subtract points from the user who sent the message + self.subtract_points(&user_id.get(), 1).await?; + } + _ => {} + } + } + } + Ok(()) + } + + async fn on_reaction_remove( + &self, + p_ctx: &PluginContext, + reaction: &Reaction, + ) -> Result<()> { + let Some(guild_id) = reaction.guild_id else { + // Reaction is not in a guild, so we can skip processing + return Ok(()); + }; + + let reaction_user_id = + reaction.user_id.ok_or(PluginError::MissingUserId)?; + let bot_user_id = p_ctx.ctx.cache.current_user().id.get(); + + let mut db = Database::open().await?; + let Some(stored_channel_ids) = db + .get_guild_config(guild_id.get(), GuildConfigKey::PointsChannels) + .await? + else { + // No channels configured for this guild, so we can skip processing + return Ok(()); + }; + + if let GuildConfigValue::ChannelIds(channel_ids) = + stored_channel_ids.value() + { + if channel_ids.contains(&reaction.channel_id.get()) + && !reaction_user_id.eq(&bot_user_id) + { + let message = p_ctx + .ctx + .http + .get_message(reaction.channel_id, reaction.message_id) + .await?; + let user_id = message.author.id; + match reaction.emoji { + ReactionType::Custom { id, .. } + if id == 1494772872500088913 => + { + // Subtract previously added points from the user who sent the message + self.subtract_points(&user_id.get(), 1).await?; + } + ReactionType::Custom { id, .. } + if id == 1494772826945617940 => + { + // Add previously subtracted points to the user who sent the message + self.add_points(&user_id.get(), 1).await?; + } + _ => {} + } + } + } + Ok(()) + } + + async fn on_interaction_create( + &self, + p_ctx: &PluginContext, + interaction: &Interaction, + ) -> Result<()> { + let Interaction::Command(command) = interaction else { + // Not a command interaction, so we can skip processing + return Ok(()); + }; + if command.data.name != "points" { + // Not the "points" command, so we can skip processing + return Ok(()); + } + let mut db = Database::open().await?; + + let options = command.data.options(); + let first_option = options.first(); + if first_option.is_some() { + let resolved_option = + first_option.ok_or(PluginError::InvalidSubCommand)?; + match resolved_option.name { + "channel" => { + let ResolvedValue::SubCommand(subcommand) = + resolved_option.value.clone() + else { + return Err(Error::Plugin( + PluginError::InvalidSubCommand, + )); + }; + let channel = subcommand + .iter() + .find_map(|opt| { + if opt.name == "channel" { + if let ResolvedValue::Channel(channel) = + opt.value + { + return Some(channel); + } + } + None + }) + .ok_or(PluginError::InvalidChannel)?; + let guild_id = + command.guild_id.ok_or(PluginError::ExpectedGuild)?; + + let mut channel_ids = match db + .get_guild_config( + guild_id.get(), + GuildConfigKey::PointsChannels, + ) + .await? + { + Some(config) => match config.value { + GuildConfigValue::ChannelIds(ids) => ids, + _ => Vec::new(), + }, + None => Vec::new(), + }; + + let response_msg = if !channel_ids + .contains(&channel.id.get()) + { + channel_ids.push(channel.id.get()); + + let new_value = GuildConfig::from(( + GuildConfigKey::PointsChannels, + GuildConfigValue::ChannelIds(channel_ids.clone()), + )); + db.set_guild_config(guild_id.get(), new_value).await?; + + format!("Channel <#{}> has been added to the points system!", channel.id.get()) + } else { + format!( + "Channel <#{}> is already part of the points system.", + channel.id.get() + ) + }; + + command + .create_response( + &p_ctx.ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(response_msg), + ), + ) + .await + .map_err(|err| PluginError::FailedToRespond { err })?; + } + "user" => { + let ResolvedValue::SubCommand(subcommand) = + resolved_option.value.clone() + else { + return Err(Error::Plugin( + PluginError::InvalidSubCommand, + )); + }; + let user = subcommand + .iter() + .find_map(|opt| { + if opt.name == "user" { + if let ResolvedValue::User(user, _) = opt.value + { + return Some(user); + } + } + None + }) + .ok_or(PluginError::InvalidUser)?; + let username = user.tag(); + let user_score = db.user_score(&user.id.get()).await?; + command.create_response( + &p_ctx.ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(format!("<:pouch:1494773684295041256> {}'s score is {}", username, user_score.score)) + ) + ) + .await + .map_err(|err| { + PluginError::FailedToRespond { err } + })?; + } + _ => { + command + .create_response( + &p_ctx.ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("<:warn:1495130104613965994> **You've chosen... poorly.**\nPlease select a valid subcommand: `user` or `channel`."), + ), + ) + .await + .map_err(|err| PluginError::FailedToRespond { err })?; + } + } + } else { + // No sub command or user provided, default to showing the user's + // own points + let user_id = command.user.id; + let username = command.user.tag(); + + let user_score = db.user_score(&user_id.get()).await?; + command + .create_response( + &p_ctx.ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new().content( + format!( + "<:pouch:1494773684295041256> {}'s score is {}", + username, user_score.score + ), + ), + ), + ) + .await + .map_err(|err| PluginError::FailedToRespond { err })?; + } + db.close().await?; + Ok(()) + } +} diff --git a/src/plugins/time.rs b/src/plugins/time.rs new file mode 100644 index 0000000..0a17a79 --- /dev/null +++ b/src/plugins/time.rs @@ -0,0 +1,78 @@ +use async_trait::async_trait; +use serenity::all::{ + Command, CreateCommand, CreateInteractionResponse, + CreateInteractionResponseMessage, Interaction, +}; +use tracing::info; + +use crate::plugin::PluginError; +use crate::{Error, Result}; + +use crate::plugin::{MotorbotPlugin, PluginContext, PluginInfo}; + +pub struct TimePlugin; + +/// A simple plugin that responds with the current server time when a user sends +/// a "time" slash command. This plugin serves as a basic example of how to +/// create a plugin for the MotorBot system. +#[async_trait] +impl MotorbotPlugin for TimePlugin { + fn new() -> Self + where + Self: Sized, + { + TimePlugin + } + + fn info(&self) -> PluginInfo { + PluginInfo { + name: "Time".to_string(), + description: "Tells the current server time".to_string(), + version: "0.1.0".to_string(), + } + } + + async fn on_ready(&self, p_ctx: &PluginContext) -> Result<()> { + info!("Time Plugin is ready!"); + + Command::create_global_command( + &p_ctx.ctx.http, + CreateCommand::new("time") + .description("Returns server time for MotorBot"), + ) + .await?; + + Ok(()) + } + + async fn on_interaction_create( + &self, + p_ctx: &PluginContext, + interaction: &Interaction, + ) -> Result<()> { + if let Interaction::Command(command) = interaction { + if command.data.name == "time" { + let current_time = chrono::Local::now() + .format("%Y-%m-%d %H:%M:%S") + .to_string(); + command + .create_response( + &p_ctx.ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new().content( + format!( + ":alarm_clock: The current time is: {}", + current_time + ), + ), + ), + ) + .await + .map_err(|err| { + Error::Plugin(PluginError::FailedToRespond { err }) + })?; + } + } + Ok(()) + } +} diff --git a/src/storage.rs b/src/storage.rs index 6ed1038..60e53ae 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1,5 +1,6 @@ pub mod database; - +pub mod error; // Re-exporting the Database struct for easier access #[allow(unused)] -pub use database::{Database, GuildConfig, GuildConfigKey, GuildConfigValue}; \ No newline at end of file +pub use database::{Database, GuildConfig, GuildConfigKey, GuildConfigValue}; +pub use error::Error as StorageError; diff --git a/src/storage/database.rs b/src/storage/database.rs index 9d0e528..485c749 100644 --- a/src/storage/database.rs +++ b/src/storage/database.rs @@ -1,14 +1,16 @@ use rusqlite::{params, Connection}; -use std::{path::Path}; +use std::path::Path; use tracing::{debug, error, info, warn}; -use crate::plugins::patches::{game_data::{GameData, GuildGameData}, platforms::platform::Platform}; - -#[path = "errors.rs"] -mod errors; - -pub use errors::{Error, Result}; +use crate::{ + plugins::patches::{ + game_data::{GameData, GuildGameData}, + platforms::platform::Platform, + }, + storage::StorageError, +}; +pub use crate::{Error, Result}; /// The current schema version of the database. This is used to manage /// database migrations in the future. If the schema changes, this version /// should be incremented, and migration logic should be added. @@ -19,562 +21,796 @@ const SCHEMA_VERSION: u8 = 1; #[derive(Debug)] #[allow(dead_code)] pub struct UserScore { - /// The unique discord user ID - pub user_id: u64, - /// The score value for the user - pub score: i32, + /// The unique discord user ID + pub user_id: u64, + /// The score value for the user + pub score: i32, } /// Represents a key for guild configuration options in the database. This enum /// defines the different configuration options that can be set for a guild. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum GuildConfigKey { - // The channel ID for where patch notes should be posted in the guild - PatchNotesChannel, + /// The channel ID for where patch notes should be posted in the guild + PatchNotesChannel, + /// The channel IDs where vote reactions should be added to messages + PointsChannels, + /// The channel ID for where jokes should be posted in the guild + JokesChannel, + /// The channel ID for where bot events should be posted in the guild + EventsChannel, } impl AsRef for GuildConfigKey { - fn as_ref(&self) -> &str { - match self { - GuildConfigKey::PatchNotesChannel => "PATCH_NOTES_CHANNEL", + fn as_ref(&self) -> &str { + match self { + GuildConfigKey::PatchNotesChannel => "PATCH_NOTES_CHANNEL", + GuildConfigKey::PointsChannels => "POINTS_CHANNELS", + GuildConfigKey::JokesChannel => "JOKES_CHANNEL", + GuildConfigKey::EventsChannel => "EVENTS_CHANNEL", + } } - } } /// Represents a value for guild configuration options in the database -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum GuildConfigValue { - ChannelId(u64), + ChannelId(u64), + ChannelIds(Vec), } impl Into for GuildConfigValue { - fn into(self) -> String { - match self { - GuildConfigValue::ChannelId(id) => id.to_string(), + fn into(self) -> String { + match self { + GuildConfigValue::ChannelId(id) => id.to_string(), + GuildConfigValue::ChannelIds(ids) => ids + .into_iter() + .map(|id| id.to_string()) + .collect::>() + .join(","), + } } - } } pub struct GuildConfig { - pub key: GuildConfigKey, - pub value: GuildConfigValue, + pub key: GuildConfigKey, + pub value: GuildConfigValue, } impl GuildConfig { - pub fn key(&self) -> &GuildConfigKey { - &self.key - } + pub fn key(&self) -> &GuildConfigKey { + &self.key + } - pub fn value(&self) -> &GuildConfigValue { - &self.value - } + pub fn value(&self) -> &GuildConfigValue { + &self.value + } } impl From<(GuildConfigKey, String)> for GuildConfig { - fn from((key, value): (GuildConfigKey, String)) -> Self { - let value = match key { - GuildConfigKey::PatchNotesChannel => { - let channel_id = value.parse::().unwrap_or(0); - GuildConfigValue::ChannelId(channel_id) - }, - }; - Self { key, value } - } + fn from((key, value): (GuildConfigKey, String)) -> Self { + let value = match key { + GuildConfigKey::PatchNotesChannel => { + let channel_id = value.parse::().unwrap_or(0); + GuildConfigValue::ChannelId(channel_id) + } + GuildConfigKey::PointsChannels => { + let channel_ids = value + .split(',') + .filter_map(|s| s.trim().parse::().ok()) + .collect(); + GuildConfigValue::ChannelIds(channel_ids) + } + GuildConfigKey::JokesChannel => { + let channel_id = value.parse::().unwrap_or(0); + GuildConfigValue::ChannelId(channel_id) + } + GuildConfigKey::EventsChannel => { + let channel_id = value.parse::().unwrap_or(0); + GuildConfigValue::ChannelId(channel_id) + } + }; + Self { key, value } + } +} + +impl From<(GuildConfigKey, GuildConfigValue)> for GuildConfig { + fn from((key, value): (GuildConfigKey, GuildConfigValue)) -> Self { + Self { key, value } + } } /// Database struct that manages the SQLite /// connection and provides methods for interacting /// with the database. pub struct Database { - /// The SQLite connection to the database file. - /// This connection is used for all database operations. - /// - /// If the connection is `None`, it indicates that the database - /// is not currently connected. Utilising the connection in - /// this state causes an InvalidConnectionState error to be - /// returned. - connection: Option + /// The SQLite connection to the database file. + /// This connection is used for all database operations. + /// + /// If the connection is `None`, it indicates that the database + /// is not currently connected. Utilising the connection in + /// this state causes an InvalidConnectionState error to be + /// returned. + connection: Option, } impl Database { - - /// Creates a new Database instance and establishes a connection - /// to the SQLite database file. - /// - /// This method creates a new Database instance. If the database - /// file does not exist, it will create a new one and set up the - /// necessary tables. If the file already exists, it will simply - /// open the connection after verifying that the schema version - /// matches the expected version or carry out necessary migrations - /// if it does not. - /// - /// ### File Path Configuration - /// The file path can be configured using the `DATABASE_PATH` environment - /// variable, which should point to a directory where the database - /// file can be created and accessed. If the environment variable - /// is not set, it defaults to `/data/`. The `data` directory is - /// created in the Dockerfile by default. - /// - /// ## Returns - /// * `Ok(Database)` - If the database connection is successfully - /// established and the schema version is valid, it returns a - /// Database instance. - /// * `Err(Error)` - If there is an error during connection, schema - /// version validation, or table creation, it returns an appropriate - /// [Error] variant. - pub async fn open() -> Result { - let default_db_path = "/data/"; - // Retrieve the database path from the environment variable, or use the default path if not set - let db_path = std::env::var("DATABASE_PATH").unwrap_or_else(|_| default_db_path.to_string()); - let path = Path::new(&db_path).join("motorbot.db"); - if !path.exists() { - info!("Database file not found, creating new database at {}", &path.to_string_lossy()); - match Connection::open(&path) { - Ok(conn) => { - let mut db = Self { connection: Some(conn) }; - db.create_tables().await?; - info!("Database created successfully at {}", &path.to_string_lossy()); - Ok(db) - }, - Err(e) => { - error!("Failed to create database: {:?}", e); - Err(Error::ConnectionError { err: e }) + /// Creates a new Database instance and establishes a connection + /// to the SQLite database file. + /// + /// This method creates a new Database instance. If the database + /// file does not exist, it will create a new one and set up the + /// necessary tables. If the file already exists, it will simply + /// open the connection after verifying that the schema version + /// matches the expected version or carry out necessary migrations + /// if it does not. + /// + /// ### File Path Configuration + /// The file path can be configured using the `DATABASE_PATH` environment + /// variable, which should point to a directory where the database + /// file can be created and accessed. If the environment variable + /// is not set, it defaults to `/data/`. The `data` directory is + /// created in the Dockerfile by default. + /// + /// ## Returns + /// * `Ok(Database)` - If the database connection is successfully + /// established and the schema version is valid, it returns a + /// Database instance. + /// * `Err(Error)` - If there is an error during connection, schema + /// version validation, or table creation, it returns an appropriate + /// [Error] variant. + pub async fn open() -> Result { + let default_db_path = "/data/"; + // Retrieve the database path from the environment variable, or use the default path if not set + let db_path = std::env::var("DATABASE_PATH") + .unwrap_or_else(|_| default_db_path.to_string()); + let path = Path::new(&db_path).join("motorbot.db"); + if !path.exists() { + info!( + "Database file not found, creating new database at {}", + &path.to_string_lossy() + ); + match Connection::open(&path) { + Ok(conn) => { + let mut db = Self { + connection: Some(conn), + }; + db.create_tables().await?; + info!( + "Database created successfully at {}", + &path.to_string_lossy() + ); + Ok(db) + } + Err(e) => { + error!("Failed to create database: {:?}", e); + Err(Error::Storage(StorageError::ConnectionError { + err: e, + })) + } + } + } else { + let mut db = Self { + connection: Some(Connection::open(path).map_err(|err| { + Error::Storage(StorageError::ConnectionError { err }) + })?), + }; + let version = db.schema_version().await?; + // Check if schema version matches current version. + // Future migrations should be handled here. + if version == SCHEMA_VERSION { + Ok(db) + } else { + Err(Error::Storage(StorageError::InvalidSchemaVersion { + expected: SCHEMA_VERSION, + found: version, + })) + } } - } - } else { - let mut db = Self { - connection: Some(Connection::open(path).map_err(|err| Error::ConnectionError { err })?) - }; - let version = db.schema_version().await?; - // Check if schema version matches current version. - // Future migrations should be handled here. - if version == SCHEMA_VERSION { - Ok(db) - } else { - Err(Error::InvalidSchemaVersion { expected: SCHEMA_VERSION, found: version }) - } } - } - - /// Fetches the user score for a given user ID - /// - /// ## Arguments - /// * `user_id` - The ID of the user for whom to fetch the score. - /// - /// ## Returns - /// * `Ok(UserScore)` - If the user score is found, it returns a - /// [UserScore] struct containing the user ID and score. If the - /// user is not found, it returns a UserScore with a score of 0. - /// * `Err(Error)` - If there is an error during the - /// database query, it returns a [Error] variant with details - /// about the failure. - pub async fn user_score(&mut self, user_id: &u64) -> Result { - match &mut self.connection { - Some(connection) => { - const Q_USER_SCORE: &str = "SELECT user_id, score FROM user_scores WHERE user_id = ?1"; - let mut stmt = connection.prepare(Q_USER_SCORE) - .map_err(|e| Error::with_sql(e, Q_USER_SCORE))?; - let mut rows = stmt.query(params![user_id.to_string()]) - .map_err(|e| Error::with_sql(e, Q_USER_SCORE))?; - - if let Some(row) = rows.next()? { - let user_id: String = row.get(0)?; - Ok(UserScore { - user_id: user_id.parse::().unwrap_or(0), - score: row.get(1)?, - }) - } else { - debug!("Score not found for user {}, returning 0", user_id); - Ok(UserScore { - user_id: *user_id, - score: 0, - }) + + /// Fetches the user score for a given user ID + /// + /// ## Arguments + /// * `user_id` - The ID of the user for whom to fetch the score. + /// + /// ## Returns + /// * `Ok(UserScore)` - If the user score is found, it returns a + /// [UserScore] struct containing the user ID and score. If the + /// user is not found, it returns a UserScore with a score of 0. + /// * `Err(Error)` - If there is an error during the + /// database query, it returns a [Error] variant with details + /// about the failure. + pub async fn user_score(&mut self, user_id: &u64) -> Result { + match &mut self.connection { + Some(connection) => { + const Q_USER_SCORE: &str = + "SELECT user_id, score FROM user_scores WHERE user_id = ?1"; + let mut stmt = + connection.prepare(Q_USER_SCORE).map_err(|e| { + Error::Storage(StorageError::with_sql(e, Q_USER_SCORE)) + })?; + let mut rows = + stmt.query(params![user_id.to_string()]).map_err(|e| { + Error::Storage(StorageError::with_sql(e, Q_USER_SCORE)) + })?; + + if let Some(row) = rows.next()? { + let user_id: String = row.get(0)?; + Ok(UserScore { + user_id: user_id.parse::().unwrap_or(0), + score: row.get(1)?, + }) + } else { + debug!("Score not found for user {}, returning 0", user_id); + Ok(UserScore { + user_id: *user_id, + score: 0, + }) + } + } + None => { + return Err(Error::Storage(StorageError::InvalidConnection)) + } } - }, - None => return Err(Error::InvalidConnection), } - } - - /// Sets the user score for a given user ID - /// - /// ## Arguments - /// * `user_id` - The ID of the user for whom to set the score. - /// * `score` - The score value to set for the user. - /// - /// ## Returns - /// * `Ok(UserScore)` - If the user score is successfully set, it returns - /// a [UserScore] struct containing the user ID and the new score. - /// * `Err(Error)` - If there is an error during the database operation, it - /// returns a [Error] variant with details about the failure. - pub async fn set_user_score(&mut self, user_id: &u64, score: i32) -> Result { - match &mut self.connection { - Some(connection) => { - const Q_SET_USER_SCORE: &str = "INSERT INTO user_scores (user_id, score) VALUES (?1, ?2) + + /// Sets the user score for a given user ID + /// + /// ## Arguments + /// * `user_id` - The ID of the user for whom to set the score. + /// * `score` - The score value to set for the user. + /// + /// ## Returns + /// * `Ok(UserScore)` - If the user score is successfully set, it returns + /// a [UserScore] struct containing the user ID and the new score. + /// * `Err(Error)` - If there is an error during the database operation, it + /// returns a [Error] variant with details about the failure. + pub async fn set_user_score( + &mut self, + user_id: &u64, + score: i32, + ) -> Result { + match &mut self.connection { + Some(connection) => { + const Q_SET_USER_SCORE: &str = + "INSERT INTO user_scores (user_id, score) VALUES (?1, ?2) ON CONFLICT(user_id) DO UPDATE SET score = excluded.score"; - connection.execute(Q_SET_USER_SCORE, params![user_id.to_string(), score]) - .map_err(|e| Error::with_sql(e, Q_SET_USER_SCORE))?; - Ok(UserScore { - user_id: *user_id, - score, - }) - }, - None => return Err(Error::InvalidConnection), - } - } - - /// Gets the game news for a given game ID - /// - /// ## Arguments - /// * `game_id` - The ID of the game for which to fetch news. - /// - /// ## Returns - /// * `Ok(Vec)` - If the game news is found, it returns a vector of - /// news item hashes. - /// * `Err(Error)` - If there is an error during the database query, it - /// returns a [Error] variant with details about the failure. - pub async fn game_news(&mut self, game_id: &str) -> Result> { - match &mut self.connection { - Some(connection) => { - const Q_GAME_NEWS: &str = "SELECT game_id, news_id FROM game_news WHERE game_id = ?1"; - let mut stmt = connection.prepare(Q_GAME_NEWS) - .map_err(|e| Error::with_sql(e, Q_GAME_NEWS))?; - let mut rows = stmt.query(params![game_id]) - .map_err(|e| Error::with_sql(e, Q_GAME_NEWS))?; - - let mut news_items = Vec::new(); - while let Some(row) = rows.next()? { - let news_id: String = row.get(1)?; - news_items.push(news_id); + connection + .execute( + Q_SET_USER_SCORE, + params![user_id.to_string(), score], + ) + .map_err(|e| { + Error::Storage(StorageError::with_sql( + e, + Q_SET_USER_SCORE, + )) + })?; + Ok(UserScore { + user_id: *user_id, + score, + }) + } + None => { + return Err(Error::Storage(StorageError::InvalidConnection)) + } } + } - Ok(news_items) - }, - None => return Err(Error::InvalidConnection), + /// Gets the game news for a given game ID + /// + /// ## Arguments + /// * `game_id` - The ID of the game for which to fetch news. + /// + /// ## Returns + /// * `Ok(Vec)` - If the game news is found, it returns a vector of + /// news item hashes. + /// * `Err(Error)` - If there is an error during the database query, it + /// returns a [Error] variant with details about the failure. + pub async fn game_news(&mut self, game_id: &str) -> Result> { + match &mut self.connection { + Some(connection) => { + const Q_GAME_NEWS: &str = + "SELECT game_id, news_id FROM game_news WHERE game_id = ?1"; + let mut stmt = + connection.prepare(Q_GAME_NEWS).map_err(|e| { + Error::Storage(StorageError::with_sql(e, Q_GAME_NEWS)) + })?; + let mut rows = stmt.query(params![game_id]).map_err(|e| { + Error::Storage(StorageError::with_sql(e, Q_GAME_NEWS)) + })?; + + let mut news_items = Vec::new(); + while let Some(row) = rows.next()? { + let news_id: String = row.get(1)?; + news_items.push(news_id); + } + + Ok(news_items) + } + None => { + return Err(Error::Storage(StorageError::InvalidConnection)) + } + } } - } - - /// Adds a new game to the database with the provided details - /// - /// ## Arguments - /// * `game_id` - The unique ID for the game. This should be a steam game ID - /// or a riot games short code (e.g. "val" for Valorant). - /// * `guild` - The guild ID associated with the game, used to determine which - /// Discord server it belongs to. - /// * `platform` - The platform for the game. - /// * `name` - The name of the game. - /// * `thumbnail` - The URL of the game's thumbnail. - /// * `color` - The color associated with the game. - /// - /// ## Returns - /// * `Ok(())` - If the game is successfully added to the database, it returns - /// an empty Ok result. - /// * `Err(Error)` - If there is an error during the database operation, it - /// returns a [Error] variant with details about the failure. - pub async fn add_game(&mut self, game_id: &str, guild: u64, platform: Platform, name: &str, thumbnail: &str, color: &str) -> Result<()> { - match &mut self.connection { - Some(connection) => { - const Q_ADD_GAME: &str = "INSERT INTO games (id, guild, platform, name, thumbnail, color) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"; - connection.execute(Q_ADD_GAME, params![game_id, guild.to_string(), platform.to_string(), name, thumbnail, color]) - .map_err(|e| Error::with_sql(e, Q_ADD_GAME))?; - Ok(()) - }, - None => return Err(Error::InvalidConnection), + + /// Adds a new game to the database with the provided details + /// + /// ## Arguments + /// * `game_id` - The unique ID for the game. This should be a steam game ID + /// or a riot games short code (e.g. "val" for Valorant). + /// * `guild` - The guild ID associated with the game, used to determine which + /// Discord server it belongs to. + /// * `platform` - The platform for the game. + /// * `name` - The name of the game. + /// * `thumbnail` - The URL of the game's thumbnail. + /// * `color` - The color associated with the game. + /// + /// ## Returns + /// * `Ok(())` - If the game is successfully added to the database, it returns + /// an empty Ok result. + /// * `Err(Error)` - If there is an error during the database operation, it + /// returns a [Error] variant with details about the failure. + pub async fn add_game( + &mut self, + game_id: &str, + guild: u64, + platform: Platform, + name: &str, + thumbnail: &str, + color: &str, + ) -> Result<()> { + match &mut self.connection { + Some(connection) => { + const Q_ADD_GAME: &str = "INSERT INTO games (id, guild, platform, name, thumbnail, color) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"; + connection + .execute( + Q_ADD_GAME, + params![ + game_id, + guild.to_string(), + platform.to_string(), + name, + thumbnail, + color + ], + ) + .map_err(|e| { + Error::Storage(StorageError::with_sql(e, Q_ADD_GAME)) + })?; + Ok(()) + } + None => { + return Err(Error::Storage(StorageError::InvalidConnection)) + } + } } - } - - /// Removes a game from the database based on the provided game ID and guild ID - /// - /// ## Arguments - /// * `game_id` - The unique ID of the game to be removed. - /// * `guild` - The guild ID associated with the game, used to determine which - /// Discord server it belongs to. - /// - /// ## Returns - /// * `Ok(())` - If the game is successfully removed from the database, it returns - /// an empty Ok result. - /// * `Err(Error)` - If there is an error during the database operation, it - /// returns a [Error] variant with details about the failure. - pub async fn remove_game(&mut self, game_id: &str, guild: u64) -> Result<()> { - match &mut self.connection { - Some(connection) => { - const Q_REMOVE_GAME: &str = "DELETE FROM games WHERE id = ?1 AND guild = ?2"; - connection.execute(Q_REMOVE_GAME, params![game_id, guild.to_string()]) - .map_err(|e| Error::with_sql(e, Q_REMOVE_GAME))?; - Ok(()) - }, - None => return Err(Error::InvalidConnection), + + /// Removes a game from the database based on the provided game ID and guild ID + /// + /// ## Arguments + /// * `game_id` - The unique ID of the game to be removed. + /// * `guild` - The guild ID associated with the game, used to determine which + /// Discord server it belongs to. + /// + /// ## Returns + /// * `Ok(())` - If the game is successfully removed from the database, it returns + /// an empty Ok result. + /// * `Err(Error)` - If there is an error during the database operation, it + /// returns a [Error] variant with details about the failure. + pub async fn remove_game( + &mut self, + game_id: &str, + guild: u64, + ) -> Result<()> { + match &mut self.connection { + Some(connection) => { + const Q_REMOVE_GAME: &str = + "DELETE FROM games WHERE id = ?1 AND guild = ?2"; + connection + .execute(Q_REMOVE_GAME, params![game_id, guild.to_string()]) + .map_err(|e| { + Error::Storage(StorageError::with_sql(e, Q_REMOVE_GAME)) + })?; + Ok(()) + } + None => { + return Err(Error::Storage(StorageError::InvalidConnection)) + } + } } - } - - /// Gets the game details for a given game ID - /// - /// ## Arguments - /// * `game_id` - The ID of the game for which to fetch details. - /// - /// ## Returns - /// * `Ok(GameData)` - If the game details are found, it returns a [GameData] - /// struct. - /// * `Err(Error)` - If there is an error during the database query, it - /// returns a [Error] variant with details about the failure. - pub async fn game_details(&mut self, game_id: &str) -> Result { - match &mut self.connection { - Some(connection) => { - const Q_GAME_DETAILS: &str = "SELECT guild, platform, name, thumbnail, color FROM games WHERE id = ?1"; - let mut stmt = connection.prepare(Q_GAME_DETAILS) - .map_err(|e| Error::with_sql(e, Q_GAME_DETAILS))?; - let mut rows = stmt.query(params![game_id]) - .map_err(|e| Error::with_sql(e, Q_GAME_DETAILS))?; - - let mut platform: Platform = Platform::Unknown; - let news_items = None; - let mut guild_data = Vec::new(); - while let Some(row) = rows.next()? { - let guild_str: String = row.get(0)?; - let platform_str: String = row.get(1)?; - platform = Platform::from(platform_str.as_str()); - let name: String = row.get(2)?; - let thumbnail: String = row.get(3)?; - let color: String = row.get(4)?; - guild_data.push(GuildGameData { - guild: guild_str.parse::().unwrap_or(0), - name, - thumbnail, - color, - }); + + /// Gets the game details for a given game ID + /// + /// ## Arguments + /// * `game_id` - The ID of the game for which to fetch details. + /// + /// ## Returns + /// * `Ok(GameData)` - If the game details are found, it returns a [GameData] + /// struct. + /// * `Err(Error)` - If there is an error during the database query, it + /// returns a [Error] variant with details about the failure. + pub async fn game_details(&mut self, game_id: &str) -> Result { + match &mut self.connection { + Some(connection) => { + const Q_GAME_DETAILS: &str = "SELECT guild, platform, name, thumbnail, color FROM games WHERE id = ?1"; + let mut stmt = + connection.prepare(Q_GAME_DETAILS).map_err(|e| { + Error::Storage(StorageError::with_sql( + e, + Q_GAME_DETAILS, + )) + })?; + let mut rows = stmt.query(params![game_id]).map_err(|e| { + Error::Storage(StorageError::with_sql(e, Q_GAME_DETAILS)) + })?; + + let mut platform: Platform = Platform::Unknown; + let news_items = None; + let mut guild_data = Vec::new(); + while let Some(row) = rows.next()? { + let guild_str: String = row.get(0)?; + let platform_str: String = row.get(1)?; + platform = Platform::from(platform_str.as_str()); + let name: String = row.get(2)?; + let thumbnail: String = row.get(3)?; + let color: String = row.get(4)?; + guild_data.push(GuildGameData { + guild: guild_str.parse::().unwrap_or(0), + name, + thumbnail, + color, + }); + } + Ok(GameData { + id: game_id.to_string(), + platform, + news_items, + guild_data, + }) + } + None => { + return Err(Error::Storage(StorageError::InvalidConnection)) + } } - Ok(GameData { - id: game_id.to_string(), - platform, - news_items, - guild_data, - }) - }, - None => return Err(Error::InvalidConnection), } - } - - /// Fetches all game ids from the database - /// - /// ## Returns - /// * `Ok(Vec)` - If the query is successful, it returns a - /// vector of game IDs that are currently being monitored. - /// * `Err(Error)` - If there is an error during the database query, it - /// returns a [Error] variant with details about the failure. - pub async fn game_ids(&mut self) -> Result> { - match &mut self.connection { - Some(connection) => { - const Q_GAME_IDS: &str = "SELECT id FROM games GROUP BY id"; - let mut stmt = connection.prepare(Q_GAME_IDS) - .map_err(|e| Error::with_sql(e, Q_GAME_IDS))?; - let mut rows = stmt.query([]).map_err(|e| Error::with_sql(e, Q_GAME_IDS))?; - - let mut game_ids = Vec::new(); - while let Some(row) = rows.next()? { - let game_id: String = row.get(0)?; - game_ids.push(game_id); + + /// Fetches all game ids from the database + /// + /// ## Returns + /// * `Ok(Vec)` - If the query is successful, it returns a + /// vector of game IDs that are currently being monitored. + /// * `Err(Error)` - If there is an error during the database query, it + /// returns a [Error] variant with details about the failure. + pub async fn game_ids(&mut self) -> Result> { + match &mut self.connection { + Some(connection) => { + const Q_GAME_IDS: &str = "SELECT id FROM games GROUP BY id"; + let mut stmt = connection.prepare(Q_GAME_IDS).map_err(|e| { + Error::Storage(StorageError::with_sql(e, Q_GAME_IDS)) + })?; + let mut rows = stmt.query([]).map_err(|e| { + Error::Storage(StorageError::with_sql(e, Q_GAME_IDS)) + })?; + + let mut game_ids = Vec::new(); + while let Some(row) = rows.next()? { + let game_id: String = row.get(0)?; + game_ids.push(game_id); + } + Ok(game_ids) + } + None => { + return Err(Error::Storage(StorageError::InvalidConnection)) + } } - Ok(game_ids) - }, - None => return Err(Error::InvalidConnection), } - } - - /// Fetches all game ids for a specific guild from the database - /// - /// ## Arguments - /// * `guild` - The guild ID for which to fetch the game IDs. - /// - /// ## Returns - /// * `Ok(Vec)` - If the query is successful, it returns a vector of - /// game IDs that are currently being monitored for the specified guild. - /// * `Err(Error)` - If there is an error during the database query, it - /// returns a [Error] variant with details about the failure. - pub async fn game_ids_for_guild(&mut self, guild: u64) -> Result> { - match &mut self.connection { - Some(connection) => { - const Q_GAME_IDS_FOR_GUILD: &str = "SELECT id FROM games WHERE guild = ?1"; - let mut stmt = connection.prepare(Q_GAME_IDS_FOR_GUILD) - .map_err(|e| Error::with_sql(e, Q_GAME_IDS_FOR_GUILD))?; - let mut rows = stmt.query(params![guild.to_string()]).map_err(|e| Error::with_sql(e, Q_GAME_IDS_FOR_GUILD))?; - - let mut game_ids = Vec::new(); - while let Some(row) = rows.next()? { - let game_id: String = row.get(0)?; - game_ids.push(game_id); + + /// Fetches all game ids for a specific guild from the database + /// + /// ## Arguments + /// * `guild` - The guild ID for which to fetch the game IDs. + /// + /// ## Returns + /// * `Ok(Vec)` - If the query is successful, it returns a vector of + /// game IDs that are currently being monitored for the specified guild. + /// * `Err(Error)` - If there is an error during the database query, it + /// returns a [Error] variant with details about the failure. + pub async fn game_ids_for_guild( + &mut self, + guild: u64, + ) -> Result> { + match &mut self.connection { + Some(connection) => { + const Q_GAME_IDS_FOR_GUILD: &str = + "SELECT id FROM games WHERE guild = ?1"; + let mut stmt = + connection.prepare(Q_GAME_IDS_FOR_GUILD).map_err(|e| { + Error::Storage(StorageError::with_sql( + e, + Q_GAME_IDS_FOR_GUILD, + )) + })?; + let mut rows = + stmt.query(params![guild.to_string()]).map_err(|e| { + Error::Storage(StorageError::with_sql( + e, + Q_GAME_IDS_FOR_GUILD, + )) + })?; + + let mut game_ids = Vec::new(); + while let Some(row) = rows.next()? { + let game_id: String = row.get(0)?; + game_ids.push(game_id); + } + Ok(game_ids) + } + None => { + return Err(Error::Storage(StorageError::InvalidConnection)) + } } - Ok(game_ids) - }, - None => return Err(Error::InvalidConnection), } - } - - /// Adds a news item for a given game ID and automatically removes old news - /// items if there are more than 5 - /// - /// ## Arguments - /// * `game_id` - The ID of the game for which to add the news - /// * `news_id` - The Hash/ID of the news item to add - /// - /// ## Returns - /// * `Ok(())` - If the news item is successfully added, it returns - /// an empty Ok result. - /// * `Err(Error)` - If there is an error during the database operation, it - /// returns a [Error] variant with details about the failure. - pub async fn add_news_item(&mut self, game_id: &str, news_id: &str) -> Result<()> { - match &mut self.connection { - Some(connection) => { - const Q_ADD_NEWS_ITEM: &str = "INSERT INTO game_news (game_id, news_id) VALUES (?1, ?2)"; - connection.execute(Q_ADD_NEWS_ITEM, params![game_id, news_id]) - .map_err(|e| Error::with_sql(e, Q_ADD_NEWS_ITEM))?; - - // Remove old news items if there are more than 5 - const Q_COUNT_NEWS_ITEMS: &str = "SELECT COUNT(*) FROM game_news WHERE game_id = ?1"; - let mut stmt = connection.prepare(Q_COUNT_NEWS_ITEMS) - .map_err(|e| Error::with_sql(e, Q_COUNT_NEWS_ITEMS))?; - let count: i32 = stmt.query_row(params![game_id], |row| row.get(0)) - .map_err(|e| Error::with_sql(e, Q_COUNT_NEWS_ITEMS))?; - - if count > 5 { - const Q_DELETE_OLD_NEWS: &str = "DELETE FROM game_news WHERE game_id = ?1 AND uid IN ( + + /// Adds a news item for a given game ID and automatically removes old news + /// items if there are more than 5 + /// + /// ## Arguments + /// * `game_id` - The ID of the game for which to add the news + /// * `news_id` - The Hash/ID of the news item to add + /// + /// ## Returns + /// * `Ok(())` - If the news item is successfully added, it returns + /// an empty Ok result. + /// * `Err(Error)` - If there is an error during the database operation, it + /// returns a [Error] variant with details about the failure. + pub async fn add_news_item( + &mut self, + game_id: &str, + news_id: &str, + ) -> Result<()> { + match &mut self.connection { + Some(connection) => { + const Q_ADD_NEWS_ITEM: &str = + "INSERT INTO game_news (game_id, news_id) VALUES (?1, ?2)"; + connection + .execute(Q_ADD_NEWS_ITEM, params![game_id, news_id]) + .map_err(|e| { + Error::Storage(StorageError::with_sql( + e, + Q_ADD_NEWS_ITEM, + )) + })?; + + // Remove old news items if there are more than 5 + const Q_COUNT_NEWS_ITEMS: &str = + "SELECT COUNT(*) FROM game_news WHERE game_id = ?1"; + let mut stmt = + connection.prepare(Q_COUNT_NEWS_ITEMS).map_err(|e| { + Error::Storage(StorageError::with_sql( + e, + Q_COUNT_NEWS_ITEMS, + )) + })?; + let count: i32 = stmt + .query_row(params![game_id], |row| row.get(0)) + .map_err(|e| { + Error::Storage(StorageError::with_sql( + e, + Q_COUNT_NEWS_ITEMS, + )) + })?; + + if count > 5 { + const Q_DELETE_OLD_NEWS: &str = "DELETE FROM game_news WHERE game_id = ?1 AND uid IN ( SELECT uid FROM game_news WHERE game_id = ?1 ORDER BY uid ASC LIMIT ?2 )"; - connection.execute(Q_DELETE_OLD_NEWS, params![game_id, count - 5]) - .map_err(|e| Error::with_sql(e, Q_DELETE_OLD_NEWS))?; + connection + .execute(Q_DELETE_OLD_NEWS, params![game_id, count - 5]) + .map_err(|e| { + Error::Storage(StorageError::with_sql( + e, + Q_DELETE_OLD_NEWS, + )) + })?; + } + Ok(()) + } + None => { + return Err(Error::Storage(StorageError::InvalidConnection)) + } } - Ok(()) - }, - None => return Err(Error::InvalidConnection), } - } - - /// Sets a guild configuration option in the database - /// - /// ## Arguments - /// * `guild_id` - The ID of the guild for which to set the configuration - /// * `guild_config` - The guild configuration option to set - /// - /// ## Returns - /// * `Ok(())` - If the guild configuration is successfully set, it returns - /// an empty Ok result. - /// * `Err(Error)` - If there is an error during the database operation, it - /// returns a [Error] variant with details about the failure. - pub async fn set_guild_config(&mut self, guild_id: u64, guild_config: GuildConfig) -> Result<()> { - match &mut self.connection { - Some(connection) => { - const Q_SET_GUILD_CONFIG: &str = "INSERT INTO guild_config (guild_id, config_key, config_value) VALUES (?1, ?2, ?3) + + /// Sets a guild configuration option in the database + /// + /// ## Arguments + /// * `guild_id` - The ID of the guild for which to set the configuration + /// * `guild_config` - The guild configuration option to set + /// + /// ## Returns + /// * `Ok(())` - If the guild configuration is successfully set, it returns + /// an empty Ok result. + /// * `Err(Error)` - If there is an error during the database operation, it + /// returns a [Error] variant with details about the failure. + pub async fn set_guild_config( + &mut self, + guild_id: u64, + guild_config: GuildConfig, + ) -> Result<()> { + match &mut self.connection { + Some(connection) => { + const Q_SET_GUILD_CONFIG: &str = "INSERT INTO guild_config (guild_id, config_key, config_value) VALUES (?1, ?2, ?3) ON CONFLICT(guild_id, config_key) DO UPDATE SET config_value = excluded.config_value"; - let config_key: &str = guild_config.key().as_ref(); - let config_value: String = guild_config.value().clone().into(); - connection.execute(Q_SET_GUILD_CONFIG, params![guild_id.to_string(), config_key, config_value]) - .map_err(|e| Error::with_sql(e, Q_SET_GUILD_CONFIG))?; - Ok(()) - }, - None => return Err(Error::InvalidConnection), + let config_key: &str = guild_config.key().as_ref(); + let config_value: String = guild_config.value().clone().into(); + connection + .execute( + Q_SET_GUILD_CONFIG, + params![guild_id.to_string(), config_key, config_value], + ) + .map_err(|e| { + Error::Storage(StorageError::with_sql( + e, + Q_SET_GUILD_CONFIG, + )) + })?; + Ok(()) + } + None => { + return Err(Error::Storage(StorageError::InvalidConnection)) + } + } } - } - - /// Gets a guild configuration option from the database - /// - /// ## Arguments - /// * `guild_id` - The ID of the guild for which to fetch the configuration - /// * `guild_config` - The guild configuration option to fetch - /// - /// ## Returns - /// * `Ok(Option)` - If the guild configuration is found, it returns - /// the configuration value as a string wrapped in Some. If the configuration is - /// not found, it returns None. - /// * `Err(Error)` - If there is an error during the database query, it - /// returns a [Error] variant with details about the failure. - pub async fn get_guild_config(&mut self, guild_id: u64, guild_config_key: GuildConfigKey) -> Result> { - match &mut self.connection { - Some(connection) => { - const Q_GET_GUILD_CONFIG: &str = "SELECT config_value FROM guild_config WHERE guild_id = ?1 AND config_key = ?2"; - let mut stmt = connection.prepare(Q_GET_GUILD_CONFIG) - .map_err(|e| Error::with_sql(e, Q_GET_GUILD_CONFIG))?; - let mut rows = stmt.query(params![guild_id.to_string(), guild_config_key.as_ref()]) - .map_err(|e| Error::with_sql(e, Q_GET_GUILD_CONFIG))?; - - if let Some(row) = rows.next()? { - let config_value: String = row.get(0)?; - Ok(Some(GuildConfig::from((guild_config_key, config_value)))) - } else { - debug!("Guild config not found for guild {}, key {}, returning None", guild_id, guild_config_key.as_ref()); - Ok(None) + + /// Gets a guild configuration option from the database + /// + /// ## Arguments + /// * `guild_id` - The ID of the guild for which to fetch the configuration + /// * `guild_config` - The guild configuration option to fetch + /// + /// ## Returns + /// * `Ok(Option)` - If the guild configuration is found, it returns + /// the configuration value as a string wrapped in Some. If the configuration is + /// not found, it returns None. + /// * `Err(Error)` - If there is an error during the database query, it + /// returns a [Error] variant with details about the failure. + pub async fn get_guild_config( + &mut self, + guild_id: u64, + guild_config_key: GuildConfigKey, + ) -> Result> { + match &mut self.connection { + Some(connection) => { + const Q_GET_GUILD_CONFIG: &str = "SELECT config_value FROM guild_config WHERE guild_id = ?1 AND config_key = ?2"; + let mut stmt = + connection.prepare(Q_GET_GUILD_CONFIG).map_err(|e| { + Error::Storage(StorageError::with_sql( + e, + Q_GET_GUILD_CONFIG, + )) + })?; + let mut rows = stmt + .query(params![ + guild_id.to_string(), + guild_config_key.as_ref() + ]) + .map_err(|e| { + Error::Storage(StorageError::with_sql( + e, + Q_GET_GUILD_CONFIG, + )) + })?; + + if let Some(row) = rows.next()? { + let config_value: String = row.get(0)?; + Ok(Some(GuildConfig::from(( + guild_config_key, + config_value, + )))) + } else { + debug!("Guild config not found for guild {}, key {}, returning None", guild_id, guild_config_key.as_ref()); + Ok(None) + } + } + None => { + return Err(Error::Storage(StorageError::InvalidConnection)) + } } - }, - None => return Err(Error::InvalidConnection), } - } - - /// Closes the database connection - /// - /// This method should be called when the database is no longer needed to - /// ensure that all resources are properly released. It will attempt to close - /// the connection and return any errors that occur during the process. - /// - /// ## Returns - /// * `Ok(())` - If the connection is successfully closed it returns an - /// empty Ok result. - /// * `Err(Error)` - If there is an error during the closing of the connection - /// or if the connection is already closed or not initialized, it returns - /// [Error::InvalidConnection] error. - pub async fn close(&mut self) -> Result<()> { - if let Some(conn) = self.connection.take() { - conn.close().map_err(|(_conn, err)| Error::ConnectionError { err })?; - self.connection = None; - Ok(()) - } else { - warn!("Unable to close database: connection not initialized or already closed"); - Err(Error::InvalidConnection) + + /// Closes the database connection + /// + /// This method should be called when the database is no longer needed to + /// ensure that all resources are properly released. It will attempt to close + /// the connection and return any errors that occur during the process. + /// + /// ## Returns + /// * `Ok(())` - If the connection is successfully closed it returns an + /// empty Ok result. + /// * `Err(Error)` - If there is an error during the closing of the connection + /// or if the connection is already closed or not initialized, it returns + /// [Error::InvalidConnection] error. + pub async fn close(&mut self) -> Result<()> { + if let Some(conn) = self.connection.take() { + conn.close().map_err(|(_conn, err)| { + Error::Storage(StorageError::ConnectionError { err }) + })?; + self.connection = None; + Ok(()) + } else { + warn!("Unable to close database: connection not initialized or already closed"); + Err(Error::Storage(StorageError::InvalidConnection)) + } } - } - - /// Creates the necessary tables in the database - /// - /// This method is called when a new database is - /// created to set up the required schema. - /// - /// ## Returns - /// * `Ok(())` - If the tables are created successfully, it returns an - /// empty Ok result. - /// * `Err(Error)` - If there is an error during table creation, it returns - /// an appropriate [Error] variant with details about the failure. - async fn create_tables(&mut self) -> Result<()> { - match &mut self.connection { - Some(connection) => { - // Set the schema version - connection.pragma_update(None, "user_version", &SCHEMA_VERSION) - .map_err(|e| Error::with_sql(e, "Failed to set schema version"))?; - - // Create user_scores table - const Q_CREATE_USER_SCORES_TABLE: &str = "CREATE TABLE IF NOT EXISTS user_scores ( + + /// Creates the necessary tables in the database + /// + /// This method is called when a new database is + /// created to set up the required schema. + /// + /// ## Returns + /// * `Ok(())` - If the tables are created successfully, it returns an + /// empty Ok result. + /// * `Err(Error)` - If there is an error during table creation, it returns + /// an appropriate [Error] variant with details about the failure. + async fn create_tables(&mut self) -> Result<()> { + match &mut self.connection { + Some(connection) => { + // Set the schema version + connection + .pragma_update(None, "user_version", &SCHEMA_VERSION) + .map_err(|e| { + Error::Storage(StorageError::with_sql( + e, + "Failed to set schema version", + )) + })?; + + // Create user_scores table + const Q_CREATE_USER_SCORES_TABLE: &str = + "CREATE TABLE IF NOT EXISTS user_scores ( user_id TEXT PRIMARY KEY, score INTEGER NOT NULL )"; - connection.execute( - Q_CREATE_USER_SCORES_TABLE, - [] - ).map_err(|e| Error::with_sql(e, Q_CREATE_USER_SCORES_TABLE))?; - - // Create platforms table - const Q_CREATE_PLATFORMS_TABLE: &str = "CREATE TABLE IF NOT EXISTS platforms ( + connection.execute(Q_CREATE_USER_SCORES_TABLE, []).map_err( + |e| { + Error::Storage(StorageError::with_sql( + e, + Q_CREATE_USER_SCORES_TABLE, + )) + }, + )?; + + // Create platforms table + const Q_CREATE_PLATFORMS_TABLE: &str = + "CREATE TABLE IF NOT EXISTS platforms ( id TEXT PRIMARY KEY, name TEXT NOT NULL )"; - connection.execute( - Q_CREATE_PLATFORMS_TABLE, - [], - ).map_err(|e| Error::with_sql(e, Q_CREATE_PLATFORMS_TABLE))?; - const Q_INSERT_PLATFORMS: &str = "INSERT OR IGNORE INTO platforms (id, name) VALUES + connection.execute(Q_CREATE_PLATFORMS_TABLE, []).map_err( + |e| { + Error::Storage(StorageError::with_sql( + e, + Q_CREATE_PLATFORMS_TABLE, + )) + }, + )?; + const Q_INSERT_PLATFORMS: &str = + "INSERT OR IGNORE INTO platforms (id, name) VALUES ('steam', 'Steam'), ('riot', 'Riot Games')"; - connection.execute( - Q_INSERT_PLATFORMS, - [] - ).map_err(|e| Error::with_sql(e, Q_INSERT_PLATFORMS))?; - - // Create games table - const Q_CREATE_GAMES_TABLE: &str = "CREATE TABLE IF NOT EXISTS games ( + connection.execute(Q_INSERT_PLATFORMS, []).map_err(|e| { + Error::Storage(StorageError::with_sql( + e, + Q_INSERT_PLATFORMS, + )) + })?; + + // Create games table + const Q_CREATE_GAMES_TABLE: &str = + "CREATE TABLE IF NOT EXISTS games ( id TEXT NOT NULL, guild TEXT NOT NULL, platform TEXT NOT NULL, @@ -584,62 +820,87 @@ impl Database { PRIMARY KEY (id, guild), FOREIGN KEY (platform) REFERENCES platforms(id) )"; - connection.execute( - Q_CREATE_GAMES_TABLE, - [], - ).map_err(|e| Error::with_sql(e, Q_CREATE_GAMES_TABLE))?; - - // Create game_news table - const Q_CREATE_GAME_NEWS_TABLE: &str = "CREATE TABLE IF NOT EXISTS game_news ( + connection.execute(Q_CREATE_GAMES_TABLE, []).map_err(|e| { + Error::Storage(StorageError::with_sql( + e, + Q_CREATE_GAMES_TABLE, + )) + })?; + + // Create game_news table + const Q_CREATE_GAME_NEWS_TABLE: &str = + "CREATE TABLE IF NOT EXISTS game_news ( uid INTEGER PRIMARY KEY, game_id TEXT NOT NULL, news_id TEXT NOT NULL )"; - connection.execute( - Q_CREATE_GAME_NEWS_TABLE, - [], - ).map_err(|e| Error::with_sql(e, Q_CREATE_GAME_NEWS_TABLE))?; - - // Create guild_config table - const Q_CREATE_GUILD_CONFIG_TABLE: &str = "CREATE TABLE IF NOT EXISTS guild_config ( + connection.execute(Q_CREATE_GAME_NEWS_TABLE, []).map_err( + |e| { + Error::Storage(StorageError::with_sql( + e, + Q_CREATE_GAME_NEWS_TABLE, + )) + }, + )?; + + // Create guild_config table + const Q_CREATE_GUILD_CONFIG_TABLE: &str = + "CREATE TABLE IF NOT EXISTS guild_config ( guild_id TEXT NOT NULL, config_key TEXT NOT NULL, config_value TEXT NOT NULL, PRIMARY KEY (guild_id, config_key) )"; - connection.execute( - Q_CREATE_GUILD_CONFIG_TABLE, - [], - ).map_err(|e| Error::with_sql(e, Q_CREATE_GUILD_CONFIG_TABLE))?; - - Ok(()) - }, - None => return Err(Error::InvalidConnection), - } - } - - /// Retrieves the current schema version of the - /// database. - /// - /// This method executes a PRAGMA query to fetch the user_version - /// from the database. - /// - /// ## Returns - /// * `Ok(u8)` - If the query is successful, it returns the schema version as an unsigned 8-bit integer. - /// * `Err(Error)` - If there is an error it returns an appropriate [Error] variant. - async fn schema_version(&mut self) -> Result { - match &mut self.connection { - Some(connection) => { - const Q_GET_SCHEMA_VERSION: &str = "PRAGMA user_version"; - let mut stmt = connection.prepare(Q_GET_SCHEMA_VERSION) - .map_err(|e| Error::with_sql(e, Q_GET_SCHEMA_VERSION))?; - let version: u8 = stmt.query_row([], |row| row.get(0)) - .map_err(|e| Error::with_sql(e, Q_GET_SCHEMA_VERSION))?; - debug!("Database schema version: {}", version); - Ok(version) - }, - None => return Err(Error::InvalidConnection), + connection + .execute(Q_CREATE_GUILD_CONFIG_TABLE, []) + .map_err(|e| { + Error::Storage(StorageError::with_sql( + e, + Q_CREATE_GUILD_CONFIG_TABLE, + )) + })?; + + Ok(()) + } + None => { + return Err(Error::Storage(StorageError::InvalidConnection)) + } + } } - } -} \ No newline at end of file + /// Retrieves the current schema version of the + /// database. + /// + /// This method executes a PRAGMA query to fetch the user_version + /// from the database. + /// + /// ## Returns + /// * `Ok(u8)` - If the query is successful, it returns the schema version as an unsigned 8-bit integer. + /// * `Err(Error)` - If there is an error it returns an appropriate [Error] variant. + async fn schema_version(&mut self) -> Result { + match &mut self.connection { + Some(connection) => { + const Q_GET_SCHEMA_VERSION: &str = "PRAGMA user_version"; + let mut stmt = + connection.prepare(Q_GET_SCHEMA_VERSION).map_err(|e| { + Error::Storage(StorageError::with_sql( + e, + Q_GET_SCHEMA_VERSION, + )) + })?; + let version: u8 = + stmt.query_row([], |row| row.get(0)).map_err(|e| { + Error::Storage(StorageError::with_sql( + e, + Q_GET_SCHEMA_VERSION, + )) + })?; + debug!("Database schema version: {}", version); + Ok(version) + } + None => { + return Err(Error::Storage(StorageError::InvalidConnection)) + } + } + } +} diff --git a/src/storage/error.rs b/src/storage/error.rs new file mode 100644 index 0000000..f8ca552 --- /dev/null +++ b/src/storage/error.rs @@ -0,0 +1,70 @@ +use std::fmt; + +/// Custom error type for database operations. This enum encapsulates +/// various error scenarios that can occur during database interactions, +/// such as connection issues, SQL errors, and schema version mismatches. +#[derive(Debug)] +#[non_exhaustive] +pub enum Error { + /// A Rusqlite error + RusqliteError { + /// The SQL query that caused the error + query: String, + /// The Rusqlite error that occurred + err: rusqlite::Error, + }, + /// A connection error, typically occurs when the + /// database file cannot be accessed or created + ConnectionError { + /// The error that occurred during connection + err: rusqlite::Error, + }, + /// The connection is in an invalid state, such + /// as being closed or not initialized + InvalidConnection, + /// An error indicating that the database schema version is invalid + InvalidSchemaVersion { + /// The expected schema version + expected: u8, + /// The actual schema version found in the database + found: u8, + }, +} + +impl Error { + /// Creates a new `Error` from a Rusqlite error and the associated SQL query. + /// This is a helper method to simplify error handling when executing SQL queries. + /// # Arguments + /// * `e` - The Rusqlite error that occurred. + /// * `query` - The SQL query that caused the error. + /// # Returns + /// A new `Error` instance containing the query and the error. + pub fn with_sql(e: rusqlite::Error, query: &str) -> Self { + Self::RusqliteError { + query: query.into(), + err: e, + } + } +} + +impl From for Error { + fn from(e: rusqlite::Error) -> Self { + Self::RusqliteError { + query: String::new(), + err: e, + } + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::RusqliteError { query, err } => write!(f, "Database error: {}. Query: {}", err, query), + Self::ConnectionError { err } => write!(f, "Connection error: {}", err), + Self::InvalidSchemaVersion { expected, found } => write!(f, "Invalid schema version: expected {}, found {}", expected, found), + Self::InvalidConnection => write!(f, "Invalid connection state: connection is not initialized or has been closed"), + } + } +} + +impl std::error::Error for Error {} diff --git a/src/storage/errors.rs b/src/storage/errors.rs deleted file mode 100644 index 7854ecc..0000000 --- a/src/storage/errors.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::fmt; - -/// A typedef of the result returned by many methods. -pub type Result = std::result::Result; - -/// Custom error type for database operations. This enum encapsulates -/// various error scenarios that can occur during database interactions, -/// such as connection issues, SQL errors, and schema version mismatches. -#[derive(Debug)] -#[non_exhaustive] -pub enum Error { - /// A Rusqlite error - RusqliteError { - /// The SQL query that caused the error - query: String, - /// The Rusqlite error that occurred - err: rusqlite::Error, - }, - /// A connection error, typically occurs when the - /// database file cannot be accessed or created - ConnectionError { - /// The error that occurred during connection - err: rusqlite::Error, - }, - /// The connection is in an invalid state, such - /// as being closed or not initialized - InvalidConnection, - /// An error indicating that the database schema version is invalid - InvalidSchemaVersion { - /// The expected schema version - expected: u8, - /// The actual schema version found in the database - found: u8, - } -} - -impl Error { - /// Creates a new `Error` from a Rusqlite error and the associated SQL query. - /// This is a helper method to simplify error handling when executing SQL queries. - /// # Arguments - /// * `e` - The Rusqlite error that occurred. - /// * `query` - The SQL query that caused the error. - /// # Returns - /// A new `Error` instance containing the query and the error. - pub fn with_sql(e: rusqlite::Error, query: &str) -> Self { - Self::RusqliteError { query: query.into(), err: e } - } -} - -impl From for Error { - fn from(e: rusqlite::Error) -> Self { - Self::RusqliteError { query: String::new(), err: e } - } -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::RusqliteError { query, err } => write!(f, "Database error: {}. Query: {}", err, query), - Self::ConnectionError { err } => write!(f, "Connection error: {}", err), - Self::InvalidSchemaVersion { expected, found } => write!(f, "Invalid schema version: expected {}, found {}", expected, found), - Self::InvalidConnection => write!(f, "Invalid connection state: connection is not initialized or has been closed"), - } - } -} \ No newline at end of file From cace398a6a689723a2b605c4f328f9ac2144329a Mon Sep 17 00:00:00 2001 From: Lennart Hase <1834270+motorlatitude@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:43:00 +0100 Subject: [PATCH 02/15] =?UTF-8?q?=F0=9F=90=9B=20Better=20error=20handling?= =?UTF-8?q?=20in=20=20jokes=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- src/error.rs | 8 +++++++- src/plugin/error.rs | 7 +++++++ src/plugins/jokes.rs | 48 ++++++++++++++++++++++++-------------------- 3 files changed, 40 insertions(+), 23 deletions(-) diff --git a/src/error.rs b/src/error.rs index 57ceb92..db37002 100644 --- a/src/error.rs +++ b/src/error.rs @@ -16,9 +16,15 @@ pub enum Error { Plugin(plugin::PluginError), // -- External - /// An error related to the Serenity library, such as issues with the Discord API. + /// An error related to the Serenity library, such as issues with the + /// Discord API. #[from] Serenity(serenity::Error), + + /// An error related to the reqwest library, such as issues with making + /// HTTP requests. + #[from] + Reqwest(reqwest::Error), } impl From for Error { diff --git a/src/plugin/error.rs b/src/plugin/error.rs index c9d09ec..cd33b0c 100644 --- a/src/plugin/error.rs +++ b/src/plugin/error.rs @@ -20,6 +20,13 @@ pub enum Error { #[display("Expected a ChannelId but got None")] MissingChannelId, + /// Plugin expected an environment variable to be set but it was missing + #[display("Missing environment variable: {}", var_name)] + MissingEnvironmentVariable { + /// The name of the missing environment variable + var_name: String, + }, + /// Plugin expected something to happen in a guild context but it was used /// somewhere else (e.g. in a DM) ExpectedGuild, diff --git a/src/plugins/jokes.rs b/src/plugins/jokes.rs index 87c6218..e47e9d5 100644 --- a/src/plugins/jokes.rs +++ b/src/plugins/jokes.rs @@ -36,14 +36,8 @@ impl JokesPlugin { async fn joke( http: std::sync::Arc, guilds: Vec, - ) { - let mut db = match Database::open().await { - Ok(db) => db, - Err(why) => { - error!("Failed to open database connection: {:?}", why); - return; - } - }; + ) -> Result<()> { + let mut db = Database::open().await?; let mut channel_ids = Vec::new(); for guild in &guilds { let guild_id = guild.get(); @@ -68,26 +62,31 @@ impl JokesPlugin { let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); - let rapid_api_key: String = env::var("RAPID_API_KEY") - .expect("Expected a token in the environment"); + let rapid_api_key: String = + env::var("RAPID_API_KEY").map_err(|_| { + Error::Plugin(PluginError::MissingEnvironmentVariable { + var_name: "RAPID_API_KEY".to_string(), + }) + })?; headers.insert( "X-RapidAPI-Key", - HeaderValue::from_str(&rapid_api_key).unwrap(), + HeaderValue::from_str(&rapid_api_key) + .unwrap_or(HeaderValue::from_static("")), ); headers.insert( "X-RapidAPI-Host", - HeaderValue::from_str("dad-jokes.p.rapidapi.com").unwrap(), + HeaderValue::from_static("dad-jokes.p.rapidapi.com"), ); let http_response = client .get("https://dad-jokes.p.rapidapi.com/random/joke") .headers(headers) .send() - .await - .expect("Failed to request joke") - .json::() - .await - .expect("Failed to parse joke"); + .await?; + + let joke = http_response.json::().await?; + let setup = joke.body[0].setup.to_string(); + let punchline = joke.body[0].punchline.to_string(); for channel_id in channel_ids { let pixel_animal_emojis = [ @@ -99,19 +98,22 @@ impl JokesPlugin { ]; let random_emoji = pixel_animal_emojis [rand::random_range(0..pixel_animal_emojis.len())]; - let _ = channel_id + channel_id .send_message( &http, CreateMessage::new().content(format!( "## {} Joke for {}\n{}\n\n||{}||", random_emoji, chrono::Local::now().format("%d %B %Y"), - http_response.body[0].setup.as_str().unwrap(), - http_response.body[0].punchline.as_str().unwrap() + setup, + punchline )), ) - .await; + .await + .map_err(|err| PluginError::FailedToRespond { err })?; } + db.close().await?; + Ok(()) } } @@ -170,7 +172,9 @@ impl MotorbotPlugin for JokesPlugin { let http = http.clone(); let guilds = guilds.clone(); async move { - JokesPlugin::joke(http, guilds).await; + JokesPlugin::joke(http, guilds).await.unwrap_or_else(|err| { + error!("Failed to send joke: {:?}", err); + }); } }); From dc1f951a6866dca4e19f95758f09ec6112052f74 Mon Sep 17 00:00:00 2001 From: Lennart Hase <1834270+motorlatitude@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:30:02 +0100 Subject: [PATCH 03/15] =?UTF-8?q?=F0=9F=90=9B=20Fixed`/points`=20user=20co?= =?UTF-8?q?mmand=20to=20correctly=20get=20supplied=20user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- src/plugin.rs | 12 ++++++++++++ src/plugins/points.rs | 31 ++++++++----------------------- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/src/plugin.rs b/src/plugin.rs index edf75d2..06f86d1 100644 --- a/src/plugin.rs +++ b/src/plugin.rs @@ -115,6 +115,18 @@ pub trait MotorbotPlugin { /// ).ok_or(PluginError::MissingChannelId)?.id.get(); /// ``` macro_rules! option { + ($cmd:ident, $name:expr, ResolvedValue::User) => {{ + let option = $cmd.iter().find(|opt| opt.name == $name); + if let Some(option) = option { + if let ResolvedValue::User(value, _) = option.value { + Some(value) + } else { + None + } + } else { + None + } + }}; ($cmd:ident, $name:expr, $t:path) => {{ let option = $cmd.iter().find(|opt| opt.name == $name); if let Some(option) = option { diff --git a/src/plugins/points.rs b/src/plugins/points.rs index 299d91a..55e7730 100644 --- a/src/plugins/points.rs +++ b/src/plugins/points.rs @@ -6,7 +6,7 @@ use serenity::all::{ }; use tracing::info; -use crate::plugin::PluginError; +use crate::plugin::{option, PluginError}; use crate::{Error, Result}; use crate::{ @@ -263,9 +263,7 @@ impl MotorbotPlugin for PointsPlugin { let options = command.data.options(); let first_option = options.first(); - if first_option.is_some() { - let resolved_option = - first_option.ok_or(PluginError::InvalidSubCommand)?; + if let Some(resolved_option) = first_option { match resolved_option.name { "channel" => { let ResolvedValue::SubCommand(subcommand) = @@ -336,25 +334,12 @@ impl MotorbotPlugin for PointsPlugin { .map_err(|err| PluginError::FailedToRespond { err })?; } "user" => { - let ResolvedValue::SubCommand(subcommand) = - resolved_option.value.clone() - else { - return Err(Error::Plugin( - PluginError::InvalidSubCommand, - )); - }; - let user = subcommand - .iter() - .find_map(|opt| { - if opt.name == "user" { - if let ResolvedValue::User(user, _) = opt.value - { - return Some(user); - } - } - None - }) - .ok_or(PluginError::InvalidUser)?; + let user: &serenity::all::User = option! { + options, + "user", + ResolvedValue::User + } + .ok_or(PluginError::InvalidUser)?; let username = user.tag(); let user_score = db.user_score(&user.id.get()).await?; command.create_response( From 6ee7455b1c13966a219fc8c8ff84ef0ed2a01290 Mon Sep 17 00:00:00 2001 From: Lennart Hase <1834270+motorlatitude@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:51:46 +0100 Subject: [PATCH 04/15] =?UTF-8?q?=F0=9F=8E=A8=20Update=20reaction=20handli?= =?UTF-8?q?ng=20in=20PointsPlugin=20to=20use=20EmojiId=20for=20upvote/down?= =?UTF-8?q?vote=20to=20avoid=20potential=20panics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- src/plugins/points.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/plugins/points.rs b/src/plugins/points.rs index 55e7730..231fdc5 100644 --- a/src/plugins/points.rs +++ b/src/plugins/points.rs @@ -1,8 +1,8 @@ use async_trait::async_trait; use serenity::all::{ CommandOptionType, CreateCommand, CreateCommandOption, - CreateInteractionResponse, CreateInteractionResponseMessage, GuildId, - Interaction, Message, Reaction, ReactionType, ResolvedValue, + CreateInteractionResponse, CreateInteractionResponseMessage, EmojiId, + GuildId, Interaction, Message, Reaction, ReactionType, ResolvedValue, }; use tracing::info; @@ -116,18 +116,22 @@ impl MotorbotPlugin for PointsPlugin { message .react( &p_ctx.ctx.http, - ReactionType::try_from("<:upvote:1494772872500088913>") - .unwrap(), + ReactionType::Custom { + animated: false, + id: EmojiId::new(1494772872500088913), + name: Some("upvote".to_string()), + }, ) .await .map_err(|err| PluginError::FailedToRespond { err })?; message .react( &p_ctx.ctx.http, - ReactionType::try_from( - "<:downvote:1494772826945617940>", - ) - .unwrap(), + ReactionType::Custom { + animated: false, + id: EmojiId::new(1494772826945617940), + name: Some("downvote".to_string()), + }, ) .await .map_err(|err| PluginError::FailedToRespond { err })?; From 9738f197eb435fd2e2cc7ec7ac1c3a5ed9380c07 Mon Sep 17 00:00:00 2001 From: Lennart Hase <1834270+motorlatitude@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:00:52 +0100 Subject: [PATCH 05/15] =?UTF-8?q?=F0=9F=90=9B=20Add=20InvalidInternalState?= =?UTF-8?q?=20error=20to=20handle=20plugin=20context=20issues=20and=20impr?= =?UTF-8?q?ove=20error=20handling=20in=20update=20method=20for=20patches?= =?UTF-8?q?=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- src/plugin/error.rs | 6 ++ src/plugins/patches.rs | 132 +++++++++++++++++++---------------------- 2 files changed, 68 insertions(+), 70 deletions(-) diff --git a/src/plugin/error.rs b/src/plugin/error.rs index cd33b0c..7e88bfe 100644 --- a/src/plugin/error.rs +++ b/src/plugin/error.rs @@ -80,6 +80,12 @@ pub enum Error { /// invalid, such as not being a valid hex color code when expected. #[display("Invalid game color provided")] InvalidGameColor, + + /// An error indicating that the plugin is in an invalid internal state, + /// such as missing necessary context or configuration that should have been + /// set during initialization or operation. + #[display("Plugin is in an invalid internal state")] + InvalidInternalState, } impl std::error::Error for Error {} diff --git a/src/plugins/patches.rs b/src/plugins/patches.rs index ec6607d..d1c568f 100644 --- a/src/plugins/patches.rs +++ b/src/plugins/patches.rs @@ -39,81 +39,71 @@ impl PatchesPlugin { } /// Looks for new patch notes for games - pub async fn update(&self) { - match &self.ctx { - Some(ctx) => { - info!("Updating sources..."); - ctx.set_presence( - Some(ActivityData::custom("🧭 Exploring...")), - OnlineStatus::DoNotDisturb, - ); - let mut db = Database::open() - .await - .expect("Failed to connect to database"); - let game_ids = db.game_ids().await; - let games_to_monitor = match game_ids { - Ok(ids) => ids, - Err(e) => { - error!("Failed to fetch game ids: {:?}", e); - return; - } - }; - // Get latest patch notes for each game_id and check latest patch notes - // against db if patch notes are different, post patch notes to channel - for game_id in games_to_monitor { - // Data from DB - let game_data = GameData::from_id(&game_id).await; - // Patch notes from Platform - let patch_notes = PatchNotes::fetch_for_platform( - game_data.platform, + pub async fn update(&self) -> Result<()> { + let ctx = &self.ctx.clone().ok_or(PluginError::InvalidInternalState)?; + info!("Updating sources..."); + ctx.set_presence( + Some(ActivityData::custom("🧭 Exploring...")), + OnlineStatus::DoNotDisturb, + ); + let mut db = Database::open().await?; + let game_ids = db.game_ids().await; + let games_to_monitor = match game_ids { + Ok(ids) => ids, + Err(e) => { + error!("Failed to fetch game ids: {:?}", e); + return Err(Error::Plugin(PluginError::InvalidInternalState)); + } + }; + // Get latest patch notes for each game_id and check latest patch notes + // against db if patch notes are different, post patch notes to channel + for game_id in games_to_monitor { + // Data from DB + let game_data = GameData::from_id(&game_id).await; + // Patch notes from Platform + let patch_notes = + PatchNotes::fetch_for_platform(game_data.platform, &game_id) + .await; + // Compare gid + let game_news_items = match &game_data.news_items { + Some(items) => items, + None => { + warn!( + "No news items found for game_id {}, skipping", + game_id + ); + continue; + } + }; + for guild_data in &game_data.guild_data { + if !game_news_items.contains(&patch_notes.gid) { + // Send patch notes + info!( + "[+] {} ({}) [{}]", + &guild_data.name, game_id, guild_data.guild + ); + self.send_patch_notes( + &mut db, + &patch_notes, &game_id, + guild_data, ) .await; - // Compare gid - let game_news_items = match &game_data.news_items { - Some(items) => items, - None => { - warn!( - "No news items found for game_id {}, skipping", - game_id - ); - continue; - } - }; - for guild_data in &game_data.guild_data { - if !game_news_items.contains(&patch_notes.gid) { - // Send patch notes - info!( - "[+] {} ({}) [{}]", - &guild_data.name, game_id, guild_data.guild - ); - self.send_patch_notes( - &mut db, - &patch_notes, - &game_id, - guild_data, - ) - .await; - } else { - info!( - "[β€’] {} ({}) [{}]", - guild_data.name, game_id, guild_data.guild - ); - } - } + } else { + info!( + "[β€’] {} ({}) [{}]", + guild_data.name, game_id, guild_data.guild + ); } - info!("Update complete"); - ctx.set_presence( - Some(ActivityData::custom("πŸ˜Άβ€πŸŒ«οΈ")), - OnlineStatus::Online, - ); - } - None => { - warn!( - "No context set for PatchesPlugin, cannot update sources" - ); } } + db.close().await?; + info!("Update complete"); + ctx.set_presence( + Some(ActivityData::custom("πŸ˜Άβ€πŸŒ«οΈ")), + OnlineStatus::Online, + ); + Ok(()) } /// Sends patch notes to a channel @@ -284,7 +274,9 @@ impl MotorbotPlugin for PatchesPlugin { scheduler.every(30.minutes()).run(move || { let plugin = plugin.clone(); async move { - plugin.update().await; + plugin.update().await.unwrap_or_else(|e| { + error!("Error during scheduled update: {:?}", e); + }); } }); From b3b6263fb8e8271c3001bcfd53964619f7dbdfef Mon Sep 17 00:00:00 2001 From: Lennart Hase <1834270+motorlatitude@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:27:10 +0100 Subject: [PATCH 06/15] =?UTF-8?q?=F0=9F=94=A5=20Remove=20OPENAI=5FAPI=5FKE?= =?UTF-8?q?Y=20from=20environment=20variables=20in=20README=20and=20docker?= =?UTF-8?q?-compose=20for=20plugin=20abstraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI command has been deprecated during this abstraction as it wasn't being used any way. --- README.md | 5 ----- docker-compose.yml | 1 - 2 files changed, 6 deletions(-) diff --git a/README.md b/README.md index e1af893..389fe5a 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,6 @@ services: - DATABASE_PATH=${DATABASE_PATH:-/data/} - DISCORD_TOKEN=${DISCORD_TOKEN} - RAPID_API_KEY=${RAPID_API_KEY} - - OPENAI_API_KEY=${OPENAI_API_KEY} - LOG_LEVEL=${LOG_LEVEL:-info} - TZ=${TZ:-UTC} volumes: @@ -53,10 +52,6 @@ This should contain a Discord Bot token and can be registered [here](https://dis This should contain a Rapid API key and can be registered [here](https://rapidapi.com/). This is used for the joke plugin. -#### `OPENAI_API_KEY` - -This should contain an OpenAI API key and can be registered [here](https://platform.openai.com/). This is used for the AI plugin. - #### `DATABASE_PATH` This should contain the path to the database file. The default is `/data/` and it will be created if it does not exist. diff --git a/docker-compose.yml b/docker-compose.yml index da2e776..35aaa72 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,6 @@ services: - DATABASE_PATH=${DATABASE_PATH:-/data/} - DISCORD_TOKEN=${DISCORD_TOKEN} - RAPID_API_KEY=${RAPID_API_KEY} - - OPENAI_API_KEY=${OPENAI_API_KEY} - LOG_LEVEL=${LOG_LEVEL:-info} - TZ=${TZ:-UTC} volumes: From 7fdd3f1e1bab03314a2c3312cea2a387001c8cae Mon Sep 17 00:00:00 2001 From: Lennart Hase <1834270+motorlatitude@users.noreply.github.com> Date: Thu, 23 Apr 2026 09:59:49 +0100 Subject: [PATCH 07/15] =?UTF-8?q?=F0=9F=8E=A8=20Check=20for=20message=20au?= =?UTF-8?q?thor=20id=20checks=20against=20actual=20bot=20ID=20rather=20tha?= =?UTF-8?q?n=20a=20hardcoded=20constant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/points.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/plugins/points.rs b/src/plugins/points.rs index 231fdc5..2f913e9 100644 --- a/src/plugins/points.rs +++ b/src/plugins/points.rs @@ -108,10 +108,11 @@ impl MotorbotPlugin for PointsPlugin { if let GuildConfigValue::ChannelIds(channel_ids) = stored_channel_ids.value() { + let bot_user_id = p_ctx.ctx.cache.current_user().id.get(); if (!message.attachments.is_empty() || message.content.contains("http")) && channel_ids.contains(&message.channel_id.get()) - && !message.author.id.eq(&169554882674556930) + && !message.author.id.eq(&bot_user_id) { message .react( From f76eaaf0950c5fbba6fa29b471c8dfda57bc7136 Mon Sep 17 00:00:00 2001 From: Lennart Hase <1834270+motorlatitude@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:19:51 +0100 Subject: [PATCH 08/15] =?UTF-8?q?=F0=9F=9A=A8=20Fix=20some=20lint=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- src/storage/database.rs | 140 ++++++++++++++++------------------------ src/storage/error.rs | 4 +- 2 files changed, 59 insertions(+), 85 deletions(-) diff --git a/src/storage/database.rs b/src/storage/database.rs index 485c749..a7d87b7 100644 --- a/src/storage/database.rs +++ b/src/storage/database.rs @@ -59,9 +59,9 @@ pub enum GuildConfigValue { ChannelIds(Vec), } -impl Into for GuildConfigValue { - fn into(self) -> String { - match self { +impl From for String { + fn from(val: GuildConfigValue) -> Self { + match val { GuildConfigValue::ChannelId(id) => id.to_string(), GuildConfigValue::ChannelIds(ids) => ids .into_iter() @@ -154,11 +154,11 @@ impl Database { /// /// ## Returns /// * `Ok(Database)` - If the database connection is successfully - /// established and the schema version is valid, it returns a - /// Database instance. + /// established and the schema version is valid, it returns a + /// Database instance. /// * `Err(Error)` - If there is an error during connection, schema - /// version validation, or table creation, it returns an appropriate - /// [Error] variant. + /// version validation, or table creation, it returns an appropriate + /// Error] variant. pub async fn open() -> Result { let default_db_path = "/data/"; // Retrieve the database path from the environment variable, or use the default path if not set @@ -184,7 +184,7 @@ impl Database { } Err(e) => { error!("Failed to create database: {:?}", e); - Err(Error::Storage(StorageError::ConnectionError { + Err(Error::Storage(StorageError::UnableToConnect { err: e, })) } @@ -192,7 +192,7 @@ impl Database { } else { let mut db = Self { connection: Some(Connection::open(path).map_err(|err| { - Error::Storage(StorageError::ConnectionError { err }) + Error::Storage(StorageError::UnableToConnect { err }) })?), }; let version = db.schema_version().await?; @@ -216,11 +216,11 @@ impl Database { /// /// ## Returns /// * `Ok(UserScore)` - If the user score is found, it returns a - /// [UserScore] struct containing the user ID and score. If the - /// user is not found, it returns a UserScore with a score of 0. + /// [UserScore] struct containing the user ID and score. If the + /// user is not found, it returns a UserScore with a score of 0. /// * `Err(Error)` - If there is an error during the - /// database query, it returns a [Error] variant with details - /// about the failure. + /// database query, it returns a [Error] variant with details + /// about the failure. pub async fn user_score(&mut self, user_id: &u64) -> Result { match &mut self.connection { Some(connection) => { @@ -249,9 +249,7 @@ impl Database { }) } } - None => { - return Err(Error::Storage(StorageError::InvalidConnection)) - } + None => Err(Error::Storage(StorageError::InvalidConnection)), } } @@ -263,9 +261,9 @@ impl Database { /// /// ## Returns /// * `Ok(UserScore)` - If the user score is successfully set, it returns - /// a [UserScore] struct containing the user ID and the new score. + /// a [UserScore] struct containing the user ID and the new score. /// * `Err(Error)` - If there is an error during the database operation, it - /// returns a [Error] variant with details about the failure. + /// returns a [Error] variant with details about the failure. pub async fn set_user_score( &mut self, user_id: &u64, @@ -292,9 +290,7 @@ impl Database { score, }) } - None => { - return Err(Error::Storage(StorageError::InvalidConnection)) - } + None => Err(Error::Storage(StorageError::InvalidConnection)), } } @@ -305,9 +301,9 @@ impl Database { /// /// ## Returns /// * `Ok(Vec)` - If the game news is found, it returns a vector of - /// news item hashes. + /// news item hashes. /// * `Err(Error)` - If there is an error during the database query, it - /// returns a [Error] variant with details about the failure. + /// returns a [Error] variant with details about the failure. pub async fn game_news(&mut self, game_id: &str) -> Result> { match &mut self.connection { Some(connection) => { @@ -329,9 +325,7 @@ impl Database { Ok(news_items) } - None => { - return Err(Error::Storage(StorageError::InvalidConnection)) - } + None => Err(Error::Storage(StorageError::InvalidConnection)), } } @@ -339,9 +333,9 @@ impl Database { /// /// ## Arguments /// * `game_id` - The unique ID for the game. This should be a steam game ID - /// or a riot games short code (e.g. "val" for Valorant). + /// or a riot games short code (e.g. "val" for Valorant). /// * `guild` - The guild ID associated with the game, used to determine which - /// Discord server it belongs to. + /// Discord server it belongs to. /// * `platform` - The platform for the game. /// * `name` - The name of the game. /// * `thumbnail` - The URL of the game's thumbnail. @@ -349,9 +343,9 @@ impl Database { /// /// ## Returns /// * `Ok(())` - If the game is successfully added to the database, it returns - /// an empty Ok result. + /// an empty Ok result. /// * `Err(Error)` - If there is an error during the database operation, it - /// returns a [Error] variant with details about the failure. + /// returns a [Error] variant with details about the failure. pub async fn add_game( &mut self, game_id: &str, @@ -381,9 +375,7 @@ impl Database { })?; Ok(()) } - None => { - return Err(Error::Storage(StorageError::InvalidConnection)) - } + None => Err(Error::Storage(StorageError::InvalidConnection)), } } @@ -392,13 +384,13 @@ impl Database { /// ## Arguments /// * `game_id` - The unique ID of the game to be removed. /// * `guild` - The guild ID associated with the game, used to determine which - /// Discord server it belongs to. + /// Discord server it belongs to. /// /// ## Returns /// * `Ok(())` - If the game is successfully removed from the database, it returns - /// an empty Ok result. + /// an empty Ok result. /// * `Err(Error)` - If there is an error during the database operation, it - /// returns a [Error] variant with details about the failure. + /// returns a [Error] variant with details about the failure. pub async fn remove_game( &mut self, game_id: &str, @@ -415,9 +407,7 @@ impl Database { })?; Ok(()) } - None => { - return Err(Error::Storage(StorageError::InvalidConnection)) - } + None => Err(Error::Storage(StorageError::InvalidConnection)), } } @@ -428,9 +418,9 @@ impl Database { /// /// ## Returns /// * `Ok(GameData)` - If the game details are found, it returns a [GameData] - /// struct. + /// struct. /// * `Err(Error)` - If there is an error during the database query, it - /// returns a [Error] variant with details about the failure. + /// returns a [Error] variant with details about the failure. pub async fn game_details(&mut self, game_id: &str) -> Result { match &mut self.connection { Some(connection) => { @@ -470,9 +460,7 @@ impl Database { guild_data, }) } - None => { - return Err(Error::Storage(StorageError::InvalidConnection)) - } + None => Err(Error::Storage(StorageError::InvalidConnection)), } } @@ -480,9 +468,9 @@ impl Database { /// /// ## Returns /// * `Ok(Vec)` - If the query is successful, it returns a - /// vector of game IDs that are currently being monitored. + /// vector of game IDs that are currently being monitored. /// * `Err(Error)` - If there is an error during the database query, it - /// returns a [Error] variant with details about the failure. + /// returns a [Error] variant with details about the failure. pub async fn game_ids(&mut self) -> Result> { match &mut self.connection { Some(connection) => { @@ -501,9 +489,7 @@ impl Database { } Ok(game_ids) } - None => { - return Err(Error::Storage(StorageError::InvalidConnection)) - } + None => Err(Error::Storage(StorageError::InvalidConnection)), } } @@ -514,9 +500,9 @@ impl Database { /// /// ## Returns /// * `Ok(Vec)` - If the query is successful, it returns a vector of - /// game IDs that are currently being monitored for the specified guild. + /// game IDs that are currently being monitored for the specified guild. /// * `Err(Error)` - If there is an error during the database query, it - /// returns a [Error] variant with details about the failure. + /// returns a [Error] variant with details about the failure. pub async fn game_ids_for_guild( &mut self, guild: u64, @@ -547,9 +533,7 @@ impl Database { } Ok(game_ids) } - None => { - return Err(Error::Storage(StorageError::InvalidConnection)) - } + None => Err(Error::Storage(StorageError::InvalidConnection)), } } @@ -562,9 +546,9 @@ impl Database { /// /// ## Returns /// * `Ok(())` - If the news item is successfully added, it returns - /// an empty Ok result. + /// an empty Ok result. /// * `Err(Error)` - If there is an error during the database operation, it - /// returns a [Error] variant with details about the failure. + /// returns a [Error] variant with details about the failure. pub async fn add_news_item( &mut self, game_id: &str, @@ -617,9 +601,7 @@ impl Database { } Ok(()) } - None => { - return Err(Error::Storage(StorageError::InvalidConnection)) - } + None => Err(Error::Storage(StorageError::InvalidConnection)), } } @@ -631,9 +613,9 @@ impl Database { /// /// ## Returns /// * `Ok(())` - If the guild configuration is successfully set, it returns - /// an empty Ok result. + /// an empty Ok result. /// * `Err(Error)` - If there is an error during the database operation, it - /// returns a [Error] variant with details about the failure. + /// returns a [Error] variant with details about the failure. pub async fn set_guild_config( &mut self, guild_id: u64, @@ -658,9 +640,7 @@ impl Database { })?; Ok(()) } - None => { - return Err(Error::Storage(StorageError::InvalidConnection)) - } + None => Err(Error::Storage(StorageError::InvalidConnection)), } } @@ -672,10 +652,10 @@ impl Database { /// /// ## Returns /// * `Ok(Option)` - If the guild configuration is found, it returns - /// the configuration value as a string wrapped in Some. If the configuration is - /// not found, it returns None. + /// the configuration value as a string wrapped in Some. If the configuration is + /// not found, it returns None. /// * `Err(Error)` - If there is an error during the database query, it - /// returns a [Error] variant with details about the failure. + /// returns a [Error] variant with details about the failure. pub async fn get_guild_config( &mut self, guild_id: u64, @@ -714,9 +694,7 @@ impl Database { Ok(None) } } - None => { - return Err(Error::Storage(StorageError::InvalidConnection)) - } + None => Err(Error::Storage(StorageError::InvalidConnection)), } } @@ -728,14 +706,14 @@ impl Database { /// /// ## Returns /// * `Ok(())` - If the connection is successfully closed it returns an - /// empty Ok result. + /// empty Ok result. /// * `Err(Error)` - If there is an error during the closing of the connection - /// or if the connection is already closed or not initialized, it returns - /// [Error::InvalidConnection] error. + /// or if the connection is already closed or not initialized, it returns + /// [Error::InvalidConnection] error. pub async fn close(&mut self) -> Result<()> { if let Some(conn) = self.connection.take() { conn.close().map_err(|(_conn, err)| { - Error::Storage(StorageError::ConnectionError { err }) + Error::Storage(StorageError::UnableToConnect { err }) })?; self.connection = None; Ok(()) @@ -752,15 +730,15 @@ impl Database { /// /// ## Returns /// * `Ok(())` - If the tables are created successfully, it returns an - /// empty Ok result. + /// empty Ok result. /// * `Err(Error)` - If there is an error during table creation, it returns - /// an appropriate [Error] variant with details about the failure. + /// an appropriate [Error] variant with details about the failure. async fn create_tables(&mut self) -> Result<()> { match &mut self.connection { Some(connection) => { // Set the schema version connection - .pragma_update(None, "user_version", &SCHEMA_VERSION) + .pragma_update(None, "user_version", SCHEMA_VERSION) .map_err(|e| { Error::Storage(StorageError::with_sql( e, @@ -862,9 +840,7 @@ impl Database { Ok(()) } - None => { - return Err(Error::Storage(StorageError::InvalidConnection)) - } + None => Err(Error::Storage(StorageError::InvalidConnection)), } } @@ -898,9 +874,7 @@ impl Database { debug!("Database schema version: {}", version); Ok(version) } - None => { - return Err(Error::Storage(StorageError::InvalidConnection)) - } + None => Err(Error::Storage(StorageError::InvalidConnection)), } } } diff --git a/src/storage/error.rs b/src/storage/error.rs index f8ca552..82137b8 100644 --- a/src/storage/error.rs +++ b/src/storage/error.rs @@ -15,7 +15,7 @@ pub enum Error { }, /// A connection error, typically occurs when the /// database file cannot be accessed or created - ConnectionError { + UnableToConnect { /// The error that occurred during connection err: rusqlite::Error, }, @@ -60,7 +60,7 @@ impl fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::RusqliteError { query, err } => write!(f, "Database error: {}. Query: {}", err, query), - Self::ConnectionError { err } => write!(f, "Connection error: {}", err), + Self::UnableToConnect { err } => write!(f, "Connection error: {}", err), Self::InvalidSchemaVersion { expected, found } => write!(f, "Invalid schema version: expected {}, found {}", expected, found), Self::InvalidConnection => write!(f, "Invalid connection state: connection is not initialized or has been closed"), } From 3bd82ccde258cf8661f28bfbf5eea8afd0d696ce Mon Sep 17 00:00:00 2001 From: Lennart Hase <1834270+motorlatitude@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:04:08 +0100 Subject: [PATCH 09/15] =?UTF-8?q?=F0=9F=8E=A8=20Clamping=20dice=20sides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries out input sanitisation to ensure dice side is between 2 and 100 inclusive --- src/plugins/dice.rs | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/plugins/dice.rs b/src/plugins/dice.rs index aa73285..cb894dc 100644 --- a/src/plugins/dice.rs +++ b/src/plugins/dice.rs @@ -6,7 +6,7 @@ use serenity::all::{ }; use tracing::info; -use crate::plugin::PluginError; +use crate::plugin::{option, PluginError}; use crate::Result; use crate::plugin::{MotorbotPlugin, PluginContext, PluginInfo}; @@ -69,17 +69,13 @@ impl MotorbotPlugin for DicePlugin { } let options = &command.data.options(); - let sides = options - .iter() - .find_map(|opt| { - if opt.name == "sides" { - if let ResolvedValue::Integer(value) = opt.value { - return Some(value as u64); - } - } - None - }) - .unwrap_or(6); // Default to a 6-sided die if no option provided + let sides = option!(options, "sides", ResolvedValue::Integer) + .ok_or_else(|| 6i64) + .unwrap_or(6i64); // Default to a 6-sided die if no option provided + + // clamp sides to a reasonable range (e.g., 2 to 100) + let sides = sides.clamp(2, 100); + let dice_roll = rand::random_range(1..=sides); command .create_response( From 3db51f91eb93134ddea43f641925bf229f110ba9 Mon Sep 17 00:00:00 2001 From: Lennart Hase <1834270+motorlatitude@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:21:56 +0100 Subject: [PATCH 10/15] =?UTF-8?q?=F0=9F=8E=A8=20Add=20InvalidInternalAPIRe?= =?UTF-8?q?sponse=20error=20variant=20and=20handle=20empty=20joke=20array?= =?UTF-8?q?=20case?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- src/plugin/error.rs | 10 ++++++++++ src/plugins/jokes.rs | 15 +++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/plugin/error.rs b/src/plugin/error.rs index 7e88bfe..8252bbc 100644 --- a/src/plugin/error.rs +++ b/src/plugin/error.rs @@ -31,6 +31,16 @@ pub enum Error { /// somewhere else (e.g. in a DM) ExpectedGuild, + /// An error indicating that an internal API response was invalid or could + /// not be parsed as expected, such as when calling an external API for a + /// plugin's functionality and receiving a response that doesn't match the + /// expected format or contains invalid data. + #[display("Received an invalid response from an internal API: {}", err)] + InvalidInternalAPIResponse { + /// The error that occurred during parsing or handling the API response + err: Box, + }, + /// A channel was expected but the provided option was either not a channel /// or was missing entirely. #[display( diff --git a/src/plugins/jokes.rs b/src/plugins/jokes.rs index e47e9d5..b9574c1 100644 --- a/src/plugins/jokes.rs +++ b/src/plugins/jokes.rs @@ -85,8 +85,19 @@ impl JokesPlugin { .await?; let joke = http_response.json::().await?; - let setup = joke.body[0].setup.to_string(); - let punchline = joke.body[0].punchline.to_string(); + let Some(first_joke) = joke.body.first() else { + error!("Dad jokes API returned an empty body array"); + return Err(Error::Plugin( + PluginError::InvalidInternalAPIResponse { + err: Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Empty joke array", + )), + }, + )); + }; + let setup = first_joke.setup.to_string(); + let punchline = first_joke.punchline.to_string(); for channel_id in channel_ids { let pixel_animal_emojis = [ From 1fca50a515b3eb7682df0e0074a4dddfc64fb330 Mon Sep 17 00:00:00 2001 From: Lennart Hase <1834270+motorlatitude@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:26:39 +0100 Subject: [PATCH 11/15] =?UTF-8?q?=F0=9F=8E=A8=20Update=20sleep=20durations?= =?UTF-8?q?=20in=20Jokes=20and=20Patches=20plugins;=20fix=20some=20lint=20?= =?UTF-8?q?issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/jokes.rs | 2 +- src/plugins/patches.rs | 5 +++-- src/storage/database.rs | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/plugins/jokes.rs b/src/plugins/jokes.rs index b9574c1..b40e6a2 100644 --- a/src/plugins/jokes.rs +++ b/src/plugins/jokes.rs @@ -192,7 +192,7 @@ impl MotorbotPlugin for JokesPlugin { tokio::spawn(async move { loop { scheduler.run_pending().await; - tokio::time::sleep(Duration::from_millis(100)).await; + tokio::time::sleep(Duration::from_mins(5)).await; } }); diff --git a/src/plugins/patches.rs b/src/plugins/patches.rs index d1c568f..0f1913e 100644 --- a/src/plugins/patches.rs +++ b/src/plugins/patches.rs @@ -40,7 +40,8 @@ impl PatchesPlugin { /// Looks for new patch notes for games pub async fn update(&self) -> Result<()> { - let ctx = &self.ctx.clone().ok_or(PluginError::InvalidInternalState)?; + let ctx = + &self.ctx.as_ref().ok_or(PluginError::InvalidInternalState)?; info!("Updating sources..."); ctx.set_presence( Some(ActivityData::custom("🧭 Exploring...")), @@ -283,7 +284,7 @@ impl MotorbotPlugin for PatchesPlugin { tokio::spawn(async move { loop { scheduler.run_pending().await; - tokio::time::sleep(Duration::from_millis(100)).await; + tokio::time::sleep(Duration::from_mins(1)).await; } }); diff --git a/src/storage/database.rs b/src/storage/database.rs index a7d87b7..656b517 100644 --- a/src/storage/database.rs +++ b/src/storage/database.rs @@ -10,7 +10,7 @@ use crate::{ storage::StorageError, }; -pub use crate::{Error, Result}; +use crate::{Error, Result}; /// The current schema version of the database. This is used to manage /// database migrations in the future. If the schema changes, this version /// should be incremented, and migration logic should be added. From 42136f3db8a949ae49a89840f9bbf2d7932f22ba Mon Sep 17 00:00:00 2001 From: Lennart Hase <1834270+motorlatitude@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:57:32 +0100 Subject: [PATCH 12/15] =?UTF-8?q?=F0=9F=8E=A8=20Enhance=20error=20handling?= =?UTF-8?q?=20and=20plugin=20event=20sending.=20Additionally,=20ensured=20?= =?UTF-8?q?all=20open=20database=20instances=20are=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- src/error.rs | 8 +++ src/main.rs | 10 +++- src/plugin/plugin_context.rs | 98 +++++++++++++++----------------- src/plugins/debug.rs | 2 + src/plugins/patches.rs | 5 +- src/plugins/patches/game_data.rs | 57 +++++-------------- src/plugins/points.rs | 5 ++ 7 files changed, 85 insertions(+), 100 deletions(-) diff --git a/src/error.rs b/src/error.rs index db37002..8b12e99 100644 --- a/src/error.rs +++ b/src/error.rs @@ -7,6 +7,14 @@ pub type Result = std::result::Result; #[derive(Debug, From, Display)] #[non_exhaustive] pub enum Error { + /// An error indicating that a plugin failed to send an event due to an + /// issue with the Serenity context or API. + #[display("Failed to send event: {}", err)] + FailedToSendEvent { + /// The error that occurred during sending the event + err: serenity::Error, + }, + // -- Internal /// An error related to storage operations, such as database interactions. #[from] diff --git a/src/main.rs b/src/main.rs index 0b30934..b509117 100644 --- a/src/main.rs +++ b/src/main.rs @@ -116,7 +116,10 @@ impl EventHandler for Handler { VERSION )), ) - .await; + .await + .unwrap_or_else(|err| { + error!("Failed to send startup event: {:?}", err); + }); for plugin in self.plugins.iter() { match plugin.on_ready(&plugin_ctx).await { Ok(()) => {} @@ -157,7 +160,10 @@ impl EventHandler for Handler { e )), ) - .await; + .await + .unwrap_or_else(|err| { + error!("Failed to send error event: {:?}", err); + }); } } } diff --git a/src/plugin/plugin_context.rs b/src/plugin/plugin_context.rs index b3f4405..d4abca0 100644 --- a/src/plugin/plugin_context.rs +++ b/src/plugin/plugin_context.rs @@ -6,6 +6,7 @@ use tracing::error; use crate::{ plugin::MotorbotPlugin, storage::{Database, GuildConfigKey, GuildConfigValue}, + Error, Result, }; /// The context passed to plugins, containing the Serenity context and @@ -88,61 +89,52 @@ impl<'a> PluginContext<'a> { /// /// Plugins can use this method to log important events or information to /// the bot events channel for easier monitoring and debugging. - pub async fn send_event(&self, event: PluginEvent) { - match Database::open().await { - Err(why) => { - error!("Error opening database: {:?}", why); - } - Ok(mut db) => { - let guilds = self.ctx.cache.guilds(); - let mut event_channel_ids = Vec::new(); - for guild_id in guilds { - let channel_id: Option = match db - .get_guild_config( - guild_id.get(), - GuildConfigKey::EventsChannel, - ) - .await - { - Ok(Some(config_value)) => match config_value.value { - GuildConfigValue::ChannelId(id) => Some(id), - _ => None, - }, - Ok(None) => None, - Err(why) => { - error!("Error fetching events channel from database: {:?}", why); - None - } - }; - if let Some(channel_id) = channel_id { - event_channel_ids.push(channel_id); - } - } - let level_color = match event.level { - PluginEventLevel::Debug => 0xAAAAAA, - PluginEventLevel::Info => 0x55B8F9, - PluginEventLevel::Warning => 0xFAE577, - PluginEventLevel::Error => 0xFF0000, - }; - let mut embed_builder = CreateEmbed::default() - .title(format!("[{}] {}", event.level, event.name)) - .color(level_color); - if let Some(description) = event.description { - embed_builder = embed_builder.description(description); - } - let builder = CreateMessage::new().embed(embed_builder); - for channel_id in event_channel_ids { - if let Err(why) = ChannelId::new(channel_id) - .send_message(&self.ctx.http, builder.clone()) - .await - { - error!( - "Error sending message to channel {}: {:?}", - channel_id, why - ); - } + pub async fn send_event(&self, event: PluginEvent) -> Result<()> { + let mut db = Database::open().await?; + let guilds = self.ctx.cache.guilds(); + let mut event_channel_ids = Vec::new(); + for guild_id in guilds { + let channel_id: Option = match db + .get_guild_config(guild_id.get(), GuildConfigKey::EventsChannel) + .await + { + Ok(Some(config_value)) => match config_value.value { + GuildConfigValue::ChannelId(id) => Some(id), + _ => None, + }, + Ok(None) => None, + Err(why) => { + error!( + "Error fetching events channel from database: {:?}", + why + ); + None } + }; + if let Some(channel_id) = channel_id { + event_channel_ids.push(channel_id); } } + let level_color = match event.level { + PluginEventLevel::Debug => 0xAAAAAA, + PluginEventLevel::Info => 0x55B8F9, + PluginEventLevel::Warning => 0xFAE577, + PluginEventLevel::Error => 0xFF0000, + }; + let mut embed_builder = CreateEmbed::default() + .title(format!("[{}] {}", event.level, event.name)) + .color(level_color); + if let Some(description) = event.description { + embed_builder = embed_builder.description(description); + } + let builder = CreateMessage::new().embed(embed_builder); + for channel_id in event_channel_ids { + ChannelId::new(channel_id) + .send_message(&self.ctx.http, builder.clone()) + .await + .map_err(|err| Error::FailedToSendEvent { err })?; + } + db.close().await?; + Ok(()) } } diff --git a/src/plugins/debug.rs b/src/plugins/debug.rs index 67e0ad9..d430fbe 100644 --- a/src/plugins/debug.rs +++ b/src/plugins/debug.rs @@ -230,6 +230,8 @@ impl MotorbotPlugin for DebugPlugin { ) .await .map_err(|err| PluginError::FailedToRespond { err })?; + + db.close().await?; } } _ => { diff --git a/src/plugins/patches.rs b/src/plugins/patches.rs index 0f1913e..1974c13 100644 --- a/src/plugins/patches.rs +++ b/src/plugins/patches.rs @@ -60,7 +60,7 @@ impl PatchesPlugin { // against db if patch notes are different, post patch notes to channel for game_id in games_to_monitor { // Data from DB - let game_data = GameData::from_id(&game_id).await; + let game_data = GameData::from_id(&game_id).await?; // Patch notes from Platform let patch_notes = PatchNotes::fetch_for_platform(game_data.platform, &game_id) @@ -345,7 +345,7 @@ impl MotorbotPlugin for PatchesPlugin { let mut response = String::new(); let mut count = 1; for game_id in game_ids { - let game_data = GameData::from_id(&game_id).await; + let game_data = GameData::from_id(&game_id).await?; let guild_data = game_data .guild_data .iter() @@ -483,6 +483,7 @@ impl MotorbotPlugin for PatchesPlugin { .await .map_err(|err| PluginError::FailedToRespond { err })?; + db.close().await?; Ok(()) } } diff --git a/src/plugins/patches/game_data.rs b/src/plugins/patches/game_data.rs index df1a577..238ba97 100644 --- a/src/plugins/patches/game_data.rs +++ b/src/plugins/patches/game_data.rs @@ -1,6 +1,6 @@ -use tracing::error; - -use crate::{plugins::patches::platforms::platform::Platform, storage::Database}; +use crate::{ + plugins::patches::platforms::platform::Platform, storage::Database, Result, +}; /// Represents the guild specific data associated with a game, including game /// name, thumbnail, and color. @@ -51,45 +51,16 @@ impl GameData { /// # Arguments /// - `game_id` - The game id /// - `guild` - The guild id - pub async fn from_id(game_id: &str) -> Self { - let mut db = Database::open() - .await - .expect("Failed to connect to database"); - let game_details = db.game_details(game_id).await; - let news_items: Option> = match db.game_news(game_id).await { - Ok(items) => Some(items), - Err(e) => { - error!("Failed to fetch game news for game_id {}: {:?}", game_id, e); - None - } - }; - if let Err(why) = db.close().await { - error!("Failed to close database connection {:?}", why); - } - match game_details { - Ok(details) => { - GameData { - id: details.id, - platform: details.platform, - news_items, - guild_data: details.guild_data, - } - }, - Err(e) => { - error!("Failed to fetch game details for game_id {}: {:?}", game_id, e); - GameData::default() - } - } - } -} - -impl Default for GameData { - fn default() -> Self { - Self { - id: String::from(""), - platform: Platform::default(), - news_items: None, - guild_data: Vec::new(), - } + pub async fn from_id(game_id: &str) -> Result { + let mut db = Database::open().await?; + let details = db.game_details(game_id).await?; + let news_items: Vec = db.game_news(game_id).await?; + db.close().await?; + Ok(GameData { + id: details.id, + platform: details.platform, + news_items: Some(news_items), + guild_data: details.guild_data, + }) } } diff --git a/src/plugins/points.rs b/src/plugins/points.rs index 2f913e9..9def980 100644 --- a/src/plugins/points.rs +++ b/src/plugins/points.rs @@ -27,6 +27,7 @@ impl PointsPlugin { let current_user_score = db.user_score(user_id).await?; db.set_user_score(user_id, current_user_score.score + points) .await?; + db.close().await?; Ok(()) } @@ -40,6 +41,7 @@ impl PointsPlugin { let current_user_score = db.user_score(user_id).await?; db.set_user_score(user_id, current_user_score.score - points) .await?; + db.close().await?; Ok(()) } } @@ -138,6 +140,7 @@ impl MotorbotPlugin for PointsPlugin { .map_err(|err| PluginError::FailedToRespond { err })?; } } + db.close().await?; Ok(()) } @@ -193,6 +196,7 @@ impl MotorbotPlugin for PointsPlugin { } } } + db.close().await?; Ok(()) } @@ -248,6 +252,7 @@ impl MotorbotPlugin for PointsPlugin { } } } + db.close().await?; Ok(()) } From 216a5c06669d9f5f426920f0f5cf4c668d55a5ae Mon Sep 17 00:00:00 2001 From: Lennart Hase <1834270+motorlatitude@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:37:42 +0100 Subject: [PATCH 13/15] =?UTF-8?q?=F0=9F=9A=A8=20=20Fix=20clippy=20warnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- src/plugin/error.rs | 17 ++++ src/plugins/patches.rs | 8 +- src/plugins/patches/patch_notes.rs | 80 ++++++++-------- src/plugins/patches/platforms/platform.rs | 24 +++-- src/plugins/patches/platforms/riot.rs | 106 ++++++++++------------ src/plugins/patches/platforms/steam.rs | 22 +++-- src/storage/database.rs | 25 +++-- src/storage/error.rs | 8 +- 8 files changed, 150 insertions(+), 140 deletions(-) diff --git a/src/plugin/error.rs b/src/plugin/error.rs index 8252bbc..fd091fe 100644 --- a/src/plugin/error.rs +++ b/src/plugin/error.rs @@ -1,5 +1,7 @@ use derive_more::{Display, From}; +use crate::plugins::patches::platforms::platform::Platform; + /// Custom error type for plugin operations. #[derive(Debug, Display, From)] #[non_exhaustive] @@ -96,6 +98,21 @@ pub enum Error { /// set during initialization or operation. #[display("Plugin is in an invalid internal state")] InvalidInternalState, + + /// An error indicating that fetching patch notes for a game failed, such + /// as when calling an external API to retrieve patch notes and receiving an + /// error response or an invalid response that cannot be parsed as patch + /// notes. + #[display( + "Failed to fetch patch notes for game '{}'on platform '{}'", + game_id, + platform + )] + FetchPatchNotesFailed { + /// The platform for which fetching patch notes failed + platform: Platform, + game_id: String, + }, } impl std::error::Error for Error {} diff --git a/src/plugins/patches.rs b/src/plugins/patches.rs index 1974c13..a3bfee6 100644 --- a/src/plugins/patches.rs +++ b/src/plugins/patches.rs @@ -64,7 +64,7 @@ impl PatchesPlugin { // Patch notes from Platform let patch_notes = PatchNotes::fetch_for_platform(game_data.platform, &game_id) - .await; + .await?; // Compare gid let game_news_items = match &game_data.news_items { Some(items) => items, @@ -343,8 +343,7 @@ impl MotorbotPlugin for PatchesPlugin { .game_ids_for_guild(guild_id.get()) .await?; let mut response = String::new(); - let mut count = 1; - for game_id in game_ids { + for (count, game_id) in (1..).zip(game_ids.into_iter()) { let game_data = GameData::from_id(&game_id).await?; let guild_data = game_data .guild_data @@ -358,7 +357,6 @@ impl MotorbotPlugin for PatchesPlugin { "{}. {} ({})\n", count, game_name, game_data.id )); - count += 1; } if response.is_empty() { "No games are currently being monitored".to_string() @@ -428,7 +426,7 @@ impl MotorbotPlugin for PatchesPlugin { db.add_game( id, guild_id.get(), - Platform::from(platform), + Platform::try_from(platform)?, name, thumbnail, &color, diff --git a/src/plugins/patches/patch_notes.rs b/src/plugins/patches/patch_notes.rs index 435f473..dbed739 100644 --- a/src/plugins/patches/patch_notes.rs +++ b/src/plugins/patches/patch_notes.rs @@ -1,4 +1,5 @@ use super::platforms::{platform::Platform, riot::Riot, steam::Steam}; +use crate::Result; pub struct PatchNotes { pub title: String, @@ -15,11 +16,13 @@ impl PatchNotes { /// # Arguments /// - `platform` - The platform /// - `game_id` - The game id - pub async fn fetch_for_platform(platform: Platform, game_id: &str) -> Self { + pub async fn fetch_for_platform( + platform: Platform, + game_id: &str, + ) -> Result { match platform { Platform::Steam => Self::from_steam(game_id).await, Platform::Riot => Self::from_riot(game_id).await, - Platform::Unknown => Self::default(), } } @@ -27,48 +30,43 @@ impl PatchNotes { /// /// # Arguments /// - `game_id` - The Steam game id - async fn from_steam(game_id: &str) -> Self { + async fn from_steam(game_id: &str) -> Result { let steam_platform = Steam::new(); - let notes = steam_platform.fetch(game_id).await; - match notes { - Some(n) => Self { - title: n.title, - content: n.content, - url: n.url, - image: n.image, - gid: n.gid, - success: true, - }, - None => Self::default(), - } + let notes = steam_platform.fetch(game_id).await.ok_or_else(|| { + crate::Error::Plugin( + crate::plugin::PluginError::FetchPatchNotesFailed { + platform: Platform::Steam, + game_id: game_id.to_string(), + }, + ) + })?; + Ok(Self { + title: notes.title, + content: notes.content, + url: notes.url, + image: notes.image, + gid: notes.gid, + success: true, + }) } - async fn from_riot(game_id: &str) -> Self { + async fn from_riot(game_id: &str) -> Result { let riot_platform: Riot = Riot::new(); - let notes = riot_platform.fetch(game_id).await; - match notes { - Some(n) => Self { - title: n.title, - content: n.content, - url: n.url, - image: n.image, - gid: n.gid, - success: true, - }, - None => Self::default(), - } - } -} - -impl Default for PatchNotes { - fn default() -> Self { - Self { - title: String::from(""), - content: String::from(""), - url: String::from(""), - image: String::from(""), - gid: String::from(""), - success: false, - } + let notes = riot_platform.fetch(game_id).await.ok_or_else(|| { + crate::Error::Plugin( + crate::plugin::PluginError::FetchPatchNotesFailed { + platform: Platform::Riot, + game_id: game_id.to_string(), + }, + ) + })?; + Ok(Self { + title: notes.title, + content: notes.content, + url: notes.url, + image: notes.image, + gid: notes.gid, + success: true, + }) } } diff --git a/src/plugins/patches/platforms/platform.rs b/src/plugins/patches/platforms/platform.rs index 645887c..e522224 100644 --- a/src/plugins/patches/platforms/platform.rs +++ b/src/plugins/patches/platforms/platform.rs @@ -1,15 +1,12 @@ +use crate::{plugin::PluginError, Error}; use std::{fmt, str::FromStr}; -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Copy)] pub enum Platform { /// Steam platform Steam, /// Riot platform Riot, - /// An unknown platform, used as a fallback for invalid or unrecognized - /// platform strings - #[default] - Unknown, } impl Platform { @@ -25,12 +22,14 @@ impl Platform { } } -impl From<&str> for Platform { - fn from(s: &str) -> Self { +impl TryFrom<&str> for Platform { + type Error = Error; + + fn try_from(s: &str) -> Result { match s { - "steam" => Platform::Steam, - "riot" => Platform::Riot, - _ => Platform::Unknown, + "steam" => Ok(Platform::Steam), + "riot" => Ok(Platform::Riot), + _ => Err(Error::Plugin(PluginError::InvalidGamePlatform)), } } } @@ -40,19 +39,18 @@ impl fmt::Display for Platform { match self { Platform::Steam => write!(f, "steam"), Platform::Riot => write!(f, "riot"), - Platform::Unknown => write!(f, "unknown"), } } } impl FromStr for Platform { - type Err = (); + type Err = Error; fn from_str(input: &str) -> Result { match input { "steam" => Ok(Platform::Steam), "riot" => Ok(Platform::Riot), - _ => Ok(Platform::Unknown), + _ => Err(Error::Plugin(PluginError::InvalidGamePlatform)), } } } diff --git a/src/plugins/patches/platforms/riot.rs b/src/plugins/patches/platforms/riot.rs index e793679..4f185d3 100644 --- a/src/plugins/patches/platforms/riot.rs +++ b/src/plugins/patches/platforms/riot.rs @@ -23,22 +23,22 @@ use tracing::{debug, error}; /// use patches::platforms::riot::RiotGameId; /// /// let game_id = RiotGameId::from_str("lol").unwrap(); -/// assert_eq!(game_id, RiotGameId::LoL); +/// assert_eq!(game_id, RiotGameId::Lol); /// ``` #[derive(Debug, Clone, Copy)] pub enum RiotGameId { - LoL, - TFT, - VAL, + Lol, + Tft, + Val, Unknown, } impl fmt::Display for RiotGameId { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { - RiotGameId::LoL => write!(f, "lol"), - RiotGameId::TFT => write!(f, "tft"), - RiotGameId::VAL => write!(f, "val"), + RiotGameId::Lol => write!(f, "lol"), + RiotGameId::Tft => write!(f, "tft"), + RiotGameId::Val => write!(f, "val"), RiotGameId::Unknown => write!(f, "unknown"), } } @@ -49,9 +49,9 @@ impl FromStr for RiotGameId { fn from_str(input: &str) -> Result { match input { - "lol" => Ok(RiotGameId::LoL), - "tft" => Ok(RiotGameId::TFT), - "val" => Ok(RiotGameId::VAL), + "lol" => Ok(RiotGameId::Lol), + "tft" => Ok(RiotGameId::Tft), + "val" => Ok(RiotGameId::Val), _ => Ok(RiotGameId::Unknown), } } @@ -78,9 +78,11 @@ const LOL_BASE_NEWS_URL: &str = "https://www.leagueoflegends.com/en-gb/news/"; /// League of Legends base url const LOL_BASE_URL: &str = "https://www.leagueoflegends.com/en-gb/"; /// Teamfight Tactics base page data url -const TFT_BASE_NEWS_URL: &str = "https://teamfighttactics.leagueoflegends.com/en-gb/news/"; +const TFT_BASE_NEWS_URL: &str = + "https://teamfighttactics.leagueoflegends.com/en-gb/news/"; /// Teamfight Tactics base url -const TFT_BASE_URL: &str = "https://teamfighttactics.leagueoflegends.com/en-gb/"; +const TFT_BASE_URL: &str = + "https://teamfighttactics.leagueoflegends.com/en-gb/"; /// Valorant's base page data url const VAL_BASE_NEWS_URL: &str = "https://playvalorant.com/en-gb/news/"; /// Valorant's base url @@ -118,39 +120,43 @@ impl Riot { pub fn parse_news(&self, game_id: &str, raw_html: String) -> Vec { let document = Html::parse_document(raw_html.as_str()); let mut articles: Vec = vec![]; - let selector = Selector::parse(r#"section[id="news"] a[role="button"]"#).unwrap(); - let next_data_selector = Selector::parse(r#"script[id="__NEXT_DATA__"]"#).unwrap(); + let selector = + Selector::parse(r#"section[id="news"] a[role="button"]"#).unwrap(); + let next_data_selector = + Selector::parse(r#"script[id="__NEXT_DATA__"]"#).unwrap(); let content_description_selector = Selector::parse(r#"div[data-testid="card-description"]"#).unwrap(); for article in document.select(&selector) { - let mut url: String = article.value().attr("href").unwrap_or_default().to_string(); + let mut url: String = + article.value().attr("href").unwrap_or_default().to_string(); if url.starts_with("/") { let base_url = match RiotGameId::from_str(game_id).unwrap() { - RiotGameId::LoL => LOL_BASE_URL, - RiotGameId::TFT => TFT_BASE_URL, - RiotGameId::VAL => VAL_BASE_URL, + RiotGameId::Lol => LOL_BASE_URL, + RiotGameId::Tft => TFT_BASE_URL, + RiotGameId::Val => VAL_BASE_URL, RiotGameId::Unknown => "", }; url = format!("{}{}", base_url, url); } let title = article.value().attr("aria-label").unwrap_or_default(); - let content_description = match article - .select(&content_description_selector) - .next() { - Some(c) => c.text().collect::(), - None => "".to_string(), - }; + let content_description = + match article.select(&content_description_selector).next() { + Some(c) => c.text().collect::(), + None => "".to_string(), + }; // image is not available in the html and would require rendering // the page to get the image url, however a script JSON element can be // parsed in order to retreive the image url from JSON //let next_data = document.select(&next_data_selector).next().unwrap(); - let next_data_text = match document.select(&next_data_selector).next() { - Some(d) => d.text().collect::(), - None => "{}".to_string(), - }; - let next_data_json: serde_json::Value = serde_json::from_str(&next_data_text).unwrap(); - let image = next_data_json["props"]["pageProps"]["page"]["blades"][2]["items"][0] - ["media"]["url"] + let next_data_text = + match document.select(&next_data_selector).next() { + Some(d) => d.text().collect::(), + None => "{}".to_string(), + }; + let next_data_json: serde_json::Value = + serde_json::from_str(&next_data_text).unwrap(); + let image = next_data_json["props"]["pageProps"]["page"]["blades"] + [2]["items"][0]["media"]["url"] .as_str() .unwrap_or_default(); let mut hasher = DefaultHasher::new(); @@ -164,8 +170,8 @@ impl Riot { gid, }; //println!("{:?}", news); - if articles.iter().find(|a| a.title == news.title).is_none() { - articles.push(news); + if articles.iter().find(|a| a.title == news.title).is_none() { + articles.push(news); } } articles @@ -182,36 +188,16 @@ impl Riot { ) -> Result { let client = reqwest::Client::new(); let mut url = format!("{}{}", LOL_BASE_NEWS_URL, ""); + let article_path = article_path.unwrap_or(""); match RiotGameId::from_str(game_id).unwrap() { - RiotGameId::LoL => { - url = format!( - "{}{}", - LOL_BASE_NEWS_URL, - article_path - .is_some() - .then(|| article_path.unwrap()) - .unwrap_or("") - ); + RiotGameId::Lol => { + url = format!("{}{}", LOL_BASE_NEWS_URL, article_path); } - RiotGameId::TFT => { - url = format!( - "{}{}", - TFT_BASE_NEWS_URL, - article_path - .is_some() - .then(|| article_path.unwrap()) - .unwrap_or("") - ); + RiotGameId::Tft => { + url = format!("{}{}", TFT_BASE_NEWS_URL, article_path); } - RiotGameId::VAL => { - url = format!( - "{}{}", - VAL_BASE_NEWS_URL, - article_path - .is_some() - .then(|| article_path.unwrap()) - .unwrap_or("") - ); + RiotGameId::Val => { + url = format!("{}{}", VAL_BASE_NEWS_URL, article_path); } RiotGameId::Unknown => {} } diff --git a/src/plugins/patches/platforms/steam.rs b/src/plugins/patches/platforms/steam.rs index e191656..22dcd42 100644 --- a/src/plugins/patches/platforms/steam.rs +++ b/src/plugins/patches/platforms/steam.rs @@ -96,7 +96,8 @@ impl Steam { images.push(path.replace("{STEAM_CLAN_IMAGE}", STEAM_CLAN_IMAGE)); } let re3 = Regex::new(r"\[url=(.*?)](.*?)\[/url]").unwrap(); - let re_youtube = Regex::new(r"\[previewyoutube=(.*?)]\[/previewyoutube]").unwrap(); + let re_youtube = + Regex::new(r"\[previewyoutube=(.*?)]\[/previewyoutube]").unwrap(); // Remove img tags from content let parsed_content_1 = re.replace_all(&content, ""); let parsed_content_2 = re2.replace_all(&parsed_content_1, ""); @@ -112,22 +113,25 @@ impl Steam { .to_string() .replace("\"", ""); let mut image = ""; - if images.len() > 0 { + if !images.is_empty() { image = &images[0]; } - let truncated_content = match parsed_trimmed_content.char_indices().nth(400) { - None => parsed_trimmed_content, - Some((idx, _)) => &parsed_trimmed_content[..idx], - }; + let truncated_content = + match parsed_trimmed_content.char_indices().nth(400) { + None => parsed_trimmed_content, + Some((idx, _)) => &parsed_trimmed_content[..idx], + }; Some(SteamNews { title: patch_notes_title, content: format!( "{}{}", &truncated_content, - (parsed_trimmed_content.len() > 400) - .then(|| "...") - .unwrap_or("") + if parsed_trimmed_content.len() > 400 { + "..." + } else { + "" + } ), url: patch_notes_url, image: image.to_string(), diff --git a/src/storage/database.rs b/src/storage/database.rs index 656b517..4806ef2 100644 --- a/src/storage/database.rs +++ b/src/storage/database.rs @@ -3,6 +3,7 @@ use std::path::Path; use tracing::{debug, error, info, warn}; use crate::{ + plugin::PluginError, plugins::patches::{ game_data::{GameData, GuildGameData}, platforms::platform::Platform, @@ -436,13 +437,17 @@ impl Database { Error::Storage(StorageError::with_sql(e, Q_GAME_DETAILS)) })?; - let mut platform: Platform = Platform::Unknown; + let mut platform: Option = None; let news_items = None; let mut guild_data = Vec::new(); while let Some(row) = rows.next()? { let guild_str: String = row.get(0)?; let platform_str: String = row.get(1)?; - platform = Platform::from(platform_str.as_str()); + platform = Some( + Platform::try_from(platform_str.as_str()).map_err( + |_| Error::Plugin(PluginError::InvalidGamePlatform), + )?, + ); let name: String = row.get(2)?; let thumbnail: String = row.get(3)?; let color: String = row.get(4)?; @@ -453,12 +458,16 @@ impl Database { color, }); } - Ok(GameData { - id: game_id.to_string(), - platform, - news_items, - guild_data, - }) + if let Some(platform) = platform { + Ok(GameData { + id: game_id.to_string(), + platform, + news_items, + guild_data, + }) + } else { + Err(Error::Plugin(PluginError::InvalidGamePlatform)) + } } None => Err(Error::Storage(StorageError::InvalidConnection)), } diff --git a/src/storage/error.rs b/src/storage/error.rs index 82137b8..0f086e5 100644 --- a/src/storage/error.rs +++ b/src/storage/error.rs @@ -7,7 +7,7 @@ use std::fmt; #[non_exhaustive] pub enum Error { /// A Rusqlite error - RusqliteError { + Rusqlite { /// The SQL query that caused the error query: String, /// The Rusqlite error that occurred @@ -40,7 +40,7 @@ impl Error { /// # Returns /// A new `Error` instance containing the query and the error. pub fn with_sql(e: rusqlite::Error, query: &str) -> Self { - Self::RusqliteError { + Self::Rusqlite { query: query.into(), err: e, } @@ -49,7 +49,7 @@ impl Error { impl From for Error { fn from(e: rusqlite::Error) -> Self { - Self::RusqliteError { + Self::Rusqlite { query: String::new(), err: e, } @@ -59,7 +59,7 @@ impl From for Error { impl fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::RusqliteError { query, err } => write!(f, "Database error: {}. Query: {}", err, query), + Self::Rusqlite { query, err } => write!(f, "Database error: {}. Query: {}", err, query), Self::UnableToConnect { err } => write!(f, "Connection error: {}", err), Self::InvalidSchemaVersion { expected, found } => write!(f, "Invalid schema version: expected {}, found {}", expected, found), Self::InvalidConnection => write!(f, "Invalid connection state: connection is not initialized or has been closed"), From 057892a5668f0663207b728f0a243e50443e2e43 Mon Sep 17 00:00:00 2001 From: Lennart Hase <1834270+motorlatitude@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:45:40 +0100 Subject: [PATCH 14/15] =?UTF-8?q?=F0=9F=90=9B=20Fix=20iteration=20over=20g?= =?UTF-8?q?ame=20IDs=20in=20PatchesPlugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/patches.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/patches.rs b/src/plugins/patches.rs index a3bfee6..0858712 100644 --- a/src/plugins/patches.rs +++ b/src/plugins/patches.rs @@ -343,7 +343,7 @@ impl MotorbotPlugin for PatchesPlugin { .game_ids_for_guild(guild_id.get()) .await?; let mut response = String::new(); - for (count, game_id) in (1..).zip(game_ids.into_iter()) { + for (count, game_id) in (1..).zip(game_ids) { let game_data = GameData::from_id(&game_id).await?; let guild_data = game_data .guild_data From 1d5eca4a8b4467f90f0b67367cec99dd0af4a9a0 Mon Sep 17 00:00:00 2001 From: Lennart Hase <1834270+motorlatitude@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:06:36 +0100 Subject: [PATCH 15/15] =?UTF-8?q?=F0=9F=8E=A8=20Introduce=20PatchesError?= =?UTF-8?q?=20enum=20for=20improved=20error=20handling=20in=20Patches=20pl?= =?UTF-8?q?ugin;=20replace=20PluginError=20usage=20with=20custom=20error?= =?UTF-8?q?=20variants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- src/error.rs | 8 ++++ src/plugin/error.rs | 46 -------------------- src/plugins/patches.rs | 26 +++++------- src/plugins/patches/error.rs | 52 +++++++++++++++++++++++ src/plugins/patches/patch_notes.rs | 23 +++++----- src/plugins/patches/platforms/platform.rs | 10 +++-- src/storage/database.rs | 12 ++++-- 7 files changed, 97 insertions(+), 80 deletions(-) create mode 100644 src/plugins/patches/error.rs diff --git a/src/error.rs b/src/error.rs index 8b12e99..8d45d82 100644 --- a/src/error.rs +++ b/src/error.rs @@ -23,6 +23,14 @@ pub enum Error { #[from] Plugin(plugin::PluginError), + // -- Plugin errors + /// A plugin encountered an error that doesn't fit into any of the other + /// predefined categories, allowing plugins to return custom error messages + /// or types that can be displayed or logged without needing to define a new + /// variant in the main `Error` enum for every possible plugin error + /// scenario. + Custom(Box), + // -- External /// An error related to the Serenity library, such as issues with the /// Discord API. diff --git a/src/plugin/error.rs b/src/plugin/error.rs index fd091fe..b149f0b 100644 --- a/src/plugin/error.rs +++ b/src/plugin/error.rs @@ -1,7 +1,5 @@ use derive_more::{Display, From}; -use crate::plugins::patches::platforms::platform::Platform; - /// Custom error type for plugin operations. #[derive(Debug, Display, From)] #[non_exhaustive] @@ -64,55 +62,11 @@ pub enum Error { )] InvalidSubCommand, - /// An error indicating that a game ID provided to the Patches plugin was - /// invalid, such as not being a valid Steam game ID or not being found in - /// the database when expected. - #[display("Invalid game ID provided")] - InvalidGameId, - - /// An error indicating that a game platform provided to the Patches plugin - /// was invalid, such as not being a recognized platform like "steam" or - /// "riot". - #[display("Invalid game platform provided")] - InvalidGamePlatform, - - /// An error indicating that a game name provided to the Patches plugin was - /// invalid, such as being empty or not matching any known game names in the - /// database when expected. - #[display("Invalid game name provided")] - InvalidGameName, - - /// An error indicating that a game thumbnail URL provided to the Patches - /// plugin was invalid, such as not being a valid URL or not pointing to an - /// image when expected. - #[display("Invalid game thumbnail URL provided")] - InvalidGameThumbnail, - - /// An error indicating that a game color provided to the Patches plugin was - /// invalid, such as not being a valid hex color code when expected. - #[display("Invalid game color provided")] - InvalidGameColor, - /// An error indicating that the plugin is in an invalid internal state, /// such as missing necessary context or configuration that should have been /// set during initialization or operation. #[display("Plugin is in an invalid internal state")] InvalidInternalState, - - /// An error indicating that fetching patch notes for a game failed, such - /// as when calling an external API to retrieve patch notes and receiving an - /// error response or an invalid response that cannot be parsed as patch - /// notes. - #[display( - "Failed to fetch patch notes for game '{}'on platform '{}'", - game_id, - platform - )] - FetchPatchNotesFailed { - /// The platform for which fetching patch notes failed - platform: Platform, - game_id: String, - }, } impl std::error::Error for Error {} diff --git a/src/plugins/patches.rs b/src/plugins/patches.rs index 0858712..5404f1c 100644 --- a/src/plugins/patches.rs +++ b/src/plugins/patches.rs @@ -21,6 +21,8 @@ use serenity::all::{ }; use tracing::{error, info, warn}; +pub mod error; +pub use error::PatchesError; pub mod game_data; pub mod patch_notes; pub mod platforms; @@ -380,47 +382,41 @@ impl MotorbotPlugin for PatchesPlugin { "id", ResolvedValue::String ) - .ok_or(PluginError::InvalidGameId)?; + .ok_or(Error::Custom(Box::new(PatchesError::InvalidGameId)))?; let platform = option!( subcommand_options, "platform", ResolvedValue::String ) - .ok_or(PluginError::InvalidGamePlatform)?; + .ok_or(Error::Custom(Box::new(PatchesError::InvalidGamePlatform)))?; if !Platform::is_valid_platform(platform) { - return Err(Error::Plugin( - PluginError::InvalidGamePlatform, - )); + return Err(Error::Custom(Box::new(PatchesError::InvalidGamePlatform))); } else { let name = option!( subcommand_options, "name", ResolvedValue::String - ).ok_or(PluginError::InvalidGameName)?; + ).ok_or(Error::Custom(Box::new(PatchesError::InvalidGameName)))?; let thumbnail = option!( subcommand_options, "thumbnail", ResolvedValue::String - ).ok_or(PluginError::InvalidGameThumbnail)?; + ).ok_or(Error::Custom(Box::new(PatchesError::InvalidGameThumbnail)))?; if !thumbnail.starts_with("http") { - return Err(Error::Plugin( - PluginError::InvalidGameThumbnail, - )); + return Err(Error::Custom(Box::new(PatchesError::InvalidGameThumbnail))); } else { let raw_color = option!( subcommand_options, "color", ResolvedValue::String - ).ok_or(PluginError::InvalidGameColor)?; + ).ok_or(Error::Custom(Box::new(PatchesError::InvalidGameColor)))?; if !raw_color.starts_with("#") || raw_color.len() != 7 { - return Err(Error::Plugin( - PluginError::InvalidGameColor, - )); + return Err(Error::Custom(Box::new(PatchesError::InvalidGameColor))); } else { let color = raw_color[1..].to_uppercase(); db.add_game( @@ -453,7 +449,7 @@ impl MotorbotPlugin for PatchesPlugin { "id", ResolvedValue::String ) - .ok_or(PluginError::InvalidGameId)?; + .ok_or(Error::Custom(Box::new(PatchesError::InvalidGameId)))?; let guild = command .guild_id .ok_or(PluginError::ExpectedGuild)? diff --git a/src/plugins/patches/error.rs b/src/plugins/patches/error.rs new file mode 100644 index 0000000..939a1c0 --- /dev/null +++ b/src/plugins/patches/error.rs @@ -0,0 +1,52 @@ +use derive_more::Display; + +use crate::plugins::patches::platforms::platform::Platform; + +#[derive(Display, Debug)] +pub enum PatchesError { + /// An error indicating that a game ID provided to the Patches plugin was + /// invalid, such as not being a valid Steam game ID or not being found in + /// the database when expected. + #[display("Invalid game ID provided")] + InvalidGameId, + + /// An error indicating that a game platform provided to the Patches plugin + /// was invalid, such as not being a recognized platform like "steam" or + /// "riot". + #[display("Invalid game platform provided")] + InvalidGamePlatform, + + /// An error indicating that a game name provided to the Patches plugin was + /// invalid, such as being empty or not matching any known game names in the + /// database when expected. + #[display("Invalid game name provided")] + InvalidGameName, + + /// An error indicating that a game thumbnail URL provided to the Patches + /// plugin was invalid, such as not being a valid URL or not pointing to an + /// image when expected. + #[display("Invalid game thumbnail URL provided")] + InvalidGameThumbnail, + + /// An error indicating that a game color provided to the Patches plugin was + /// invalid, such as not being a valid hex color code when expected. + #[display("Invalid game color provided")] + InvalidGameColor, + + /// An error indicating that fetching patch notes for a game failed, such + /// as when calling an external API to retrieve patch notes and receiving an + /// error response or an invalid response that cannot be parsed as patch + /// notes. + #[display( + "Failed to fetch patch notes for game '{}'on platform '{}'", + game_id, + platform + )] + FetchPatchNotesFailed { + /// The platform for which fetching patch notes failed + platform: Platform, + game_id: String, + }, +} + +impl std::error::Error for PatchesError {} diff --git a/src/plugins/patches/patch_notes.rs b/src/plugins/patches/patch_notes.rs index dbed739..5589dfb 100644 --- a/src/plugins/patches/patch_notes.rs +++ b/src/plugins/patches/patch_notes.rs @@ -1,5 +1,6 @@ use super::platforms::{platform::Platform, riot::Riot, steam::Steam}; -use crate::Result; +use crate::plugins::patches::error::PatchesError; +use crate::{Error, Result}; pub struct PatchNotes { pub title: String, @@ -33,12 +34,10 @@ impl PatchNotes { async fn from_steam(game_id: &str) -> Result { let steam_platform = Steam::new(); let notes = steam_platform.fetch(game_id).await.ok_or_else(|| { - crate::Error::Plugin( - crate::plugin::PluginError::FetchPatchNotesFailed { - platform: Platform::Steam, - game_id: game_id.to_string(), - }, - ) + Error::Custom(Box::new(PatchesError::FetchPatchNotesFailed { + platform: Platform::Steam, + game_id: game_id.to_string(), + })) })?; Ok(Self { title: notes.title, @@ -53,12 +52,10 @@ impl PatchNotes { async fn from_riot(game_id: &str) -> Result { let riot_platform: Riot = Riot::new(); let notes = riot_platform.fetch(game_id).await.ok_or_else(|| { - crate::Error::Plugin( - crate::plugin::PluginError::FetchPatchNotesFailed { - platform: Platform::Riot, - game_id: game_id.to_string(), - }, - ) + Error::Custom(Box::new(PatchesError::FetchPatchNotesFailed { + platform: Platform::Riot, + game_id: game_id.to_string(), + })) })?; Ok(Self { title: notes.title, diff --git a/src/plugins/patches/platforms/platform.rs b/src/plugins/patches/platforms/platform.rs index e522224..671dae4 100644 --- a/src/plugins/patches/platforms/platform.rs +++ b/src/plugins/patches/platforms/platform.rs @@ -1,4 +1,4 @@ -use crate::{plugin::PluginError, Error}; +use crate::{plugins::patches::PatchesError, Error}; use std::{fmt, str::FromStr}; #[derive(Debug, Clone, Copy)] @@ -29,7 +29,9 @@ impl TryFrom<&str> for Platform { match s { "steam" => Ok(Platform::Steam), "riot" => Ok(Platform::Riot), - _ => Err(Error::Plugin(PluginError::InvalidGamePlatform)), + _ => { + Err(Error::Custom(Box::new(PatchesError::InvalidGamePlatform))) + } } } } @@ -50,7 +52,9 @@ impl FromStr for Platform { match input { "steam" => Ok(Platform::Steam), "riot" => Ok(Platform::Riot), - _ => Err(Error::Plugin(PluginError::InvalidGamePlatform)), + _ => { + Err(Error::Custom(Box::new(PatchesError::InvalidGamePlatform))) + } } } } diff --git a/src/storage/database.rs b/src/storage/database.rs index 4806ef2..688cc81 100644 --- a/src/storage/database.rs +++ b/src/storage/database.rs @@ -3,10 +3,10 @@ use std::path::Path; use tracing::{debug, error, info, warn}; use crate::{ - plugin::PluginError, plugins::patches::{ game_data::{GameData, GuildGameData}, platforms::platform::Platform, + PatchesError, }, storage::StorageError, }; @@ -445,7 +445,11 @@ impl Database { let platform_str: String = row.get(1)?; platform = Some( Platform::try_from(platform_str.as_str()).map_err( - |_| Error::Plugin(PluginError::InvalidGamePlatform), + |_| { + Error::Custom(Box::new( + PatchesError::InvalidGamePlatform, + )) + }, )?, ); let name: String = row.get(2)?; @@ -466,7 +470,9 @@ impl Database { guild_data, }) } else { - Err(Error::Plugin(PluginError::InvalidGamePlatform)) + Err(Error::Custom(Box::new( + PatchesError::InvalidGamePlatform, + ))) } } None => Err(Error::Storage(StorageError::InvalidConnection)),