From d4c55a3fd85d0a80426e88fba5f8502b6bea4660 Mon Sep 17 00:00:00 2001 From: Xevion Date: Tue, 26 Aug 2025 15:22:44 -0500 Subject: [PATCH] feat!: begin rust rewrite service scheduling, configs, all dependencies, tracing, graceful shutdown, concurrency --- .gitignore | 11 +- Cargo.lock | 3663 ++++++++++++++++++++++++++++++++++++ Cargo.toml | 21 + Taskfile.yml | 46 - cmd/banner/main.go | 299 --- go.mod | 27 - go.sum | 52 - internal/api/api.go | 491 ----- internal/api/scrape.go | 240 --- internal/api/search.go | 350 ---- internal/api/session.go | 64 - internal/bot/commands.go | 649 ------- internal/bot/handlers.go | 91 - internal/bot/state.go | 44 - internal/config/config.go | 72 - internal/config/logging.go | 71 - internal/config/terms.go | 140 -- internal/errors.go | 13 - internal/helpers.go | 376 ---- internal/meta.go | 96 - internal/models/types.go | 323 ---- src/bot/mod.rs | 16 + src/config/mod.rs | 0 src/error.rs | 0 src/lib.rs | 1 + src/main.rs | 342 ++++ tests/config_test.go | 229 --- 27 files changed, 4045 insertions(+), 3682 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml delete mode 100644 Taskfile.yml delete mode 100644 cmd/banner/main.go delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 internal/api/api.go delete mode 100644 internal/api/scrape.go delete mode 100644 internal/api/search.go delete mode 100644 internal/api/session.go delete mode 100644 internal/bot/commands.go delete mode 100644 internal/bot/handlers.go delete mode 100644 internal/bot/state.go delete mode 100644 internal/config/config.go delete mode 100644 internal/config/logging.go delete mode 100644 internal/config/terms.go delete mode 100644 internal/errors.go delete mode 100644 internal/helpers.go delete mode 100644 internal/meta.go delete mode 100644 internal/models/types.go create mode 100644 src/bot/mod.rs create mode 100644 src/config/mod.rs create mode 100644 src/error.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs delete mode 100644 tests/config_test.go diff --git a/.gitignore b/.gitignore index 24554e9..a9ff018 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,3 @@ .env -cover.cov -/banner -.*.go -dumps/ -js/ -.vscode/ -*.prof -.task/ -bin/ \ No newline at end of file +/target +/go/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b972e8d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3663 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +dependencies = [ + "serde", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.7.0", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +dependencies = [ + "bytes", + "futures-core", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "banner" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "diesel", + "dotenvy", + "figment", + "governor", + "poise", + "redis", + "reqwest 0.12.23", + "serde", + "serde_json", + "serenity", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytemuck" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "camino" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d07aa9a93b00c76f71bc35d598bed923f6d4f3a9ca5c24b7737ae1a292841c0" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", +] + +[[package]] +name = "cc" +version = "1.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "command_attr" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fcc89439e1bb4e19050a9586a767781a3060000d2f3296fd2a40597ad9421c5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", + "serde", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "diesel" +version = "2.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229850a212cd9b84d4f0290ad9d294afc0ae70fccaa8949dbe8b43ffafa1e20c" +dependencies = [ + "bitflags 2.9.3", + "byteorder", + "chrono", + "diesel_derives", + "itoa", + "pq-sys", + "uuid", +] + +[[package]] +name = "diesel_derives" +version = "2.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b96984c469425cb577bf6f17121ecb3e4fe1e81de5d8f780dd372802858d756" +dependencies = [ + "diesel_table_macro_syntax", + "dsl_auto_type", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "209c735641a413bc68c4923a9d6ad4bcb3ca306b794edaa7eb0b3228a99ffb25" +dependencies = [ + "syn 2.0.106", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "document-features" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" +dependencies = [ + "litrs", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dsl_auto_type" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ae9aca7527f85f26dd76483eb38533fd84bd571065da1739656ef71c5ff5b" +dependencies = [ + "darling", + "either", + "heck", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "error-chain" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" +dependencies = [ + "version_check", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "pear", + "serde", + "toml", + "uncased", + "version_check", +] + +[[package]] +name = "flate2" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "governor" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "444405bbb1a762387aa22dd569429533b54a1d8759d35d3b64cb39b0293eaa19" +dependencies = [ + "cfg-if", + "dashmap 6.1.0", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.3", + "hashbrown 0.15.5", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.9.2", + "smallvec", + "spinning_top", + "web-time", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.3.1", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.3.1", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.3.1", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.12", + "http 1.3.1", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.3.1", + "hyper 1.7.0", + "hyper-util", + "rustls 0.23.31", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.2", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.7.0", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "hyper 1.7.0", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.0", + "system-configuration 0.6.1", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" +dependencies = [ + "equivalent", + "hashbrown 0.15.5", +] + +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +dependencies = [ + "bitflags 2.9.3", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "levenshtein" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "litrs" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mini-moka" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c325dfab65f261f386debee8b0969da215b3fa0037e74c8a1234db7ba986d803" +dependencies = [ + "crossbeam-channel", + "crossbeam-utils", + "dashmap 5.5.3", + "skeptic", + "smallvec", + "tagptr", + "triomphe", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags 2.9.3", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "poise" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1819d5a45e3590ef33754abce46432570c54a120798bdbf893112b4211fa09a6" +dependencies = [ + "async-trait", + "derivative", + "futures-util", + "parking_lot", + "poise_macros", + "regex", + "serenity", + "tokio", + "tracing", +] + +[[package]] +name = "poise_macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fa2c123c961e78315cd3deac7663177f12be4460f5440dbf62a7ed37b1effea" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pq-sys" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfd6cf44cca8f9624bc19df234fc4112873432f5fda1caff174527846d026fa9" +dependencies = [ + "libc", + "vcpkg", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "version_check", + "yansi", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "pulldown-cmark" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" +dependencies = [ + "bitflags 2.9.3", + "memchr", + "unicase", +] + +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi 0.11.1+wasi-snapshot-preview1", + "web-sys", + "winapi", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + +[[package]] +name = "raw-cpuid" +version = "11.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6df7ab838ed27997ba19a4664507e6f82b41fe6e20be42929332156e5e85146" +dependencies = [ + "bitflags 2.9.3", +] + +[[package]] +name = "redis" +version = "0.32.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd3650deebc68526b304898b192fa4102a4ef0b9ada24da096559cb60e0eef8" +dependencies = [ + "bytes", + "cfg-if", + "combine", + "futures-util", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.6.0", + "tokio", + "tokio-util", + "url", +] + +[[package]] +name = "redox_syscall" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags 2.9.3", +] + +[[package]] +name = "regex" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.10", + "regex-syntax 0.8.6", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.6", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls 0.24.2", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls 0.21.12", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", + "tokio", + "tokio-rustls 0.24.1", + "tokio-util", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots 0.25.4", + "winreg", +] + +[[package]] +name = "reqwest" +version = "0.12.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cookie", + "cookie_store", + "encoding_rs", + "futures-core", + "h2 0.4.12", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.7.0", + "hyper-rustls 0.27.7", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustix" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +dependencies = [ + "bitflags 2.9.3", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls" +version = "0.23.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki 0.103.4", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "secrecy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" +dependencies = [ + "serde", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.9.3", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_cow" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7bbbec7196bfde255ab54b65e34087c0849629280028238e67ee25d6a4b7da" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "serde_json" +version = "1.0.143" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serenity" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d72ec4323681bf9a3cabe40fd080abc2435859b502a1b5aa9bf693f125bfa76" +dependencies = [ + "arrayvec", + "async-trait", + "base64 0.22.1", + "bitflags 2.9.3", + "bytes", + "chrono", + "command_attr", + "dashmap 5.5.3", + "flate2", + "futures", + "fxhash", + "levenshtein", + "mime_guess", + "parking_lot", + "percent-encoding", + "reqwest 0.11.27", + "secrecy", + "serde", + "serde_cow", + "serde_json", + "static_assertions", + "time", + "tokio", + "tokio-tungstenite", + "tracing", + "typemap_rev", + "typesize", + "url", + "uwl", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "skeptic" +version = "0.13.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8" +dependencies = [ + "bytecount", + "cargo_metadata", + "error-chain", + "glob", + "pulldown-cmark", + "tempfile", + "walkdir", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys 0.5.0", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.9.3", + "core-foundation", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tempfile" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.60.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "slab", + "socket2 0.6.0", + "tokio-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +dependencies = [ + "rustls 0.23.31", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "rustls 0.22.4", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.25.0", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags 2.9.3", + "bytes", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "triomphe" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8f7726da4807b58ea5c96fdc122f80702030edc33b35aff9190a51148ccc85" + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.3.1", + "httparse", + "log", + "rand 0.8.5", + "rustls 0.22.4", + "rustls-pki-types", + "sha1", + "thiserror", + "url", + "utf-8", +] + +[[package]] +name = "typemap_rev" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74b08b0c1257381af16a5c3605254d529d3e7e109f3c62befc5d168968192998" + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "typesize" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da66c62c5b7017a2787e77373c03e6a5aafde77a73bff1ff96e91cd2e128179" +dependencies = [ + "chrono", + "dashmap 5.5.3", + "hashbrown 0.14.5", + "mini-moka", + "parking_lot", + "secrecy", + "serde_json", + "time", + "typesize-derive", + "url", +] + +[[package]] +name = "typesize-derive" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "536b6812192bda8551cfa0e52524e328c6a951b48e66529ee4522d6c721243d6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "uwl" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4bf03e0ca70d626ecc4ba6b0763b934b6f2976e8c744088bb3c1d646fbb1ad0" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.2", +] + +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0978bf7171b3d90bac376700cb56d606feb40f251a475a5d6634613564460b22" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.9.3", +] + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5fa3ca4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "banner" +version = "0.1.0" +edition = "2024" + +[dependencies] +tokio = { version = "1.47.1", features = ["full"] } +axum = "0.8.4" +serenity = { version = "0.12.4", features = ["rustls_backend"] } +reqwest = { version = "0.12.23", features = ["json", "cookies"] } +diesel = { version = "2.2.12", features = ["chrono", "postgres", "uuid"] } +redis = { version = "0.32.5", features = ["tokio-comp"] } +figment = { version = "0.10.19", features = ["toml", "env"] } +serde_json = "1.0.143" +serde = { version = "1.0.219", features = ["derive"] } +governor = "0.10.1" +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +dotenvy = "0.15.7" +poise = "0.6.1" +async-trait = "0.1" diff --git a/Taskfile.yml b/Taskfile.yml deleted file mode 100644 index f9bdc3c..0000000 --- a/Taskfile.yml +++ /dev/null @@ -1,46 +0,0 @@ -version: "3" - -tasks: - build: - desc: Build the application - cmds: - - go build -o bin/banner ./cmd/banner - sources: - - ./cmd/banner/**/*.go - - ./internal/**/*.go - generates: - - bin/banner - - run: - desc: Run the application - cmds: - - go run ./cmd/banner - deps: [build] - - test: - desc: Run tests - cmds: - - go test ./tests/... - env: - ENVIRONMENT: test - - test-coverage: - desc: Run tests with coverage - cmds: - - go test -coverpkg=./internal/... -cover ./tests/... - env: - ENVIRONMENT: test - - clean: - desc: Clean build artifacts - cmds: - - rm -rf bin/ - - go clean -cache - - go clean -modcache - - dev: - desc: Run in development mode - cmds: - - go run ./cmd/banner - env: - ENVIRONMENT: development diff --git a/cmd/banner/main.go b/cmd/banner/main.go deleted file mode 100644 index 8f7c141..0000000 --- a/cmd/banner/main.go +++ /dev/null @@ -1,299 +0,0 @@ -// Package main is the entry point for the banner application. -package main - -import ( - "context" - "flag" - "net/http" - "net/http/cookiejar" - _ "net/http/pprof" - "os" - "os/signal" - "strings" - "syscall" - "time" - _ "time/tzdata" - - "github.com/bwmarrin/discordgo" - "github.com/joho/godotenv" - "github.com/redis/go-redis/v9" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" - "github.com/rs/zerolog/pkgerrors" - "github.com/samber/lo" - "resty.dev/v3" - - "banner/internal" - "banner/internal/api" - "banner/internal/bot" - "banner/internal/config" -) - -var ( - Session *discordgo.Session -) - -const ( - ICalTimestampFormatUtc = "20060102T150405Z" - ICalTimestampFormatLocal = "20060102T150405" - CentralTimezoneName = "America/Chicago" -) - -func init() { - // Load environment variables - if err := godotenv.Load(); err != nil { - log.Debug().Err(err).Msg("Error loading .env file") - } - - // Set zerolog's timestamp function to use the central timezone - zerolog.TimestampFunc = func() time.Time { - // TODO: Move this to config - loc, err := time.LoadLocation(CentralTimezoneName) - if err != nil { - panic(err) - } - return time.Now().In(loc) - } - - zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack - - // Use the custom console writer if we're in development - isDevelopment := internal.GetFirstEnv("ENVIRONMENT", "RAILWAY_ENVIRONMENT") - if isDevelopment == "" { - isDevelopment = "development" - } - - if isDevelopment == "development" { - log.Logger = zerolog.New(config.NewConsoleWriter()).With().Timestamp().Logger() - } else { - log.Logger = zerolog.New(config.LogSplitter{Std: os.Stdout, Err: os.Stderr}).With().Timestamp().Logger() - } - log.Debug().Str("environment", isDevelopment).Msg("Loggers Setup") - - // Set discordgo's logger to use zerolog - discordgo.Logger = internal.DiscordGoLogger -} - -// initRedis initializes the Redis client and pings the server to ensure a connection. -func initRedis(cfg *config.Config) { - // Setup redis - redisUrl := internal.GetFirstEnv("REDIS_URL", "REDIS_PRIVATE_URL") - if redisUrl == "" { - log.Fatal().Stack().Msg("REDIS_URL/REDIS_PRIVATE_URL not set") - } - - // Parse URL and create client - options, err := redis.ParseURL(redisUrl) - if err != nil { - log.Fatal().Stack().Err(err).Msg("Cannot parse redis url") - } - kv := redis.NewClient(options) - cfg.SetRedis(kv) - - var lastPingErr error - pingCount := 0 // Nth ping being attempted - totalPings := 5 // Total pings to attempt - - // Wait for private networking to kick in (production only) - if !cfg.IsDevelopment { - time.Sleep(250 * time.Millisecond) - } - - // Test the redis instance, try to ping every 2 seconds 5 times, otherwise panic - for { - pingCount++ - if pingCount > totalPings { - log.Fatal().Stack().Err(lastPingErr).Msg("Reached ping limit while trying to connect") - } - - // Ping redis - pong, err := cfg.KV.Ping(cfg.Ctx).Result() - - // Failed; log error and wait 2 seconds - if err != nil { - lastPingErr = err - log.Warn().Err(err).Int("pings", pingCount).Int("remaining", totalPings-pingCount).Msg("Cannot ping redis") - time.Sleep(2 * time.Second) - - continue - } - - log.Debug().Str("ping", pong).Msg("Redis connection successful") - break - } -} - -func main() { - flag.Parse() - - cfg, err := config.New() - if err != nil { - log.Fatal().Stack().Err(err).Msg("Cannot create config") - } - - // Try to grab the environment variable, or default to development - environment := internal.GetFirstEnv("ENVIRONMENT", "RAILWAY_ENVIRONMENT") - if environment == "" { - environment = "development" - } - cfg.SetEnvironment(environment) - - initRedis(cfg) - - if strings.EqualFold(os.Getenv("PPROF_ENABLE"), "true") { - // Start pprof server with graceful shutdown - go func() { - port := os.Getenv("PORT") - log.Info().Str("port", port).Msg("Starting pprof server") - - server := &http.Server{ - Addr: ":" + port, - } - - // Start server in a separate goroutine - go func() { - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatal().Stack().Err(err).Msg("Cannot start pprof server") - } - }() - - // Wait for context cancellation and then shutdown - <-cfg.Ctx.Done() - log.Info().Msg("Shutting down pprof server") - - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer shutdownCancel() - - if err := server.Shutdown(shutdownCtx); err != nil { - log.Error().Err(err).Msg("Pprof server forced to shutdown") - } - }() - } - - // Create cookie jar - cookies, err := cookiejar.New(nil) - if err != nil { - log.Err(err).Msg("Cannot create cookie jar") - } - - // Create Resty client with timeout and cookie jar - baseURL := os.Getenv("BANNER_BASE_URL") - client := resty.New(). - SetBaseURL(baseURL). - SetTimeout(30*time.Second). - SetCookieJar(cookies). - SetHeader("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36"). - AddResponseMiddleware(api.SessionMiddleware) - - cfg.SetClient(client) - cfg.SetBaseURL(baseURL) - - apiInstance := api.New(cfg) - apiInstance.Setup() - - // Create discord session - session, err := discordgo.New("Bot " + os.Getenv("BOT_TOKEN")) - if err != nil { - log.Err(err).Msg("Invalid bot parameters") - } - - botInstance := bot.New(session, apiInstance, cfg) - botInstance.RegisterHandlers() - - // Open discord session - session.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) { - log.Info().Str("username", r.User.Username).Str("discriminator", r.User.Discriminator).Str("id", r.User.ID).Str("session", s.State.SessionID).Msg("Bot is logged in") - }) - err = session.Open() - if err != nil { - log.Fatal().Stack().Err(err).Msg("Cannot open the session") - } - - // Setup command handlers - // Register commands with discord - arr := zerolog.Arr() - lo.ForEach(bot.CommandDefinitions, func(cmd *discordgo.ApplicationCommand, _ int) { - arr.Str(cmd.Name) - }) - log.Info().Array("commands", arr).Msg("Registering commands") - - // In development, use test server, otherwise empty (global) for command registration - guildTarget := "" - if cfg.IsDevelopment { - guildTarget = os.Getenv("BOT_TARGET_GUILD") - } - - // Register commands - existingCommands, err := session.ApplicationCommands(session.State.User.ID, guildTarget) - if err != nil { - log.Fatal().Stack().Err(err).Msg("Cannot get existing commands") - } - newCommands, err := session.ApplicationCommandBulkOverwrite(session.State.User.ID, guildTarget, bot.CommandDefinitions) - if err != nil { - log.Fatal().Stack().Err(err).Msg("Cannot register commands") - } - - // Compare existing commands with new commands - for _, newCommand := range newCommands { - existingCommand, found := lo.Find(existingCommands, func(cmd *discordgo.ApplicationCommand) bool { - return cmd.Name == newCommand.Name - }) - - // New command - if !found { - log.Info().Str("commandName", newCommand.Name).Msg("Registered new command") - continue - } - - // Compare versions - if newCommand.Version != existingCommand.Version { - log.Info().Str("commandName", newCommand.Name). - Str("oldVersion", existingCommand.Version).Str("newVersion", newCommand.Version). - Msg("Command Updated") - } - } - - // Fetch terms on startup - err = apiInstance.TryReloadTerms() - if err != nil { - log.Fatal().Stack().Err(err).Msg("Cannot fetch terms on startup") - } - - // Launch a goroutine to scrape the banner system periodically - go func() { - ticker := time.NewTicker(3 * time.Minute) - defer ticker.Stop() - - for { - select { - case <-cfg.Ctx.Done(): - log.Info().Msg("Periodic scraper stopped due to context cancellation") - return - case <-ticker.C: - err := apiInstance.Scrape() - if err != nil { - log.Err(err).Stack().Msg("Periodic Scrape Failed") - } - } - } - }() - - // Close session, ensure Resty client closes - defer session.Close() - defer client.Close() - - // Setup signal handler channel - stop := make(chan os.Signal, 1) - signal.Notify(stop, os.Interrupt) // Ctrl+C signal - signal.Notify(stop, syscall.SIGTERM) // Container stop signal - - // Wait for signal (indefinite) - closingSignal := <-stop - botInstance.SetClosing() // TODO: Switch to atomic lock with forced close after 10 seconds - - // Cancel the context to signal all operations to stop - cfg.CancelFunc() - - // Defers are called after this - log.Warn().Str("signal", closingSignal.String()).Msg("Gracefully shutting down") -} diff --git a/go.mod b/go.mod deleted file mode 100644 index db9638a..0000000 --- a/go.mod +++ /dev/null @@ -1,27 +0,0 @@ -module banner - -go 1.24.0 - -toolchain go1.24.2 - -require ( - github.com/bwmarrin/discordgo v0.29.0 - github.com/joho/godotenv v1.5.1 - github.com/pkg/errors v0.9.1 - github.com/redis/go-redis/v9 v9.12.1 - github.com/rs/zerolog v1.34.0 - github.com/samber/lo v1.51.0 - resty.dev/v3 v3.0.0-beta.3 -) - -require ( - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/gorilla/websocket v1.5.3 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - golang.org/x/crypto v0.41.0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index aa0edf1..0000000 --- a/go.sum +++ /dev/null @@ -1,52 +0,0 @@ -github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= -github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= -github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= -github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= -github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg= -github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= -github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI= -github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= -golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -resty.dev/v3 v3.0.0-beta.3 h1:3kEwzEgCnnS6Ob4Emlk94t+I/gClyoah7SnNi67lt+E= -resty.dev/v3 v3.0.0-beta.3/go.mod h1:OgkqiPvTDtOuV4MGZuUDhwOpkY8enjOsjjMzeOHefy4= diff --git a/internal/api/api.go b/internal/api/api.go deleted file mode 100644 index a7e778b..0000000 --- a/internal/api/api.go +++ /dev/null @@ -1,491 +0,0 @@ -package api - -import ( - "banner/internal" - "banner/internal/config" - "banner/internal/models" - "context" - "encoding/json" - "errors" - "fmt" - "net/url" - "strconv" - "strings" - - "time" - - "github.com/redis/go-redis/v9" - "github.com/rs/zerolog/log" - "github.com/samber/lo" - "resty.dev/v3" -) - -// API provides a client for interacting with the Banner API. -type API struct { - config *config.Config -} - -// New creates a new API client with the given configuration. -func New(config *config.Config) *API { - return &API{config: config} -} - -var ( - latestSession string - sessionTime time.Time - expiryTime = 25 * time.Minute -) - -// SessionMiddleware creates a Resty middleware that resets the session timer on each successful Banner API call. -func SessionMiddleware(_ *resty.Client, r *resty.Response) error { - // log.Debug().Str("url", r.Request.RawRequest.URL.Path).Msg("Session middleware") - - // Reset session timer on successful requests to Banner API endpoints - if r.IsSuccess() && strings.HasPrefix(r.Request.RawRequest.URL.Path, "StudentRegistrationSsb/ssb/classSearch/") { - // Only reset the session time if the session is still valid - if time.Since(sessionTime) <= expiryTime { - sessionTime = time.Now() - } - } - return nil -} - -// GenerateSession generates a new session ID for use with the Banner API. -// This function should not be used directly; use EnsureSession instead. -func GenerateSession() string { - return internal.RandomString(5) + internal.Nonce() -} - -// DefaultTerm returns the default term, which is the current term if it exists, otherwise the next term. -func (a *API) DefaultTerm(t time.Time) config.Term { - currentTerm, nextTerm := config.GetCurrentTerm(*a.config.SeasonRanges, t) - if currentTerm == nil { - return *nextTerm - } - return *currentTerm -} - -var terms []BannerTerm -var lastTermUpdate time.Time - -// TryReloadTerms attempts to reload the terms if they are not loaded or if the last update was more than 24 hours ago. -func (a *API) TryReloadTerms() error { - if len(terms) > 0 && time.Since(lastTermUpdate) < 24*time.Hour { - return nil - } - - // Load the terms - var err error - terms, err = a.GetTerms("", 1, 100) - if err != nil { - return fmt.Errorf("failed to load terms: %w", err) - } - - lastTermUpdate = time.Now() - return nil -} - -// IsTermArchived checks if the given term is archived (view only). -// -// TODO: Add error handling for when a term does not exist. -func (a *API) IsTermArchived(term string) bool { - // Ensure the terms are loaded - err := a.TryReloadTerms() - if err != nil { - log.Err(err).Stack().Msg("Failed to reload terms") - return true - } - - // Check if the term is in the list of terms - bannerTerm, exists := lo.Find(terms, func(t BannerTerm) bool { - return t.Code == term - }) - - if !exists { - log.Warn().Str("term", term).Msg("Term does not exist") - return true - } - - return bannerTerm.Archived() -} - -// EnsureSession ensures that a valid session is available, creating one if necessary. -func (a *API) EnsureSession() string { - if latestSession == "" || time.Since(sessionTime) >= expiryTime { - latestSession = GenerateSession() - sessionTime = time.Now() - } - return latestSession -} - -// Pair represents a key-value pair from the Banner API. -type Pair struct { - Code string `json:"code"` - Description string `json:"description"` -} - -// BannerTerm represents a term in the Banner system. -type BannerTerm Pair - -// Instructor represents an instructor in the Banner system. -type Instructor Pair - -// Archived returns true if the term is in an archival (view-only) state. -func (term BannerTerm) Archived() bool { - return strings.Contains(term.Description, "View Only") -} - -// GetTerms retrieves a list of terms from the Banner API. -// The page number must be at least 1. -func (a *API) GetTerms(search string, page int, maxResults int) ([]BannerTerm, error) { - // Ensure offset is valid - if page <= 0 { - return nil, errors.New("offset must be greater than 0") - } - - req := a.config.Client.NewRequest(). - SetQueryParam("searchTerm", search). - SetQueryParam("offset", strconv.Itoa(page)). - SetQueryParam("max", strconv.Itoa(maxResults)). - SetQueryParam("_", internal.Nonce()). - SetExpectResponseContentType("application/json"). - SetResult(&[]BannerTerm{}) - - res, err := req.Get("/classSearch/getTerms") - if err != nil { - return nil, fmt.Errorf("failed to get terms: %w", err) - } - - terms, ok := res.Result().(*[]BannerTerm) - if !ok { - return nil, fmt.Errorf("terms parsing failed to cast: %v", res.Result()) - } - - return *terms, nil -} - -// SelectTerm selects a term in the Banner system for the given session. -// This is required before other API calls can be made. -func (a *API) SelectTerm(term string, sessionID string) error { - form := url.Values{ - "term": {term}, - "studyPath": {""}, - "studyPathText": {""}, - "startDatepicker": {""}, - "endDatepicker": {""}, - "uniqueSessionId": {sessionID}, - } - - type RedirectResponse struct { - FwdURL string `json:"fwdUrl"` - } - - req := a.config.Client.NewRequest(). - SetResult(&RedirectResponse{}). - SetQueryParam("mode", "search"). - SetBody(form.Encode()). - SetExpectResponseContentType("application/json"). - SetHeader("Content-Type", "application/x-www-form-urlencoded") - - res, err := req.Post("/term/search") - if err != nil { - return fmt.Errorf("failed to select term: %w", err) - } - - redirectResponse := res.Result().(*RedirectResponse) - - // TODO: Mild validation to ensure the redirect is appropriate - - // Make a GET request to the fwdUrl - req = a.config.Client.NewRequest() - res, err = req.Get(redirectResponse.FwdURL) - - // Assert that the response is OK (200) - if res.StatusCode() != 200 { - return fmt.Errorf("redirect response was not OK: %d", res.StatusCode()) - } - - return nil -} - -// GetPartOfTerms retrieves a list of parts of a term from the Banner API. -// The page number must be at least 1. -func (a *API) GetPartOfTerms(search string, term int, offset int, maxResults int) ([]BannerTerm, error) { - // Ensure offset is valid - if offset <= 0 { - return nil, errors.New("offset must be greater than 0") - } - - req := a.config.Client.NewRequest(). - SetQueryParam("searchTerm", search). - SetQueryParam("term", strconv.Itoa(term)). - SetQueryParam("offset", strconv.Itoa(offset)). - SetQueryParam("max", strconv.Itoa(maxResults)). - SetQueryParam("uniqueSessionId", a.EnsureSession()). - SetQueryParam("_", internal.Nonce()). - SetExpectResponseContentType("application/json"). - SetResult(&[]BannerTerm{}) - - res, err := req.Get("/classSearch/get_partOfTerm") - if err != nil { - return nil, fmt.Errorf("failed to get part of terms: %w", err) - } - - terms, ok := res.Result().(*[]BannerTerm) - if !ok { - return nil, fmt.Errorf("term parsing failed to cast: %v", res.Result()) - } - - return *terms, nil -} - -// GetInstructors retrieves a list of instructors from the Banner API. -func (a *API) GetInstructors(search string, term string, offset int, maxResults int) ([]Instructor, error) { - // Ensure offset is valid - if offset <= 0 { - return nil, errors.New("offset must be greater than 0") - } - - req := a.config.Client.NewRequest(). - SetQueryParam("searchTerm", search). - SetQueryParam("term", term). - SetQueryParam("offset", strconv.Itoa(offset)). - SetQueryParam("max", strconv.Itoa(maxResults)). - SetQueryParam("uniqueSessionId", a.EnsureSession()). - SetQueryParam("_", internal.Nonce()). - SetExpectResponseContentType("application/json"). - SetResult(&[]Instructor{}) - - res, err := req.Get("/classSearch/get_instructor") - if err != nil { - return nil, fmt.Errorf("failed to get instructors: %w", err) - } - - instructors, ok := res.Result().(*[]Instructor) - if !ok { - return nil, fmt.Errorf("instructor parsing failed to cast: %v", res.Result()) - } - - return *instructors, nil -} - -// ClassDetails represents the detailed information for a class. -// -// TODO: Implement this struct and the associated GetCourseDetails function. -type ClassDetails struct { -} - -// GetCourseDetails retrieves the details for a specific course. -func (a *API) GetCourseDetails(term int, crn int) (*ClassDetails, error) { - body, err := json.Marshal(map[string]string{ - "term": strconv.Itoa(term), - "courseReferenceNumber": strconv.Itoa(crn), - "first": "first", // TODO: What is this? - }) - if err != nil { - log.Fatal().Stack().Err(err).Msg("Failed to marshal body") - } - - req := a.config.Client.NewRequest(). - SetBody(body). - SetExpectResponseContentType("application/json"). - SetResult(&ClassDetails{}) - - res, err := req.Get("/searchResults/getClassDetails") - if err != nil { - return nil, fmt.Errorf("failed to get course details: %w", err) - } - - details, ok := res.Result().(*ClassDetails) - if !ok { - return nil, fmt.Errorf("course details parsing failed to cast: %v", res.Result()) - } - - return details, nil -} - -// Search performs a search for courses with the given query and returns the results. -func (a *API) Search(term string, query *Query, sort string, sortDescending bool) (*models.SearchResult, error) { - a.ResetDataForm() - - params := query.Paramify() - - params["txt_term"] = term - params["uniqueSessionId"] = a.EnsureSession() - params["sortColumn"] = sort - params["sortDirection"] = "asc" - - // These dates are not available for usage anywhere in the UI, but are included in every query - params["startDatepicker"] = "" - params["endDatepicker"] = "" - - req := a.config.Client.NewRequest(). - SetQueryParams(params). - SetExpectResponseContentType("application/json"). - SetResult(&models.SearchResult{}) - - res, err := req.Get("/searchResults/searchResults") - if err != nil { - return nil, fmt.Errorf("failed to search: %w", err) - } - - searchResult, ok := res.Result().(*models.SearchResult) - if !ok { - return nil, fmt.Errorf("search result parsing failed to cast: %v", res.Result()) - } - - return searchResult, nil -} - -// GetSubjects retrieves a list of subjects from the Banner API. -// The page number must be at least 1. -func (a *API) GetSubjects(search string, term string, offset int, maxResults int) ([]Pair, error) { - // Ensure offset is valid - if offset <= 0 { - return nil, errors.New("offset must be greater than 0") - } - - req := a.config.Client.NewRequest(). - SetQueryParam("searchTerm", search). - SetQueryParam("term", term). - SetQueryParam("offset", strconv.Itoa(offset)). - SetQueryParam("max", strconv.Itoa(maxResults)). - SetQueryParam("uniqueSessionId", a.EnsureSession()). - SetQueryParam("_", internal.Nonce()). - SetExpectResponseContentType("application/json"). - SetResult(&[]Pair{}) - - res, err := req.Get("/classSearch/get_subject") - if err != nil { - return nil, fmt.Errorf("failed to get subjects: %w", err) - } - - subjects, ok := res.Result().(*[]Pair) - if !ok { - return nil, fmt.Errorf("subjects parsing failed to cast: %v", res.Result()) - } - - return *subjects, nil -} - -// GetCampuses retrieves a list of campuses from the Banner API. -// The page number must be at least 1. -func (a *API) GetCampuses(search string, term int, offset int, maxResults int) ([]Pair, error) { - // Ensure offset is valid - if offset <= 0 { - return nil, errors.New("offset must be greater than 0") - } - - req := a.config.Client.NewRequest(). - SetQueryParam("searchTerm", search). - SetQueryParam("term", strconv.Itoa(term)). - SetQueryParam("offset", strconv.Itoa(offset)). - SetQueryParam("max", strconv.Itoa(maxResults)). - SetQueryParam("uniqueSessionId", a.EnsureSession()). - SetQueryParam("_", internal.Nonce()). - SetExpectResponseContentType("application/json"). - SetResult(&[]Pair{}) - - res, err := req.Get("/classSearch/get_campus") - if err != nil { - return nil, fmt.Errorf("failed to get campuses: %w", err) - } - - campuses, ok := res.Result().(*[]Pair) - if !ok { - return nil, fmt.Errorf("campuses parsing failed to cast: %v", res.Result()) - } - - return *campuses, nil -} - -// GetInstructionalMethods retrieves a list of instructional methods from the Banner API. -// The page number must be at least 1. -func (a *API) GetInstructionalMethods(search string, term string, offset int, maxResults int) ([]Pair, error) { - // Ensure offset is valid - if offset <= 0 { - return nil, errors.New("offset must be greater than 0") - } - - req := a.config.Client.NewRequest(). - SetQueryParam("searchTerm", search). - SetQueryParam("term", term). - SetQueryParam("offset", strconv.Itoa(offset)). - SetQueryParam("max", strconv.Itoa(maxResults)). - SetQueryParam("uniqueSessionId", a.EnsureSession()). - SetQueryParam("_", internal.Nonce()). - SetExpectResponseContentType("application/json"). - SetResult(&[]Pair{}) - - res, err := req.Get("/classSearch/get_instructionalMethod") - if err != nil { - return nil, fmt.Errorf("failed to get instructional methods: %w", err) - } - - methods, ok := res.Result().(*[]Pair) - if !ok { - return nil, fmt.Errorf("instructional methods parsing failed to cast: %v", res.Result()) - } - return *methods, nil -} - -// GetCourseMeetingTime retrieves the meeting time information for a course. -func (a *API) GetCourseMeetingTime(term int, crn int) ([]models.MeetingTimeResponse, error) { - type responseWrapper struct { - Fmt []models.MeetingTimeResponse `json:"fmt"` - } - - req := a.config.Client.NewRequest(). - SetQueryParam("term", strconv.Itoa(term)). - SetQueryParam("courseReferenceNumber", strconv.Itoa(crn)). - SetExpectResponseContentType("application/json"). - SetResult(&responseWrapper{}) - - res, err := req.Get("/searchResults/getFacultyMeetingTimes") - if err != nil { - return nil, fmt.Errorf("failed to get meeting time: %w", err) - } - - result, ok := res.Result().(*responseWrapper) - if !ok { - return nil, fmt.Errorf("meeting times parsing failed to cast: %v", res.Result()) - } - - return result.Fmt, nil -} - -// ResetDataForm resets the search form in the Banner system. -// This must be called before a new search can be performed. -func (a *API) ResetDataForm() { - req := a.config.Client.NewRequest() - - _, err := req.Post("/classSearch/resetDataForm") - if err != nil { - log.Fatal().Stack().Err(err).Msg("Failed to reset data form") - } -} - -// GetCourse retrieves course information from the Redis cache. -func (a *API) GetCourse(crn string) (*models.Course, error) { - // Create a timeout context for Redis operations - ctx, cancel := context.WithTimeout(a.config.Ctx, 5*time.Second) - defer cancel() - - // Retrieve raw data - result, err := a.config.KV.Get(ctx, fmt.Sprintf("class:%s", crn)).Result() - if err != nil { - if err == redis.Nil { - return nil, fmt.Errorf("course not found: %w", err) - } - return nil, fmt.Errorf("failed to get course: %w", err) - } - - // Unmarshal the raw data - var course models.Course - err = json.Unmarshal([]byte(result), &course) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal course: %w", err) - } - - return &course, nil -} diff --git a/internal/api/scrape.go b/internal/api/scrape.go deleted file mode 100644 index 7d54320..0000000 --- a/internal/api/scrape.go +++ /dev/null @@ -1,240 +0,0 @@ -// Package api provides the core functionality for interacting with the Banner API. -package api - -import ( - "banner/internal" - "banner/internal/models" - "context" - "fmt" - "math/rand" - "time" - - "github.com/rs/zerolog/log" - "github.com/samber/lo" -) - -const ( - // MaxPageSize is the maximum number of courses one can scrape per page. - MaxPageSize = 500 -) - -var ( - // PriorityMajors is a list of majors that are considered to be high priority for scraping. - // This list is used to determine which majors to scrape first/most often. - PriorityMajors = []string{"CS", "CPE", "MAT", "EE", "IS"} - // AncillaryMajors is a list of majors that are considered to be low priority for scraping. - // This list will not contain any majors that are in PriorityMajors. - AncillaryMajors []string - // AllMajors is a list of all majors that are available in the Banner system. - AllMajors []string -) - -// Scrape retrieves all courses from the Banner API and stores them in Redis. -// This is a long-running process that should be run in a goroutine. -// -// TODO: Switch from hardcoded term to dynamic term -func (a *API) Scrape() error { - // For each subject, retrieve all courses - // For each course, get the details and store it in redis - // Make sure to handle pagination - subjects, err := a.GetSubjects("", "202510", 1, 100) - if err != nil { - return fmt.Errorf("failed to get subjects: %w", err) - } - - // Ensure subjects were found - if len(subjects) == 0 { - return fmt.Errorf("no subjects found") - } - - // Extract major code name - for _, subject := range subjects { - // Add to AncillaryMajors if not in PriorityMajors - if !lo.Contains(PriorityMajors, subject.Code) { - AncillaryMajors = append(AncillaryMajors, subject.Code) - } - } - - AllMajors = lo.Flatten([][]string{PriorityMajors, AncillaryMajors}) - - expiredSubjects, err := a.GetExpiredSubjects() - if err != nil { - return fmt.Errorf("failed to get scrapable majors: %w", err) - } - - log.Info().Strs("majors", expiredSubjects).Msg("Scraping majors") - for _, subject := range expiredSubjects { - err := a.ScrapeMajor(subject) - if err != nil { - return fmt.Errorf("failed to scrape major %s: %w", subject, err) - } - } - - return nil -} - -// GetExpiredSubjects returns a list of subjects that have expired and should be scraped again. -// It checks Redis for the "scraped" status of each major for the current term. -func (a *API) GetExpiredSubjects() ([]string, error) { - term := a.DefaultTerm(time.Now()).ToString() - subjects := make([]string, 0) - - // Create a timeout context for Redis operations - ctx, cancel := context.WithTimeout(a.config.Ctx, 10*time.Second) - defer cancel() - - // Get all subjects - values, err := a.config.KV.MGet(ctx, lo.Map(AllMajors, func(major string, _ int) string { - return fmt.Sprintf("scraped:%s:%s", major, term) - })...).Result() - if err != nil { - return nil, fmt.Errorf("failed to get all subjects: %w", err) - } - - // Extract expired subjects - for i, value := range values { - subject := AllMajors[i] - - // If the value is nil or "0", then the subject is expired - if value == nil || value == "0" { - subjects = append(subjects, subject) - } - } - - log.Debug().Strs("majors", subjects).Msg("Expired Subjects") - - return subjects, nil -} - -// ScrapeMajor scrapes all courses for a specific major. -// This function does not check whether scraping is required at this time; it is assumed that the caller has already done so. -func (a *API) ScrapeMajor(subject string) error { - offset := 0 - totalClassCount := 0 - - for { - // Build & execute the query - query := NewQuery().Offset(offset).MaxResults(MaxPageSize * 2).Subject(subject) - term := a.DefaultTerm(time.Now()).ToString() - result, err := a.Search(term, query, "subjectDescription", false) - if err != nil { - return fmt.Errorf("search failed: %w (%s)", err, query.String()) - } - - // Isn't it bullshit that they decided not to leave an actual 'reason' field for the failure? - if !result.Success { - return fmt.Errorf("result marked unsuccessful when searching for classes (%s)", query.String()) - } - - classCount := len(result.Data) - totalClassCount += classCount - log.Debug().Str("subject", subject).Int("count", classCount).Int("offset", offset).Msg("Placing classes in Redis") - - // Process each class and store it in Redis - for _, course := range result.Data { - // Store class in Redis - err := a.IntakeCourse(course) - if err != nil { - log.Error().Err(err).Msg("failed to store class in Redis") - } - } - - // Increment and continue if the results are full - if classCount >= MaxPageSize { - // This is unlikely to happen, but log it just in case - if classCount > MaxPageSize { - log.Warn().Int("page", offset).Int("count", classCount).Msg("Results exceed MaxPageSize") - } - - offset += MaxPageSize - - // TODO: Replace sleep with smarter rate limiting - log.Debug().Str("subject", subject).Int("nextOffset", offset).Msg("Sleeping before next page") - time.Sleep(time.Second * 3) - continue - } - // Log the number of classes scraped - log.Info().Str("subject", subject).Int("total", totalClassCount).Msgf("Subject %s Scraped", subject) - break - } - - term := a.DefaultTerm(time.Now()).ToString() - - // Calculate the expiry time for the scrape (1 hour for every 200 classes, random +-15%) with a minimum of 1 hour - var scrapeExpiry time.Duration - if totalClassCount == 0 { - scrapeExpiry = time.Hour * 12 - } else { - scrapeExpiry = a.CalculateExpiry(term, totalClassCount, lo.Contains(PriorityMajors, subject)) - } - - // Mark the major as scraped - if totalClassCount == 0 { - totalClassCount = -1 - } - - // Create a timeout context for Redis operations - ctx, cancel := context.WithTimeout(a.config.Ctx, 5*time.Second) - defer cancel() - - err := a.config.KV.Set(ctx, fmt.Sprintf("scraped:%s:%s", subject, term), totalClassCount, scrapeExpiry).Err() - if err != nil { - log.Error().Err(err).Msg("failed to mark major as scraped") - } - - return nil -} - -// CalculateExpiry calculates the expiry time until the next scrape for a major. -// The duration is based on the number of courses, whether the major is a priority, and if the term is archived. -func (a *API) CalculateExpiry(term string, count int, priority bool) time.Duration { - // An hour for every 100 classes - baseExpiry := time.Hour * time.Duration(count/100) - - // Subjects with less than 50 classes have a reversed expiry (less classes, longer interval) - // 1 class => 12 hours, 49 classes => 1 hour - if count < 50 { - hours := internal.Slope(internal.Point{X: 1, Y: 12}, internal.Point{X: 49, Y: 1}, float64(count)).Y - baseExpiry = time.Duration(hours * float64(time.Hour)) - } - - // If the subject is a priority, then the expiry is halved without variance - if priority { - return baseExpiry / 3 - } - - // If the term is considered "view only" or "archived", then the expiry is multiplied by 5 - var expiry = baseExpiry - if a.IsTermArchived(term) { - expiry *= 5 - } - - // Add minor variance to the expiry - expiryVariance := baseExpiry.Seconds() * (rand.Float64() * 0.15) // Between 0 and 15% of the total - if rand.Intn(2) == 0 { - expiry -= time.Duration(expiryVariance) * time.Second - } else { - expiry += time.Duration(expiryVariance) * time.Second - } - - // Ensure the expiry is at least 1 hour with up to 15 extra minutes - if expiry < time.Hour { - baseExpiry = time.Hour + time.Duration(rand.Intn(60*15))*time.Second - } - - return baseExpiry -} - -// IntakeCourse stores a course in Redis. -// This function will be used to handle change identification, notifications, and SQLite upserts in the future. -func (a *API) IntakeCourse(course models.Course) error { - // Create a timeout context for Redis operations - ctx, cancel := context.WithTimeout(a.config.Ctx, 5*time.Second) - defer cancel() - - err := a.config.KV.Set(ctx, fmt.Sprintf("class:%s", course.CourseReferenceNumber), course, 0).Err() - if err != nil { - return fmt.Errorf("failed to store class in Redis: %w", err) - } - return nil -} diff --git a/internal/api/search.go b/internal/api/search.go deleted file mode 100644 index e75c882..0000000 --- a/internal/api/search.go +++ /dev/null @@ -1,350 +0,0 @@ -package api - -import ( - "fmt" - "strconv" - "strings" - "time" - - "github.com/samber/lo" -) - -const ( - paramSubject = "txt_subject" - paramTitle = "txt_courseTitle" - paramKeywords = "txt_keywordlike" - paramOpenOnly = "chk_open_only" - paramTermPart = "txt_partOfTerm" - paramCampus = "txt_campus" - paramAttributes = "txt_attribute" - paramInstructor = "txt_instructor" - paramStartTimeHour = "select_start_hour" - paramStartTimeMinute = "select_start_min" - paramStartTimeMeridiem = "select_start_ampm" - paramEndTimeHour = "select_end_hour" - paramEndTimeMinute = "select_end_min" - paramEndTimeMeridiem = "select_end_ampm" - paramMinCredits = "txt_credithourlow" - paramMaxCredits = "txt_credithourhigh" - paramCourseNumberLow = "txt_course_number_range" - paramCourseNumberHigh = "txt_course_number_range_to" - paramOffset = "pageOffset" - paramMaxResults = "pageMaxSize" -) - -// Query represents a search query for courses. -// It is a builder that allows for chaining methods to construct a query. -type Query struct { - subject *string - title *string - keywords *[]string - openOnly *bool - termPart *[]string // e.g. [1, B6, 8, J] - campus *[]string // e.g. [9, 1DT, 1LR] - instructionalMethod *[]string // e.g. [HB] - attributes *[]string // e.g. [060, 010] - instructor *[]uint64 // e.g. [27957, 27961] - startTime *time.Duration - endTime *time.Duration - minCredits *int - maxCredits *int - offset int - maxResults int - courseNumberRange *Range -} - -// NewQuery creates a new Query with default values. -func NewQuery() *Query { - return &Query{maxResults: 8, offset: 0} -} - -// Subject sets the subject for the query. -func (q *Query) Subject(subject string) *Query { - q.subject = &subject - return q -} - -// Title sets the title for the query. -func (q *Query) Title(title string) *Query { - q.title = &title - return q -} - -// Keywords sets the keywords for the query. -func (q *Query) Keywords(keywords []string) *Query { - q.keywords = &keywords - return q -} - -// Keyword adds a keyword to the query. -func (q *Query) Keyword(keyword string) *Query { - if q.keywords == nil { - q.keywords = &[]string{keyword} - } else { - *q.keywords = append(*q.keywords, keyword) - } - return q -} - -// OpenOnly sets whether to search for open courses only. -func (q *Query) OpenOnly(openOnly bool) *Query { - q.openOnly = &openOnly - return q -} - -// TermPart sets the term part for the query. -func (q *Query) TermPart(termPart []string) *Query { - q.termPart = &termPart - return q -} - -// Campus sets the campuses for the query. -func (q *Query) Campus(campus []string) *Query { - q.campus = &campus - return q -} - -// InstructionalMethod sets the instructional methods for the query. -func (q *Query) InstructionalMethod(instructionalMethod []string) *Query { - q.instructionalMethod = &instructionalMethod - return q -} - -// Attributes sets the attributes for the query. -func (q *Query) Attributes(attributes []string) *Query { - q.attributes = &attributes - return q -} - -// Instructor sets the instructors for the query. -func (q *Query) Instructor(instructor []uint64) *Query { - q.instructor = &instructor - return q -} - -// StartTime sets the start time for the query. -func (q *Query) StartTime(startTime time.Duration) *Query { - q.startTime = &startTime - return q -} - -// EndTime sets the end time for the query. -func (q *Query) EndTime(endTime time.Duration) *Query { - q.endTime = &endTime - return q -} - -// Credits sets the credit range for the query. -func (q *Query) Credits(low int, high int) *Query { - q.minCredits = &low - q.maxCredits = &high - return q -} - -// MinCredits sets the minimum credits for the query. -func (q *Query) MinCredits(value int) *Query { - q.minCredits = &value - return q -} - -// MaxCredits sets the maximum credits for the query. -func (q *Query) MaxCredits(value int) *Query { - q.maxCredits = &value - return q -} - -// CourseNumbers sets the course number range for the query. -func (q *Query) CourseNumbers(low int, high int) *Query { - q.courseNumberRange = &Range{low, high} - return q -} - -// Offset sets the offset for pagination. -func (q *Query) Offset(offset int) *Query { - q.offset = offset - return q -} - -// MaxResults sets the maximum number of results to return. -func (q *Query) MaxResults(maxResults int) *Query { - q.maxResults = maxResults - return q -} - -// Range represents a range of two integers. -type Range struct { - Low int - High int -} - -// FormatTimeParameter formats a time.Duration into a tuple of strings for use in a POST request. -// It returns the hour, minute, and meridiem (AM/PM) as separate strings. -func FormatTimeParameter(d time.Duration) (string, string, string) { - hourParameter, minuteParameter, meridiemParameter := "", "", "" - - hours := int64(d.Hours()) - minutes := int64(d.Minutes()) % 60 - - minuteParameter = strconv.FormatInt(minutes, 10) - - if hours >= 12 { - hourParameter = "PM" - - // Exceptional case: 12PM = 12, 1PM = 1, 2PM = 2 - if hours >= 13 { - hourParameter = strconv.FormatInt(hours-12, 10) // 13 - 12 = 1, 14 - 12 = 2 - } else { - hourParameter = strconv.FormatInt(hours, 10) - } - } else { - meridiemParameter = "AM" - hourParameter = strconv.FormatInt(hours, 10) - } - - return hourParameter, minuteParameter, meridiemParameter -} - -// Paramify converts a Query into a map of parameters for a POST request. -// This function assumes each query key only appears once. -func (q *Query) Paramify() map[string]string { - params := map[string]string{} - - if q.subject != nil { - params[paramSubject] = *q.subject - } - - if q.title != nil { - // Whitespace can prevent valid queries from succeeding - params[paramTitle] = strings.TrimSpace(*q.title) - } - - if q.keywords != nil { - params[paramKeywords] = strings.Join(*q.keywords, " ") - } - - if q.openOnly != nil { - params[paramOpenOnly] = "true" - } - - if q.termPart != nil { - params[paramTermPart] = strings.Join(*q.termPart, ",") - } - - if q.campus != nil { - params[paramCampus] = strings.Join(*q.campus, ",") - } - - if q.attributes != nil { - params[paramAttributes] = strings.Join(*q.attributes, ",") - } - - if q.instructor != nil { - params[paramInstructor] = strings.Join(lo.Map(*q.instructor, func(i uint64, _ int) string { - return strconv.FormatUint(i, 10) - }), ",") - } - - if q.startTime != nil { - hour, minute, meridiem := FormatTimeParameter(*q.startTime) - params[paramStartTimeHour] = hour - params[paramStartTimeMinute] = minute - params[paramStartTimeMeridiem] = meridiem - } - - if q.endTime != nil { - hour, minute, meridiem := FormatTimeParameter(*q.endTime) - params[paramEndTimeHour] = hour - params[paramEndTimeMinute] = minute - params[paramEndTimeMeridiem] = meridiem - } - - if q.minCredits != nil { - params[paramMinCredits] = strconv.Itoa(*q.minCredits) - } - - if q.maxCredits != nil { - params[paramMaxCredits] = strconv.Itoa(*q.maxCredits) - } - - if q.courseNumberRange != nil { - params[paramCourseNumberLow] = strconv.Itoa(q.courseNumberRange.Low) - params[paramCourseNumberHigh] = strconv.Itoa(q.courseNumberRange.High) - } - - params[paramOffset] = strconv.Itoa(q.offset) - params[paramMaxResults] = strconv.Itoa(q.maxResults) - - return params -} - -// String returns a string representation of the query, ideal for debugging & logging. -func (q *Query) String() string { - var sb strings.Builder - - if q.subject != nil { - fmt.Fprintf(&sb, "subject=%s, ", *q.subject) - } - - if q.title != nil { - // Whitespace can prevent valid queries from succeeding - fmt.Fprintf(&sb, "title=%s, ", strings.TrimSpace(*q.title)) - } - - if q.keywords != nil { - fmt.Fprintf(&sb, "keywords=%s, ", strings.Join(*q.keywords, " ")) - } - - if q.openOnly != nil { - fmt.Fprintf(&sb, "openOnly=%t, ", *q.openOnly) - } - - if q.termPart != nil { - fmt.Fprintf(&sb, "termPart=%s, ", strings.Join(*q.termPart, ",")) - } - - if q.campus != nil { - fmt.Fprintf(&sb, "campus=%s, ", strings.Join(*q.campus, ",")) - } - - if q.attributes != nil { - fmt.Fprintf(&sb, "attributes=%s, ", strings.Join(*q.attributes, ",")) - } - - if q.instructor != nil { - fmt.Fprintf(&sb, "instructor=%s, ", strings.Join(lo.Map(*q.instructor, func(i uint64, _ int) string { - return strconv.FormatUint(i, 10) - }), ",")) - } - - if q.startTime != nil { - hour, minute, meridiem := FormatTimeParameter(*q.startTime) - fmt.Fprintf(&sb, "startTime=%s:%s%s, ", hour, minute, meridiem) - } - - if q.endTime != nil { - hour, minute, meridiem := FormatTimeParameter(*q.endTime) - fmt.Fprintf(&sb, "endTime=%s:%s%s, ", hour, minute, meridiem) - } - - if q.minCredits != nil { - fmt.Fprintf(&sb, "minCredits=%d, ", *q.minCredits) - } - - if q.maxCredits != nil { - fmt.Fprintf(&sb, "maxCredits=%d, ", *q.maxCredits) - } - - if q.courseNumberRange != nil { - fmt.Fprintf(&sb, "courseNumberRange=%d-%d, ", q.courseNumberRange.Low, q.courseNumberRange.High) - } - - fmt.Fprintf(&sb, "offset=%d, ", q.offset) - fmt.Fprintf(&sb, "maxResults=%d", q.maxResults) - - return sb.String() -} - -// Dict returns a map representation of the query, ideal for debugging & logging. -// This dict is represented with zerolog's Event type. -// func (q *Query) Dict() *zerolog.Event { -// } diff --git a/internal/api/session.go b/internal/api/session.go deleted file mode 100644 index bf00cf9..0000000 --- a/internal/api/session.go +++ /dev/null @@ -1,64 +0,0 @@ -package api - -import ( - "banner/internal" - "net/url" - - log "github.com/rs/zerolog/log" -) - -// Setup makes the initial requests to set up the session cookies for the application. -func (a *API) Setup() { - // Makes the initial requests that sets up the session cookies for the rest of the application - log.Info().Msg("Setting up session...") - - requestQueue := []string{ - "/registration/registration", - "/selfServiceMenu/data", - } - - for _, path := range requestQueue { - req := a.config.Client.NewRequest(). - SetQueryParam("_", internal.Nonce()). - SetExpectResponseContentType("application/json") - - res, err := req.Get(path) - if err != nil { - log.Fatal().Stack().Str("path", path).Err(err).Msg("Failed to make request") - } - - if res.StatusCode() != 200 { - log.Fatal().Stack().Str("path", path).Int("status", res.StatusCode()).Msg("Failed to make request") - } - } - - // Validate that cookies were set - baseURLParsed, err := url.Parse(a.config.BaseURL) - if err != nil { - log.Fatal().Stack().Str("baseURL", a.config.BaseURL).Err(err).Msg("Failed to parse baseURL") - } - - currentCookies := a.config.Client.CookieJar().Cookies(baseURLParsed) - requiredCookies := map[string]bool{ - "JSESSIONID": false, - "SSB_COOKIE": false, - } - - for _, cookie := range currentCookies { - _, present := requiredCookies[cookie.Name] - // Check if this cookie is required - if present { - requiredCookies[cookie.Name] = true - } - } - - // Check if all required cookies were set - for cookieName, cookieSet := range requiredCookies { - if !cookieSet { - log.Warn().Str("cookieName", cookieName).Msg("Required cookie not set") - } - } - log.Debug().Msg("All required cookies set, session setup complete") - - // TODO: Validate that the session allows access to termSelection -} diff --git a/internal/bot/commands.go b/internal/bot/commands.go deleted file mode 100644 index 8290704..0000000 --- a/internal/bot/commands.go +++ /dev/null @@ -1,649 +0,0 @@ -package bot - -import ( - "banner/internal" - "banner/internal/api" - "banner/internal/models" - "fmt" - "net/url" - "regexp" - "strconv" - "strings" - "time" - - "github.com/bwmarrin/discordgo" - "github.com/pkg/errors" - "github.com/rs/zerolog/log" - "github.com/samber/lo" -) - -const ( - // ICalTimestampLayoutUtc is the formatting layout for timestamps in the UTC timezone. - ICalTimestampLayoutUtc = "20060102T150405Z" - // ICalTimestampLayoutLocal is the formatting layout for timestamps in the local timezone. - ICalTimestampLayoutLocal = "20060102T150405" -) - -// CommandHandler is a function that handles a slash command interaction. -type CommandHandler func(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error - -var ( - // CommandDefinitions is a list of all the bot's command definitions. - CommandDefinitions = []*discordgo.ApplicationCommand{TermCommandDefinition, TimeCommandDefinition, SearchCommandDefinition, IcsCommandDefinition, GCalCommandDefinition} - // CommandHandlers is a map of command names to their handlers. - CommandHandlers = map[string]CommandHandler{ - TimeCommandDefinition.Name: TimeCommandHandler, - TermCommandDefinition.Name: TermCommandHandler, - SearchCommandDefinition.Name: SearchCommandHandler, - IcsCommandDefinition.Name: IcsCommandHandler, - GCalCommandDefinition.Name: GCalCommandHandler, - } -) - -var SearchCommandDefinition = &discordgo.ApplicationCommand{ - Name: "search", - Description: "Search for a course", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionString, - MinLength: internal.GetIntPointer(0), - MaxLength: 48, - Name: "title", - Description: "Course Title (exact, use autocomplete)", - Required: false, - Autocomplete: true, - }, - { - Type: discordgo.ApplicationCommandOptionString, - Name: "code", - MinLength: internal.GetIntPointer(4), - Description: "Course Code (e.g. 3743, 3000-3999, 3xxx, 3000-)", - Required: false, - }, - { - Type: discordgo.ApplicationCommandOptionInteger, - Name: "max", - Description: "Maximum number of results", - Required: false, - }, - { - Type: discordgo.ApplicationCommandOptionString, - Name: "keywords", - Description: "Keywords in Title or Description (space separated)", - }, - { - Type: discordgo.ApplicationCommandOptionString, - Name: "instructor", - Description: "Instructor Name", - Required: false, - Autocomplete: true, - }, - { - Type: discordgo.ApplicationCommandOptionString, - Name: "subject", - Description: "Subject (e.g. Computer Science/CS, Mathematics/MAT)", - Required: false, - Autocomplete: true, - }, - }, -} - -// SearchCommandHandler handles the /search command, which allows users to search for courses. -func SearchCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error { - data := i.ApplicationCommandData() - query := api.NewQuery().Credits(3, 6) - - for _, option := range data.Options { - switch option.Name { - case "title": - query.Title(option.StringValue()) - case "code": - var ( - low = -1 - high = -1 - ) - var err error - valueRaw := strings.TrimSpace(option.StringValue()) - - // Partially/fully specified range - if strings.Contains(valueRaw, "-") { - match := regexp.MustCompile(`(\d{1,4})-(\d{1,4})?`).FindSubmatch([]byte(valueRaw)) - - if match == nil { - return fmt.Errorf("invalid range format: %s", valueRaw) - } - - // If not 2 or 3 matches, it's invalid - if len(match) != 3 && len(match) != 4 { - return fmt.Errorf("invalid range format: %s", match[0]) - } - - low, err = strconv.Atoi(string(match[1])) - if err != nil { - return errors.Wrap(err, "error parsing course code (low)") - } - - // If there's not a high value, set it to max (open ended) - if len(match) == 2 || len(match[2]) == 0 { - high = 9999 - } else { - high, err = strconv.Atoi(string(match[2])) - if err != nil { - return errors.Wrap(err, "error parsing course code (high)") - } - } - } - - // #xxx, ##xx, ###x format (34xx -> 3400-3499) - if strings.Contains(valueRaw, "x") { - if len(valueRaw) != 4 { - return fmt.Errorf("code range format invalid: must be 1 or more digits followed by x's (%s)", valueRaw) - } - - match := regexp.MustCompile(`\d{1,}([xX]{1,3})`).Match([]byte(valueRaw)) - if !match { - return fmt.Errorf("code range format invalid: must be 1 or more digits followed by x's (%s)", valueRaw) - } - - // Replace x's with 0's - low, err = strconv.Atoi(strings.Replace(valueRaw, "x", "0", -1)) - if err != nil { - return errors.Wrap(err, "error parsing implied course code (low)") - } - - // Replace x's with 9's - high, err = strconv.Atoi(strings.Replace(valueRaw, "x", "9", -1)) - if err != nil { - return errors.Wrap(err, "error parsing implied course code (high)") - } - } else if len(valueRaw) == 4 { - // 4 digit code - low, err = strconv.Atoi(valueRaw) - if err != nil { - return errors.Wrap(err, "error parsing course code") - } - - high = low - } - - if low == -1 || high == -1 { - return fmt.Errorf("course code range invalid (%s)", valueRaw) - } - - if low > high { - return fmt.Errorf("course code range is invalid: low is greater than high (%d > %d)", low, high) - } - - if low < 1000 || high < 1000 || low > 9999 || high > 9999 { - return fmt.Errorf("course code range is invalid: must be 1000-9999 (%d-%d)", low, high) - } - - query.CourseNumbers(low, high) - case "keywords": - query.Keywords( - strings.Split(option.StringValue(), " "), - ) - case "max": - query.MaxResults( - min(8, int(option.IntValue())), - ) - } - } - - term, err := b.GetSession() - if err != nil { - return err - } - - courses, err := b.API.Search(term, query, "", false) - if err != nil { - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: "Error searching for courses", - }, - }) - return err - } - - fetchTime := time.Now() - fields := []*discordgo.MessageEmbedField{} - - for _, course := range courses.Data { - // Safe instructor name handling - displayName := "TBA" - if len(course.Faculty) > 0 { - displayName = course.Faculty[0].DisplayName - } - - categoryLink := fmt.Sprintf("[%s](https://catalog.utsa.edu/undergraduate/coursedescriptions/%s/)", course.Subject, strings.ToLower(course.Subject)) - classLink := fmt.Sprintf("[%s-%s](https://catalog.utsa.edu/search/?P=%s%%20%s)", course.CourseNumber, course.SequenceNumber, course.Subject, course.CourseNumber) - professorLink := fmt.Sprintf("[%s](https://www.ratemyprofessors.com/search/professors/1516?q=%s)", displayName, url.QueryEscape(displayName)) - - identifierText := fmt.Sprintf("%s %s (CRN %s)\n%s", categoryLink, classLink, course.CourseReferenceNumber, professorLink) - - // Safe meeting time handling - meetingTime := "No scheduled meetings" - if len(course.MeetingsFaculty) > 0 { - meetingTime = course.MeetingsFaculty[0].String() - } - - fields = append(fields, &discordgo.MessageEmbedField{ - Name: "Identifier", - Value: identifierText, - Inline: true, - }, &discordgo.MessageEmbedField{ - Name: "Name", - Value: course.CourseTitle, - Inline: true, - }, &discordgo.MessageEmbedField{ - Name: "Meeting Time", - Value: meetingTime, - Inline: true, - }, - ) - } - - // Blue if there are results, orange if there are none - color := 0x0073FF - if courses.TotalCount == 0 { - color = 0xFF6500 - } - - err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Embeds: []*discordgo.MessageEmbed{ - { - Footer: internal.GetFetchedFooter(b.Config, fetchTime), - Description: fmt.Sprintf("%d Class%s", courses.TotalCount, internal.Plural(courses.TotalCount)), - Fields: fields[:min(25, len(fields))], - Color: color, - }, - }, - AllowedMentions: &discordgo.MessageAllowedMentions{}, - }, - }) - - return err -} - -var TermCommandDefinition = &discordgo.ApplicationCommand{ - Name: "terms", - Description: "Guess the current term, or search for a specific term", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionString, - MinLength: internal.GetIntPointer(0), - MaxLength: 8, - Name: "search", - Description: "Term to search for", - Required: false, - }, - { - Type: discordgo.ApplicationCommandOptionInteger, - Name: "page", - Description: "Page Number", - Required: false, - MinValue: internal.GetFloatPointer(1), - }, - }, -} - -// TermCommandHandler handles the /terms command, which allows users to search for terms. -func TermCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error { - data := i.ApplicationCommandData() - - searchTerm := "" - pageNumber := 1 - - for _, option := range data.Options { - switch option.Name { - case "search": - searchTerm = option.StringValue() - case "page": - pageNumber = int(option.IntValue()) - default: - log.Warn().Str("option", option.Name).Msg("Unexpected option in term command") - } - } - - termResult, err := b.API.GetTerms(searchTerm, pageNumber, 25) - - if err != nil { - internal.RespondError(s, i.Interaction, "Error while fetching terms", err) - return err - } - - fields := []*discordgo.MessageEmbedField{} - - for _, t := range termResult { - fields = append(fields, &discordgo.MessageEmbedField{ - Name: t.Description, - Value: t.Code, - Inline: true, - }) - } - - fetchTime := time.Now() - - if len(fields) > 25 { - log.Warn().Int("count", len(fields)).Msg("Too many fields in term command (trimmed)") - } - - err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Embeds: []*discordgo.MessageEmbed{ - { - Footer: internal.GetFetchedFooter(b.Config, fetchTime), - Description: fmt.Sprintf("%d term%s (page %d)", len(termResult), internal.Plural(len(termResult)), pageNumber), - Fields: fields[:min(25, len(fields))], - }, - }, - AllowedMentions: &discordgo.MessageAllowedMentions{}, - }, - }) - - return err -} - -var TimeCommandDefinition = &discordgo.ApplicationCommand{ - Name: "time", - Description: "Get Class Meeting Time", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionInteger, - Name: "crn", - Description: "Course Reference Number", - Required: true, - }, - }, -} - -// TimeCommandHandler handles the /time command, which allows users to get the meeting times for a course. -func TimeCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error { - fetchTime := time.Now() - crn := i.ApplicationCommandData().Options[0].IntValue() - - // Fix static term - meetingTimes, err := b.API.GetCourseMeetingTime(202510, int(crn)) - if err != nil { - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: "Error getting meeting time", - }, - }) - return err - } - - if len(meetingTimes) == 0 { - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: "No meeting times found for this course", - }, - }) - return fmt.Errorf("no meeting times found for CRN %d", crn) - } - - meetingTime := meetingTimes[0] - duration := meetingTime.EndTime().Sub(meetingTime.StartTime()) - - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Embeds: []*discordgo.MessageEmbed{ - { - Footer: internal.GetFetchedFooter(b.Config, fetchTime), - Description: "", - Fields: []*discordgo.MessageEmbedField{ - { - Name: "Start Date", - Value: meetingTime.StartDay().Format("Monday, January 2, 2006"), - }, - { - Name: "End Date", - Value: meetingTime.EndDay().Format("Monday, January 2, 2006"), - }, - { - Name: "Start/End Time", - Value: fmt.Sprintf("%s - %s (%d min)", meetingTime.StartTime().String(), meetingTime.EndTime().String(), int64(duration.Minutes())), - }, - { - Name: "Days of Week", - Value: internal.WeekdaysToString(meetingTime.Days()), - }, - }, - }, - }, - AllowedMentions: &discordgo.MessageAllowedMentions{}, - }, - }) - return nil -} - -var IcsCommandDefinition = &discordgo.ApplicationCommand{ - Name: "ics", - Description: "Generate an ICS file for a course", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionInteger, - Name: "crn", - Description: "Course Reference Number", - Required: true, - }, - }, -} - -var GCalCommandDefinition = &discordgo.ApplicationCommand{ - Name: "gcal", - Description: "Generate a link to create a Google Calendar event for a course", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionInteger, - Name: "crn", - Description: "Course Reference Number", - Required: true, - }, - }, -} - -// GCalCommandHandler handles the /gcal command, which allows users to generate a link to create a Google Calendar event for a course. -func GCalCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error { - // Parse all options - options := internal.ParseOptions(i.ApplicationCommandData().Options) - crn := options.GetInt("crn") - - course, err := b.API.GetCourse(strconv.Itoa(int(crn))) - if err != nil { - return fmt.Errorf("Error retrieving course data: %w", err) - } - - meetingTimes, err := b.API.GetCourseMeetingTime(202510, int(crn)) - if err != nil { - return fmt.Errorf("Error requesting meeting time: %w", err) - } - - if len(meetingTimes) == 0 { - return fmt.Errorf("unexpected - no meeting time data found for course") - } - - // Check if the course has any meeting times - meetingTime, exists := lo.Find(meetingTimes, func(mt models.MeetingTimeResponse) bool { - switch mt.MeetingTime.MeetingType { - case "ID", "OA": - return false - default: - return true - } - }) - - if !exists { - internal.RespondError(s, i.Interaction, "The course requested does not meet at a defined moment in time.", nil) - return nil - } - - startDay := meetingTime.StartDay() - startTime := meetingTime.StartTime() - endTime := meetingTime.EndTime() - - // Create timestamps in UTC - dtStart := time.Date(startDay.Year(), startDay.Month(), startDay.Day(), int(startTime.Hours), int(startTime.Minutes), 0, 0, b.Config.CentralTimeLocation) - dtEnd := time.Date(startDay.Year(), startDay.Month(), startDay.Day(), int(endTime.Hours), int(endTime.Minutes), 0, 0, b.Config.CentralTimeLocation) - - // Format times in UTC for Google Calendar - startStr := dtStart.UTC().Format(ICalTimestampLayoutUtc) - endStr := dtEnd.UTC().Format(ICalTimestampLayoutUtc) - - // Generate RRULE for recurrence - rrule := meetingTime.RRule() - recurRule := fmt.Sprintf("FREQ=WEEKLY;BYDAY=%s;UNTIL=%s", rrule.ByDay, rrule.Until) - - // Build calendar URL - params := url.Values{} - params.Add("action", "TEMPLATE") - params.Add("text", fmt.Sprintf("%s %s - %s", course.Subject, course.CourseNumber, course.CourseTitle)) - params.Add("dates", fmt.Sprintf("%s/%s", startStr, endStr)) - params.Add("details", fmt.Sprintf("CRN: %s\nInstructor: %s\nDays: %s", course.CourseReferenceNumber, meetingTime.Faculty[0].DisplayName, internal.WeekdaysToString(meetingTime.Days()))) - params.Add("location", meetingTime.PlaceString()) - params.Add("trp", "true") - params.Add("ctz", b.Config.CentralTimeLocation.String()) - params.Add("recur", "RRULE:"+recurRule) - - calendarURL := "https://calendar.google.com/calendar/render?" + params.Encode() - - err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: fmt.Sprintf("[Add to Google Calendar](<%s>)", calendarURL), - AllowedMentions: &discordgo.MessageAllowedMentions{}, - }, - }) - return err -} - -// IcsCommandHandler handles the /ics command, which allows users to generate an ICS file for a course. -func IcsCommandHandler(b *Bot, s *discordgo.Session, i *discordgo.InteractionCreate) error { - // Parse all options - options := internal.ParseOptions(i.ApplicationCommandData().Options) - crn := options.GetInt("crn") - - course, err := b.API.GetCourse(strconv.Itoa(int(crn))) - if err != nil { - return fmt.Errorf("Error retrieving course data: %w", err) - } - - // Fix static term - meetingTimes, err := b.API.GetCourseMeetingTime(202510, int(crn)) - if err != nil { - return fmt.Errorf("Error requesting meeting time: %w", err) - } - - if len(meetingTimes) == 0 { - return fmt.Errorf("unexpected - no meeting time data found for course") - } - - // Check if the course has any meeting times - _, exists := lo.Find(meetingTimes, func(mt models.MeetingTimeResponse) bool { - switch mt.MeetingTime.MeetingType { - case "ID", "OA": - return false - default: - return true - } - }) - - if !exists { - log.Warn().Str("crn", course.CourseReferenceNumber).Msg("Non-meeting course requested for ICS file") - internal.RespondError(s, i.Interaction, "The course requested does not meet at a defined moment in time.", nil) - return nil - } - - events := []string{} - for _, meeting := range meetingTimes { - now := time.Now().In(b.Config.CentralTimeLocation) - uid := fmt.Sprintf("%d-%s@ical.banner.xevion.dev", now.Unix(), meeting.CourseReferenceNumber) - - startDay := meeting.StartDay() - startTime := meeting.StartTime() - endTime := meeting.EndTime() - dtStart := time.Date(startDay.Year(), startDay.Month(), startDay.Day(), int(startTime.Hours), int(startTime.Minutes), 0, 0, b.Config.CentralTimeLocation) - dtEnd := time.Date(startDay.Year(), startDay.Month(), startDay.Day(), int(endTime.Hours), int(endTime.Minutes), 0, 0, b.Config.CentralTimeLocation) - - // endDay := meeting.EndDay() - // until := time.Date(endDay.Year(), endDay.Month(), endDay.Day(), 23, 59, 59, 0, b.Config.CentralTimeLocation) - - summary := fmt.Sprintf("%s %s %s", course.Subject, course.CourseNumber, course.CourseTitle) - - // Safe instructor name handling - instructorName := "TBA" - if len(course.Faculty) > 0 { - instructorName = course.Faculty[0].DisplayName - } - - description := fmt.Sprintf("Instructor: %s\nSection: %s\nCRN: %s", instructorName, course.SequenceNumber, meeting.CourseReferenceNumber) - location := meeting.PlaceString() - - rrule := meeting.RRule() - - event := fmt.Sprintf(`BEGIN:VEVENT -DTSTAMP:%s -UID:%s -DTSTART;TZID=America/Chicago:%s -RRULE:FREQ=WEEKLY;BYDAY=%s;UNTIL=%s -DTEND;TZID=America/Chicago:%s -SUMMARY:%s -DESCRIPTION:%s -LOCATION:%s -END:VEVENT`, now.Format(ICalTimestampLayoutLocal), uid, dtStart.Format(ICalTimestampLayoutLocal), rrule.ByDay, rrule.Until, dtEnd.Format(ICalTimestampLayoutLocal), summary, strings.Replace(description, "\n", `\n`, -1), location) - - events = append(events, event) - } - - // TODO: Make this dynamically requested, parsed & cached from tzurl.org - vTimezone := `BEGIN:VTIMEZONE -TZID:America/Chicago -LAST-MODIFIED:20231222T233358Z -TZURL:https://www.tzurl.org/zoneinfo-outlook/America/Chicago -X-LIC-LOCATION:America/Chicago -BEGIN:DAYLIGHT -TZNAME:CDT -TZOFFSETFROM:-0600 -TZOFFSETTO:-0500 -DTSTART:19700308T020000 -RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU -END:DAYLIGHT -BEGIN:STANDARD -TZNAME:CST -TZOFFSETFROM:-0500 -TZOFFSETTO:-0600 -DTSTART:19701101T020000 -RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU -END:STANDARD -END:VTIMEZONE` - - ics := fmt.Sprintf(`BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//xevion//Banner Discord Bot//EN -CALSCALE:GREGORIAN -%s -%s -END:VCALENDAR`, vTimezone, strings.Join(events, "\n")) - - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Files: []*discordgo.File{ - { - Name: fmt.Sprintf("%s-%s-%s_%s.ics", course.Subject, course.CourseNumber, course.SequenceNumber, course.CourseReferenceNumber), - ContentType: "text/calendar", - Reader: strings.NewReader(ics), - }, - }, - AllowedMentions: &discordgo.MessageAllowedMentions{}, - }, - }) - return nil -} diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go deleted file mode 100644 index 01df55b..0000000 --- a/internal/bot/handlers.go +++ /dev/null @@ -1,91 +0,0 @@ -package bot - -import ( - "banner/internal" - "fmt" - - "github.com/bwmarrin/discordgo" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" -) - -// RegisterHandlers registers the bot's command handlers. -func (b *Bot) RegisterHandlers() { - b.Session.AddHandler(func(internalSession *discordgo.Session, interaction *discordgo.InteractionCreate) { - // Handle commands during restart (highly unlikely, but just in case) - if b.isClosing { - err := internal.RespondError(internalSession, interaction.Interaction, "Bot is currently restarting, try again later.", nil) - if err != nil { - log.Error().Err(err).Msg("Failed to respond with restart error feedback") - } - return - } - - name := interaction.ApplicationCommandData().Name - if handler, ok := CommandHandlers[name]; ok { - // Build dict of options for the log - options := zerolog.Dict() - for _, option := range interaction.ApplicationCommandData().Options { - options.Str(option.Name, fmt.Sprintf("%v", option.Value)) - } - - event := log.Info().Str("name", name).Str("user", internal.GetUser(interaction).Username).Dict("options", options) - - // If the command was invoked in a guild, add guild & channel info to the log - if interaction.Member != nil { - guild := zerolog.Dict() - guild.Str("id", interaction.GuildID) - guild.Str("name", internal.GetGuildName(b.Config, internalSession, interaction.GuildID)) - event.Dict("guild", guild) - - channel := zerolog.Dict() - channel.Str("id", interaction.ChannelID) - guild.Str("name", internal.GetChannelName(b.Config, internalSession, interaction.ChannelID)) - event.Dict("channel", channel) - } else { - // If the command was invoked in a DM, add the user info to the log - user := zerolog.Dict() - user.Str("id", interaction.User.ID) - user.Str("name", interaction.User.Username) - event.Dict("user", user) - } - - // Log command invocation - event.Msg("Command Invoked") - - // Prepare to recover - defer func() { - if err := recover(); err != nil { - log.Error().Stack().Str("commandName", name).Interface("detail", err).Msg("Command Handler Panic") - - // Respond with error - err := internal.RespondError(internalSession, interaction.Interaction, "Unexpected Error: command handler panic", nil) - if err != nil { - log.Error().Stack().Str("commandName", name).Err(err).Msg("Failed to respond with panic error feedback") - } - } - }() - - // Call handler - err := handler(b, internalSession, interaction) - - // Log & respond error - if err != nil { - // TODO: Find a way to merge the response with the handler's error - log.Error().Str("commandName", name).Err(err).Msg("Command Handler Error") - - // Respond with error - err = internal.RespondError(internalSession, interaction.Interaction, fmt.Sprintf("Unexpected Error: %s", err.Error()), nil) - if err != nil { - log.Error().Stack().Str("commandName", name).Err(err).Msg("Failed to respond with error feedback") - } - } - - } else { - log.Error().Stack().Str("commandName", name).Msg("Command Interaction Has No Handler") - - // Respond with error - internal.RespondError(internalSession, interaction.Interaction, "Unexpected Error: interaction has no handler", nil) - } - }) -} diff --git a/internal/bot/state.go b/internal/bot/state.go deleted file mode 100644 index d04c6d5..0000000 --- a/internal/bot/state.go +++ /dev/null @@ -1,44 +0,0 @@ -// Package bot provides the core functionality for the Discord bot. -package bot - -import ( - "banner/internal/api" - "banner/internal/config" - "fmt" - "time" - - "github.com/bwmarrin/discordgo" - "github.com/rs/zerolog/log" -) - -// Bot represents the state of the Discord bot. -type Bot struct { - Session *discordgo.Session - API *api.API - Config *config.Config - isClosing bool -} - -// New creates a new Bot instance. -func New(s *discordgo.Session, a *api.API, c *config.Config) *Bot { - return &Bot{Session: s, API: a, Config: c} -} - -// SetClosing marks the bot as closing, preventing new commands from being processed. -func (b *Bot) SetClosing() { - b.isClosing = true -} - -// GetSession ensures a valid session is available and selects the default term. -func (b *Bot) GetSession() (string, error) { - sessionID := b.API.EnsureSession() - term := b.API.DefaultTerm(time.Now()).ToString() - - log.Info().Str("term", term).Str("sessionID", sessionID).Msg("Setting selected term") - err := b.API.SelectTerm(term, sessionID) - if err != nil { - return "", fmt.Errorf("failed to select term while generating session ID: %w", err) - } - - return sessionID, nil -} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 1d47a3a..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,72 +0,0 @@ -package config - -import ( - "context" - "time" - - "github.com/redis/go-redis/v9" - "resty.dev/v3" -) - -// Config holds the application's configuration. -type Config struct { - // Ctx is the application's root context. - Ctx context.Context - // CancelFunc cancels the application's root context. - CancelFunc context.CancelFunc - // KV provides access to the Redis cache. - KV *redis.Client - // Client is the HTTP client for making API requests. - Client *resty.Client - // IsDevelopment is true if the application is running in a development environment. - IsDevelopment bool - // BaseURL is the base URL for the Banner API. - BaseURL string - // Environment is the application's running environment (e.g. "development"). - Environment string - // CentralTimeLocation is the time.Location for US Central Time. - CentralTimeLocation *time.Location - // SeasonRanges is the time.Location for US Central Time. - SeasonRanges *SeasonRanges -} - -// New creates a new Config instance with a cancellable context. -func New() (*Config, error) { - ctx, cancel := context.WithCancel(context.Background()) - - loc, err := time.LoadLocation("America/Chicago") - if err != nil { - cancel() - return nil, err - } - - seasonRanges := GetYearDayRange(loc, uint16(time.Now().Year())) - - return &Config{ - Ctx: ctx, - CancelFunc: cancel, - CentralTimeLocation: loc, - SeasonRanges: &seasonRanges, - }, nil -} - -// SetBaseURL sets the base URL for the Banner API. -func (c *Config) SetBaseURL(url string) { - c.BaseURL = url -} - -// SetEnvironment sets the application's environment. -func (c *Config) SetEnvironment(env string) { - c.Environment = env - c.IsDevelopment = env == "development" -} - -// SetClient sets the Resty client for making HTTP requests. -func (c *Config) SetClient(client *resty.Client) { - c.Client = client -} - -// SetRedis sets the Redis client for caching. -func (c *Config) SetRedis(r *redis.Client) { - c.KV = r -} diff --git a/internal/config/logging.go b/internal/config/logging.go deleted file mode 100644 index 09e0fab..0000000 --- a/internal/config/logging.go +++ /dev/null @@ -1,71 +0,0 @@ -// Package config provides the configuration and logging setup for the application. -package config - -import ( - "io" - "os" - - "github.com/rs/zerolog" -) - -const timeFormat = "2006-01-02 15:04:05" - -// NewConsoleWriter creates a new console writer that splits logs between stdout and stderr. -func NewConsoleWriter() zerolog.LevelWriter { - return &ConsoleLogSplitter{ - stdConsole: zerolog.ConsoleWriter{ - Out: os.Stdout, - TimeFormat: timeFormat, - NoColor: false, - PartsOrder: []string{zerolog.TimestampFieldName, zerolog.LevelFieldName, zerolog.MessageFieldName}, - PartsExclude: []string{}, - FieldsExclude: []string{}, - }, - errConsole: zerolog.ConsoleWriter{ - Out: os.Stderr, - TimeFormat: timeFormat, - NoColor: false, - PartsOrder: []string{zerolog.TimestampFieldName, zerolog.LevelFieldName, zerolog.MessageFieldName}, - PartsExclude: []string{}, - FieldsExclude: []string{}, - }, - } -} - -// ConsoleLogSplitter is a zerolog.LevelWriter that writes to stdout for info/debug logs and stderr for warn/error logs, with console-friendly formatting. -type ConsoleLogSplitter struct { - stdConsole zerolog.ConsoleWriter - errConsole zerolog.ConsoleWriter -} - -// Write is a passthrough to the standard console writer and should not be called directly. -func (c *ConsoleLogSplitter) Write(p []byte) (n int, err error) { - return c.stdConsole.Write(p) -} - -// WriteLevel writes to the appropriate output (stdout or stderr) with console formatting based on the log level. -func (c *ConsoleLogSplitter) WriteLevel(level zerolog.Level, p []byte) (n int, err error) { - if level <= zerolog.WarnLevel { - return c.stdConsole.Write(p) - } - return c.errConsole.Write(p) -} - -// LogSplitter is a zerolog.LevelWriter that writes to stdout for info/debug logs and stderr for warn/error logs. -type LogSplitter struct { - Std io.Writer - Err io.Writer -} - -// Write is a passthrough to the standard writer and should not be called directly. -func (l LogSplitter) Write(p []byte) (n int, err error) { - return l.Std.Write(p) -} - -// WriteLevel writes to the appropriate output (stdout or stderr) based on the log level. -func (l LogSplitter) WriteLevel(level zerolog.Level, p []byte) (n int, err error) { - if level <= zerolog.WarnLevel { - return l.Std.Write(p) - } - return l.Err.Write(p) -} diff --git a/internal/config/terms.go b/internal/config/terms.go deleted file mode 100644 index d603b5e..0000000 --- a/internal/config/terms.go +++ /dev/null @@ -1,140 +0,0 @@ -package config - -import ( - "fmt" - "strconv" - "time" -) - -// Term selection should yield smart results based on the current time, as well as the input provided. -// Fall 2024, "spring" => Spring 2025 -// Fall 2024, "fall" => Fall 2025 -// Summer 2024, "fall" => Fall 2024 - -const ( - // Fall is the first term of the school year. - Fall = iota - // Spring is the second term of the school year. - Spring - // Summer is the third term of the school year. - Summer -) - -// Term represents a school term, consisting of a year and a season. -type Term struct { - Year uint16 - Season uint8 -} - -// SeasonRanges represents the start and end day of each term within a year. -type SeasonRanges struct { - Spring YearDayRange - Summer YearDayRange - Fall YearDayRange -} - -// YearDayRange represents the start and end day of a term within a year. -type YearDayRange struct { - Start uint16 - End uint16 -} - -// GetYearDayRange returns the start and end day of each term for the given year. -// The ranges are inclusive of the start day and exclusive of the end day. -func GetYearDayRange(loc *time.Location, year uint16) SeasonRanges { - springStart := time.Date(int(year), time.January, 14, 0, 0, 0, 0, loc).YearDay() - springEnd := time.Date(int(year), time.May, 1, 0, 0, 0, 0, loc).YearDay() - summerStart := time.Date(int(year), time.May, 25, 0, 0, 0, 0, loc).YearDay() - summerEnd := time.Date(int(year), time.August, 15, 0, 0, 0, 0, loc).YearDay() - fallStart := time.Date(int(year), time.August, 18, 0, 0, 0, 0, loc).YearDay() - fallEnd := time.Date(int(year), time.December, 10, 0, 0, 0, 0, loc).YearDay() - - return SeasonRanges{ - Spring: YearDayRange{ - Start: uint16(springStart), - End: uint16(springEnd), - }, - Summer: YearDayRange{ - Start: uint16(summerStart), - End: uint16(summerEnd), - }, - Fall: YearDayRange{ - Start: uint16(fallStart), - End: uint16(fallEnd), - }, - } -} - -// GetCurrentTerm returns the current and next terms based on the provided time. -// The current term can be nil if the time falls between terms. -// The 'year' in the term corresponds to the academic year, which may differ from the calendar year. -func GetCurrentTerm(ranges SeasonRanges, now time.Time) (*Term, *Term) { - literalYear := uint16(now.Year()) - dayOfYear := uint16(now.YearDay()) - - // If we're past the end of the summer term, we're 'in' the next school year. - var termYear uint16 - if dayOfYear > ranges.Summer.End { - termYear = literalYear + 1 - } else { - termYear = literalYear - } - - if (dayOfYear < ranges.Spring.Start) || (dayOfYear >= ranges.Fall.End) { - // Fall over, Spring not yet begun - return nil, &Term{Year: termYear, Season: Spring} - } else if (dayOfYear >= ranges.Spring.Start) && (dayOfYear < ranges.Spring.End) { - // Spring - return &Term{Year: termYear, Season: Spring}, &Term{Year: termYear, Season: Summer} - } else if dayOfYear < ranges.Summer.Start { - // Spring over, Summer not yet begun - return nil, &Term{Year: termYear, Season: Summer} - } else if (dayOfYear >= ranges.Summer.Start) && (dayOfYear < ranges.Summer.End) { - // Summer - return &Term{Year: termYear, Season: Summer}, &Term{Year: termYear, Season: Fall} - } else if dayOfYear < ranges.Fall.Start { - // Summer over, Fall not yet begun - return nil, &Term{Year: termYear, Season: Fall} - } else if (dayOfYear >= ranges.Fall.Start) && (dayOfYear < ranges.Fall.End) { - // Fall - return &Term{Year: termYear, Season: Fall}, nil - } - - panic(fmt.Sprintf("Impossible Code Reached (dayOfYear: %d)", dayOfYear)) -} - -// ParseTerm converts a Banner term code string to a Term struct. -func ParseTerm(code string) Term { - year, _ := strconv.ParseUint(code[0:4], 10, 16) - - var season uint8 - termCode := code[4:6] - switch termCode { - case "10": - season = Fall - case "20": - season = Spring - case "30": - season = Summer - } - - return Term{ - Year: uint16(year), - Season: season, - } -} - -// ToString converts a Term struct to a Banner term code string. -func (term Term) ToString() string { - var season string - switch term.Season { - case Fall: - season = "10" - case Spring: - season = "20" - case Summer: - season = "30" - } - - return fmt.Sprintf("%d%s", term.Year, season) -} diff --git a/internal/errors.go b/internal/errors.go deleted file mode 100644 index 87c0b21..0000000 --- a/internal/errors.go +++ /dev/null @@ -1,13 +0,0 @@ -package internal - -import "fmt" - -// UnexpectedContentTypeError is returned when the Content-Type header of a response does not match the expected value. -type UnexpectedContentTypeError struct { - Expected string - Actual string -} - -func (e *UnexpectedContentTypeError) Error() string { - return fmt.Sprintf("Expected content type '%s', received '%s'", e.Expected, e.Actual) -} diff --git a/internal/helpers.go b/internal/helpers.go deleted file mode 100644 index 6c4d67e..0000000 --- a/internal/helpers.go +++ /dev/null @@ -1,376 +0,0 @@ -package internal - -import ( - "fmt" - "io" - "math/rand" - "net/http" - "net/url" - "os" - "runtime" - "sort" - "strconv" - "strings" - "time" - - "github.com/bwmarrin/discordgo" - "github.com/rs/zerolog" - log "github.com/rs/zerolog/log" - "resty.dev/v3" - - "banner/internal/config" -) - -// Options is a map of options from a Discord command. -type Options map[string]*discordgo.ApplicationCommandInteractionDataOption - -// GetInt returns the integer value of an option, or 0 if it doesn't exist. -func (o Options) GetInt(key string) int64 { - if opt, ok := o[key]; ok { - return opt.IntValue() - } - return 0 -} - -// ParseOptions parses slash command options into a map for easier access. -func ParseOptions(options []*discordgo.ApplicationCommandInteractionDataOption) Options { - optionMap := make(Options) - for _, opt := range options { - optionMap[opt.Name] = opt - } - return optionMap -} - -// AddUserAgent adds a consistent user agent to the request to mimic a real browser. -func AddUserAgent(req *http.Request) { - req.Header.Add("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36") -} - -// ContentTypeMatch checks if a Resty response has the given content type. -func ContentTypeMatch(res *resty.Response, expectedContentType string) bool { - contentType := res.Header().Get("Content-Type") - if contentType == "" { - return expectedContentType == "application/octect-stream" - } - return strings.HasPrefix(contentType, expectedContentType) -} - -const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" - -// RandomString returns a random string of length n. -// The character set is chosen to mimic Ellucian's Banner session ID generation. -func RandomString(n int) string { - b := make([]byte, n) - for i := range b { - b[i] = letterBytes[rand.Intn(len(letterBytes))] - } - return string(b) -} - -// DiscordGoLogger is a helper function that implements discordgo's logging interface, directing all logs to zerolog. -func DiscordGoLogger(msgL, caller int, format string, a ...interface{}) { - pc, file, line, _ := runtime.Caller(caller) - - files := strings.Split(file, "/") - file = files[len(files)-1] - - name := runtime.FuncForPC(pc).Name() - fns := strings.Split(name, ".") - name = fns[len(fns)-1] - - msg := fmt.Sprintf(format, a...) - - var event *zerolog.Event - switch msgL { - case 0: - event = log.Debug() - case 1: - event = log.Info() - case 2: - event = log.Warn() - case 3: - event = log.Error() - default: - event = log.Info() - } - - event.Str("file", file).Int("line", line).Str("function", name).Msg(msg) -} - -// Nonce returns the current time in milliseconds since the Unix epoch as a string. -// This is typically used as a query parameter to prevent request caching. -func Nonce() string { - return strconv.Itoa(int(time.Now().UnixMilli())) -} - -// Plural returns "s" if n is not 1. -func Plural(n int) string { - if n == 1 { - return "" - } - return "s" -} - -// Plurale returns "es" if n is not 1. -func Plurale(n int) string { - if n == 1 { - return "" - } - return "es" -} - -// WeekdaysToString converts a map of weekdays to a compact string representation (e.g., "MWF"). -func WeekdaysToString(days map[time.Weekday]bool) string { - // If no days are present - numDays := len(days) - if numDays == 0 { - return "None" - } - - // If all days are present - if numDays == 7 { - return "Everyday" - } - - str := "" - - if days[time.Monday] { - str += "M" - } - - if days[time.Tuesday] { - str += "Tu" - } - - if days[time.Wednesday] { - str += "W" - } - - if days[time.Thursday] { - str += "Th" - } - - if days[time.Friday] { - str += "F" - } - - if days[time.Saturday] { - str += "Sa" - } - - if days[time.Sunday] { - str += "Su" - } - - return str -} - -// NaiveTime represents a time of day without a date or timezone. -type NaiveTime struct { - Hours uint - Minutes uint -} - -// Sub returns the duration between two NaiveTime instances. -func (nt *NaiveTime) Sub(other *NaiveTime) time.Duration { - return time.Hour*time.Duration(nt.Hours-other.Hours) + time.Minute*time.Duration(nt.Minutes-other.Minutes) -} - -// ParseNaiveTime converts an integer representation of time (e.g., 1430) to a NaiveTime struct. -func ParseNaiveTime(integer uint64) *NaiveTime { - minutes := uint(integer % 100) - hours := uint(integer / 100) - - return &NaiveTime{Hours: hours, Minutes: minutes} -} - -// String returns a string representation of the NaiveTime in 12-hour format (e.g., "2:30PM"). -func (nt NaiveTime) String() string { - meridiem := "AM" - hour := nt.Hours - if nt.Hours >= 12 { - meridiem = "PM" - if nt.Hours > 12 { - hour -= 12 - } - } - return fmt.Sprintf("%d:%02d%s", hour, nt.Minutes, meridiem) -} - -// GetFirstEnv returns the value of the first environment variable that is set. -func GetFirstEnv(key ...string) string { - for _, k := range key { - if v := os.Getenv(k); v != "" { - return v - } - } - return "" -} - -// GetIntPointer returns a pointer to the given integer. -func GetIntPointer(value int) *int { - return &value -} - -// GetFloatPointer returns a pointer to the given float. -func GetFloatPointer(value float64) *float64 { - return &value -} - -var extensionMap = map[string]string{ - "text/plain": "txt", - "application/json": "json", - "text/html": "html", - "text/css": "css", - "text/csv": "csv", - "text/calendar": "ics", - "text/markdown": "md", - "text/xml": "xml", - "text/yaml": "yaml", - "text/javascript": "js", - "text/vtt": "vtt", - "image/jpeg": "jpg", - "image/png": "png", - "image/gif": "gif", - "image/webp": "webp", - "image/tiff": "tiff", - "image/svg+xml": "svg", - "image/bmp": "bmp", - "image/vnd.microsoft.icon": "ico", - "image/x-icon": "ico", - "image/x-xbitmap": "xbm", - "image/x-xpixmap": "xpm", - "image/x-xwindowdump": "xwd", - "image/avif": "avif", - "image/apng": "apng", - "image/jxl": "jxl", -} - -// GuessExtension guesses the file extension for a given content type. -func GuessExtension(contentType string) string { - ext, ok := extensionMap[strings.ToLower(contentType)] - if !ok { - return "" - } - return ext -} - -// DumpResponse dumps the body of a Resty response to a file for debugging. -func DumpResponse(res *resty.Response) { - contentType := res.Header().Get("Content-Type") - ext := GuessExtension(contentType) - - // Use current time as filename + /dumps/ prefix - filename := fmt.Sprintf("dumps/%d.%s", time.Now().Unix(), ext) - file, err := os.Create(filename) - - if err != nil { - log.Err(err).Stack().Msg("Error creating file") - return - } - defer file.Close() - - body, err := io.ReadAll(res.Body) - if err != nil { - log.Err(err).Stack().Msg("Error reading response body") - return - } - - _, err = file.Write(body) - if err != nil { - log.Err(err).Stack().Msg("Error writing response body") - return - } - - log.Info().Str("filename", filename).Str("content-type", contentType).Msg("Dumped response body") -} - -// RespondError responds to an interaction with a formatted error message. -func RespondError(session *discordgo.Session, interaction *discordgo.Interaction, message string, err error) error { - // Optional: log the error - if err != nil { - log.Err(err).Stack().Msg(message) - } - - return session.InteractionRespond(interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Embeds: []*discordgo.MessageEmbed{ - { - Footer: &discordgo.MessageEmbedFooter{ - Text: fmt.Sprintf("Occurred at %s", time.Now().Format("Monday, January 2, 2006 at 3:04:05PM")), - }, - Description: message, - Color: 0xff0000, - }, - }, - AllowedMentions: &discordgo.MessageAllowedMentions{}, - }, - }) -} - -// GetFetchedFooter returns a standard footer for embeds, indicating when the data was fetched. -func GetFetchedFooter(cfg *config.Config, time time.Time) *discordgo.MessageEmbedFooter { - return &discordgo.MessageEmbedFooter{ - Text: fmt.Sprintf("Fetched at %s", time.In(cfg.CentralTimeLocation).Format("Monday, January 2, 2006 at 3:04:05PM")), - } -} - -// GetUser returns the user from an interaction, regardless of whether it was in a guild or a DM. -func GetUser(interaction *discordgo.InteractionCreate) *discordgo.User { - // If the interaction is in a guild, the user is in the Member field - if interaction.Member != nil { - return interaction.Member.User - } - - // If the interaction is in a DM, the user is in the User field - return interaction.User -} - -// EncodeParams encodes a map of parameters into a URL-encoded string, sorted by key. -func EncodeParams(params map[string]*[]string) string { - // Escape hatch for nil - if params == nil { - return "" - } - - // Sort the keys - keys := make([]string, 0, len(params)) - for k := range params { - keys = append(keys, k) - } - sort.Strings(keys) - - var buf strings.Builder - for _, k := range keys { - // Multiple values are allowed, so extract the slice & prepare the key - values := params[k] - keyEscaped := url.QueryEscape(k) - - for _, v := range *values { - // If any parameters have been written, add the ampersand - if buf.Len() > 0 { - buf.WriteByte('&') - } - - // Write the key and value - buf.WriteString(keyEscaped) - buf.WriteByte('=') - buf.WriteString(url.QueryEscape(v)) - } - } - - return buf.String() -} - -// Point represents a point in 2D space. -type Point struct { - X, Y float64 -} - -// Slope calculates the y-coordinate of a point on a line given two other points and an x-coordinate. -func Slope(p1 Point, p2 Point, x float64) Point { - slope := (p2.Y - p1.Y) / (p2.X - p1.X) - newY := slope*(x-p1.X) + p1.Y - return Point{X: x, Y: newY} -} diff --git a/internal/meta.go b/internal/meta.go deleted file mode 100644 index b7116d7..0000000 --- a/internal/meta.go +++ /dev/null @@ -1,96 +0,0 @@ -// Package internal provides shared functionality for the banner application. -package internal - -import ( - "banner/internal/config" - "context" - "time" - - "github.com/bwmarrin/discordgo" - "github.com/redis/go-redis/v9" - log "github.com/rs/zerolog/log" -) - -// GetGuildName returns the name of a guild by its ID, using Redis for caching. -func GetGuildName(cfg *config.Config, session *discordgo.Session, guildID string) string { - // Create a timeout context for Redis operations - ctx, cancel := context.WithTimeout(cfg.Ctx, 5*time.Second) - defer cancel() - - // Check Redis for the guild name - guildName, err := cfg.KV.Get(ctx, "guild:"+guildID+":name").Result() - if err != nil && err != redis.Nil { - log.Error().Stack().Err(err).Msg("Error getting guild name from Redis") - return "err" - } - - // If the guild name is invalid (1 character long), then return "unknown" - if len(guildName) == 1 { - return "unknown" - } - - // If the guild name isn't in Redis, get it from Discord and cache it - guild, err := session.Guild(guildID) - if err != nil { - log.Error().Stack().Err(err).Msg("Error getting guild name") - - // Store an invalid value in Redis so we don't keep trying to get the guild name - ctx2, cancel2 := context.WithTimeout(cfg.Ctx, 5*time.Second) - defer cancel2() - _, err := cfg.KV.Set(ctx2, "guild:"+guildID+":name", "x", time.Minute*5).Result() - if err != nil { - log.Error().Stack().Err(err).Msg("Error setting false guild name in Redis") - } - - return "unknown" - } - - // Cache the guild name in Redis - ctx3, cancel3 := context.WithTimeout(cfg.Ctx, 5*time.Second) - defer cancel3() - cfg.KV.Set(ctx3, "guild:"+guildID+":name", guild.Name, time.Hour*3) - - return guild.Name -} - -// GetChannelName returns the name of a channel by its ID, using Redis for caching. -func GetChannelName(cfg *config.Config, session *discordgo.Session, channelID string) string { - // Create a timeout context for Redis operations - ctx, cancel := context.WithTimeout(cfg.Ctx, 5*time.Second) - defer cancel() - - // Check Redis for the channel name - channelName, err := cfg.KV.Get(ctx, "channel:"+channelID+":name").Result() - if err != nil && err != redis.Nil { - log.Error().Stack().Err(err).Msg("Error getting channel name from Redis") - return "err" - } - - // If the channel name is invalid (1 character long), then return "unknown" - if len(channelName) == 1 { - return "unknown" - } - - // If the channel name isn't in Redis, get it from Discord and cache it - channel, err := session.Channel(channelID) - if err != nil { - log.Error().Stack().Err(err).Msg("Error getting channel name") - - // Store an invalid value in Redis so we don't keep trying to get the channel name - ctx2, cancel2 := context.WithTimeout(cfg.Ctx, 5*time.Second) - defer cancel2() - _, err := cfg.KV.Set(ctx2, "channel:"+channelID+":name", "x", time.Minute*5).Result() - if err != nil { - log.Error().Stack().Err(err).Msg("Error setting false channel name in Redis") - } - - return "unknown" - } - - // Cache the channel name in Redis - ctx3, cancel3 := context.WithTimeout(cfg.Ctx, 5*time.Second) - defer cancel3() - cfg.KV.Set(ctx3, "channel:"+channelID+":name", channel.Name, time.Hour*3) - - return channel.Name -} diff --git a/internal/models/types.go b/internal/models/types.go deleted file mode 100644 index 348abda..0000000 --- a/internal/models/types.go +++ /dev/null @@ -1,323 +0,0 @@ -// Package models provides the data structures for the Banner API. -package models - -import ( - "banner/internal" - "encoding/json" - "fmt" - "strconv" - "strings" - "time" - - log "github.com/rs/zerolog/log" -) - -// FacultyItem represents a faculty member associated with a course. -type FacultyItem struct { - BannerID string `json:"bannerId"` - Category *string `json:"category"` - Class string `json:"class"` - CourseReferenceNumber string `json:"courseReferenceNumber"` - DisplayName string `json:"displayName"` - Email string `json:"emailAddress"` - Primary bool `json:"primaryIndicator"` - Term string `json:"term"` -} - -// MeetingTimeResponse represents the meeting time information for a course. -type MeetingTimeResponse struct { - Category *string `json:"category"` - Class string `json:"class"` - CourseReferenceNumber string `json:"courseReferenceNumber"` - Faculty []FacultyItem - MeetingTime struct { - Category string `json:"category"` - // Some sort of metadata used internally by Banner (net.hedtech.banner.student.schedule.SectionSessionDecorator) - Class string `json:"class"` - // The start date of the meeting time in MM/DD/YYYY format (e.g. 01/16/2024) - StartDate string `json:"startDate"` - // The end date of the meeting time in MM/DD/YYYY format (e.g. 05/10/2024) - EndDate string `json:"endDate"` - // The start time of the meeting time in 24-hour format, hours & minutes, digits only (e.g. 1630) - BeginTime string `json:"beginTime"` - // The end time of the meeting time in 24-hour format, hours & minutes, digits only (e.g. 1745) - EndTime string `json:"endTime"` - // The room number within the building this course takes place at (e.g. 3.01.08, 200A) - Room string `json:"room"` - // The internal identifier for the term this course takes place in (e.g. 202420) - Term string `json:"term"` - // The internal identifier for the building this course takes place at (e.g. SP1) - Building string `json:"building"` - // The long name of the building this course takes place at (e.g. San Pedro I - Data Science) - BuildingDescription string `json:"buildingDescription"` - // The internal identifier for the campus this course takes place at (e.g. 1DT) - Campus string `json:"campus"` - // The long name of the campus this course takes place at (e.g. Main Campus, Downtown Campus) - CampusDescription string `json:"campusDescription"` - CourseReferenceNumber string `json:"courseReferenceNumber"` - // The number of credit hours this class is worth (assumably) - CreditHourSession float64 `json:"creditHourSession"` - // The number of hours per week this class meets (e.g. 2.5) - HoursWeek float64 `json:"hoursWeek"` - // Unknown meaning - e.g. AFF, AIN, AHB, FFF, AFF, EFF, DFF, IFF, EHB, JFF, KFF, BFF, BIN - MeetingScheduleType string `json:"meetingScheduleType"` - // The short identifier for the meeting type (e.g. FF, HB, OS, OA) - MeetingType string `json:"meetingType"` - // The long name of the meeting type (e.g. Traditional in-person) - MeetingTypeDescription string `json:"meetingTypeDescription"` - // A boolean indicating if the class will meet on each Monday of the term - Monday bool `json:"monday"` - // A boolean indicating if the class will meet on each Tuesday of the term - Tuesday bool `json:"tuesday"` - // A boolean indicating if the class will meet on each Wednesday of the term - Wednesday bool `json:"wednesday"` - // A boolean indicating if the class will meet on each Thursday of the term - Thursday bool `json:"thursday"` - // A boolean indicating if the class will meet on each Friday of the term - Friday bool `json:"friday"` - // A boolean indicating if the class will meet on each Saturday of the term - Saturday bool `json:"saturday"` - // A boolean indicating if the class will meet on each Sunday of the term - Sunday bool `json:"sunday"` - } `json:"meetingTime"` - Term string `json:"term"` -} - -// String returns a formatted string representation of the meeting time. -func (m *MeetingTimeResponse) String() string { - switch m.MeetingTime.MeetingType { - case "HB": - return fmt.Sprintf("%s\nHybrid %s", m.TimeString(), m.PlaceString()) - case "H2": - return fmt.Sprintf("%s\nHybrid %s", m.TimeString(), m.PlaceString()) - case "H1": - return fmt.Sprintf("%s\nHybrid %s", m.TimeString(), m.PlaceString()) - case "OS": - return fmt.Sprintf("%s\nOnline Only", m.TimeString()) - case "OA": - return "No Time\nOnline Asynchronous" - case "OH": - return fmt.Sprintf("%s\nOnline Partial", m.TimeString()) - case "ID": - return "To Be Arranged" - case "FF": - return fmt.Sprintf("%s\n%s", m.TimeString(), m.PlaceString()) - } - - // TODO: Add error log - return "Unknown" -} - -// TimeString returns a formatted string of the meeting times (e.g., "MWF 1:00PM-2:15PM"). -func (m *MeetingTimeResponse) TimeString() string { - startTime := m.StartTime() - endTime := m.EndTime() - - if startTime == nil || endTime == nil { - return "???" - } - - return fmt.Sprintf("%s %s-%s", internal.WeekdaysToString(m.Days()), m.StartTime().String(), m.EndTime().String()) -} - -// PlaceString returns a formatted string representing the location of the meeting. -func (m *MeetingTimeResponse) PlaceString() string { - mt := m.MeetingTime - - // TODO: Add format case for partial online classes - if mt.Room == "" { - return "Online" - } - - return fmt.Sprintf("%s | %s | %s %s", mt.CampusDescription, mt.BuildingDescription, mt.Building, mt.Room) -} - -// Days returns a map of weekdays on which the course meets. -func (m *MeetingTimeResponse) Days() map[time.Weekday]bool { - days := map[time.Weekday]bool{} - - days[time.Monday] = m.MeetingTime.Monday - days[time.Tuesday] = m.MeetingTime.Tuesday - days[time.Wednesday] = m.MeetingTime.Wednesday - days[time.Thursday] = m.MeetingTime.Thursday - days[time.Friday] = m.MeetingTime.Friday - days[time.Saturday] = m.MeetingTime.Saturday - - return days -} - -// ByDay returns a comma-separated string of two-letter day abbreviations for the iCalendar RRule. -func (m *MeetingTimeResponse) ByDay() string { - days := []string{} - - if m.MeetingTime.Sunday { - days = append(days, "SU") - } - if m.MeetingTime.Monday { - days = append(days, "MO") - } - if m.MeetingTime.Tuesday { - days = append(days, "TU") - } - if m.MeetingTime.Wednesday { - days = append(days, "WE") - } - if m.MeetingTime.Thursday { - days = append(days, "TH") - } - if m.MeetingTime.Friday { - days = append(days, "FR") - } - if m.MeetingTime.Saturday { - days = append(days, "SA") - } - - return strings.Join(days, ",") -} - -const layout = "01/02/2006" - -// StartDay returns the start date of the meeting as a time.Time object. -// This method is not cached and will panic if the date cannot be parsed. -func (m *MeetingTimeResponse) StartDay() time.Time { - t, err := time.Parse(layout, m.MeetingTime.StartDate) - if err != nil { - log.Panic().Stack().Err(err).Str("raw", m.MeetingTime.StartDate).Msg("Cannot parse start date") - } - return t -} - -// EndDay returns the end date of the meeting as a time.Time object. -// This method is not cached and will panic if the date cannot be parsed. -func (m *MeetingTimeResponse) EndDay() time.Time { - t, err := time.Parse(layout, m.MeetingTime.EndDate) - if err != nil { - log.Panic().Stack().Err(err).Str("raw", m.MeetingTime.EndDate).Msg("Cannot parse end date") - } - return t -} - -// StartTime returns the start time of the meeting as a NaiveTime object. -// This method is not cached and will panic if the time cannot be parsed. -func (m *MeetingTimeResponse) StartTime() *internal.NaiveTime { - raw := m.MeetingTime.BeginTime - if raw == "" { - log.Panic().Stack().Msg("Start time is empty") - } - - value, err := strconv.ParseUint(raw, 10, 32) - if err != nil { - log.Panic().Stack().Err(err).Str("raw", raw).Msg("Cannot parse start time integer") - } - - return internal.ParseNaiveTime(value) -} - -// EndTime returns the end time of the meeting as a NaiveTime object. -// This method is not cached and will panic if the time cannot be parsed. -func (m *MeetingTimeResponse) EndTime() *internal.NaiveTime { - raw := m.MeetingTime.EndTime - if raw == "" { - return nil - } - - value, err := strconv.ParseUint(raw, 10, 32) - if err != nil { - log.Panic().Stack().Err(err).Str("raw", raw).Msg("Cannot parse end time integer") - } - - return internal.ParseNaiveTime(value) -} - -// RRule represents a recurrence rule for an iCalendar event. -type RRule struct { - Until string - ByDay string -} - -// RRule converts the meeting time to a struct that satisfies the iCalendar RRule format. -func (m *MeetingTimeResponse) RRule() RRule { - return RRule{ - Until: m.EndDay().UTC().Format("20060102T150405Z"), - ByDay: m.ByDay(), - } -} - -// SearchResult represents the result of a course search. -type SearchResult struct { - Success bool `json:"success"` - TotalCount int `json:"totalCount"` - PageOffset int `json:"pageOffset"` - PageMaxSize int `json:"pageMaxSize"` - PathMode string `json:"pathMode"` - SearchResultsConfig []struct { - Config string `json:"config"` - Display string `json:"display"` - } `json:"searchResultsConfig"` - Data []Course `json:"data"` -} - -// Course represents a single course returned from a search. -type Course struct { - // ID is an internal identifier not used outside of the Banner system. - ID int `json:"id"` - // Term is the internal identifier for the term this class is in (e.g. 202420). - Term string `json:"term"` - // TermDesc is the human-readable name of the term this class is in (e.g. Fall 2021). - TermDesc string `json:"termDesc"` - // CourseReferenceNumber is the unique identifier for a course within a term. - CourseReferenceNumber string `json:"courseReferenceNumber"` - // PartOfTerm specifies which part of the term the course is in (e.g. B6, B5). - PartOfTerm string `json:"partOfTerm"` - // CourseNumber is the 4-digit code for the course (e.g. 3743). - CourseNumber string `json:"courseNumber"` - // Subject is the subject acronym (e.g. CS, AEPI). - Subject string `json:"subject"` - // SubjectDescription is the full name of the course subject. - SubjectDescription string `json:"subjectDescription"` - // SequenceNumber is the course section (e.g. 001, 002). - SequenceNumber string `json:"sequenceNumber"` - CampusDescription string `json:"campusDescription"` - // ScheduleTypeDescription is the type of schedule for the course (e.g. Lecture, Seminar). - ScheduleTypeDescription string `json:"scheduleTypeDescription"` - CourseTitle string `json:"courseTitle"` - CreditHours int `json:"creditHours"` - // MaximumEnrollment is the maximum number of students that can enroll. - MaximumEnrollment int `json:"maximumEnrollment"` - Enrollment int `json:"enrollment"` - SeatsAvailable int `json:"seatsAvailable"` - WaitCapacity int `json:"waitCapacity"` - WaitCount int `json:"waitCount"` - CrossList *string `json:"crossList"` - CrossListCapacity *int `json:"crossListCapacity"` - CrossListCount *int `json:"crossListCount"` - CrossListAvailable *int `json:"crossListAvailable"` - CreditHourHigh *int `json:"creditHourHigh"` - CreditHourLow *int `json:"creditHourLow"` - CreditHourIndicator *string `json:"creditHourIndicator"` - OpenSection bool `json:"openSection"` - LinkIdentifier *string `json:"linkIdentifier"` - IsSectionLinked bool `json:"isSectionLinked"` - // SubjectCourse is the combination of the subject and course number (e.g. CS3443). - SubjectCourse string `json:"subjectCourse"` - ReservedSeatSummary *string `json:"reservedSeatSummary"` - InstructionalMethod string `json:"instructionalMethod"` - InstructionalMethodDescription string `json:"instructionalMethodDescription"` - SectionAttributes []struct { - // Class is an internal API class identifier used by Banner. - Class string `json:"class"` - CourseReferenceNumber string `json:"courseReferenceNumber"` - // Code for the attribute (e.g., UPPR, ZIEP, AIS). - Code string `json:"code"` - Description string `json:"description"` - TermCode string `json:"termCode"` - IsZtcAttribute bool `json:"isZTCAttribute"` - } `json:"sectionAttributes"` - Faculty []FacultyItem `json:"faculty"` - MeetingsFaculty []MeetingTimeResponse `json:"meetingsFaculty"` -} - -// MarshalBinary implements the encoding.BinaryMarshaler interface. -func (course Course) MarshalBinary() ([]byte, error) { - return json.Marshal(course) -} diff --git a/src/bot/mod.rs b/src/bot/mod.rs new file mode 100644 index 0000000..225fe64 --- /dev/null +++ b/src/bot/mod.rs @@ -0,0 +1,16 @@ +use poise::serenity_prelude as serenity; +pub struct Data {} // User data, which is stored and accessible in all command invocations +pub type Error = Box; +pub type Context<'a> = poise::Context<'a, Data, Error>; + +/// Displays your or another user's account creation date +#[poise::command(slash_command, prefix_command)] +pub async fn age( + ctx: Context<'_>, + #[description = "Selected user"] user: Option, +) -> Result<(), Error> { + let u = user.as_ref().unwrap_or_else(|| ctx.author()); + let response = format!("{}'s account was created at {}", u.name, u.created_at()); + ctx.say(response).await?; + Ok(()) +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..1cbfd96 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1 @@ +pub mod bot; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..5e425b2 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,342 @@ +use serde::Deserialize; +use serenity::all::{ClientBuilder, GatewayIntents}; +use std::time::Duration; +use tokio::{signal, sync::broadcast, task::JoinSet}; +use tracing::{error, info, warn}; +use tracing_subscriber::{EnvFilter, FmtSubscriber}; + +use crate::bot::{Data, age}; +use figment::{Figment, providers::Env}; + +#[derive(Deserialize)] +struct Config { + bot_token: String, + database_url: String, + redis_url: String, + banner_base_url: String, + bot_target_guild: u64, + bot_app_id: u64, +} + +mod bot; + +#[derive(Debug)] +enum ServiceResult { + GracefulShutdown, + NormalCompletion, + Error(Box), +} + +/// Common trait for all services in the application +#[async_trait::async_trait] +trait Service: Send + Sync { + /// The name of the service for logging + fn name(&self) -> &'static str; + + /// Run the service's main work loop + async fn run(&mut self) -> Result<(), Box>; + + /// Gracefully shutdown the service + async fn shutdown(&mut self) -> Result<(), Box>; +} + +/// Generic service runner that handles the lifecycle +async fn run_service( + mut service: Box, + mut shutdown_rx: broadcast::Receiver<()>, +) -> ServiceResult { + let name = service.name(); + info!(service = name, "Service started"); + + let work = async { + match service.run().await { + Ok(()) => { + warn!(service = name, "Service completed unexpectedly"); + ServiceResult::NormalCompletion + } + Err(e) => { + error!(service = name, "Service failed: {e}"); + ServiceResult::Error(e) + } + } + }; + + tokio::select! { + result = work => result, + _ = shutdown_rx.recv() => { + info!(service = name, "Shutting down..."); + let start_time = std::time::Instant::now(); + + match service.shutdown().await { + Ok(()) => { + let elapsed = start_time.elapsed(); + info!(service = name, "Shutdown completed in {elapsed:.2?}"); + ServiceResult::GracefulShutdown + } + Err(e) => { + let elapsed = start_time.elapsed(); + error!(service = name, "Shutdown failed after {elapsed:.2?}: {e}"); + ServiceResult::Error(e) + } + } + } + } +} + +/// Shutdown coordinator for managing graceful shutdown of multiple services +struct ShutdownCoordinator { + shutdown_tx: broadcast::Sender<()>, +} + +impl ShutdownCoordinator { + fn new() -> Self { + let (shutdown_tx, _) = broadcast::channel(1); + Self { shutdown_tx } + } + + fn subscribe(&self) -> broadcast::Receiver<()> { + self.shutdown_tx.subscribe() + } + + fn shutdown(&self) { + let _ = self.shutdown_tx.send(()); + } +} + +/// Discord bot service implementation +struct BotService { + client: serenity::Client, + shard_manager: std::sync::Arc, +} + +impl BotService { + fn new(client: serenity::Client) -> Self { + let shard_manager = client.shard_manager.clone(); + Self { + client, + shard_manager, + } + } +} + +#[async_trait::async_trait] +impl Service for BotService { + fn name(&self) -> &'static str { + "bot" + } + + async fn run(&mut self) -> Result<(), Box> { + match self.client.start().await { + Ok(()) => { + warn!(service = "bot", "Stopped early."); + Err("bot stopped early".into()) + } + Err(e) => { + error!(service = "bot", "Error: {e:?}"); + Err(e.into()) + } + } + } + + async fn shutdown(&mut self) -> Result<(), Box> { + self.shard_manager.shutdown_all().await; + Ok(()) + } +} + +/// Dummy service implementation for demonstration +struct DummyService { + name: &'static str, +} + +impl DummyService { + fn new(name: &'static str) -> Self { + Self { name } + } +} + +#[async_trait::async_trait] +impl Service for DummyService { + fn name(&self) -> &'static str { + self.name + } + + async fn run(&mut self) -> Result<(), Box> { + let mut counter = 0; + loop { + tokio::time::sleep(Duration::from_secs(10)).await; + counter += 1; + info!(service = self.name, "Service heartbeat ({counter})"); + + // Simulate service failure after 60 seconds for demo + if counter >= 6 { + error!(service = self.name, "Service encountered an error"); + return Err("Service error".into()); + } + } + } + + async fn shutdown(&mut self) -> Result<(), Box> { + // Simulate cleanup work + tokio::time::sleep(Duration::from_millis(3500)).await; + Ok(()) + } +} + +#[tokio::main] +async fn main() { + dotenvy::dotenv().ok(); + + // Configure logging + let filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn,banner=debug")); + let subscriber = FmtSubscriber::builder().with_env_filter(filter).finish(); + tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); + + let config: Config = Figment::new() + .merge(Env::prefixed("APP_")) + .extract() + .expect("Failed to load config"); + + // Configure the client with your Discord bot token in the environment. + let intents = GatewayIntents::non_privileged(); + + let framework = poise::Framework::builder() + .options(poise::FrameworkOptions { + commands: vec![age()], + ..Default::default() + }) + .setup(|ctx, _ready, framework| { + Box::pin(async move { + poise::builtins::register_globally(ctx, &framework.options().commands).await?; + Ok(Data {}) + }) + }) + .build(); + + let client = ClientBuilder::new(config.bot_token, intents) + .framework(framework) + .await + .expect("Failed to build client"); + + let shutdown_coordinator = ShutdownCoordinator::new(); + + // Create services + let bot_service = Box::new(BotService::new(client)); + let dummy_service = Box::new(DummyService::new("background")); + + // Start services using the unified runner + let bot_handle = { + let shutdown_rx = shutdown_coordinator.subscribe(); + tokio::spawn(run_service(bot_service, shutdown_rx)) + }; + + let dummy_handle = { + let shutdown_rx = shutdown_coordinator.subscribe(); + tokio::spawn(run_service(dummy_service, shutdown_rx)) + }; + + // Set up signal handling + let signal_handle = { + let coordinator = shutdown_coordinator.shutdown_tx.clone(); + tokio::spawn(async move { + signal::ctrl_c() + .await + .expect("Failed to install CTRL+C signal handler"); + info!("Received CTRL+C, initiating shutdown..."); + let _ = coordinator.send(()); + ServiceResult::GracefulShutdown + }) + }; + + // Put all services in a JoinSet for unified handling + let mut services = JoinSet::new(); + services.spawn(bot_handle); + services.spawn(dummy_handle); + services.spawn(signal_handle); + + // Wait for any service to complete or signal + let mut exit_code = 0; + let first_completion = services.join_next().await; + + let service_result = match first_completion { + Some(Ok(Ok(service_result))) => { + // A service completed successfully + match &service_result { + ServiceResult::GracefulShutdown => { + // This means CTRL+C was pressed + } + ServiceResult::NormalCompletion => { + warn!("A service completed unexpectedly"); + exit_code = 1; + } + ServiceResult::Error(e) => { + error!("Service failure: {e}"); + exit_code = 1; + } + } + service_result + } + Some(Ok(Err(e))) => { + error!("Service task panicked: {e}"); + exit_code = 1; + ServiceResult::Error("Task panic".into()) + } + Some(Err(e)) => { + error!("JoinSet error: {e}"); + exit_code = 1; + ServiceResult::Error("JoinSet error".into()) + } + None => { + warn!("No services running"); + exit_code = 1; + ServiceResult::Error("No services".into()) + } + }; + + // Signal all services to shut down + shutdown_coordinator.shutdown(); + + // Wait for graceful shutdown with timeout + let remaining_count = services.len(); + if remaining_count > 0 { + info!("Waiting for {remaining_count} remaining services to shutdown (5s timeout)..."); + let shutdown_result = tokio::time::timeout(Duration::from_secs(5), async { + while let Some(result) = services.join_next().await { + match result { + Ok(Ok(ServiceResult::GracefulShutdown)) => { + // Service shutdown logged by the service itself + } + Ok(Ok(ServiceResult::NormalCompletion)) => { + warn!("Service completed normally during shutdown"); + } + Ok(Ok(ServiceResult::Error(e))) => { + error!("Service error during shutdown: {e}"); + } + Ok(Err(e)) => { + error!("Service panic during shutdown: {e}"); + } + Err(e) => { + error!("Service join error: {e}"); + } + } + } + }) + .await; + + match shutdown_result { + Ok(()) => { + info!("All services shutdown completed"); + } + Err(_) => { + warn!("Shutdown timeout - some services may not have completed"); + exit_code = if exit_code == 0 { 2 } else { exit_code }; + } + } + } else { + info!("No remaining services to shutdown"); + } + + info!("Application shutdown complete (exit code: {})", exit_code); + std::process::exit(exit_code); +} diff --git a/tests/config_test.go b/tests/config_test.go deleted file mode 100644 index b012f2f..0000000 --- a/tests/config_test.go +++ /dev/null @@ -1,229 +0,0 @@ -package config_test - -import ( - "banner/internal/config" - "testing" - "time" -) - -func TestGetCurrentTerm(t *testing.T) { - // Initialize location for testing - loc, _ := time.LoadLocation("America/Chicago") - - // Use current year to avoid issues with global state - currentYear := uint16(time.Now().Year()) - ranges := config.GetYearDayRange(loc, currentYear) - - tests := []struct { - name string - date time.Time - expectedCurrent *config.Term - expectedNext *config.Term - }{ - { - name: "Spring term", - date: time.Date(int(currentYear), 3, 15, 12, 0, 0, 0, loc), - expectedCurrent: &config.Term{Year: currentYear, Season: config.Spring}, - expectedNext: &config.Term{Year: currentYear, Season: config.Summer}, - }, - { - name: "Summer term", - date: time.Date(int(currentYear), 6, 15, 12, 0, 0, 0, loc), - expectedCurrent: &config.Term{Year: currentYear, Season: config.Summer}, - expectedNext: &config.Term{Year: currentYear, Season: config.Fall}, - }, - { - name: "Fall term", - date: time.Date(int(currentYear), 9, 15, 12, 0, 0, 0, loc), - expectedCurrent: &config.Term{Year: currentYear + 1, Season: config.Fall}, - expectedNext: nil, - }, - { - name: "Between Spring and Summer", - date: time.Date(int(currentYear), 5, 20, 12, 0, 0, 0, loc), - expectedCurrent: nil, - expectedNext: &config.Term{Year: currentYear, Season: config.Summer}, - }, - { - name: "Between Summer and Fall", - date: time.Date(int(currentYear), 8, 16, 12, 0, 0, 0, loc), - expectedCurrent: nil, - expectedNext: &config.Term{Year: currentYear + 1, Season: config.Fall}, - }, - { - name: "Between Fall and Spring", - date: time.Date(int(currentYear), 12, 15, 12, 0, 0, 0, loc), - expectedCurrent: nil, - expectedNext: &config.Term{Year: currentYear + 1, Season: config.Spring}, - }, - { - name: "Early January before Spring", - date: time.Date(int(currentYear), 1, 10, 12, 0, 0, 0, loc), - expectedCurrent: nil, - expectedNext: &config.Term{Year: currentYear, Season: config.Spring}, - }, - { - name: "Spring start date", - date: time.Date(int(currentYear), 1, 14, 0, 0, 0, 0, loc), - expectedCurrent: &config.Term{Year: currentYear, Season: config.Spring}, - expectedNext: &config.Term{Year: currentYear, Season: config.Summer}, - }, - { - name: "Summer start date", - date: time.Date(int(currentYear), 5, 25, 0, 0, 0, 0, loc), - expectedCurrent: &config.Term{Year: currentYear, Season: config.Summer}, - expectedNext: &config.Term{Year: currentYear, Season: config.Fall}, - }, - { - name: "Fall start date", - date: time.Date(int(currentYear), 8, 18, 0, 0, 0, 0, loc), - expectedCurrent: &config.Term{Year: currentYear + 1, Season: config.Fall}, - expectedNext: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - current, next := config.GetCurrentTerm(ranges, tt.date) - - if !termsEqual(current, tt.expectedCurrent) { - t.Errorf("GetCurrentTerm() current = %v, want %v", current, tt.expectedCurrent) - } - - if !termsEqual(next, tt.expectedNext) { - t.Errorf("GetCurrentTerm() next = %v, want %v", next, tt.expectedNext) - } - }) - } -} - -func TestGetYearDayRange(t *testing.T) { - loc, _ := time.LoadLocation("America/Chicago") - - ranges := config.GetYearDayRange(loc, 2024) - - // Verify Spring range (Jan 14 to May 1) - expectedSpringStart := time.Date(2024, 1, 14, 0, 0, 0, 0, loc).YearDay() - expectedSpringEnd := time.Date(2024, 5, 1, 0, 0, 0, 0, loc).YearDay() - - if ranges.Spring.Start != uint16(expectedSpringStart) { - t.Errorf("Spring start = %d, want %d", ranges.Spring.Start, expectedSpringStart) - } - if ranges.Spring.End != uint16(expectedSpringEnd) { - t.Errorf("Spring end = %d, want %d", ranges.Spring.End, expectedSpringEnd) - } - - // Verify Summer range (May 25 to Aug 15) - expectedSummerStart := time.Date(2024, 5, 25, 0, 0, 0, 0, loc).YearDay() - expectedSummerEnd := time.Date(2024, 8, 15, 0, 0, 0, 0, loc).YearDay() - - if ranges.Summer.Start != uint16(expectedSummerStart) { - t.Errorf("Summer start = %d, want %d", ranges.Summer.Start, expectedSummerStart) - } - if ranges.Summer.End != uint16(expectedSummerEnd) { - t.Errorf("Summer end = %d, want %d", ranges.Summer.End, expectedSummerEnd) - } - - // Verify Fall range (Aug 18 to Dec 10) - expectedFallStart := time.Date(2024, 8, 18, 0, 0, 0, 0, loc).YearDay() - expectedFallEnd := time.Date(2024, 12, 10, 0, 0, 0, 0, loc).YearDay() - - if ranges.Fall.Start != uint16(expectedFallStart) { - t.Errorf("Fall start = %d, want %d", ranges.Fall.Start, expectedFallStart) - } - if ranges.Fall.End != uint16(expectedFallEnd) { - t.Errorf("Fall end = %d, want %d", ranges.Fall.End, expectedFallEnd) - } -} - -func TestParseTerm(t *testing.T) { - tests := []struct { - code string - expected config.Term - }{ - {"202410", config.Term{Year: 2024, Season: config.Fall}}, - {"202420", config.Term{Year: 2024, Season: config.Spring}}, - {"202430", config.Term{Year: 2024, Season: config.Summer}}, - {"202510", config.Term{Year: 2025, Season: config.Fall}}, - } - - for _, tt := range tests { - t.Run(tt.code, func(t *testing.T) { - result := config.ParseTerm(tt.code) - if result != tt.expected { - t.Errorf("ParseTerm(%s) = %v, want %v", tt.code, result, tt.expected) - } - }) - } -} - -func TestTermToString(t *testing.T) { - tests := []struct { - term config.Term - expected string - }{ - {config.Term{Year: 2024, Season: config.Fall}, "202410"}, - {config.Term{Year: 2024, Season: config.Spring}, "202420"}, - {config.Term{Year: 2024, Season: config.Summer}, "202430"}, - {config.Term{Year: 2025, Season: config.Fall}, "202510"}, - } - - for _, tt := range tests { - t.Run(tt.expected, func(t *testing.T) { - result := tt.term.ToString() - if result != tt.expected { - t.Errorf("Term{Year: %d, Season: %d}.ToString() = %s, want %s", - tt.term.Year, tt.term.Season, result, tt.expected) - } - }) - } -} - -func TestDefaultTerm(t *testing.T) { - loc, _ := time.LoadLocation("America/Chicago") - ranges := config.GetYearDayRange(loc, 2024) - - tests := []struct { - name string - date time.Time - expected config.Term - }{ - { - name: "During Spring term", - date: time.Date(2024, 3, 15, 12, 0, 0, 0, loc), - expected: config.Term{Year: 2024, Season: config.Spring}, - }, - { - name: "Between terms - returns next term", - date: time.Date(2024, 5, 20, 12, 0, 0, 0, loc), - expected: config.Term{Year: 2024, Season: config.Summer}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - currentTerm, nextTerm := config.GetCurrentTerm(ranges, tt.date) - var result config.Term - if currentTerm == nil { - result = *nextTerm - } else { - result = *currentTerm - } - - if result != tt.expected { - t.Errorf("DefaultTerm() = %v, want %v", result, tt.expected) - } - }) - } -} - -// Helper function to compare terms, handling nil cases -func termsEqual(a, b *config.Term) bool { - if a == nil && b == nil { - return true - } - if a == nil || b == nil { - return false - } - return *a == *b -}