diff --git a/Cargo.lock b/Cargo.lock index a941dac..998fc48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,9 +62,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -83,9 +83,9 @@ checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -112,9 +112,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arbitrary" @@ -216,11 +216,23 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "byteview" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c53ba0f290bfc610084c05582d9c5d421662128fc69f4bf236707af6fd321b9" [[package]] name = "cap-fs-ext" @@ -332,9 +344,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -355,9 +367,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.54" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -365,9 +377,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -377,9 +389,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", @@ -389,9 +401,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.7" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmake" @@ -427,6 +439,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "compare" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0095f6103c2a8b44acd6fd15960c801dafebf02e21940360833e0673f48ba7" + [[package]] name = "core-foundation" version = "0.9.4" @@ -663,6 +681,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-skiplist" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -814,38 +842,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "discord-bot" -version = "0.1.0" -dependencies = [ - "anyhow", - "bytes", - "chrono", - "clap", - "dotenvy", - "indexmap", - "reqwest", - "rustls 0.23.36", - "semver", - "serde", - "serde_yaml_ng", - "sonic-rs", - "tokio", - "tokio-cron-scheduler", - "tokio-util", - "tracing", - "tracing-appender", - "tracing-subscriber", - "twilight-cache-inmemory", - "twilight-gateway", - "twilight-http", - "twilight-model", - "url", - "wasmtime", - "wasmtime-wasi", - "wasmtime-wasi-http", -] - [[package]] name = "displaydoc" version = "0.2.5" @@ -917,6 +913,18 @@ dependencies = [ "syn", ] +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -980,6 +988,23 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "fjall" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a9530ff159bc3ad3a15da746da0f6e95375c2ac64708cbb85ec1ebd26761a84" +dependencies = [ + "byteorder-lite", + "byteview", + "dashmap", + "flume", + "log", + "lsm-tree", + "lz4_flex", + "tempfile", + "xxhash-rust", +] + [[package]] name = "float-cmp" version = "0.10.0" @@ -989,6 +1014,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1144,11 +1178,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "gimli" version = "0.32.3" @@ -1356,7 +1403,7 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls 0.23.36", + "rustls 0.23.37", "rustls-pki-types", "rustls-platform-verifier", "tokio", @@ -1382,7 +1429,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -1544,9 +1591,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -1554,6 +1601,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "interval-heap" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11274e5e8e89b8607cfedc2910b6626e998779b48a019151c7604d0adcb86ac6" +dependencies = [ + "compare", +] + [[package]] name = "io-extras" version = "0.18.4" @@ -1701,9 +1757,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libm" @@ -1760,6 +1816,37 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lsm-tree" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d67f95fd716870329c30aaeedf87f23d426564e6ce46efa045a91444faf2a19" +dependencies = [ + "byteorder-lite", + "byteview", + "crossbeam-skiplist", + "enum_dispatch", + "interval-heap", + "log", + "lz4_flex", + "quick_cache", + "rustc-hash", + "self_cell", + "sfa", + "tempfile", + "varint-rs", + "xxhash-rust", +] + +[[package]] +name = "lz4_flex" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db9a0d582c2874f68138a16ce1867e0ffde6c0bb0a0df85e1f36d04146db488a" +dependencies = [ + "twox-hash", +] + [[package]] name = "mach2" version = "0.4.3" @@ -1798,9 +1885,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -2039,6 +2126,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2091,6 +2188,16 @@ dependencies = [ "syn", ] +[[package]] +name = "quick_cache" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a70b1b8b47e31d0498ecbc3c5470bb931399a8bfed1fd79d1717a61ce7f96e3" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2103,8 +2210,8 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.36", - "socket2 0.6.2", + "rustls 0.23.37", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -2124,7 +2231,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustc-hash", - "rustls 0.23.36", + "rustls 0.23.37", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -2142,16 +2249,16 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.2", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -2162,6 +2269,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rancor" version = "0.1.1" @@ -2346,9 +2459,9 @@ checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" [[package]] name = "reqwest" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64", "bytes", @@ -2369,7 +2482,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.36", + "rustls 0.23.37", "rustls-pki-types", "rustls-platform-verifier", "sync_wrapper", @@ -2406,9 +2519,9 @@ dependencies = [ [[package]] name = "rkyv" -version = "0.8.14" +version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "360b333c61ae24e5af3ae7c8660bd6b21ccd8200dbbc5d33c2454421e85b9c69" +checksum = "1a30e631b7f4a03dee9056b8ef6982e8ba371dd5bedb74d3ec86df4499132c70" dependencies = [ "bytes", "hashbrown 0.16.1", @@ -2424,9 +2537,9 @@ dependencies = [ [[package]] name = "rkyv_derive" -version = "0.8.14" +version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02f8cdd12b307ab69fe0acf4cd2249c7460eb89dce64a0febadf934ebb6a9e" +checksum = "8100bb34c0a1d0f907143db3149e6b4eea3c33b9ee8b189720168e818303986f" dependencies = [ "proc-macro2", "quote", @@ -2497,9 +2610,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.36" +version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", "log", @@ -2543,7 +2656,7 @@ dependencies = [ "jni", "log", "once_cell", - "rustls 0.23.36", + "rustls 0.23.37", "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki 0.103.9", @@ -2641,6 +2754,12 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "semver" version = "1.0.27" @@ -2750,6 +2869,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sfa" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1296838937cab56cd6c4eeeb8718ec777383700c33f060e2869867bd01d1175" +dependencies = [ + "byteorder-lite", + "log", + "xxhash-rust", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -2855,12 +2985,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2874,9 +3004,9 @@ dependencies = [ [[package]] name = "sonic-rs" -version = "0.5.6" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4425ea8d66ec950e0a8f2ef52c766cc3d68d661d9a0845c353c40833179fd866" +checksum = "d971cc77a245ccf1756dbd1a87c3e7f709c0191464096510d43eec056d0f2c4f" dependencies = [ "ahash", "bumpalo", @@ -2885,12 +3015,12 @@ dependencies = [ "faststr", "itoa", "ref-cast", - "ryu", "serde", "simdutf8", "sonic-number", "sonic-simd", "thiserror 2.0.18", + "zmij", ] [[package]] @@ -2902,6 +3032,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2943,9 +3082,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -3150,9 +3289,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" dependencies = [ "bytes", "libc", @@ -3160,7 +3299,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -3184,9 +3323,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -3210,7 +3349,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.36", + "rustls 0.23.37", "tokio", ] @@ -3391,9 +3530,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "nu-ansi-term", "sharded-slab", @@ -3464,7 +3603,7 @@ dependencies = [ "hyper-rustls", "hyper-util", "percent-encoding", - "rustls 0.23.36", + "rustls 0.23.37", "serde", "serde_json", "simd-json", @@ -3506,6 +3645,12 @@ dependencies = [ "twilight-model", ] +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + [[package]] name = "typenum" version = "1.19.0" @@ -3568,11 +3713,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.20.0" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] @@ -3595,6 +3740,12 @@ dependencies = [ "ryu", ] +[[package]] +name = "varint-rs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f54a172d0620933a27a4360d3db3e2ae0dd6cceae9730751a036bbf182c4b23" + [[package]] name = "version_check" version = "0.9.5" @@ -3635,6 +3786,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -3735,6 +3895,18 @@ dependencies = [ "wasmparser 0.244.0", ] +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", +] + [[package]] name = "wasmparser" version = "0.243.0" @@ -3755,6 +3927,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", + "hashbrown 0.15.5", "indexmap", "semver", ] @@ -3886,7 +4059,7 @@ dependencies = [ "syn", "wasmtime-internal-component-util", "wasmtime-internal-wit-bindgen", - "wit-parser", + "wit-parser 0.243.0", ] [[package]] @@ -4027,7 +4200,7 @@ dependencies = [ "bitflags", "heck", "indexmap", - "wit-parser", + "wit-parser 0.243.0", ] [[package]] @@ -4671,6 +4844,70 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.244.0", + "wasm-metadata", + "wasmparser 0.244.0", + "wit-parser 0.244.0", +] [[package]] name = "wit-parser" @@ -4690,6 +4927,24 @@ dependencies = [ "wasmparser 0.243.0", ] +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.244.0", +] + [[package]] name = "witx" version = "0.9.1" @@ -4702,12 +4957,52 @@ dependencies = [ "wast 35.0.2", ] +[[package]] +name = "wpbs" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytes", + "chrono", + "clap", + "dotenvy", + "fjall", + "indexmap", + "reqwest", + "rustls 0.23.37", + "semver", + "serde", + "serde_yaml_ng", + "sonic-rs", + "tokio", + "tokio-cron-scheduler", + "tokio-util", + "tracing", + "tracing-appender", + "tracing-subscriber", + "twilight-cache-inmemory", + "twilight-gateway", + "twilight-http", + "twilight-model", + "url", + "uuid", + "wasmtime", + "wasmtime-wasi", + "wasmtime-wasi-http", +] + [[package]] name = "writeable" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 804d60f..983e367 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "discord-bot" -description = "A WASI plugin based Discord bot, configurable through YAML." +name = "wpbs" +description = "WASM plugin based services." version = "0.1.0" authors = ["Eduard Smet "] license = "GPL-3.0-or-later" @@ -9,24 +9,25 @@ edition = "2024" [features] [dependencies] -anyhow = "1" -bytes = "1" -chrono = "0.4" -clap = { version = "4", features = ["derive"] } -dotenvy = "0.15" -indexmap = "2" -reqwest = { version = "0.13", features = ["hickory-dns"] } -rustls = "0.23" -semver = "1" -serde = "1" -serde_yaml_ng = "0.10" # Should replace this with a better maintained YAML 1.2 supporting alternative -sonic-rs = "0.5" -tokio = { version = "1", features = ["full"] } -tokio-cron-scheduler = { version = "0.15", features = ["english"] } -tokio-util = "0.7" -tracing = "0.1" -tracing-subscriber = "0.3" -tracing-appender = "0.2" +anyhow = "1.0.102" +bytes = "1.11.1" +chrono = "0.4.44" +clap = { version = "4.6.0", features = ["derive"] } +dotenvy = "0.15.7" +fjall = "3.1.2" +indexmap = "2.13.1" +reqwest = { version = "0.13.2", features = ["hickory-dns"] } +rustls = "0.23.37" +semver = "1.0.27" +serde = "1.0.102" +serde_yaml_ng = "0.10.0" # Should replace this with a better maintained YAML 1.2 supporting alternative +sonic-rs = "0.5.8" +tokio = { version = "1.51.0", features = ["full"] } +tokio-cron-scheduler = { version = "0.15.1", features = ["english"] } +tokio-util = "0.7.18" +tracing = "0.1.44" +tracing-subscriber = "0.3.23" +tracing-appender = "0.2.4" twilight-cache-inmemory = { git = "https://github.com/celarye/twilight/", branch = "feat/form-buffer" } twilight-gateway = { git = "https://github.com/celarye/twilight/", branch = "feat/form-buffer", features = [ "simd-json", @@ -36,7 +37,8 @@ twilight-http = { git = "https://github.com/celarye/twilight/", branch = "feat/f "hickory", ] } twilight-model = { git = "https://github.com/celarye/twilight/", branch = "feat/form-buffer" } -url = "2" +url = "2.5.8" +uuid = "1.11.1" wasmtime = "41" wasmtime-wasi = "41" wasmtime-wasi-http = "41" diff --git a/README.md b/README.md index 9649621..5be79c7 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,17 @@ -# Discord Bot +# wbps -A WASI plugin based Discord bot, configurable through YAML. +WASM based plugin services. ## Project Goal The goal of this project is to create a **Docker Compose-like experience** for -Discord bots. +running services like Discord bots or cron jobs. -Users are able to self-host their own personal bot, assembled from plugins +Users are able to self-host their own personal instance, assembled from plugins available from the official registry. -This official registry contains plugins covering the most common features -required from Discord bots. +This official registry contains plugins covering the most used features. Programmers are also able to add their own custom plugins. This allows them to focus on what really matters (the functionality of that plugin) while relying on features provided by other plugins. - -## To Do List - -- [X] Codebase restructure -- [X] Complete the job scheduler -- [X] Implement all WASI host functions -- [X] Implement the Discord request handler -- [ ] Microservice based daemon rewrite -- [ ] Implement all TODOs -- [ ] Add support for all Discord events and requests -- [ ] Make plugins diff --git a/justfile b/justfile index cc84f4b..ef74a2a 100644 --- a/justfile +++ b/justfile @@ -12,6 +12,9 @@ clippy: clippy-fix: cargo clippy --fix -- -W clippy::pedantic +fmt: + cargo fmt + build-dev: cargo build diff --git a/src/cli.rs b/src/cli.rs index 6d34823..f90b59f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -39,6 +39,9 @@ pub struct Cli { #[arg(action=ArgAction::Set, default_value_t = true, short = 'C', long, value_name = "BOOL", help = "Enable the usage of cached plugins", long_help = None, hide_possible_values = true)] pub cache: bool, + #[arg(default_value = "./database", short, long, value_name = "DIRECTORY PATH", help = "The path to the program its database", long_help = None)] + pub database_directory: PathBuf, + #[arg(default_value_t = 15, short = 't', long, value_name = "SECONDS", help = "The amount of seconds after which the HTTP client should timeout", long_help = None)] pub http_client_timeout_seconds: u64, } diff --git a/src/config.rs b/src/config.rs index bdf4854..1020da2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,7 +7,9 @@ use indexmap::IndexMap; use serde::Deserialize; use tracing::{error, info}; -use crate::plugins::ConfigPlugin; +use crate::config::plugins::ConfigPlugin; + +pub mod plugins; #[derive(Deserialize)] pub struct Config { diff --git a/src/config/plugins.rs b/src/config/plugins.rs new file mode 100644 index 0000000..fda9f2f --- /dev/null +++ b/src/config/plugins.rs @@ -0,0 +1,19 @@ +use std::collections::HashMap; + +use serde::Deserialize; +use sonic_rs::Value; + +use crate::config::plugins::permissions::PluginPermissions; + +pub mod permissions; + +#[derive(Deserialize)] +pub struct ConfigPlugin { + pub plugin: String, + pub cache: Option, + pub permissions: PluginPermissions, + #[serde(default)] + pub environment: HashMap, + #[serde(default)] + pub settings: Value, +} diff --git a/src/config/plugins/permissions.rs b/src/config/plugins/permissions.rs new file mode 100644 index 0000000..b123f17 --- /dev/null +++ b/src/config/plugins/permissions.rs @@ -0,0 +1,51 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize)] +pub struct PluginPermissions { + #[serde(default)] + pub core: Vec, + #[serde(default)] + pub job_scheduler: Vec, + #[serde(default)] + pub discord: PluginPermissionsDiscord, +} + +#[derive(Default, Deserialize, Serialize)] +pub struct PluginPermissionsDiscord { + pub events: Vec, + pub interactions: Vec, +} + +#[derive(Debug, Deserialize, PartialEq, Serialize)] +#[serde(untagged)] +pub enum PluginPermissionsCore { + DependencyFunctions, + Shutdown, +} + +#[derive(Debug, Deserialize, PartialEq, Serialize)] +#[serde(untagged)] +pub enum PluginPermissionsJobScheduler { + ScheduledJobs, +} + +#[derive(Debug, Deserialize, PartialEq, Serialize)] +#[serde(untagged)] +pub enum PluginPermissionsDiscordEvents { + MessageCreate, + InteractionCreate, + ThreadCreate, + ThreadDelete, + ThreadListSync, + ThreadMemberUpdate, + ThreadMembersUpdate, + ThreadUpdate, +} + +#[derive(Debug, Deserialize, PartialEq, Serialize)] +#[serde(untagged)] +pub enum PluginPermissionsDiscordInteractions { + ApplicationCommands, + MessageComponents, + Modals, +} diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 0000000..5dfdf48 --- /dev/null +++ b/src/database.rs @@ -0,0 +1,131 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +/* Copyright © 2026 Eduard Smet */ + +use std::{ + fs::{self}, + io::ErrorKind, + ops::RangeBounds, + path::Path, +}; + +use anyhow::{Result, bail}; +use fjall::{Database, Iter, KeyspaceCreateOptions, PersistMode, Slice}; + +use crate::utils::channels::DatabaseMessages; + +pub enum Keyspaces { + Plugins, // K: Uuid; AvailablePlugin + PluginStore, // K: String (Uuid-String); V: Vec + DependencyFunctions, // K: String (registry_id/plugin_id): V: Uuid + ScheduledJobs, // K: Uuid; V: Uuid; + DiscordEvents, // K: String; V: Vec + DiscordApplicationCommands, // K: u64; V: Uuid + DiscordMessageComponents, // K: Uuid; V: Uuid + DiscordModals, // K: Uuid; V: Uuid +} + +pub fn new(database_directory_path: &Path) -> Result { + if let Err(err) = fs::create_dir_all(database_directory_path) + && err.kind() != ErrorKind::AlreadyExists + { + bail!(err); + } + + Ok(Database::builder(database_directory_path).open()?) +} + +pub fn handle_action(database: Database, message: DatabaseMessages) { + match message { + DatabaseMessages::Get(keyspace, key, response_sender) => { + response_sender.send(get(database, keyspace, key)); + } + DatabaseMessages::GetAllKeys(keyspace, response_sender) => { + response_sender.send(get_all_keys(database, keyspace)); + } + DatabaseMessages::GetAllValues(keyspace, response_sender) => { + response_sender.send(get_all_values(database, keyspace)); + } + DatabaseMessages::Insert(keyspace, key, value, response_sender) => { + response_sender.send(insert(database, keyspace, key, value)); + } + DatabaseMessages::Remove(keyspace, key, response_sender) => { + response_sender.send(remove(database, keyspace, key)); + } + DatabaseMessages::ContainsKey(keyspace, key, response_sender) => { + response_sender.send(contains_key(database, keyspace, key)); + } + DatabaseMessages::Clear(keyspace, response_sender) => { + response_sender.send(clear(database, keyspace)); + } + } +} + +pub fn get(database: Database, keyspace: Keyspaces, key: Vec) -> Result> { + let keyspace = database.keyspace(get_keyspace(keyspace), KeyspaceCreateOptions::default)?; + + Ok(keyspace.get(key)?) +} + +// TODO: Need to look into how to support this through the MPSC channel +pub fn range(database: Database, keyspace: Keyspaces, range: R) -> Result +where + K: AsRef<[u8]>, + R: RangeBounds, +{ + let keyspace = database.keyspace(get_keyspace(keyspace), KeyspaceCreateOptions::default)?; + + Ok(keyspace.range(range)) +} + +pub fn get_all_keys(database: Database, keyspace: Keyspaces) -> Result> { + Ok(range(database, keyspace, Vec::new()..=Vec::new())? + .map(|g| g.key()) + .collect::, fjall::Error>>()?) +} + +pub fn get_all_values(database: Database, keyspace: Keyspaces) -> Result> { + Ok(range(database, keyspace, Vec::new()..=Vec::new())? + .map(|g| g.value()) + .collect::, fjall::Error>>()?) +} + +pub fn insert(database: Database, keyspace: Keyspaces, key: Vec, value: Vec) -> Result<()> { + let keyspace = database.keyspace(get_keyspace(keyspace), KeyspaceCreateOptions::default)?; + + Ok(keyspace.insert(key, value)?) +} + +pub fn remove(database: Database, keyspace: Keyspaces, key: Vec) -> Result<()> { + let keyspace = database.keyspace(get_keyspace(keyspace), KeyspaceCreateOptions::default)?; + + Ok(keyspace.remove(key)?) +} + +pub fn contains_key(database: Database, keyspace: Keyspaces, key: Vec) -> Result { + let keyspace = database.keyspace(get_keyspace(keyspace), KeyspaceCreateOptions::default)?; + + Ok(keyspace.contains_key(key)?) +} + +pub fn clear(database: Database, keyspace: Keyspaces) -> Result<()> { + let keyspace = database.keyspace(get_keyspace(keyspace), KeyspaceCreateOptions::default)?; + + Ok(keyspace.clear()?) +} + +pub fn persist(database: Database, persist_mode: PersistMode) -> Result<()> { + Ok(database.persist(persist_mode)?) +} + +fn get_keyspace(keyspace: Keyspaces) -> &'static str { + match keyspace { + Keyspaces::Plugins => "plugins", + Keyspaces::PluginStore => "plugin_store", + Keyspaces::DependencyFunctions => "dependency_functions", + Keyspaces::ScheduledJobs => "scheduled_jobs", + Keyspaces::DiscordEvents => "discord_events", + Keyspaces::DiscordApplicationCommands => "discord_application_commands", + Keyspaces::DiscordMessageComponents => "discord_message_componets", + Keyspaces::DiscordModals => "discord_modals", + } +} diff --git a/src/discord.rs b/src/discord.rs deleted file mode 100644 index 59bfb07..0000000 --- a/src/discord.rs +++ /dev/null @@ -1,178 +0,0 @@ -/* SPDX-License-Identifier: GPL-3.0-or-later */ -/* Copyright © 2026 Eduard Smet */ - -use std::{collections::HashMap, sync::Arc}; - -use tokio::{ - sync::{ - Mutex, RwLock, - mpsc::{Receiver, Sender}, - }, - task::JoinHandle, -}; -use tracing::{error, info}; -use twilight_cache_inmemory::{DefaultInMemoryCache, InMemoryCache, ResourceType}; -use twilight_gateway::{ - CloseFrame, Config, Event, EventType, EventTypeFlags, Intents, MessageSender, Shard, StreamExt, -}; -use twilight_http::Client; -use twilight_model::id::{Id, marker::GuildMarker}; - -use crate::{ - SHUTDOWN, - plugins::PluginRegistrations, - utils::channels::{DiscordBotClientMessages, RuntimeMessages}, -}; - -mod events; -mod interactions; -mod requests; - -pub struct DiscordBotClient { - http_client: Arc, - shard_message_senders: Arc, Arc>>>, - cache: Arc, - plugin_registrations: Arc>, - runtime_tx: Arc>, - runtime_rx: Arc>>, -} - -impl DiscordBotClient { - pub async fn new( - token: String, - plugin_registrations: Arc>, - runtime_tx: Sender, - runtime_rx: Receiver, - ) -> Result<(Self, Box + Send>), ()> { - info!("Creating the Discord bot client"); - - let intents = Intents::all(); - - rustls::crypto::aws_lc_rs::default_provider() - .install_default() - .unwrap(); - - let http_client = Client::new(token.clone()); - - let config = Config::new(token, intents); - - let shards = match twilight_gateway::create_recommended( - &http_client, - config, - |_, builder| builder.build(), - ) - .await - { - Ok(shards) => Box::new(shards), - Err(err) => { - error!( - "Something went wrong while getting the recommended amount of shards from Discord, error: {}", - &err - ); - return Err(()); - } - }; - - let shard_message_senders = Arc::new(RwLock::new(HashMap::new())); - - let cache = Arc::new( - DefaultInMemoryCache::builder() - .resource_types(ResourceType::all()) - .build(), - ); - - Ok(( - DiscordBotClient { - http_client: Arc::new(http_client), - shard_message_senders, - cache, - plugin_registrations, - runtime_tx: Arc::new(runtime_tx), - runtime_rx: Arc::new(Mutex::new(runtime_rx)), - }, - shards, - )) - } - - pub fn start(self, shards: Box + Send>) -> JoinHandle<()> { - let mut tasks = Vec::with_capacity(shards.len()); - - let discord_bot_client = Arc::new(self); - - for shard in shards { - tasks.push(tokio::spawn(Self::shard_runner( - discord_bot_client.clone(), - shard, - ))); - } - - tokio::spawn(async move { - while let Some(message) = discord_bot_client.runtime_rx.lock().await.recv().await { - match message { - DiscordBotClientMessages::RegisterApplicationCommands(commands) => { - let _ = discord_bot_client - .application_command_registrations(commands) - .await; - } - DiscordBotClientMessages::Request(request, response_sender) => { - let _ = response_sender.send(discord_bot_client.request(request).await); - } - DiscordBotClientMessages::Shutdown(is_done) => { - for sender in discord_bot_client - .shard_message_senders - .read() - .await - .values() - { - _ = sender.close(CloseFrame::NORMAL); - } - - for task in tasks.drain(..) { - let _ = task.await; - } - - let _ = is_done.send(()); - } - } - } - }) - } - - pub async fn shard_runner(discord_bot_client: Arc, mut shard: Shard) { - let shard_message_sender = Arc::new(shard.sender()); - - while let Some(item) = shard.next_event(EventTypeFlags::all()).await { - let Ok(event) = item else { - error!( - "Something went wrong while receiving the next gateway event: {}", - item.as_ref().unwrap_err() - ); - - continue; - }; - - if event.kind() == EventType::GatewayClose && SHUTDOWN.read().await.is_some() { - break; - } - - discord_bot_client.cache.update(&event); - - match event { - Event::Ready(ready) => { - info!("Shard is ready, logged in as {}", &ready.user.name); - - for guild in ready.guilds { - discord_bot_client - .shard_message_senders - .write() - .await - .insert(guild.id, shard_message_sender.clone()); - } - } - _ => { - tokio::spawn(Self::handle_event(discord_bot_client.clone(), event)); - } - } - } - } -} diff --git a/src/discord/events.rs b/src/discord/events.rs deleted file mode 100644 index 42cb2e7..0000000 --- a/src/discord/events.rs +++ /dev/null @@ -1,231 +0,0 @@ -/* SPDX-License-Identifier: GPL-3.0-or-later */ -/* Copyright © 2026 Eduard Smet */ - -use std::{any::Any, sync::Arc}; - -use tracing::{debug, error}; -use twilight_gateway::Event; -use twilight_model::application::interaction::InteractionData; - -use crate::{ - discord::DiscordBotClient, - plugins::discord_bot::plugin::discord_types::Events as DiscordEvents, - utils::channels::RuntimeMessages, -}; - -impl DiscordBotClient { - #[allow(clippy::too_many_lines)] - pub async fn handle_event(discord_bot_client: Arc, event: Event) { - match event { - Event::InteractionCreate(interaction_create) => { - match interaction_create.data.as_ref() { - Some(InteractionData::ApplicationCommand(command_data)) => { - let initialized_plugins = - discord_bot_client.plugin_registrations.read().await; - - let Some(plugin) = initialized_plugins - .discord_events - .interaction_create - .application_commands - .get(&command_data.id) - else { - return; - }; - - let _ = discord_bot_client - .runtime_tx - .send(RuntimeMessages::CallDiscordEvent( - plugin.clone(), - DiscordEvents::InteractionCreate( - sonic_rs::to_vec(&interaction_create).unwrap(), - ), - )) - .await; - } - Some(InteractionData::MessageComponent(message_component_interaction_data)) => { - let initialized_plugins = - discord_bot_client.plugin_registrations.read().await; - - let Some(plugin) = initialized_plugins - .discord_events - .interaction_create - .message_components - .get(&message_component_interaction_data.custom_id) - else { - return; - }; - - let _ = discord_bot_client - .runtime_tx - .send(RuntimeMessages::CallDiscordEvent( - plugin.clone(), - DiscordEvents::InteractionCreate( - sonic_rs::to_vec(&interaction_create).unwrap(), - ), - )) - .await; - } - Some(InteractionData::ModalSubmit(modal_interaction_data)) => { - let initialized_plugins = - discord_bot_client.plugin_registrations.read().await; - - let Some(plugin) = initialized_plugins - .discord_events - .interaction_create - .modals - .get(&modal_interaction_data.custom_id) - else { - return; - }; - - let _ = discord_bot_client - .runtime_tx - .send(RuntimeMessages::CallDiscordEvent( - plugin.clone(), - DiscordEvents::InteractionCreate( - sonic_rs::to_vec(&interaction_create).unwrap(), - ), - )) - .await; - } - None => error!("Interaction data is required."), - _ => error!( - "This interaction create data type does not have support yet, interaction data type: {:#?}", - &interaction_create.data.as_ref().unwrap().type_id() - ), - } - } - Event::MessageCreate(message_create) => { - for plugin in &discord_bot_client - .plugin_registrations - .read() - .await - .discord_events - .message_create - { - let _ = discord_bot_client - .runtime_tx - .send(RuntimeMessages::CallDiscordEvent( - plugin.clone(), - DiscordEvents::MessageCreate( - sonic_rs::to_vec(&message_create).unwrap(), - ), - )) - .await; - } - } - Event::ThreadCreate(thread_create) => { - for plugin in &discord_bot_client - .plugin_registrations - .read() - .await - .discord_events - .thread_create - { - let _ = discord_bot_client - .runtime_tx - .send(RuntimeMessages::CallDiscordEvent( - plugin.clone(), - DiscordEvents::ThreadCreate(sonic_rs::to_vec(&thread_create).unwrap()), - )) - .await; - } - } - Event::ThreadDelete(thread_delete) => { - for plugin in &discord_bot_client - .plugin_registrations - .read() - .await - .discord_events - .thread_delete - { - let _ = discord_bot_client - .runtime_tx - .send(RuntimeMessages::CallDiscordEvent( - plugin.clone(), - DiscordEvents::ThreadDelete(sonic_rs::to_vec(&thread_delete).unwrap()), - )) - .await; - } - } - Event::ThreadListSync(thread_list_sync) => { - for plugin in &discord_bot_client - .plugin_registrations - .read() - .await - .discord_events - .thread_list_sync - { - let _ = discord_bot_client - .runtime_tx - .send(RuntimeMessages::CallDiscordEvent( - plugin.clone(), - DiscordEvents::ThreadListSync( - sonic_rs::to_vec(&thread_list_sync).unwrap(), - ), - )) - .await; - } - } - Event::ThreadMemberUpdate(thread_member_update) => { - for plugin in &discord_bot_client - .plugin_registrations - .read() - .await - .discord_events - .thread_member_update - { - let _ = discord_bot_client - .runtime_tx - .send(RuntimeMessages::CallDiscordEvent( - plugin.clone(), - DiscordEvents::ThreadMemberUpdate( - sonic_rs::to_vec(&thread_member_update).unwrap(), - ), - )) - .await; - } - } - Event::ThreadMembersUpdate(thread_members_update) => { - for plugin in &discord_bot_client - .plugin_registrations - .read() - .await - .discord_events - .thread_members_update - { - let _ = discord_bot_client - .runtime_tx - .send(RuntimeMessages::CallDiscordEvent( - plugin.clone(), - DiscordEvents::ThreadMembersUpdate( - sonic_rs::to_vec(&thread_members_update).unwrap(), - ), - )) - .await; - } - } - Event::ThreadUpdate(thread_update) => { - for plugin in &discord_bot_client - .plugin_registrations - .read() - .await - .discord_events - .thread_update - { - let _ = discord_bot_client - .runtime_tx - .send(RuntimeMessages::CallDiscordEvent( - plugin.clone(), - DiscordEvents::ThreadUpdate(sonic_rs::to_vec(&thread_update).unwrap()), - )) - .await; - } - } - _ => debug!( - "Received an unhandled event: {}", - &event.kind().name().unwrap_or("[No event kind name]") - ), - } - } -} diff --git a/src/http/registry.rs b/src/http/registry.rs index 41583a4..1bf826d 100644 --- a/src/http/registry.rs +++ b/src/http/registry.rs @@ -3,7 +3,7 @@ use std::str::FromStr; -use anyhow::{Context, Error, Result}; +use anyhow::{Context, Result, bail}; use reqwest::StatusCode; use tracing::debug; use url::{ParseError, Url}; @@ -19,10 +19,10 @@ impl HttpClient { let response = self.client.get(url).send().await?; if response.status() != StatusCode::OK { - return Err(Error::msg(format!( + bail!( "The response was undesired, status code: {}", response.status() - ))); + ); } Ok(response diff --git a/src/job_scheduler.rs b/src/job_scheduler.rs deleted file mode 100644 index 25e1581..0000000 --- a/src/job_scheduler.rs +++ /dev/null @@ -1,147 +0,0 @@ -/* SPDX-License-Identifier: GPL-3.0-or-later */ -/* Copyright © 2026 Eduard Smet */ - -use std::sync::Arc; - -use tokio::{ - sync::{ - Mutex, RwLock, - mpsc::{Receiver, Sender}, - }, - task::JoinHandle, -}; -use tokio_cron_scheduler::{Job, JobScheduler as TCScheduler}; -use tracing::{error, info}; - -use crate::{ - plugins::{PluginRegistrationRequestsScheduledJob, PluginRegistrations}, - utils::channels::{JobSchedulerMessages, RuntimeMessages}, -}; - -pub struct JobScheduler { - tokio_cron_scheduler: Arc>, - plugin_registrations: Arc>, - runtime_tx: Arc>, - runtime_rx: Arc>>, -} - -impl JobScheduler { - pub async fn new( - plugin_registrations: Arc>, - runtime_tx: Sender, - runtime_rx: Receiver, - ) -> Result { - match TCScheduler::new().await { - Ok(job_scheduler) => Ok(JobScheduler { - tokio_cron_scheduler: Arc::new(RwLock::new(job_scheduler)), - plugin_registrations, - runtime_tx: Arc::new(runtime_tx), - runtime_rx: Arc::new(Mutex::new(runtime_rx)), - }), - Err(err) => { - error!( - "Something went wrong while creating a new instance of the job scheduler, error {}", - &err - ); - Err(()) - } - } - } - - pub async fn scheduled_job_registrations( - &self, - initialized_plugin_registrations_scheduled_jobs: Vec< - PluginRegistrationRequestsScheduledJob, - >, - ) { - for scheduled_job in &initialized_plugin_registrations_scheduled_jobs { - for cron in &scheduled_job.crons { - info!( - "Scheduled Job {} from the {} plugin requested to be registered.", - &scheduled_job.id, &scheduled_job.plugin_id - ); - - let runtime_tx = self.runtime_tx.clone(); - let plugin_id = scheduled_job.plugin_id.clone(); - let internal_id = scheduled_job.id.clone(); - - let job = match Job::new_async_tz( - cron.clone(), - chrono::Local, - move |_uuid, _lock| { - let runtime_tx = runtime_tx.clone(); - let plugin_id = plugin_id.clone(); - let internal_id = internal_id.clone(); - - Box::pin(async move { - let _ = runtime_tx - .send(RuntimeMessages::CallScheduledJob(plugin_id, internal_id)) - .await; - }) - }, - ) { - Ok(job) => job, - Err(err) => { - error!( - "Something went wrong while adding {} job from the {} plugin to the job scheduler, error: {}", - &scheduled_job.id, &scheduled_job.plugin_id, &err - ); - continue; - } - }; - - match self.tokio_cron_scheduler.read().await.add(job).await { - Ok(uuid) => { - self.plugin_registrations - .write() - .await - .scheduled_jobs - .insert( - uuid.as_u128(), - (scheduled_job.plugin_id.clone(), scheduled_job.id.clone()), - ); - } - Err(err) => { - error!( - "Something went wrong while adding {} job from the {} plugin to the job scheduler, error: {}", - &scheduled_job.id, &scheduled_job.plugin_id, &err - ); - } - } - } - } - } - - pub async fn start(self) -> Result, ()> { - if let Err(err) = self.tokio_cron_scheduler.read().await.start().await { - error!( - "Something went wrong while starting the job scheduler, error: {}", - &err - ); - return Err(()); - } - - let job_scheduler = Arc::new(self); - - Ok(tokio::spawn(async move { - while let Some(message) = job_scheduler.runtime_rx.lock().await.recv().await { - match message { - JobSchedulerMessages::RegisterScheduledJobs(scheduled_jobs) => { - job_scheduler - .scheduled_job_registrations(scheduled_jobs) - .await; - } - JobSchedulerMessages::Shutdown(is_done) => { - let _ = job_scheduler - .tokio_cron_scheduler - .write() - .await - .shutdown() - .await; - let _ = is_done.send(()); - } - } - } - })) - } -} diff --git a/src/main.rs b/src/main.rs index 0f31578..c6f3219 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,37 +5,38 @@ use std::os::unix::process::CommandExt; use std::{ - collections::{HashMap, VecDeque}, + collections::VecDeque, env, ffi::OsString, - path::{Path, PathBuf}, + path::Path, process::{Command, ExitCode, exit}, - sync::{Arc, LazyLock}, + sync::LazyLock, }; use clap::Parser; -use tokio::{signal, sync::RwLock}; +use fjall::{Database, PersistMode}; +use tokio::{signal, sync::RwLock, task::JoinHandle}; use tracing::{error, info, warn}; use tracing_appender::non_blocking::WorkerGuard; mod cli; mod config; -mod discord; +mod database; mod http; -mod job_scheduler; -mod plugins; +mod registry; +mod runtime; +mod services; mod utils; use cli::{Cli, CliLogParameters}; use config::Config; -use discord::DiscordBotClient; -use http::HttpClient; -use job_scheduler::JobScheduler; -use plugins::{ - AvailablePlugin, PluginRegistrations, builder::PluginBuilder, registry, runtime::Runtime, -}; +use services::{discord::DiscordBotClient, job_scheduler::JobScheduler}; +use utils::{channels::Channels, env::Secrets}; -use crate::utils::channels::Channels; +use crate::{ + runtime::Runtime, + utils::channels::{ChannelsCore, CoreMessages}, +}; #[derive(PartialEq)] enum Shutdown { @@ -60,19 +61,23 @@ async fn main() -> ExitCode { } } - ExitCode::from(0) + ExitCode::from(1) } async fn run() -> Result<(), ()> { let cli = Cli::parse(); - //let mut tasks: Arc>>> = Arc::new(Mutex::new(vec![])); // TODO: Rework shutdown - let (_guard, discord_bot_client_token, channels) = - initialization(cli.log_parameters, &cli.env_file)?; + let mut tasks: Vec> = vec![]; + + let (_guard, secrets, channels) = initialization(cli.log_parameters, &cli.env_file)?; let config = Config::new(&cli.config_file)?; - let available_plugins = registry_get_plugins( + let database = database::new(&cli.database_directory).map_err(|_| ())?; + + tasks.push(start(database, channels.core)); + + let available_plugins = registry::registry_get_plugins( cli.http_client_timeout_seconds, config, cli.plugin_directory.clone(), @@ -80,125 +85,111 @@ async fn run() -> Result<(), ()> { ) .await?; - let plugin_registrations = Arc::new(RwLock::new(PluginRegistrations::new())); - - let (discord_bot_client, shards) = DiscordBotClient::new( - discord_bot_client_token, - plugin_registrations.clone(), - channels.runtime.discord_bot_client_sender, - channels.discord_bot_client.receiver, + let discord_bot_client = DiscordBotClient::new( + secrets.discord_bot_client, + channels.discord_bot_client.core_tx, + channels.discord_bot_client.rx, ) .await?; - info!("Creating the job scheduler"); - let job_scheduler = JobScheduler::new( - plugin_registrations.clone(), - channels.runtime.job_scheduler_sender, - channels.job_scheduler.receiver, - ) - .await?; + let job_scheduler = + JobScheduler::new(channels.job_scheduler.core_tx, channels.job_scheduler.rx) + .await + .map_err(|_| ())?; - info!("Creating the WASI runtime"); - let runtime = Arc::new(Runtime::new( - channels.discord_bot_client.sender, - channels.job_scheduler.sender, - channels.runtime.receiver, - )); + let runtime = Runtime::new(channels.runtime.core_tx.clone(), channels.runtime.rx); - discord_bot_client.start(shards); + tasks.push(job_scheduler.start().await.map_err(|_| ())?); - job_scheduler.start().await?; + tasks.push(discord_bot_client.start()); - plugin_initializations( - runtime.clone(), - available_plugins, - plugin_registrations, - &cli.plugin_directory, - ) - .await?; + runtime + .initialize_plugins( + available_plugins, + channels.runtime.core_tx, + &cli.plugin_directory, + ) + .await?; - Runtime::start(runtime.clone()); + tasks.push(runtime.start()); - shutdown(runtime).await + shutdown(tasks).await } fn initialization( cli_log_parameters: CliLogParameters, env_file: &Path, -) -> Result<(Option, String, Channels), ()> { +) -> Result<(Option, Secrets, Channels), ()> { let guard = utils::logger::new(cli_log_parameters)?; - utils::env::load_env_file(env_file)?; + utils::env::load_env_file(env_file).map_err(|_| ())?; - let discord_bot_client_token = utils::env::validate()?; + let secrets = utils::env::get_secrets().map_err(|_| ())?; let channels = utils::channels::new(); - Ok((guard, discord_bot_client_token, channels)) + Ok((guard, secrets, channels)) } -async fn registry_get_plugins( - http_client_timeout_seconds: u64, - config: Config, - plugin_directory: PathBuf, - cache: bool, -) -> Result, ()> { - let http_client = Arc::new(HttpClient::new(http_client_timeout_seconds)?); - - registry::get_plugins(http_client, config, plugin_directory, cache).await -} +fn start(database: Database, mut channels_core: ChannelsCore) -> JoinHandle<()> { + tokio::spawn(async move { + while let Some(core_message) = channels_core.rx.recv().await { + match core_message { + CoreMessages::DatabaseModule(database_message) => { + let database = database.clone(); + + tokio::spawn(async { + database::handle_action(database, database_message); + }); + } + CoreMessages::JobSchedulerModule(job_scheduler_message) => { + channels_core.job_scheduler_tx.send(job_scheduler_message); + } + CoreMessages::DiscordBotClientModule(discord_bot_client_message) => { + channels_core + .discord_bot_client_tx + .send(discord_bot_client_message); + } + CoreMessages::Runtime(runtime_message) => { + channels_core.runtime_tx.send(runtime_message); + } + CoreMessages::Shutdown(shutdown) => todo!(), // TODO: Figure shutdown out + } + } -async fn plugin_initializations( - runtime: Arc, - available_plugins: HashMap, - plugin_registrations: Arc>, - config_directory: &Path, -) -> Result<(), ()> { - info!("Creating the WASI plugin builder"); - let plugin_builder = PluginBuilder::new(); - - info!("Initializing the plugins"); - Runtime::initialize_plugins( - runtime, - plugin_builder, - available_plugins, - plugin_registrations, - config_directory, - ) - .await + database::persist(database, PersistMode::SyncAll); + }) } -async fn shutdown(runtime: Arc) -> Result<(), ()> { - let cancellation_token = runtime.cancellation_token.clone(); - - tokio::select! { - result = async move { - if let Err(err) = signal::ctrl_c().await { - error!( - "Failed to listen for the terminal interrupt signal, error: {}", - &err - ); - return Err(()); - } - - info!("Terminal interrupt signal received, send another to force immediate shutdown"); +async fn shutdown(mut tasks: Vec>) -> Result<(), ()> { + tokio::spawn(async { + if let Err(err) = signal::ctrl_c().await { + error!( + "Failed to listen for the terminal interrupt signal, error: {}", + &err + ); + return Err(()); + } - tokio::spawn(async { - signal::ctrl_c() - .await - .expect("failed to listen for the terminal interrupt signal"); + info!("Terminal interrupt signal received, send another to force immediate shutdown"); - warn!("Second terminal interrupt signal received, forcing immediate shutdown"); - exit(130); - }); + tokio::spawn(async { + signal::ctrl_c() + .await + .expect("failed to listen for the terminal interrupt signal"); - runtime.shutdown(Shutdown::SigInt).await; + warn!("Second terminal interrupt signal received, forcing immediate shutdown"); + exit(130); + }); - Ok(()) + Ok(()) + }); - } => {result} - () = cancellation_token.cancelled() => {Ok(())} + for task in tasks.drain(..) { + task.await; } + + Ok(()) } fn restart() { diff --git a/src/plugins.rs b/src/plugins.rs deleted file mode 100644 index dc7becd..0000000 --- a/src/plugins.rs +++ /dev/null @@ -1,179 +0,0 @@ -/* SPDX-License-Identifier: GPL-3.0-or-later */ -/* Copyright © 2026 Eduard Smet */ - -pub mod builder; -pub mod registry; -pub mod runtime; - -use std::collections::{HashMap, HashSet}; - -use semver::Version; -use serde::{Deserialize, Deserializer}; -use serde_yaml_ng::Value; -use twilight_model::id::{Id, marker::CommandMarker}; - -use crate::plugins::discord_bot::plugin::plugin_types::SupportedRegistrations; - -wasmtime::component::bindgen!({ imports: { default: async }, exports: { default: async } }); - -#[derive(Clone, Deserialize)] -pub struct ConfigPlugin { - pub plugin: String, - pub cache: Option, - #[serde(default = "ConfigPlugin::permissions_default")] - pub permissions: SupportedRegistrations, - pub environment: Option>, - pub settings: Option, -} - -impl ConfigPlugin { - fn permissions_default() -> SupportedRegistrations { - let mut supported_registrations = SupportedRegistrations::all(); - - supported_registrations &= !SupportedRegistrations::SHUTDOWN; - - supported_registrations - } -} - -impl<'de> Deserialize<'de> for SupportedRegistrations { - fn deserialize>(deserializer: D) -> Result { - let mut supported_registrations = SupportedRegistrations::empty(); - - let supported_registration_strings = Vec::::deserialize(deserializer)?; - - for supported_registration_string in supported_registration_strings { - match supported_registration_string.to_uppercase().as_str() { - "DEPENDENCY_FUNCTIONS" => { - supported_registrations |= SupportedRegistrations::DEPENDENCY_FUNCTIONS; - } - "DISCORD_EVENT_MESSAGE_CREATE" => { - supported_registrations |= SupportedRegistrations::DISCORD_EVENT_MESSAGE_CREATE; - } - "DISCORD_EVENT_INTERACTION_CREATE" => { - supported_registrations |= - SupportedRegistrations::DISCORD_EVENT_INTERACTION_CREATE; - } - "DISCORD_EVENT_THREAD_CREATE" => { - supported_registrations |= SupportedRegistrations::DISCORD_EVENT_THREAD_CREATE; - } - "DISCORD_EVENT_THREAD_DELETE" => { - supported_registrations |= SupportedRegistrations::DISCORD_EVENT_THREAD_DELETE; - } - "DISCORD_EVENT_THREAD_LIST_SYNC" => { - supported_registrations |= - SupportedRegistrations::DISCORD_EVENT_THREAD_LIST_SYNC; - } - "DISCORD_EVENT_THREAD_MEMBER_UPDATE" => { - supported_registrations |= - SupportedRegistrations::DISCORD_EVENT_THREAD_MEMBER_UPDATE; - } - "DISCORD_EVENT_THREAD_MEMBERS_UPDATE" => { - supported_registrations |= - SupportedRegistrations::DISCORD_EVENT_THREAD_MEMBERS_UPDATE; - } - "DISCORD_EVENT_THREAD_UPDATE" => { - supported_registrations |= SupportedRegistrations::DISCORD_EVENT_THREAD_UPDATE; - } - "SHUTDOWN" => { - supported_registrations |= SupportedRegistrations::SHUTDOWN; - } - &_ => unimplemented!(), - } - } - - Ok(supported_registrations) - } -} - -pub struct AvailablePlugin { - pub registry_id: String, - pub id: String, - pub version: Version, - pub permissions: SupportedRegistrations, - pub environment: Option>, - pub settings: Option, -} - -// TODO: Plugins which did not register anything should get dropped -pub struct PluginRegistrations { - pub discord_events: PluginRegistrationsDiscordEvents, - pub scheduled_jobs: HashMap, // UUID, plugin ID, internal ID - pub dependency_functions: HashMap>, -} - -pub struct PluginRegistrationsDiscordEvents { - pub interaction_create: PluginRegistrationsInteractionCreate, - pub message_create: Vec, - pub thread_create: Vec, - pub thread_delete: Vec, - pub thread_list_sync: Vec, - pub thread_member_update: Vec, - pub thread_members_update: Vec, - pub thread_update: Vec, -} - -pub struct PluginRegistrationsInteractionCreate { - pub application_commands: HashMap, String>, // Command ID, plugin ID - pub message_components: HashMap, // Message Component ID, plugin ID ISSUE: ID overlap is possible - pub modals: HashMap, // Modal ID, plugin ID ISSUE: ID overlap is possible -} - -pub struct PluginRegistrationRequests { - pub discord_event_interaction_create: PluginRegistrationRequestsInteractionCreate, - pub scheduled_jobs: Vec, -} - -pub struct PluginRegistrationRequestsInteractionCreate { - pub application_commands: Vec, - #[allow(unused)] // Will be used when wasmtime provides component information on host calls - pub message_component: Vec, - #[allow(unused)] // Will be used when wasmtime provides component information on host calls - pub modals: Vec, -} - -pub struct PluginRegistrationRequestsApplicationCommand { - pub plugin_id: String, - pub data: Vec, -} - -#[allow(unused)] // Will be used when wasmtime provides component information on host calls -pub struct PluginRegistrationRequestsMessageComponent { - pub plugin_id: String, - pub id: String, -} - -#[allow(unused)] // Will be used when wasmtime provides component information on host calls -pub struct PluginRegistrationRequestsModal { - pub plugin_id: String, - pub id: String, -} - -pub struct PluginRegistrationRequestsScheduledJob { - pub plugin_id: String, - pub id: String, - pub crons: Vec, -} - -impl PluginRegistrations { - pub fn new() -> Self { - PluginRegistrations { - discord_events: PluginRegistrationsDiscordEvents { - interaction_create: PluginRegistrationsInteractionCreate { - application_commands: HashMap::new(), - message_components: HashMap::new(), - modals: HashMap::new(), - }, - message_create: vec![], - thread_create: vec![], - thread_delete: vec![], - thread_list_sync: vec![], - thread_member_update: vec![], - thread_members_update: vec![], - thread_update: vec![], - }, - scheduled_jobs: HashMap::new(), - dependency_functions: HashMap::new(), - } - } -} diff --git a/src/plugins/runtime.rs b/src/plugins/runtime.rs deleted file mode 100644 index 45a6459..0000000 --- a/src/plugins/runtime.rs +++ /dev/null @@ -1,487 +0,0 @@ -/* SPDX-License-Identifier: GPL-3.0-or-later */ -/* Copyright © 2026 Eduard Smet */ - -pub mod internal; - -use std::{ - collections::{HashMap, HashSet}, - fs, - path::Path, - sync::Arc, -}; - -use serde_yaml_ng::Value; -use tokio::sync::{ - Mutex, RwLock, - mpsc::{Receiver, Sender}, - oneshot, -}; -use tokio_util::sync::CancellationToken; -use tracing::{error, info}; -use wasmtime::{Store, component::Component}; -use wasmtime_wasi::{DirPerms, FilePerms, ResourceTable, WasiCtxBuilder}; -use wasmtime_wasi_http::WasiHttpCtx; - -use crate::{ - SHUTDOWN, Shutdown, - plugins::{ - AvailablePlugin, Plugin, PluginRegistrationRequests, - PluginRegistrationRequestsApplicationCommand, PluginRegistrationRequestsScheduledJob, - PluginRegistrations, builder::PluginBuilder, - discord_bot::plugin::discord_types::Events as DiscordEvents, - runtime::internal::InternalRuntime, - }, - utils::channels::{DiscordBotClientMessages, JobSchedulerMessages, RuntimeMessages}, -}; - -pub struct Runtime { - plugins: RwLock>, - discord_bot_client_tx: Arc>, - job_scheduler_tx: Arc>, - dbc_js_rx: RwLock>, - pub cancellation_token: CancellationToken, -} - -pub struct RuntimePlugin { - instance: Plugin, - store: Mutex>, // TODO: Add async support, waiting for better WASIp3 component creation support -} - -impl Runtime { - pub fn new( - discord_bot_client_tx: Sender, - job_scheduler_tx: Sender, - dbc_js_rx: Receiver, - ) -> Self { - Runtime { - plugins: RwLock::new(HashMap::new()), - discord_bot_client_tx: Arc::new(discord_bot_client_tx), - job_scheduler_tx: Arc::new(job_scheduler_tx), - dbc_js_rx: RwLock::new(dbc_js_rx), - cancellation_token: CancellationToken::new(), - } - } - - pub fn start(runtime: Arc) { - tokio::spawn(async move { - let mut dbc_js_rx = runtime.dbc_js_rx.write().await; - - tokio::select! { - () = async { - while let Some(message) = dbc_js_rx.recv().await { - match message { - RuntimeMessages::CallDiscordEvent(plugin_name, event) => { - runtime.call_discord_event(&plugin_name, &event).await; - } - RuntimeMessages::CallScheduledJob(plugin_name, scheduled_job_name) => { - runtime.call_scheduled_job(&plugin_name, &scheduled_job_name).await;} - } - } - } => {} - () = runtime.cancellation_token.cancelled() => { - dbc_js_rx.close(); - } - } - }); - } - - pub async fn initialize_plugins( - runtime: Arc, - plugin_builder: PluginBuilder, - plugins: HashMap, - plugin_registrations: Arc>, - directory: &Path, - ) -> Result<(), ()> { - let mut registration_requests = PluginRegistrationRequests { - discord_event_interaction_create: super::PluginRegistrationRequestsInteractionCreate { - application_commands: vec![], - message_component: vec![], - modals: vec![], - }, - scheduled_jobs: vec![], - }; - - for (plugin_uid, plugin) in plugins { - let plugin_directory = directory - .join(&plugin.registry_id) - .join(&plugin.id) - .join(plugin.version.to_string()); - - let bytes = match fs::read(plugin_directory.join("plugin.wasm")) { - Ok(bytes) => bytes, - Err(err) => { - error!( - "An error occured while reading the {} plugin file: {err}", - plugin_uid - ); - continue; - } - }; - - let component = match Component::new(&plugin_builder.engine, bytes) { - Ok(component) => component, - Err(err) => { - error!( - "An error occured while creating a WASI component from the {} plugin: {err}", - plugin_uid - ); - continue; - } - }; - - let env_hm = plugin.environment.unwrap_or(HashMap::new()); - - let env: Box<[(&str, &str)]> = env_hm - .iter() - .map(|(k, v)| (k.as_str(), v.as_str())) - .collect(); - - let workspace_plugin_dir = plugin_directory.join("workspace"); - - match fs::exists(&workspace_plugin_dir) { - Ok(exists) => { - if !exists && let Err(err) = fs::create_dir(&workspace_plugin_dir) { - error!( - "Something went wrong while creating the workspace directory for the {} plugin, error: {}", - &plugin_uid, &err - ); - } - } - Err(err) => { - error!( - "Something went wrong while checking if the workspace directory of the {} plugin exists, error: {}", - &plugin_uid, &err - ); - return Err(()); - } - } - - let wasi = WasiCtxBuilder::new() - .envs(&env) - .preopened_dir(workspace_plugin_dir, "/", DirPerms::all(), FilePerms::all()) - .unwrap() - .build(); - - let mut store = Store::::new( - &plugin_builder.engine, - InternalRuntime::new( - plugin_uid.clone(), - wasi, - WasiHttpCtx::new(), - ResourceTable::new(), - Arc::downgrade(&runtime), - ), - ); - - let instance = - match Plugin::instantiate_async(&mut store, &component, &plugin_builder.linker) - .await - { - Ok(instance) => instance, - Err(err) => { - error!( - "Failed to instantiate the {} plugin, error: {}", - &plugin_uid, &err - ); - continue; - } - }; - - let plugin_registrations_request = match instance - .discord_bot_plugin_plugin_functions() - .call_initialization( - &mut store, - &sonic_rs::to_vec(&plugin.settings.unwrap_or(Value::default())).unwrap(), - plugin.permissions, - ) - .await - { - Ok(init_result) => match init_result { - Ok(registrations_request) => registrations_request, - Err(err) => { - error!( - "Failed to initialize the {} plugin, error: {}", - &plugin_uid, &err - ); - continue; - } - }, - Err(err) => { - error!( - "The {} plugin exprienced a critical error: {}", - &plugin_uid, &err - ); - continue; - } - }; - - let plugin_context = RuntimePlugin { - instance, - store: Mutex::new(store), - }; - - if let Some(discord_events) = plugin_registrations_request.discord_events { - if discord_events.message_create { - plugin_registrations - .write() - .await - .discord_events - .message_create - .push(plugin_uid.clone()); - } - - if discord_events.thread_create { - plugin_registrations - .write() - .await - .discord_events - .thread_create - .push(plugin_uid.clone()); - } - - if discord_events.thread_delete { - plugin_registrations - .write() - .await - .discord_events - .thread_delete - .push(plugin_uid.clone()); - } - - if discord_events.thread_list_sync { - plugin_registrations - .write() - .await - .discord_events - .thread_list_sync - .push(plugin_uid.clone()); - } - - if discord_events.thread_member_update { - plugin_registrations - .write() - .await - .discord_events - .thread_member_update - .push(plugin_uid.clone()); - } - - if discord_events.thread_members_update { - plugin_registrations - .write() - .await - .discord_events - .thread_members_update - .push(plugin_uid.clone()); - } - - if discord_events.thread_update { - plugin_registrations - .write() - .await - .discord_events - .thread_update - .push(plugin_uid.clone()); - } - - if let Some(interaction_create) = discord_events.interaction_create { - if let Some(application_commands) = interaction_create.application_commands { - for application_command in application_commands { - registration_requests - .discord_event_interaction_create - .application_commands - .push(PluginRegistrationRequestsApplicationCommand { - plugin_id: plugin_uid.clone(), - data: application_command, - }); - } - } - - if let Some(message_components) = interaction_create.message_components { - // TODO: Prevent duplicate entries - - for message_component in message_components { - plugin_registrations - .write() - .await - .discord_events - .interaction_create - .message_components - .insert(message_component.clone(), plugin_uid.clone()); - } - } - - if let Some(modals) = interaction_create.modals { - // TODO: Prevent duplicate entries - - for modal in modals { - plugin_registrations - .write() - .await - .discord_events - .interaction_create - .modals - .insert(modal.clone(), plugin_uid.clone()); - } - } - } - } - - if let Some(scheduled_jobs) = plugin_registrations_request.scheduled_jobs { - for scheduled_job in scheduled_jobs { - registration_requests.scheduled_jobs.push( - PluginRegistrationRequestsScheduledJob { - plugin_id: plugin_uid.clone(), - id: scheduled_job.0, - crons: scheduled_job.1, - }, - ); - } - } - - if let Some(dependency_functions) = plugin_registrations_request.dependency_functions { - for dependency_function in dependency_functions { - let mut plugin_registrations = plugin_registrations.write().await; - let functions = plugin_registrations - .dependency_functions - .entry(plugin_uid.clone()) - .or_insert(HashSet::new()); - - functions.insert(dependency_function); - } - } - - runtime - .plugins - .write() - .await - .insert(plugin_uid, plugin_context); - } - - let _ = runtime - .discord_bot_client_tx - .send(DiscordBotClientMessages::RegisterApplicationCommands( - registration_requests - .discord_event_interaction_create - .application_commands, - )) - .await; - - let _ = runtime - .job_scheduler_tx - .send(JobSchedulerMessages::RegisterScheduledJobs( - registration_requests.scheduled_jobs, - )) - .await; - - Ok(()) - } - - // TODO: Remove trapped plugins - - async fn call_discord_event(&self, plugin_name: &str, event: &DiscordEvents) { - let plugins = self.plugins.read().await; - let plugin = plugins.get(plugin_name).unwrap(); - - match plugin - .instance - .discord_bot_plugin_plugin_functions() - .call_discord_event(&mut *plugin.store.lock().await, event) - .await - { - Ok(result) => { - if let Err(err) = result { - error!("The {} plugin returned an error: {}", plugin_name, &err); - } - } - Err(err) => { - error!( - "The {} plugin exprienced a critical error: {}", - plugin_name, &err - ); - } - } - } - - async fn call_scheduled_job(&self, plugin_name: &str, scheduled_job_name: &str) { - let plugins = self.plugins.read().await; - let plugin = plugins.get(plugin_name).unwrap(); - - match plugin - .instance - .discord_bot_plugin_plugin_functions() - .call_scheduled_job(&mut *plugin.store.lock().await, scheduled_job_name) - .await - { - Ok(result) => { - if let Err(err) = result { - error!("The {} plugin returned an error: {}", plugin_name, &err); - } - } - Err(err) => { - error!( - "The {} plugin exprienced a critical error: {}", - plugin_name, &err - ); - } - } - } - - async fn call_shutdown(&self, plugin_name: String) { - let plugins = self.plugins.read().await; - let plugin = plugins.get(&plugin_name).unwrap(); - - match plugin - .instance - .discord_bot_plugin_plugin_functions() - .call_shutdown(&mut *plugin.store.lock().await) - .await - { - Ok(result) => { - if let Err(err) = result { - error!("The {} plugin returned an error: {}", plugin_name, &err); - } - } - Err(err) => { - error!( - "The {} plugin exprienced a critical error: {}", - plugin_name, &err - ); - } - } - } - - pub async fn shutdown(&self, shutdown_type: Shutdown) { - if SHUTDOWN.read().await.is_some() { - // TODO: Do not wait for shutdown to complete, the main function shutdown logic needs to get reworked first - self.cancellation_token.cancelled().await; - return; - } - - *SHUTDOWN.write().await = Some(shutdown_type); - - let job_scheduler_is_done = oneshot::channel(); - let discord_bot_client_is_done = oneshot::channel(); - - info!("Shutting down the job scheduler"); - let _ = self - .job_scheduler_tx - .send(JobSchedulerMessages::Shutdown(job_scheduler_is_done.0)) - .await; - let _ = job_scheduler_is_done.1.await; - - info!("Shutting down the Discord bot client shards"); - let _ = self - .discord_bot_client_tx - .send(DiscordBotClientMessages::Shutdown( - discord_bot_client_is_done.0, - )) - .await; - - let _ = discord_bot_client_is_done.1.await; - - // TODO: Allow all plugin calls to finish, call the shutdown methods on them and only then return - - self.cancellation_token.cancel(); - } -} diff --git a/src/plugins/runtime/internal.rs b/src/plugins/runtime/internal.rs deleted file mode 100644 index 4aef3fb..0000000 --- a/src/plugins/runtime/internal.rs +++ /dev/null @@ -1,168 +0,0 @@ -/* SPDX-License-Identifier: GPL-3.0-or-later */ -/* Copyright © 2026 Eduard Smet */ - -use std::sync::Weak; - -use tokio::sync::oneshot; -use tracing::{debug, error, info, trace, warn}; -use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxView, WasiView}; -use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpView}; - -use crate::{ - Shutdown, - plugins::{ - discord_bot::plugin::{ - discord_types::{ - Host as DiscordTypes, Requests as DiscordRequests, Responses as DiscordResponses, - }, - host_functions::Host as HostFunctions, - host_types::{Host as HostTypes, LogLevels}, - plugin_types::Host as PluginTypes, - }, - runtime::Runtime, - }, - utils::channels::DiscordBotClientMessages, -}; - -pub struct InternalRuntime { - uid: String, - wasi: WasiCtx, - wasi_http: WasiHttpCtx, - table: ResourceTable, - runtime: Weak, -} - -impl WasiView for InternalRuntime { - fn ctx(&mut self) -> WasiCtxView<'_> { - WasiCtxView { - ctx: &mut self.wasi, - table: &mut self.table, - } - } -} - -impl WasiHttpView for InternalRuntime { - fn ctx(&mut self) -> &mut WasiHttpCtx { - &mut self.wasi_http - } - - fn table(&mut self) -> &mut ResourceTable { - &mut self.table - } -} - -impl HostFunctions for InternalRuntime { - async fn log(&mut self, level: LogLevels, message: String) { - match level { - LogLevels::Trace => trace!(message), - LogLevels::Debug => debug!(message), - LogLevels::Info => info!(message), - LogLevels::Warn => warn!(message), - LogLevels::Error => error!(message), - } - } - - async fn discord_request( - &mut self, - request: DiscordRequests, - ) -> Result, String> { - let runtime = self.runtime.upgrade().unwrap(); - - let (tx, rx) = oneshot::channel(); - - if let Err(err) = runtime - .discord_bot_client_tx - .send(DiscordBotClientMessages::Request(request, tx)) - .await - { - let err = format!( - "Something went wrong while sending a message over the Discord channel, error: {err}" - ); - - error!(err); - - return Err(err); - } - - match rx.await { - Ok(result) => result, - Err(err) => { - let err = format!("The OneShot sender was dropped: {err}"); - error!(err); - Err(err) - } - } - } - - async fn dependency_function( - &mut self, - dependency: String, - function: String, - params: Vec, - ) -> Result, String> { - let runtime = self.runtime.upgrade().unwrap(); - - let plugins = runtime.plugins.read().await; - let plugin = plugins.get(&dependency).unwrap(); - - // TODO: Check if it is an actual dependency and prevent deadlocks, the channel rework should fix - // the potential deadlocks. - - match plugin - .instance - .discord_bot_plugin_plugin_functions() - .call_dependency_function(&mut *plugin.store.lock().await, &function, ¶ms) - .await - { - Ok(call_result) => match call_result { - Ok(dependency_result) => Ok(dependency_result), - Err(err) => { - let err = format!("The plugin returned an error: {err}"); - error!(err); - Err(err) - } - }, - Err(err) => { - let err = format!("Something went wrong while calling the plugin: {err}"); - error!(err); - Err(err) - } - } - } - - async fn shutdown(&mut self, restart: bool) { - let shutdown_type = if restart { - Shutdown::Restart - } else { - Shutdown::Normal - }; - - self.runtime - .upgrade() - .unwrap() - .shutdown(shutdown_type) - .await; - } -} - -impl HostTypes for InternalRuntime {} -impl PluginTypes for InternalRuntime {} -impl DiscordTypes for InternalRuntime {} - -impl InternalRuntime { - pub fn new( - uid: String, - wasi: WasiCtx, - wasi_http: WasiHttpCtx, - table: ResourceTable, - runtime: Weak, - ) -> Self { - InternalRuntime { - uid, - wasi, - wasi_http, - table, - runtime, - } - } -} diff --git a/src/plugins/registry.rs b/src/registry.rs similarity index 89% rename from src/plugins/registry.rs rename to src/registry.rs index b8fdf8d..ba1e63e 100644 --- a/src/plugins/registry.rs +++ b/src/registry.rs @@ -1,6 +1,8 @@ /* SPDX-License-Identifier: GPL-3.0-or-later */ /* Copyright © 2026 Eduard Smet */ +pub mod plugins; + use std::{ collections::{BTreeMap, HashMap}, io::ErrorKind, @@ -8,16 +10,17 @@ use std::{ sync::{Arc, LazyLock}, }; -use anyhow::{Error, Result}; +use anyhow::{Result, bail}; use semver::{Version, VersionReq}; use serde::Deserialize; use tokio::fs; use tracing::{error, info, warn}; +use uuid::Uuid; use crate::{ - config::Config, + config::{Config, plugins::ConfigPlugin}, http::HttpClient, - plugins::{AvailablePlugin, ConfigPlugin}, + registry::plugins::{AvailablePlugin, RegistryPlugin, RegistryPluginVersion}, }; #[derive(Deserialize)] @@ -29,24 +32,7 @@ pub struct Registry { pub plugins: BTreeMap, } -#[derive(Deserialize)] -#[allow(unused)] -pub struct RegistryPlugin { - pub versions: Vec, - pub description: String, -} - -#[derive(Deserialize)] -#[allow(unused)] -pub struct RegistryPluginVersion { - pub version: String, - pub release_time: String, - pub compatible_program_version: String, - pub deprecated: Option, - pub deprecation_reason: Option, -} - -type RegistryTask = Vec>>>; +type RegistryTask = Vec>>>; static DEFAULT_REGISTRY_ID: &str = "raw.githubusercontent.com/celarye/discord-bot-plugins/refs/heads/master"; @@ -54,15 +40,26 @@ static DEFAULT_REGISTRY_ID: &str = static PROGRAM_VERSION: LazyLock = LazyLock::new(|| Version::parse(env!("CARGO_PKG_VERSION")).unwrap()); +pub async fn registry_get_plugins( + http_client_timeout_seconds: u64, + config: Config, + plugin_directory: PathBuf, + cache: bool, +) -> Result, ()> { + let http_client = Arc::new(HttpClient::new(http_client_timeout_seconds)?); + + get_plugins(http_client, config, plugin_directory, cache).await +} + pub async fn get_plugins( http_client: Arc, config: Config, base_plugin_directory_path: PathBuf, cache: bool, -) -> Result, ()> { +) -> Result, ()> { info!("Fetching and storing the plugins"); - let mut available_plugins = HashMap::new(); + let mut available_plugins = vec![]; let registries = get_cached_plugins( &base_plugin_directory_path, @@ -92,7 +89,7 @@ async fn get_cached_plugins( base_plugin_directory_path: &Path, config: Config, cache: bool, - available_plugins: &mut HashMap, + available_plugins: &mut Vec<(Uuid, AvailablePlugin)>, ) -> HashMap> { let mut registries = HashMap::new(); @@ -112,17 +109,18 @@ async fn get_cached_plugins( { Ok(cache_check) => { if let Some(plugin_version) = cache_check { - available_plugins.insert( - plugin_uid, + available_plugins.push(( + Uuid::new_v4(), AvailablePlugin { registry_id: registry_id.to_string(), id: plugin_id.to_string(), + user_id: plugin_uid, version: plugin_version, permissions: plugin_options.permissions, environment: plugin_options.environment, settings: plugin_options.settings, }, - ); + )); continue; } @@ -146,7 +144,7 @@ async fn fetch_non_cached_plugins( http_client: Arc, base_plugin_directory_path: &Path, registries: HashMap>, - available_plugins: &mut HashMap, + available_plugins: &mut Vec<(Uuid, AvailablePlugin)>, ) { let mut registry_tasks: RegistryTask = vec![]; @@ -175,8 +173,8 @@ async fn fetch_non_cached_plugins( let mut plugin_directory_path = registry_directory_path.join(plugin_id); let Some(registry_plugin) = registry.plugins.get(plugin_id) else { - return Err(Error::msg(format!("The {registry_id} registry has no {plugin_id} plugin entry", - ))); + bail!("The {registry_id} registry has no {plugin_id} plugin entry", + ); }; let Some(plugin_version) = get_plugin_matching_version( @@ -184,8 +182,8 @@ async fn fetch_non_cached_plugins( ®istry_plugin.versions, )? else { - return Err(Error::msg(format!( - "The {plugin_uid} plugin has no version which isn't marked as deprecated and is compatible with this version of the program"))); + bail!( + "The {plugin_uid} plugin has no version which isn't marked as deprecated and is compatible with this version of the program"); }; plugin_directory_path.push(plugin_version.to_string()); @@ -202,10 +200,11 @@ async fn fetch_non_cached_plugins( .await?; Ok(( - plugin_uid, + Uuid::new_v4(), AvailablePlugin { registry_id: registry_id.to_string(), id: plugin_id.to_string(), + user_id: plugin_uid, version: plugin_version, permissions: plugin_options.permissions, environment: plugin_options.environment, @@ -230,8 +229,7 @@ async fn fetch_non_cached_plugins( match registry_task.await.unwrap() { Ok(available_registry_plugins) => { for available_registry_plugin in available_registry_plugins { - available_plugins - .insert(available_registry_plugin.0, available_registry_plugin.1); + available_plugins.push(available_registry_plugin); } } Err(err) => { @@ -301,7 +299,7 @@ async fn get_plugin_latest_cached_version(plugin_path: &Path) -> Result(®istry_metadata_bytes).map_err(Error::new) + Ok(sonic_rs::from_slice::(®istry_metadata_bytes)?) } async fn fetch_plugin( diff --git a/src/registry/plugins.rs b/src/registry/plugins.rs new file mode 100644 index 0000000..e07949b --- /dev/null +++ b/src/registry/plugins.rs @@ -0,0 +1,37 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +/* Copyright © 2026 Eduard Smet */ + +use std::collections::HashMap; + +use semver::Version; +use serde::Deserialize; +use sonic_rs::Value; + +use crate::config::plugins::permissions::PluginPermissions; + +#[derive(Deserialize)] +#[allow(unused)] +pub struct RegistryPlugin { + pub versions: Vec, + pub description: String, +} + +#[derive(Deserialize)] +#[allow(unused)] +pub struct RegistryPluginVersion { + pub version: String, + pub release_time: String, + pub compatible_program_version: String, + pub deprecated: Option, + pub deprecation_reason: Option, +} + +pub struct AvailablePlugin { + pub registry_id: String, + pub id: String, + pub user_id: String, + pub version: Version, + pub permissions: PluginPermissions, + pub environment: HashMap, + pub settings: Value, +} diff --git a/src/runtime.rs b/src/runtime.rs new file mode 100644 index 0000000..bda213b --- /dev/null +++ b/src/runtime.rs @@ -0,0 +1,366 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +/* Copyright © 2026 Eduard Smet */ + +mod builder; +mod internal; +pub mod plugins; + +use std::{collections::HashMap, fs, path::Path, sync::Arc}; + +use anyhow::Result; +use tokio::{ + sync::{ + Mutex, RwLock, + mpsc::{UnboundedReceiver, UnboundedSender}, + oneshot::Sender, + }, + task::JoinHandle, +}; +use tracing::{error, info}; +use uuid::Uuid; +use wasmtime::{Store, component::Component}; +use wasmtime_wasi::{DirPerms, FilePerms, ResourceTable, WasiCtxBuilder}; +use wasmtime_wasi_http::WasiHttpCtx; + +use crate::{ + registry::plugins::AvailablePlugin, + runtime::{ + builder::PluginBuilder, + internal::InternalRuntime, + plugins::{ + Plugin, + wbps::plugin::discord_export_types::{DiscordEvents, Error}, + }, + }, + utils::channels::{ + CoreMessages, RuntimeMessages, RuntimeMessagesCore, RuntimeMessagesDiscord, + RuntimeMessagesJobScheduler, + }, +}; + +pub struct Runtime { + plugins: Arc>>, + core_tx: UnboundedSender, + rx: UnboundedReceiver, +} + +pub struct RuntimePlugin { + instance: Plugin, + store: Mutex>, // TODO: Add async support +} + +impl Runtime { + pub fn new( + core_tx: UnboundedSender, + rx: UnboundedReceiver, + ) -> Self { + info!("Creating the WASI runtime"); + + Runtime { + plugins: Arc::new(RwLock::new(HashMap::new())), + core_tx, + rx, + } + } + + pub fn start(mut self) -> JoinHandle<()> { + tokio::spawn(async move { + while let Some(message) = self.rx.recv().await { + match message { + RuntimeMessages::Core(core_message) => match core_message { + RuntimeMessagesCore::CallDependencyFunction( + plugin_id, + function_id, + params, + response_sender, + ) => { + let plugins = self.plugins.clone(); + + tokio::spawn(Self::call_dependency_function( + plugins, + plugin_id, + function_id, + params, + response_sender, + )); + } + RuntimeMessagesCore::UnloadPlugin(plugin) => { + let plugins = self.plugins.clone(); + + tokio::spawn(async move { + plugins.write().await.remove(&plugin); + }); + } + }, + RuntimeMessages::JobScheduler(job_scheduler_message) => { + match job_scheduler_message { + RuntimeMessagesJobScheduler::CallScheduledJob(plugin_id, job_id) => { + let plugins = self.plugins.clone(); + + tokio::spawn(Self::call_scheduled_job(plugins, plugin_id, job_id)); + } + } + } + RuntimeMessages::Discord(discord_message) => match discord_message { + RuntimeMessagesDiscord::CallDiscordEvent(plugin_id, event) => { + let plugins = self.plugins.clone(); + + tokio::spawn(Self::call_discord_event(plugins, plugin_id, event)); + } + }, + } + } + + self.shutdown().await; + }) + } + + pub async fn initialize_plugins( + &self, + available_plugins: Vec<(Uuid, AvailablePlugin)>, + core_tx: UnboundedSender, + plugin_directory: &Path, + ) -> Result<(), ()> { + info!("Creating the WASI plugin builder"); + let plugin_builder = PluginBuilder::new(); + + info!("Initializing the plugins"); + + for (plugin_id, plugin) in available_plugins { + let plugin_user_id = plugin.user_id.clone(); + let plugin_settings = plugin.settings.clone(); + + let plugin_directory = plugin_directory + .join(&plugin.registry_id) + .join(&plugin.id) + .join(plugin.version.to_string()); + + let bytes = match fs::read(plugin_directory.join("plugin.wasm")) { + Ok(bytes) => bytes, + Err(err) => { + error!( + "An error occured while reading the {} plugin file: {err}", + plugin_user_id + ); + continue; + } + }; + + let component = match Component::new(&plugin_builder.engine, bytes) { + Ok(component) => component, + Err(err) => { + error!( + "An error occured while creating a WASI component from the {} plugin: {err}", + plugin_user_id + ); + continue; + } + }; + + let env: Box<[(&str, &str)]> = plugin + .environment + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + + let workspace_plugin_dir = plugin_directory.join("workspace"); + + match fs::exists(&workspace_plugin_dir) { + Ok(exists) => { + if !exists && let Err(err) = fs::create_dir(&workspace_plugin_dir) { + error!( + "Something went wrong while creating the workspace directory for the {} plugin, error: {err}", + plugin_user_id + ); + } + } + Err(err) => { + error!( + "Something went wrong while checking if the workspace directory of the {} plugin exists, error: {err}", + plugin_user_id + ); + return Err(()); + } + } + + let wasi = WasiCtxBuilder::new() + .envs(&env) + .preopened_dir(workspace_plugin_dir, "/", DirPerms::all(), FilePerms::all()) + .unwrap() + .build(); + + let mut store = Store::::new( + &plugin_builder.engine, + InternalRuntime::new( + plugin_id, + plugin, + wasi, + WasiHttpCtx::new(), + ResourceTable::new(), + core_tx.clone(), + ), + ); + + let instance = + match Plugin::instantiate_async(&mut store, &component, &plugin_builder.linker) + .await + { + Ok(instance) => instance, + Err(err) => { + error!( + "Failed to instantiate the {} plugin, error: {err}", + plugin_user_id + ); + continue; + } + }; + + match instance + .wbps_plugin_core_export_functions() + .call_initialization(&mut store, &sonic_rs::to_vec(&plugin_settings).unwrap()) + .await + { + Ok(init_result) => { + if let Err(err) = init_result { + error!( + "the {} plugin returned an error while intiializing: {err}", + plugin_user_id + ); + continue; + } + } + Err(err) => { + error!( + "The {} plugin exprienced a critical error: {err}", + plugin_user_id + ); + continue; + } + }; + + let plugin_context = RuntimePlugin { + instance, + store: Mutex::new(store), + }; + + self.plugins.write().await.insert(plugin_id, plugin_context); + } + + Ok(()) + } + + // TODO: Remove trapped plugins + + async fn call_discord_event( + plugins: Arc>>, + plugin_id: Uuid, + event: DiscordEvents, + ) { + let plugins = plugins.read().await; + let plugin = plugins.get(&plugin_id).unwrap(); + + match plugin + .instance + .wbps_plugin_discord_export_functions() + .call_discord_event(&mut *plugin.store.lock().await, &event) + .await + { + Ok(result) => { + if let Err(err) = result { + error!("The {plugin_id} plugin returned an error: {err}"); + } + } + Err(err) => { + error!("The {plugin_id} plugin exprienced a critical error: {err}"); + } + } + } + + async fn call_scheduled_job( + plugins: Arc>>, + plugin_id: Uuid, + job_id: Uuid, + ) { + let plugins = plugins.read().await; + let plugin = plugins.get(&plugin_id).unwrap(); + + match plugin + .instance + .wbps_plugin_job_scheduler_export_functions() + .call_scheduled_job(&mut *plugin.store.lock().await, &job_id.to_string()) + .await + { + Ok(result) => { + if let Err(err) = result { + error!("The {plugin_id} plugin returned an error: {err}"); + } + } + Err(err) => { + error!("The {plugin_id} plugin exprienced a critical error: {err}"); + } + } + } + + async fn call_dependency_function( + plugins: Arc>>, + plugin_id: Uuid, + function_id: String, + params: Vec, + response_sender: Sender, Error>>, + ) { + let plugins = plugins.read().await; + let plugin = plugins.get(&plugin_id).unwrap(); + + match plugin + .instance + .wbps_plugin_core_export_functions() + .call_dependency_function(&mut *plugin.store.lock().await, &function_id, ¶ms) + .await + { + Ok(result) => { + response_sender.send(result); + } + Err(err) => { + let err = format!("The {plugin_id} plugin exprienced a critical error: {err}"); + + error!(err); + + response_sender.send(Err(err)); + } + }; + } + + async fn call_shutdown( + plugin_id: &Uuid, + instance: &Plugin, + store: &mut Store, + ) { + match instance + .wbps_plugin_core_export_functions() + .call_shutdown(store) + .await + { + Ok(result) => { + if let Err(err) = result { + error!("The {plugin_id} plugin returned an error: {err}"); + } + } + Err(err) => { + error!("The {plugin_id} plugin exprienced a critical error: {err}"); + } + } + } + + async fn shutdown(self) { + // TODO: Allow all plugin calls to finish and then call the shutdown methods + // This will be achieved by closing the plugin call channel tasks which then will call + // shutdown one more time before returning + // Bellow code will get replaced with channel closers + + let plugins = &mut *self.plugins.write().await; + + for (plugin_id, plugin) in plugins.into_iter() { + Self::call_shutdown(plugin_id, &plugin.instance, &mut *plugin.store.lock().await).await; + } + } +} diff --git a/src/plugins/builder.rs b/src/runtime/builder.rs similarity index 94% rename from src/plugins/builder.rs rename to src/runtime/builder.rs index 6a29668..d92da72 100644 --- a/src/plugins/builder.rs +++ b/src/runtime/builder.rs @@ -6,7 +6,7 @@ use wasmtime::{ component::{HasSelf, Linker}, }; -use crate::plugins::{Plugin, runtime::internal::InternalRuntime}; +use crate::runtime::{internal::InternalRuntime, plugins::Plugin}; pub struct PluginBuilder { pub engine: Engine, diff --git a/src/runtime/internal.rs b/src/runtime/internal.rs new file mode 100644 index 0000000..e30677f --- /dev/null +++ b/src/runtime/internal.rs @@ -0,0 +1,61 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +/* Copyright © 2026 Eduard Smet */ + +use tokio::sync::mpsc::UnboundedSender; +use uuid::Uuid; +use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxView, WasiView}; +use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpView}; + +mod core; +mod discord; +mod job_scheduler; + +use crate::{registry::plugins::AvailablePlugin, utils::channels::CoreMessages}; + +pub struct InternalRuntime { + plugin_id: Uuid, + plugin_metadata: AvailablePlugin, + wasi: WasiCtx, + wasi_http: WasiHttpCtx, + table: ResourceTable, + core_tx: UnboundedSender, +} + +impl WasiView for InternalRuntime { + fn ctx(&mut self) -> WasiCtxView<'_> { + WasiCtxView { + ctx: &mut self.wasi, + table: &mut self.table, + } + } +} + +impl WasiHttpView for InternalRuntime { + fn ctx(&mut self) -> &mut WasiHttpCtx { + &mut self.wasi_http + } + + fn table(&mut self) -> &mut ResourceTable { + &mut self.table + } +} + +impl InternalRuntime { + pub fn new( + plugin_id: Uuid, + plugin_metadata: AvailablePlugin, + wasi: WasiCtx, + wasi_http: WasiHttpCtx, + table: ResourceTable, + core_tx: UnboundedSender, + ) -> Self { + InternalRuntime { + plugin_id, + plugin_metadata, + wasi, + wasi_http, + table, + core_tx, + } + } +} diff --git a/src/runtime/internal/core.rs b/src/runtime/internal/core.rs new file mode 100644 index 0000000..a269a19 --- /dev/null +++ b/src/runtime/internal/core.rs @@ -0,0 +1,178 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +/* Copyright © 2026 Eduard Smet */ + +use tokio::sync::oneshot::channel; +use tracing::{debug, error, info, trace, warn}; +use uuid::Uuid; + +use crate::{ + Shutdown, + config::plugins::permissions::{PluginPermissions, PluginPermissionsCore}, + database::Keyspaces, + runtime::{ + internal::InternalRuntime, + plugins::wbps::plugin::{ + core_export_types::{Error, Host as CoreExportTypesHost}, + core_import_functions::Host as CoreImportFunctionsHost, + core_import_types::{ + CoreRegistrations, CoreRegistrationsResult, Host as CoreImportTypesHost, LogLevels, + SupportedCoreRegistrations, + }, + core_types::Host as CoreTypesHost, + }, + }, + utils::channels::{CoreMessages, DatabaseMessages, RuntimeMessages, RuntimeMessagesCore}, +}; + +impl CoreTypesHost for InternalRuntime {} +impl CoreImportTypesHost for InternalRuntime {} +impl CoreExportTypesHost for InternalRuntime {} + +impl CoreImportFunctionsHost for InternalRuntime { + async fn log(&mut self, level: LogLevels, message: String) { + match level { + LogLevels::Trace => trace!(message), + LogLevels::Debug => debug!(message), + LogLevels::Info => info!(message), + LogLevels::Warn => warn!(message), + LogLevels::Error => error!(message), + } + } + + async fn get_supported_registrations(&mut self) -> SupportedCoreRegistrations { + let (sender, receiver) = channel(); + + self.core_tx + .send(CoreMessages::DatabaseModule(DatabaseMessages::Get( + Keyspaces::Plugins, + self.plugin_id.as_bytes().to_vec(), + sender, + ))); + + let response_bytes = receiver.await.unwrap().unwrap().unwrap().to_vec(); + + let plugin_permissions = + sonic_rs::from_slice::(&response_bytes).unwrap(); + + plugin_permissions.core.into() + } + + async fn register(&mut self, registrations: CoreRegistrations) -> CoreRegistrationsResult { + let mut result = CoreRegistrationsResult { + dependency_functions: None, + }; + + if let Some(dependency_functions) = registrations.dependency_functions { + result.dependency_functions = Some((Vec::new(), Vec::new())); + + for dependency_function in dependency_functions { + let (sender, receiver) = channel(); + + let key = format!( + "{}/{}/{dependency_function}", + self.plugin_metadata.registry_id, self.plugin_metadata.id + ); + + self.core_tx + .send(CoreMessages::DatabaseModule(DatabaseMessages::Insert( + Keyspaces::DependencyFunctions, + key.as_bytes().to_vec(), + Vec::new(), + sender, + ))); + + let _ = receiver.await; + result + .dependency_functions + .as_mut() + .unwrap() + .0 + .push(dependency_function) + } + } + + result + } + + async fn unload(&mut self, reason: String) { + self.core_tx + .send(CoreMessages::Runtime(RuntimeMessages::Core( + RuntimeMessagesCore::UnloadPlugin(self.plugin_id), + ))); + + info!( + "The {} plugin has unloaded itself, reason: {reason}", + self.plugin_metadata.user_id + ) + } + + async fn shutdown(&mut self, restart: bool) -> Result<(), Error> { + let (sender, receiver) = channel(); + + self.core_tx + .send(CoreMessages::DatabaseModule(DatabaseMessages::Get( + Keyspaces::Plugins, + self.plugin_id.as_bytes().to_vec(), + sender, + ))); + + let response_bytes = receiver.await.unwrap().unwrap().unwrap().to_vec(); + + let plugin_permissions = + sonic_rs::from_slice::(&response_bytes).unwrap(); + + if !plugin_permissions + .core + .contains(&PluginPermissionsCore::Shutdown) + { + return Err(Error::from("Not allowed to call shutdown")); + } + + let shutdown_type = if restart { + Shutdown::Restart + } else { + Shutdown::Normal + }; + + self.core_tx.send(CoreMessages::Shutdown(shutdown_type)); + + Ok(()) + } + + async fn dependency_function( + &mut self, + registry_id: String, + plugin_id: String, + function_id: String, + params: Vec, + ) -> Result, Error> { + let (sender, receiver) = channel(); + + let key = format!("{registry_id}/{plugin_id}/{function_id}"); + + self.core_tx + .send(CoreMessages::DatabaseModule(DatabaseMessages::Get( + Keyspaces::Plugins, + key.as_bytes().to_vec(), + sender, + ))); + + let Some(response_bytes) = receiver.await.unwrap().unwrap() else { + return Err(format!("The {key} dependency function was not found")); + }; + + let (sender, receiver) = channel(); + + self.core_tx + .send(CoreMessages::Runtime(RuntimeMessages::Core( + RuntimeMessagesCore::CallDependencyFunction( + Uuid::from_slice(&response_bytes).unwrap(), + function_id, + params, + sender, + ), + ))); + + receiver.await.unwrap() + } +} diff --git a/src/runtime/internal/discord.rs b/src/runtime/internal/discord.rs new file mode 100644 index 0000000..975f4fd --- /dev/null +++ b/src/runtime/internal/discord.rs @@ -0,0 +1,135 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +/* Copyright © 2026 Eduard Smet */ + +use tokio::sync::oneshot::channel; + +use crate::{ + config::plugins::permissions::{PluginPermissions, PluginPermissionsDiscordEvents}, + database::Keyspaces, + runtime::{ + internal::InternalRuntime, + plugins::wbps::plugin::{ + core_import_types::Error, + discord_export_types::Host as DiscordExportTypesHost, + discord_import_functions::Host as DiscordImportFunctionsHost, + discord_import_types::{ + DiscordRegistrations, DiscordRegistrationsResult, + DiscordRegistrationsResultInteractions, DiscordRequests, DiscordResponses, + Host as DiscordImportTypesHost, SupportedDiscordRegistrations, + }, + }, + }, + utils::channels::{CoreMessages, DatabaseMessages, DiscordBotClientMessages}, +}; + +impl DiscordImportTypesHost for InternalRuntime {} +impl DiscordExportTypesHost for InternalRuntime {} + +impl DiscordImportFunctionsHost for InternalRuntime { + async fn get_supported_discord_registrations(&mut self) -> SupportedDiscordRegistrations { + let (sender, receiver) = channel(); + + self.core_tx + .send(CoreMessages::DatabaseModule(DatabaseMessages::Get( + Keyspaces::Plugins, + self.plugin_id.as_bytes().to_vec(), + sender, + ))); + + let response_bytes = receiver.await.unwrap().unwrap().unwrap().to_vec(); + + let plugin_permissions = + sonic_rs::from_slice::(&response_bytes).unwrap(); + + SupportedDiscordRegistrations { + events: plugin_permissions.discord.events.into(), + interactions: plugin_permissions.discord.interactions.into(), + } + } + + async fn discord_register( + &mut self, + registrations: DiscordRegistrations, + ) -> DiscordRegistrationsResult { + let (sender, receiver) = channel(); + + self.core_tx + .send(CoreMessages::DatabaseModule(DatabaseMessages::Get( + Keyspaces::Plugins, + self.plugin_id.as_bytes().to_vec(), + sender, + ))); + + let response_bytes = receiver.await.unwrap().unwrap().unwrap().to_vec(); + + let plugin_permissions = + sonic_rs::from_slice::(&response_bytes).unwrap(); + + let mut result = DiscordRegistrationsResult { + events: None, + interactions: None, + }; + + if let Some(registered_events_flags) = registrations.events { + let registered_events: Vec = + registered_events_flags.into(); + + for registered_event in registered_events { + if !plugin_permissions + .discord + .events + .contains(®istered_event) + { + result.events = Some(Err(format!( + "Plugin is not allowed to register for the {registered_event:?} event" + ))); + break; + } + } + + result.events = Some(Ok(())); + } + + if let Some(interactions) = registrations.interactions { + // TODO: + // Check permissions + + if let Some(application_commands) = interactions.application_commands { + // TODO: + // Store in db + } + + result.interactions = Some(Ok(DiscordRegistrationsResultInteractions { + message_components: None, + modals: None, + })); + + if let Some(message_components) = interactions.message_components { + // TODO: + // Create UUID entry in db + // Add UUID to result + } + + if let Some(modals) = interactions.modals { + // TODO: + // Create UUID entry in db + // Add UUID to result + } + } + + result + } + + async fn discord_request( + &mut self, + request: DiscordRequests, + ) -> Result, Error> { + let (sender, receiver) = channel(); + + self.core_tx.send(CoreMessages::DiscordBotClientModule( + DiscordBotClientMessages::Request(request, sender), + )); + + receiver.await.unwrap() + } +} diff --git a/src/runtime/internal/job_scheduler.rs b/src/runtime/internal/job_scheduler.rs new file mode 100644 index 0000000..1e39e8c --- /dev/null +++ b/src/runtime/internal/job_scheduler.rs @@ -0,0 +1,28 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +/* Copyright © 2026 Eduard Smet */ + +use crate::runtime::{ + internal::InternalRuntime, + plugins::wbps::plugin::{ + job_scheduler_import_functions::Host as JobSchedulerImportFunctionsHost, + job_scheduler_import_types::{ + Host as JobSchedulerImportTypesHost, JobSchedulerRegistrations, + JobSchedulerRegistrationsResult, SupportedJobSchedulerRegistrations, + }, + }, +}; + +impl JobSchedulerImportTypesHost for InternalRuntime {} + +impl JobSchedulerImportFunctionsHost for InternalRuntime { + async fn get_supported_registrations(&mut self) -> SupportedJobSchedulerRegistrations { + todo!() + } + + async fn register( + &mut self, + registrations: JobSchedulerRegistrations, + ) -> JobSchedulerRegistrationsResult { + todo!() + } +} diff --git a/src/runtime/plugins.rs b/src/runtime/plugins.rs new file mode 100644 index 0000000..06a92b2 --- /dev/null +++ b/src/runtime/plugins.rs @@ -0,0 +1,153 @@ +use crate::{ + config::plugins::permissions::{ + PluginPermissionsCore, PluginPermissionsDiscordEvents, + PluginPermissionsDiscordInteractions, PluginPermissionsJobScheduler, + }, + runtime::plugins::wbps::plugin::{ + core_import_types::SupportedCoreRegistrations, + discord_import_types::{DiscordEvents, SupportedDiscordRegistrationsInteractions}, + job_scheduler_import_types::SupportedJobSchedulerRegistrations, + }, +}; + +wasmtime::component::bindgen!({ imports: { default: async }, exports: { default: async } }); + +impl From> for SupportedCoreRegistrations { + fn from(plugin_permissions_core: Vec) -> Self { + let mut supported_core_registrations = Self::empty(); + + for plugin_permission_core in &plugin_permissions_core { + match plugin_permission_core { + PluginPermissionsCore::DependencyFunctions => { + supported_core_registrations &= Self::DEPENDENCY_FUNCTIONS; + } + PluginPermissionsCore::Shutdown => { + supported_core_registrations &= Self::SHUTDOWN; + } + } + } + + supported_core_registrations + } +} + +impl From> for SupportedJobSchedulerRegistrations { + fn from(plugin_permissions_job_scheduler: Vec) -> Self { + let mut supported_job_scheduler_registrations = Self::empty(); + + for plugin_permission_job_scheduler in &plugin_permissions_job_scheduler { + match plugin_permission_job_scheduler { + PluginPermissionsJobScheduler::ScheduledJobs => { + supported_job_scheduler_registrations &= Self::SCHEDULED_JOBS; + } + } + } + + supported_job_scheduler_registrations + } +} + +// The `DiscordEvents` flags is retyped several times in the plugin API. +impl From> for DiscordEvents { + fn from(plugin_permissions_discord_events: Vec) -> Self { + let mut supported_discord_registrations_events = Self::empty(); + + for plugin_permission_discord_events in &plugin_permissions_discord_events { + match plugin_permission_discord_events { + PluginPermissionsDiscordEvents::MessageCreate => { + supported_discord_registrations_events &= Self::MESSAGE_CREATE; + } + PluginPermissionsDiscordEvents::InteractionCreate => { + supported_discord_registrations_events &= Self::INTERACTION_CREATE; + } + PluginPermissionsDiscordEvents::ThreadCreate => { + supported_discord_registrations_events &= Self::THREAD_CREATE; + } + PluginPermissionsDiscordEvents::ThreadDelete => { + supported_discord_registrations_events &= Self::THREAD_DELETE; + } + PluginPermissionsDiscordEvents::ThreadListSync => { + supported_discord_registrations_events &= Self::THREAD_LIST_SYNC; + } + PluginPermissionsDiscordEvents::ThreadMemberUpdate => { + supported_discord_registrations_events &= Self::THREAD_MEMBER_UPDATE; + } + PluginPermissionsDiscordEvents::ThreadMembersUpdate => { + supported_discord_registrations_events &= Self::THREAD_MEMBERS_UPDATE; + } + PluginPermissionsDiscordEvents::ThreadUpdate => { + supported_discord_registrations_events &= Self::THREAD_UPDATE; + } + } + } + + supported_discord_registrations_events + } +} + +impl From for Vec { + fn from(requested_discord_registrations: DiscordEvents) -> Self { + let mut plugin_permissions_discord_events = Vec::new(); + + if requested_discord_registrations.contains(DiscordEvents::MESSAGE_CREATE) { + plugin_permissions_discord_events.push(PluginPermissionsDiscordEvents::MessageCreate); + } + + if requested_discord_registrations.contains(DiscordEvents::INTERACTION_CREATE) { + plugin_permissions_discord_events + .push(PluginPermissionsDiscordEvents::InteractionCreate); + } + + if requested_discord_registrations.contains(DiscordEvents::THREAD_CREATE) { + plugin_permissions_discord_events.push(PluginPermissionsDiscordEvents::ThreadCreate); + } + + if requested_discord_registrations.contains(DiscordEvents::THREAD_DELETE) { + plugin_permissions_discord_events.push(PluginPermissionsDiscordEvents::ThreadDelete); + } + + if requested_discord_registrations.contains(DiscordEvents::THREAD_LIST_SYNC) { + plugin_permissions_discord_events.push(PluginPermissionsDiscordEvents::ThreadListSync); + } + + if requested_discord_registrations.contains(DiscordEvents::THREAD_MEMBER_UPDATE) { + plugin_permissions_discord_events + .push(PluginPermissionsDiscordEvents::ThreadMemberUpdate); + } + + if requested_discord_registrations.contains(DiscordEvents::THREAD_MEMBERS_UPDATE) { + plugin_permissions_discord_events + .push(PluginPermissionsDiscordEvents::ThreadMembersUpdate); + } + + if requested_discord_registrations.contains(DiscordEvents::THREAD_UPDATE) { + plugin_permissions_discord_events.push(PluginPermissionsDiscordEvents::ThreadUpdate); + } + + plugin_permissions_discord_events + } +} + +impl From> for SupportedDiscordRegistrationsInteractions { + fn from( + plugin_permissions_discord_interactions: Vec, + ) -> Self { + let mut supported_discord_registrations_interactions = Self::empty(); + + for plugin_permission_discord_interactions in &plugin_permissions_discord_interactions { + match plugin_permission_discord_interactions { + PluginPermissionsDiscordInteractions::ApplicationCommands => { + supported_discord_registrations_interactions &= Self::APPLICATION_COMMANDS; + } + PluginPermissionsDiscordInteractions::MessageComponents => { + supported_discord_registrations_interactions &= Self::MESSAGE_COMPONENTS; + } + PluginPermissionsDiscordInteractions::Modals => { + supported_discord_registrations_interactions &= Self::MODALS; + } + } + } + + supported_discord_registrations_interactions + } +} diff --git a/src/services.rs b/src/services.rs new file mode 100644 index 0000000..d3b973d --- /dev/null +++ b/src/services.rs @@ -0,0 +1,2 @@ +pub mod discord; +pub mod job_scheduler; diff --git a/src/services/discord.rs b/src/services/discord.rs new file mode 100644 index 0000000..3daf719 --- /dev/null +++ b/src/services/discord.rs @@ -0,0 +1,167 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +/* Copyright © 2026 Eduard Smet */ + +use std::sync::Arc; + +use tokio::{ + sync::mpsc::{UnboundedReceiver, UnboundedSender}, + task::JoinHandle, +}; +use tracing::{error, info}; +use twilight_cache_inmemory::{DefaultInMemoryCache, InMemoryCache}; +use twilight_gateway::{ + CloseFrame, Config, EventType, EventTypeFlags, Intents, MessageSender, Shard, StreamExt, +}; +use twilight_http::Client; + +use crate::{ + SHUTDOWN, + utils::channels::{CoreMessages, DiscordBotClientMessages}, +}; + +mod events; +mod interactions; +mod requests; + +pub struct DiscordBotClient { + http_client: Arc, + shards: Vec, + shard_message_senders: Arc>, + cache: Arc, + core_tx: Arc>, + rx: UnboundedReceiver, +} + +impl DiscordBotClient { + pub async fn new( + token: String, + core_tx: UnboundedSender, + rx: UnboundedReceiver, + ) -> Result { + info!("Creating the Discord bot client"); + + let intents = Intents::all(); // TODO: Make this configurable + + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .unwrap(); + + let http_client = Client::new(token.clone()); + + let config = Config::new(token, intents); + + let (shards, shard_message_senders) = match twilight_gateway::create_recommended( + &http_client, + config, + |_, builder| builder.build(), + ) + .await + { + Ok(shard_iterator) => Self::shard_message_senders(Box::new(shard_iterator)), + Err(err) => { + error!( + "Something went wrong while getting the recommended amount of shards from Discord, error: {}", + &err + ); + return Err(()); + } + }; + + let cache = Arc::new(DefaultInMemoryCache::default()); // TODO: Make this configurable + + Ok(DiscordBotClient { + http_client: Arc::new(http_client), + shards, + shard_message_senders: Arc::new(shard_message_senders), + cache, + core_tx: Arc::new(core_tx), + rx, + }) + } + + pub fn start(mut self) -> JoinHandle<()> { + let mut tasks = Vec::with_capacity(self.shards.len()); + + for shard in self.shards.drain(..) { + tasks.push(tokio::spawn(Self::shard_runner( + self.cache.clone(), + self.core_tx.clone(), + shard, + ))); + } + + tokio::spawn(async move { + while let Some(message) = self.rx.recv().await { + match message { + DiscordBotClientMessages::RegisterApplicationCommands => { + tokio::spawn(Self::application_command_registrations( + self.http_client.clone(), + self.core_tx.clone(), + )); + } + DiscordBotClientMessages::Request(request, response_sender) => { + let http_client = self.http_client.clone(); + let shard_message_senders = self.shard_message_senders.clone(); + + tokio::spawn(async { + response_sender.send( + Self::request(http_client, shard_message_senders, request).await, + ); + }); + } + } + } + + self.shutdown(tasks); + }) + } + + async fn shard_runner( + cache: Arc, + core_tx: Arc>, + mut shard: Shard, + ) { + while let Some(item) = shard.next_event(EventTypeFlags::all()).await { + let Ok(event) = item else { + error!( + "Something went wrong while receiving the next gateway event: {}", + item.as_ref().unwrap_err() + ); + + continue; + }; + + if event.kind() == EventType::GatewayClose && SHUTDOWN.read().await.is_some() { + break; + } + + cache.update(&event); + + tokio::spawn(Self::handle_event(core_tx.clone(), event)); + } + } + + fn shard_message_senders( + shard_iterator: Box>, + ) -> (Vec, Vec) { + let mut shards = vec![]; + let mut shard_message_senders = vec![]; + + for shard in shard_iterator { + shard_message_senders.push(shard.sender()); + shards.push(shard); + } + + (shards, shard_message_senders) + } + + async fn shutdown(&self, mut tasks: Vec>) { + for shard_message_sender in self.shard_message_senders.iter() { + _ = shard_message_sender.close(CloseFrame::NORMAL); + } + + for task in tasks.drain(..) { + let _ = task.await; + } + } +} diff --git a/src/services/discord/events.rs b/src/services/discord/events.rs new file mode 100644 index 0000000..37d9f0b --- /dev/null +++ b/src/services/discord/events.rs @@ -0,0 +1,164 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +/* Copyright © 2026 Eduard Smet */ + +use std::{str::FromStr, sync::Arc}; + +use serde::Serialize; +use tokio::sync::{mpsc::UnboundedSender, oneshot::channel}; +use tracing::{debug, error}; +use twilight_gateway::Event; +use twilight_model::application::interaction::InteractionData; +use uuid::Uuid; + +use crate::{ + database::Keyspaces, + runtime::plugins::exports::wbps::plugin::discord_export_functions::DiscordEvents, + services::discord::DiscordBotClient, + utils::channels::{CoreMessages, DatabaseMessages, RuntimeMessages, RuntimeMessagesDiscord}, +}; + +impl DiscordBotClient { + pub async fn handle_event(core_tx: Arc>, event: Event) { + match event { + Event::InteractionCreate(interaction_create) => { + match interaction_create.data.as_ref() { + Some(InteractionData::ApplicationCommand(command_data)) => { + let (sender, receiver) = channel(); + + core_tx.send(CoreMessages::DatabaseModule(DatabaseMessages::Get( + Keyspaces::DiscordApplicationCommands, + sonic_rs::to_vec(&command_data.id.get()).unwrap(), + sender, + ))); + + let Some(response_bytes) = receiver.await.unwrap().unwrap() else { + return; + }; + + core_tx.send(CoreMessages::Runtime(RuntimeMessages::Discord( + RuntimeMessagesDiscord::CallDiscordEvent( + Uuid::from_slice(&response_bytes).unwrap(), + DiscordEvents::InteractionCreate( + sonic_rs::to_vec(&interaction_create).unwrap(), + ), + ), + ))); + } + Some(InteractionData::MessageComponent(message_component_interaction_data)) => { + let (sender, receiver) = channel(); + + let Ok(message_component_id) = + Uuid::from_str(&message_component_interaction_data.custom_id) + else { + return; + }; + + core_tx.send(CoreMessages::DatabaseModule(DatabaseMessages::Get( + Keyspaces::DiscordMessageComponents, + message_component_id.as_bytes().to_vec(), + sender, + ))); + + let Some(response_bytes) = receiver.await.unwrap().unwrap() else { + return; + }; + + core_tx.send(CoreMessages::Runtime(RuntimeMessages::Discord( + RuntimeMessagesDiscord::CallDiscordEvent( + Uuid::from_slice(&response_bytes).unwrap(), + DiscordEvents::InteractionCreate( + sonic_rs::to_vec(&interaction_create).unwrap(), + ), + ), + ))); + } + Some(InteractionData::ModalSubmit(modal_interaction_data)) => { + let (sender, receiver) = channel(); + + let Ok(modal_id) = Uuid::from_str(&modal_interaction_data.custom_id) else { + return; + }; + + core_tx.send(CoreMessages::DatabaseModule(DatabaseMessages::Get( + Keyspaces::DiscordMessageComponents, + modal_id.as_bytes().to_vec(), + sender, + ))); + + let Some(response_bytes) = receiver.await.unwrap().unwrap() else { + return; + }; + + core_tx.send(CoreMessages::Runtime(RuntimeMessages::Discord( + RuntimeMessagesDiscord::CallDiscordEvent( + Uuid::from_slice(&response_bytes).unwrap(), + DiscordEvents::InteractionCreate( + sonic_rs::to_vec(&interaction_create).unwrap(), + ), + ), + ))); + } + _ => error!( + "Received unsupported interaction event: {}", + interaction_create.kind.kind() + ), + } + } + Event::MessageCreate(message_create) => { + Self::handle_basic_event(core_tx, "MESSAGE_CREATE", message_create); + } + Event::ThreadCreate(thread_create) => { + Self::handle_basic_event(core_tx, "THREAD_CREATE", thread_create); + } + Event::ThreadDelete(thread_delete) => { + Self::handle_basic_event(core_tx, "THREAD_DELETE", thread_delete); + } + Event::ThreadMemberUpdate(thread_member_update) => { + Self::handle_basic_event(core_tx, "THREAD_MEMBER_UPDATE", thread_member_update); + } + Event::ThreadMembersUpdate(thread_members_update) => { + Self::handle_basic_event(core_tx, "THREAD_MEMBERS_UPDATE", thread_members_update); + } + Event::ThreadUpdate(thread_update) => { + Self::handle_basic_event(core_tx, "THREAD_UPDATE", thread_update); + } + _ => debug!( + "Received unsupported event: {}", + &event.kind().name().unwrap_or("[No event kind name]") + ), + } + } + + pub async fn handle_basic_event( + core_tx: Arc>, + key: &str, + data: D, + ) where + D: Serialize, + { + let (sender, receiver) = channel(); + + core_tx.send(CoreMessages::DatabaseModule(DatabaseMessages::Get( + Keyspaces::DiscordEvents, + key.as_bytes().to_vec(), + sender, + ))); + + let Some(response_bytes) = receiver.await.unwrap().unwrap() else { + return; + }; + + let plugin_ids_bytes = sonic_rs::from_slice::>>(&response_bytes).unwrap(); + + for plugin_id_bytes in plugin_ids_bytes { + let plugin_id = Uuid::from_slice(&plugin_id_bytes).unwrap(); + + core_tx.send(CoreMessages::Runtime(RuntimeMessages::Discord( + RuntimeMessagesDiscord::CallDiscordEvent( + plugin_id, + DiscordEvents::MessageCreate(sonic_rs::to_vec(&data).unwrap()), + ), + ))); + } + } +} diff --git a/src/discord/interactions.rs b/src/services/discord/interactions.rs similarity index 83% rename from src/discord/interactions.rs rename to src/services/discord/interactions.rs index 06f559e..4a1b294 100644 --- a/src/discord/interactions.rs +++ b/src/services/discord/interactions.rs @@ -1,10 +1,13 @@ /* SPDX-License-Identifier: GPL-3.0-or-later */ /* Copyright © 2026 Eduard Smet */ -use std::collections::HashMap; +use std::{collections::HashMap, sync::Arc}; +use anyhow::Result; +use fjall::Slice; +use tokio::sync::{mpsc::UnboundedSender, oneshot::channel}; use tracing::{error, info}; -use twilight_http::{request::Request, routing::Route}; +use twilight_http::{Client, request::Request, routing::Route}; use twilight_model::{ application::command::Command, id::{ @@ -13,21 +16,43 @@ use twilight_model::{ }, }; -use crate::{discord::DiscordBotClient, plugins::PluginRegistrationRequestsApplicationCommand}; +use crate::{ + database::Keyspaces, + services::discord::DiscordBotClient, + utils::channels::{CoreMessages, DatabaseMessages}, +}; impl DiscordBotClient { pub async fn application_command_registrations( - &self, - discord_application_command_registration_request: Vec< - PluginRegistrationRequestsApplicationCommand, - >, + http_client: Arc, + core_tx: Arc>, ) -> Result<(), ()> { + let (sender, receiver) = channel(); + + // NOTE: To be replaced by a get all KEYS + core_tx.send(CoreMessages::DatabaseModule(DatabaseMessages::GetAll( + Keyspaces::DiscordApplicationCommands, + sender, + ))); + + let responses: Vec = receiver.await.unwrap().unwrap(); + + for response_bytes in responses { + // NOTE: + // - Should this succeed and be tested beforehand? + // - No, as it can fail later anyways. + // - Should the plugin get called into to let it know its registration result? + // - Only reasonable solution I can think of. + // - Can happen from here. + let command: Command = sonic_rs::from_slice(&response_bytes).unwrap(); + } + let mut discord_commands = HashMap::new(); let mut commands = HashMap::new(); for command in discord_application_command_registration_request { - let command_data = match sonic_rs::from_slice::(&command.data) { + let command_data = match sonic_rs::from_slice::(&command) { Ok(command) => command, Err(err) => { error!( @@ -44,7 +69,7 @@ impl DiscordBotClient { .push((command.plugin_id, command_data.clone())); } - let application_id = match self.http_client.current_user_application().await { + let application_id = match http_client.current_user_application().await { Ok(response) => match response.model().await { Ok(application) => application.id, Err(err) => { @@ -80,8 +105,7 @@ impl DiscordBotClient { } }; - match self - .http_client + match http_client .request::>(global_discord_commands_request) .await { @@ -110,7 +134,7 @@ impl DiscordBotClient { } // TODO: Endpoint is limited to 200 guilds per request, pagination needs to be implemented. - let current_user_guilds = match self.http_client.current_user_guilds().await { + let current_user_guilds = match http_client.current_user_guilds().await { Ok(response) => match response.model().await { Ok(current_user_guilds) => current_user_guilds, Err(err) => { @@ -148,8 +172,7 @@ impl DiscordBotClient { } }; - match self - .http_client + match http_client .request::>(guild_commands_request) .await { @@ -182,18 +205,16 @@ impl DiscordBotClient { if commands_by_name.1.len() == 1 { let command = commands_by_name.1.remove(0); - match self - .register_application_command(application_id, &mut discord_commands, &command.1) - .await + match Self::register_application_command( + http_client.clone(), + application_id, + &mut discord_commands, + &command.1, + ) + .await { Ok(command_id) => { - self.plugin_registrations - .write() - .await - .discord_events - .interaction_create - .application_commands - .insert(command_id, command.0); + // TODO: return value } Err(()) => { error!( @@ -208,22 +229,16 @@ impl DiscordBotClient { for mut command in commands_by_name.1 { command.1.name += format!("~{command_name_occurence_count}").as_str(); - match self - .register_application_command( - application_id, - &mut discord_commands, - &command.1, - ) - .await + match Self::register_application_command( + http_client.clone(), + application_id, + &mut discord_commands, + &command.1, + ) + .await { Ok(command_id) => { - self.plugin_registrations - .write() - .await - .discord_events - .interaction_create - .application_commands - .insert(command_id, command.0); + // TODO: return value } Err(()) => { error!( @@ -238,14 +253,14 @@ impl DiscordBotClient { } } - self.delete_old_application_commands(application_id, &discord_commands) + Self::delete_old_application_commands(http_client, application_id, &discord_commands) .await?; Ok(()) } async fn register_application_command( - &self, + http_client: Arc, application_id: Id, discord_commands: &mut HashMap, command: &Command, @@ -304,7 +319,7 @@ impl DiscordBotClient { } }; - match self.http_client.request::(request).await { + match http_client.request::(request).await { Ok(response) => match response.model().await { Ok(command) => Ok(command.id.unwrap()), Err(err) => { @@ -326,7 +341,7 @@ impl DiscordBotClient { } async fn delete_old_application_commands( - &self, + http_client: Arc, application_id: Id, discord_commands: &HashMap, ) -> Result<(), ()> { @@ -358,7 +373,7 @@ impl DiscordBotClient { "Deleting the {} command, guild id: {:?}", &discord_command.name, &discord_command.guild_id ); - match self.http_client.request::<()>(request).await { + match http_client.request::<()>(request).await { Ok(_) => (), Err(err) => { error!( diff --git a/src/discord/requests.rs b/src/services/discord/requests.rs similarity index 86% rename from src/discord/requests.rs rename to src/services/discord/requests.rs index 7c229e3..f8c7e8b 100644 --- a/src/discord/requests.rs +++ b/src/services/discord/requests.rs @@ -1,45 +1,39 @@ /* SPDX-License-Identifier: GPL-3.0-or-later */ /* Copyright © 2026 Eduard Smet */ -use twilight_http::{request::Request, routing::Route}; -use twilight_model::{ - gateway::{ - OpCode, - payload::outgoing::{ - RequestGuildMembers, UpdatePresence, UpdateVoiceState, - request_guild_members::RequestGuildMembersInfo, update_presence::UpdatePresencePayload, - update_voice_state::UpdateVoiceStateInfo, - }, +use std::sync::Arc; + +use twilight_gateway::MessageSender; +use twilight_http::{Client, request::Request, routing::Route}; +use twilight_model::gateway::{ + OpCode, + payload::outgoing::{ + RequestGuildMembers, UpdatePresence, UpdateVoiceState, + request_guild_members::RequestGuildMembersInfo, update_presence::UpdatePresencePayload, + update_voice_state::UpdateVoiceStateInfo, }, - id::Id, }; use crate::{ - discord::DiscordBotClient, - plugins::discord_bot::plugin::{ - discord_types::Contents, - host_functions::{DiscordRequests, DiscordResponses}, + runtime::plugins::wbps::plugin::{ + core_import_types::Error, + discord_import_types::{Body, DiscordRequests, DiscordResponses}, }, + services::discord::DiscordBotClient, }; impl DiscordBotClient { #[allow(clippy::too_many_lines)] pub async fn request( - &self, + http_client: Arc, + shard_message_senders: Arc>, request: DiscordRequests, - ) -> Result, String> { + ) -> Result, Error> { let request = match request { // Shard message sender commands DiscordRequests::RequestGuildMembers((guild_id, body)) => { - let guild_id = Id::new(guild_id); - - let guild_shard_message_sender = if let Some(guild_shard_message_sender) = - self.shard_message_senders.read().await.get(&guild_id) - { - guild_shard_message_sender.clone() - } else { - return Err(String::from("No guild found")); - }; + let guild_shard_message_sender = + Self::get_guild_shard_id(&shard_message_senders, guild_id); let d = match sonic_rs::from_slice::(&body) { Ok(d) => d, @@ -60,20 +54,13 @@ impl DiscordBotClient { None } DiscordRequests::RequestSoundboardSounds(_guild_ids) => { - return Err(String::from( - "RequestSoundboardSounds has not yet been implemented in Twilight.", + return Err(format!( + "RequestSoundboardSounds has not yet been implemented in Twilight." )); } DiscordRequests::UpdateVoiceState((guild_id, body)) => { - let guild_id = Id::new(guild_id); - - let guild_shard_message_sender = if let Some(guild_shard_message_sender) = - self.shard_message_senders.read().await.get(&guild_id) - { - guild_shard_message_sender.clone() - } else { - return Err(String::from("No guild found")); - }; + let guild_shard_message_sender = + Self::get_guild_shard_id(&shard_message_senders, guild_id); let d = match sonic_rs::from_slice::(&body) { Ok(d) => d, @@ -94,13 +81,7 @@ impl DiscordBotClient { None } DiscordRequests::UpdatePresence(body) => { - let guild_shard_message_sender = if let Some(guild_shard_message_sender) = - self.shard_message_senders.read().await.values().next() - { - guild_shard_message_sender.clone() - } else { - return Err(String::from("No guild found")); - }; + let guild_shard_message_sender = shard_message_senders.get(0).unwrap(); let d = match sonic_rs::from_slice::(&body) { Ok(d) => d, @@ -154,11 +135,13 @@ impl DiscordBotClient { let request_builder = Request::builder(&Route::CreateForumThread { channel_id }); let request_builder = match content { - Contents::Json(bytes) => request_builder.body(bytes), - Contents::Form(buffer) => match request_builder.multipart(buffer) { + Body::Json(bytes) => request_builder.body(bytes), + Body::Form(buffer) => match request_builder.multipart(buffer) { Ok(request) => request, Err(err) => { - return Err(err.to_string()); + return Err(format!( + "Something went wrong while building a Discord request, error: {err}" + )); } }, }; @@ -176,11 +159,13 @@ impl DiscordBotClient { let request_builder = Request::builder(&Route::CreateMessage { channel_id }); let request_builder = match content { - Contents::Json(bytes) => request_builder.body(bytes), - Contents::Form(buffer) => match request_builder.multipart(buffer) { + Body::Json(bytes) => request_builder.body(bytes), + Body::Form(buffer) => match request_builder.multipart(buffer) { Ok(request) => request, Err(err) => { - return Err(err.to_string()); + return Err(format!( + "Something went wrong while building a Discord request, error: {err}" + )); } }, }; @@ -431,7 +416,7 @@ impl DiscordBotClient { }; if let Some(request) = request { - match self.http_client.request::>(request).await { + match http_client.request::>(request).await { Ok(response) => Ok(Some(response.bytes().await.unwrap().clone())), Err(err) => Err(format!( "Something went wrong while making a Discord request, error: {err}" @@ -441,4 +426,13 @@ impl DiscordBotClient { Ok(None) } } + + fn get_guild_shard_id( + shard_message_senders: &Arc>, + guild_id: u64, + ) -> &MessageSender { + shard_message_senders + .get((guild_id >> 22) as usize % shard_message_senders.len()) + .unwrap() + } } diff --git a/src/services/job_scheduler.rs b/src/services/job_scheduler.rs new file mode 100644 index 0000000..2c04ea0 --- /dev/null +++ b/src/services/job_scheduler.rs @@ -0,0 +1,95 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +/* Copyright © 2026 Eduard Smet */ + +use anyhow::Result; +use tokio::{ + sync::mpsc::{UnboundedReceiver, UnboundedSender}, + task::JoinHandle, +}; +use tokio_cron_scheduler::{Job, JobScheduler as TokioCronScheduler}; +use tracing::info; +use uuid::Uuid; + +use crate::utils::channels::{ + CoreMessages, JobSchedulerMessages, RuntimeMessages, RuntimeMessagesJobScheduler, +}; + +pub struct JobScheduler { + tokio_cron_scheduler: TokioCronScheduler, + core_tx: UnboundedSender, + rx: UnboundedReceiver, +} + +impl JobScheduler { + pub async fn new( + core_tx: UnboundedSender, + rx: UnboundedReceiver, + ) -> Result { + info!("Creating the job scheduler"); + + Ok(JobScheduler { + tokio_cron_scheduler: TokioCronScheduler::new().await?, + core_tx, + rx, + }) + } + + pub async fn start(mut self) -> Result> { + self.tokio_cron_scheduler.start().await?; + + Ok(tokio::spawn(async move { + while let Some(message) = self.rx.recv().await { + match message { + JobSchedulerMessages::AddJob(plugin_id, cron, result) => { + let tokio_cron_scheduler = self.tokio_cron_scheduler.clone(); + let core_tx = self.core_tx.clone(); + + tokio::spawn(async move { + result.send( + Self::add_job(tokio_cron_scheduler, core_tx, plugin_id, cron).await, + ); + }); + } + JobSchedulerMessages::RemoveJob(uuid, result) => { + let tokio_cron_scheduler = self.tokio_cron_scheduler.clone(); + + tokio::spawn(async move { + result.send(Self::remove_job(tokio_cron_scheduler, uuid).await); + }); + } + } + } + + let _ = self.tokio_cron_scheduler.shutdown().await; + })) + } + + async fn add_job( + tokio_cron_scheduler: TokioCronScheduler, + core_tx: UnboundedSender, + plugin_id: Uuid, + cron: String, + ) -> Result { + info!( + "Scheduled Job at {cron} cron from the {plugin_id} plugin requested to be registered" + ); + + let job = Job::new_async_tz(cron.clone(), chrono::Local, move |job_id, _lock| { + let core_tx = core_tx.clone(); + + Box::pin(async move { + let _ = core_tx.send(CoreMessages::Runtime(RuntimeMessages::JobScheduler( + RuntimeMessagesJobScheduler::CallScheduledJob(plugin_id, job_id), + ))); + }) + })?; + + Ok(tokio_cron_scheduler.add(job).await?) + } + + async fn remove_job(tokio_cron_scheduler: TokioCronScheduler, uuid: Uuid) -> Result<()> { + info!("Removing scheduled Job {uuid}"); + + Ok(tokio_cron_scheduler.remove(&uuid).await?) + } +} diff --git a/src/utils/channels.rs b/src/utils/channels.rs index cfa6531..8abb1be 100644 --- a/src/utils/channels.rs +++ b/src/utils/channels.rs @@ -1,76 +1,131 @@ /* SPDX-License-Identifier: GPL-3.0-or-later */ /* Copyright © 2026 Eduard Smet */ +use anyhow::Result; +use fjall::Slice; use tokio::sync::{ - mpsc::{Receiver as MPSCReceiver, Sender as MPSCSender, channel}, + mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}, oneshot::Sender as OSSender, }; +use uuid::Uuid; -use crate::plugins::{ - PluginRegistrationRequestsApplicationCommand, PluginRegistrationRequestsScheduledJob, - discord_bot::plugin::host_functions::{DiscordRequests, DiscordResponses}, - exports::discord_bot::plugin::plugin_functions::DiscordEvents, +use crate::{ + Shutdown, + database::Keyspaces, + runtime::plugins::wbps::plugin::{ + discord_export_types::{DiscordEvents, Error}, + discord_import_functions::DiscordRequests, + discord_import_types::DiscordResponses, + }, }; +pub enum CoreMessages { + DatabaseModule(DatabaseMessages), + + JobSchedulerModule(JobSchedulerMessages), + DiscordBotClientModule(DiscordBotClientMessages), + + Runtime(RuntimeMessages), + + Shutdown(Shutdown), +} + +pub enum DatabaseMessages { + Get(Keyspaces, Vec, OSSender>>), + GetAllKeys(Keyspaces, OSSender>>), + GetAllValues(Keyspaces, OSSender>>), + Insert(Keyspaces, Vec, Vec, OSSender>), + Remove(Keyspaces, Vec, OSSender>), + ContainsKey(Keyspaces, Vec, OSSender>), + Clear(Keyspaces, OSSender>), +} + +pub enum JobSchedulerMessages { + AddJob(Uuid, String, OSSender>), + RemoveJob(Uuid, OSSender>), +} + pub enum DiscordBotClientMessages { - RegisterApplicationCommands(Vec), + RegisterApplicationCommands, Request( DiscordRequests, - OSSender, String>>, + OSSender, Error>>, ), - Shutdown(OSSender<()>), } -pub enum JobSchedulerMessages { - RegisterScheduledJobs(Vec), - Shutdown(OSSender<()>), +pub enum RuntimeMessages { + Core(RuntimeMessagesCore), + JobScheduler(RuntimeMessagesJobScheduler), + Discord(RuntimeMessagesDiscord), } -pub enum RuntimeMessages { - CallDiscordEvent(String, DiscordEvents), - CallScheduledJob(String, String), +pub enum RuntimeMessagesCore { + CallDependencyFunction(Uuid, String, Vec, OSSender, Error>>), + UnloadPlugin(Uuid), +} + +pub enum RuntimeMessagesJobScheduler { + CallScheduledJob(Uuid, Uuid), +} + +pub enum RuntimeMessagesDiscord { + CallDiscordEvent(Uuid, DiscordEvents), } pub struct Channels { - pub discord_bot_client: ChannelsDiscordBotClient, + pub core: ChannelsCore, pub job_scheduler: ChannelsJobScheduler, + pub discord_bot_client: ChannelsDiscordBotClient, pub runtime: ChannelsRuntime, } -pub struct ChannelsDiscordBotClient { - pub sender: MPSCSender, - pub receiver: MPSCReceiver, +pub struct ChannelsCore { + pub job_scheduler_tx: UnboundedSender, + pub discord_bot_client_tx: UnboundedSender, + pub runtime_tx: UnboundedSender, + pub rx: UnboundedReceiver, } pub struct ChannelsJobScheduler { - pub sender: MPSCSender, - pub receiver: MPSCReceiver, + pub core_tx: UnboundedSender, + pub rx: UnboundedReceiver, +} + +pub struct ChannelsDiscordBotClient { + pub core_tx: UnboundedSender, + pub rx: UnboundedReceiver, } pub struct ChannelsRuntime { - pub discord_bot_client_sender: MPSCSender, - pub job_scheduler_sender: MPSCSender, - pub receiver: MPSCReceiver, + pub core_tx: UnboundedSender, + pub rx: UnboundedReceiver, } pub fn new() -> Channels { - let (discord_bot_client_tx, discord_bot_client_rx) = channel::(200); - let (job_scheduler_tx, job_scheduler_rx) = channel::(200); - let (runtime_tx, runtime_rx) = channel::(400); + let (core_tx, core_rx) = unbounded_channel::(); + let (job_scheduler_tx, job_scheduler_rx) = unbounded_channel::(); + let (discord_bot_client_tx, discord_bot_client_rx) = + unbounded_channel::(); + let (runtime_tx, runtime_rx) = unbounded_channel::(); Channels { - discord_bot_client: ChannelsDiscordBotClient { - sender: discord_bot_client_tx, - receiver: discord_bot_client_rx, + core: ChannelsCore { + job_scheduler_tx, + discord_bot_client_tx, + runtime_tx, + rx: core_rx, }, job_scheduler: ChannelsJobScheduler { - sender: job_scheduler_tx, - receiver: job_scheduler_rx, + core_tx: core_tx.clone(), + rx: job_scheduler_rx, + }, + discord_bot_client: ChannelsDiscordBotClient { + core_tx: core_tx.clone(), + rx: discord_bot_client_rx, }, runtime: ChannelsRuntime { - discord_bot_client_sender: runtime_tx.clone(), - job_scheduler_sender: runtime_tx, - receiver: runtime_rx, + core_tx, + rx: runtime_rx, }, } } diff --git a/src/utils/env.rs b/src/utils/env.rs index 13c58a4..fca17b4 100644 --- a/src/utils/env.rs +++ b/src/utils/env.rs @@ -3,38 +3,34 @@ use std::{env, path::Path}; -use tracing::{debug, error, info}; - +use anyhow::{Context, Result, bail}; use dotenvy; +use tracing::{debug, info}; + +pub struct Secrets { + pub discord_bot_client: String, +} -pub fn load_env_file(env_file: &Path) -> Result<(), ()> { +pub fn load_env_file(env_file_path: &Path) -> Result<()> { info!("Loading the env file"); - if let Err(err) = dotenvy::from_path(env_file) { + if let Err(err) = dotenvy::from_path(env_file_path) { if err.not_found() { - debug!("No env file found for the following path: {env_file:?}"); + debug!("No env file found at: {env_file_path:?}"); return Ok(()); } - error!("An error occurred wile trying to load the env file: {err}"); - - return Err(()); + bail!("An error occurred wile trying to load the env file: {err}"); } Ok(()) } -pub fn validate() -> Result { +pub fn get_secrets() -> Result { info!("Validating the environment variables (DISCORD_BOT_CLIENT_TOKEN)"); - if let Ok(value) = env::var("DISCORD_BOT_CLIENT_TOKEN") { - debug!("DISCORD_BOT_CLIENT_TOKEN environment variable was found: {value:.3}... (redacted)"); - - Ok(value) - } else { - error!( - "The DISCORD_BOT_CLIENT_TOKEN environment variable was not set, contains an illegal character ('=' or '0') or was not valid unicode" - ); - Err(()) - } + Ok(Secrets { + discord_bot_client: env::var("DISCORD_BOT_CLIENT_TOKEN") + .context("Failed to load the DISCORD_BOT_CLIENT_TOKEN environment variable")?, + }) } diff --git a/wit/core.wit b/wit/core.wit new file mode 100644 index 0000000..d4c2977 --- /dev/null +++ b/wit/core.wit @@ -0,0 +1,65 @@ +interface core-types { + type json = list; +} + +interface core-import-types { + type error = string; + + enum log-levels { + trace, + debug, + info, + warn, + error, + } + + flags supported-core-registrations { + dependency-functions, + shutdown, + } + + /// All registrations are opt in. + record core-registrations { + dependency-functions: option>, + } + + /// The `dependency-functions` field returns separate lists of successful and failed IDs. + /// + /// The `shutdown` field returns a result to indicate if the shutdown registration was accepted or not. + record core-registrations-result { + dependency-functions: option, list>>, + } +} + +interface core-export-types { + type error = string; +} + +interface core-import-functions { + use core-import-types.{error, log-levels, core-registrations, core-registrations-result, supported-core-registrations}; + + log: func(level: log-levels, message: string); + + get-supported-registrations: func() -> supported-core-registrations; + register: func(registrations: core-registrations) -> core-registrations-result; + + unload: func(reason: string); + + shutdown: func(restart: bool) -> result<_, error>; + + /// The `params` parameter and result ok value are bytes. The serialized format has to be decided between plugins. + dependency-function: func(registry-id: string, plugin-id: string, function-id: string, params: list) -> result, error>; +} + +interface core-export-functions { + use core-export-types.{error}; + use core-types.{json}; + + initialization: func(settings: json) -> result<_, error>; + + shutdown: func() -> result<_, error>; + + /// The `params` parameter and result ok value are bytes. The serialized format has to be decided between plugins. + dependency-function: func(function-id: string, params: list) -> result, error>; +} + diff --git a/wit/discord.wit b/wit/discord.wit index d5877b0..11d4330 100644 --- a/wit/discord.wit +++ b/wit/discord.wit @@ -1,36 +1,101 @@ -interface discord-types { - /// variant data is JSON, check the [Discord Gateway Event docs] for the structures. - /// - /// [Discord Gateway Event docs]: https://discord.com/developers/docs/events/gateway-events - variant events { - interaction-create(list), - message-create(list), - thread-create(list), - thread-delete(list), - thread-list-sync(list), - thread-member-update(list), - thread-members-update(list), - thread-update(list), - } - - /// variant data last tuple entry might be JSON, check the Discord [Gateway Send Event] and HTTP Resource (like the [Message Resource docs]) docs for the structures. - /// - /// [Gateway Send Event]: https://discord.com/developers/docs/events/gateway-events#send-events - /// [Message Resource]: https://discord.com/developers/docs/resources/message - variant requests { - // Shard message sender commands - request-guild-members(tuple>), +interface discord-import-types { + use core-types.{json}; + use core-import-types.{error}; + + /// All supported Discord gateway events. + flags discord-events { + message-create, + interaction-create, + thread-create, + thread-delete, + thread-list-sync, + thread-member-update, + thread-members-update, + thread-update, + } + + /// Discord gateway events and interactions the plugin is allowed to register + /// for. + record supported-discord-registrations { + events: supported-discord-registrations-events, + interactions: supported-discord-registrations-interactions, + } + + /// Discord gateway events the plugin is allowed to register for. + type supported-discord-registrations-events = discord-events; + + /// Discord interactions the plugin is allowed to register for. + flags supported-discord-registrations-interactions { + application-commands, + message-components, + modals, + } + + /// Opt-in Discord gateway event and interaction registrations. + record discord-registrations { + events: option, + interactions: option, + } + + /// Opt-in Discord gateway event registrations. + type discord-registrations-events = discord-events; + + /// Opt-in Discord interaction registrations. + /// + /// `application-commands`: Slash commands and context menus. + /// The structure is described in the Discord docs: https://docs.discord.com/developers/interactions/application-commands#application-command-object + // + /// `message-components`: Button/select menus. Value is the amount of UUIDs + /// needed by the plugin. + // + /// `modals`: Modals. Value is the amount of UUIDs needed by the plugin. + record discord-registrations-interactions { + application-commands: option>, + message-components: option, + modals: option, + } + + /// Result of Discord gateway event and interaction registrations. + /// + /// `events`: Returns an error if the plugin tried to register for an event + /// it is not allowed to. + // + /// `interactions`: Returns an error if the plugin does not have permission + /// to register for the `interaction-create` event. + record discord-registrations-result { + events: option>, + interactions: option>, + } + + /// UUIDs assigned to registered message components and modals. + /// + /// Each UUID can be used to recognize which component was clicked or modal + /// submitted. + record discord-registrations-result-interactions { + message-components: option>, + modals: option>, + } + + /// API requests to Discord. + /// + /// Variant data follows Discord HTTP and Gateway data structures. + /// The structures are described in the Discord docs: + /// - Gateway commands: + /// - HTTP operations: + variant discord-requests { + // Gateway commands + request-guild-members(tuple), request-soundboard-sounds(list), - update-voice-state(tuple>), - update-presence(list), + update-voice-state(tuple), + update-presence(json), - // HTTP requests + // HTTP operations add-thread-member(tuple), - create-ban(tuple>), - create-forum-thread(tuple), - create-message(tuple), - create-thread(tuple>), - create-thread-from-message(tuple>), + create-ban(tuple), + create-forum-thread(tuple), + create-message(tuple), + create-thread(tuple), + create-thread-from-message(tuple), delete-message(tuple), get-active-threads(u64), get-channel(u64), @@ -39,20 +104,84 @@ interface discord-types { get-public-archived-threads(tuple, u64, option>), get-thread-member(tuple), get-thread-members(tuple, u64, option, option>), - interaction-callback(tuple>), + interaction-callback(tuple), join-thread(u64), leave-thread(u64), remove-thread-member(tuple), - update-member(tuple>), - update-interaction-original(tuple>), + update-member(tuple), + update-interaction-original(tuple), + } + + /// Request body type: + /// - JSON: For normal payloads + /// - Form: For payloads including file attachments. + variant body { + json(json), + form(form), } - // variant data is either a JSON body or a multipart form buffer. - variant contents { - json(list), - form(list), + /// Raw form data for Discord requests with file attachments (e.g. embeds with + /// images). + type form = list; + + /// API responses from Discord. + type discord-responses = json; +} + +interface discord-export-types { + use core-types.{json}; + use core-import-types.{error}; + + /// Result of application command registrations. + /// + /// First list: Successful registrations, includes the Discord snowflake which + /// can be used to recognize the application command which got called. + // + /// Second list: Failed registrations, includes an error. + type discord-registrations-result-application-commands = tuple>, list>>; + + /// Gateway events the plugin registered for and receives. + /// + /// Payload is raw JSON from Discord. + /// The structure is described in the Discord docs: https://discord.com/developers/docs/events/gateway-events#receive-events + variant discord-events { + interaction-create(json), + message-create(json), + thread-create(json), + thread-delete(json), + thread-list-sync(json), + thread-member-update(json), + thread-members-update(json), + thread-update(json), } +} + +interface discord-import-functions { + use core-import-types.{error}; + use discord-import-types.{discord-registrations, discord-registrations-result, discord-requests, discord-responses, supported-discord-registrations}; + + /// Returns the Discord gateway events the plugin can register for. + get-supported-discord-registrations: func() -> supported-discord-registrations; + + /// Registers for Discord gateway events and interactions. + /// + /// Application commands registered after initialization are ignored by Discord. + /// More info: https://docs.discord.com/developers/interactions/application-commands#registering-a-command + discord-register: func(registrations: discord-registrations) -> discord-registrations-result; + + /// Makes a Discord request, this can be a gateway command or HTTP request. + /// + /// Gateway commands do not return data. + discord-request: func(request: discord-requests) -> result, error>; +} + +interface discord-export-functions { + use core-export-types.{error}; + use discord-export-types.{discord-events, discord-registrations-result-application-commands}; + + /// Called when Discord application command registration completes. + discord-application-commands: func(registrations-result: discord-registrations-result-application-commands) -> result<_, error>; - /// responses is JSON. - type responses = list; + /// Called when a registered Discord gatway event occurs. + discord-event: func(event: discord-events) -> result<_, error>; } diff --git a/wit/host.wit b/wit/host.wit deleted file mode 100644 index 503c03e..0000000 --- a/wit/host.wit +++ /dev/null @@ -1,42 +0,0 @@ -/// TODO: Implement the registrations-request host function, blocked by missing component -/// information on host calls, fixed by adding Store context to host functions. -/// -/// Relevant Zulip discussion: https://bytecodealliance.zulipchat.com/#narrow/channel/217126-wasmtime/topic/Support.20for.20identifying.20the.20guest.20making.20a.20host.20call/with/571099564 -interface host-types { - //record registrations-result { - // discord-events: registrations-result-discord-events-interaction-create, - // scheduled-jobs: list>, - // dependency-functions: list>, - //} - - //record registrations-result-discord-events-interaction-create { - // application-commands: list>, - // message-components: list>, - // modals: list>, - //} - - enum log-levels { - trace, - debug, - info, - warn, - error, - } -} - -interface host-functions { - use host-types.{log-levels}; - use discord-types.{requests as discord-requests, responses as discord-responses}; - - /// Can only be called during the initialization plugin call, calls at other times will be ignored. - //registrations-request: func(registrations: registrations) -> result; - - log: func(level: log-levels, message: string); - - discord-request: func(request: discord-requests) -> result, string>; - - /// params, result Ok and Err are JSON. - dependency-function: func(dependency: string, function: string, params: list) -> result, string>; - - shutdown: func(restart: bool); -} diff --git a/wit/job-scheduler.wit b/wit/job-scheduler.wit new file mode 100644 index 0000000..5b3a0ac --- /dev/null +++ b/wit/job-scheduler.wit @@ -0,0 +1,36 @@ +interface job-scheduler-import-types { + flags supported-job-scheduler-registrations { + scheduled-jobs, + } + + /// All registrations are opt in. + /// + /// The `scheduled-jobs` field list entries should exist out of cron values. + /// + /// cron: https://en.wikipedia.org/wiki/Cron + record job-scheduler-registrations { + scheduled-jobs: list, + } + + /// The `scheduled-jobs` field returns separate lists of successful and failed cron values. + record job-scheduler-registrations-result { + scheduled-jobs: tuple, list>, + } +} + +interface job-scheduler-export-types { + +} + +interface job-scheduler-import-functions { + use job-scheduler-import-types.{job-scheduler-registrations, job-scheduler-registrations-result, supported-job-scheduler-registrations}; + + get-supported-registrations: func() -> supported-job-scheduler-registrations; + register: func(registrations: job-scheduler-registrations) -> job-scheduler-registrations-result; +} + +interface job-scheduler-export-functions { + use core-export-types.{error}; + + scheduled-job: func(job-id: string) -> result<_, error>; +} diff --git a/wit/plugin.wit b/wit/plugin.wit deleted file mode 100644 index e967d1a..0000000 --- a/wit/plugin.wit +++ /dev/null @@ -1,64 +0,0 @@ -/// Down the line once map gets introduced to the component model -/// mutliple plugin types will rely on it instead of list> or list -/// to enforce uniqueness on the plugin level. -/// [Relevant PR] in the component model repository. -/// -/// [Relevant PR]: https://github.com/WebAssembly/component-model/pull/554 -interface plugin-types { - /// scheduled-jobs: tuple entry 0 is the id and entry 1 is a list of cron - /// values. - record registrations-request { - discord-events: option, - scheduled-jobs: option>>>, - dependency-functions: option>, - } - - record registrations-request-discord-events { - interaction-create: option, - message-create: bool, - thread-create: bool, - thread-delete: bool, - thread-list-sync: bool, - thread-member-update: bool, - thread-members-update: bool, - thread-update: bool, - } - - /// application-commands: tuple entry 0 is the ID and entry 1 is - /// JSON, check the [Discord Application Command docs] for the structure. - /// - /// [Discord Application Command docs]: https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-structure - record registrations-request-interaction-create { - application-commands: option>>, - message-components: option>, - modals: option>, - } - - flags supported-registrations { - dependency-functions, - discord-event-message-create, - discord-event-interaction-create, - discord-event-thread-create, - discord-event-thread-delete, - discord-event-thread-list-sync, - discord-event-thread-member-update, - discord-event-thread-members-update, - discord-event-thread-update, - scheduled-jobs, - shutdown, - } -} - -interface plugin-functions { - use plugin-types.{registrations-request, supported-registrations}; - use discord-types.{events as discord-events, requests as discord-requests}; - - /// settings is JSON. - initialization: func(settings: list, supported-registrations: supported-registrations) -> result; - shutdown: func() -> result<_, string>; - - discord-event: func(event: discord-events) -> result<_, string>; - scheduled-job: func(job: string) -> result<_, string>; - /// params and result Ok are JSON. - dependency-function: func(function: string, params: list) -> result, string>; -} diff --git a/wit/world.wit b/wit/world.wit index 14b9017..89e4874 100644 --- a/wit/world.wit +++ b/wit/world.wit @@ -1,7 +1,18 @@ -package discord-bot:plugin@0.1.0; +package wbps:plugin@0.1.0; +/// Down the line once map gets introduced to the component model +/// multiple plugin types will rely on it instead of list> or list +/// to enforce uniqueness on the plugin level. +/// +/// Relevant PR in the component model repository: https://github.com/WebAssembly/component-model/pull/554 +/// Relevant PR in the Wasmtime repository: https://github.com/bytecodealliance/wasmtime/pull/12216 world plugin { - import host-functions; + import core-import-functions; + export core-export-functions; - export plugin-functions; + import job-scheduler-import-functions; + export job-scheduler-export-functions; + + import discord-import-functions; + export discord-export-functions; }