diff --git a/.github/workflows/dotnet-docker.yml b/.github/workflows/dotnet-docker.yml index 9939320b..3ad3b108 100644 --- a/.github/workflows/dotnet-docker.yml +++ b/.github/workflows/dotnet-docker.yml @@ -1,23 +1,23 @@ name: Build and push Docker image on: + workflow_dispatch: push: paths: - - '.dockerignore' - - '.github/workflows/dotnet-docker.yml' - - 'ci/Dockerfile.dotnet' - - 'ci/dotnet-version.sh' - - 'Myriad/**' - - 'PluralKit.API/**' - - 'PluralKit.Bot/**' - - 'PluralKit.Core/**' + - ".dockerignore" + - ".github/workflows/dotnet-docker.yml" + - "ci/Dockerfile.dotnet" + - "ci/dotnet-version.sh" + - "Myriad/**" + - "PluralKit.API/**" + - "PluralKit.Bot/**" + - "PluralKit.Core/**" jobs: build: - name: '.net docker build' + name: ".net docker build" runs-on: ubuntu-latest permissions: packages: write - if: github.repository == 'PluralKit/PluralKit' steps: - uses: docker/login-action@v1 with: diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 996a7cb7..1a0d852e 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -3,22 +3,22 @@ name: .net checks on: push: paths: - - .github/workflows/dotnet.yml - - 'Myriad/**' - - 'PluralKit.API/**' - - 'PluralKit.Bot/**' - - 'PluralKit.Core/**' + - .github/workflows/dotnet.yml + - "Myriad/**" + - "PluralKit.API/**" + - "PluralKit.Bot/**" + - "PluralKit.Core/**" pull_request: paths: - - .github/workflows/dotnet.yml - - 'Myriad/**' - - 'PluralKit.API/**' - - 'PluralKit.Bot/**' - - 'PluralKit.Core/**' + - .github/workflows/dotnet.yml + - "Myriad/**" + - "PluralKit.API/**" + - "PluralKit.Bot/**" + - "PluralKit.Core/**" jobs: test: - name: 'run .net tests' + name: "run .net tests" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -30,6 +30,19 @@ jobs: with: dotnet-version: 8.0.x + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + rustflags: "" + + - name: install uniffi + run: cargo install uniffi-bindgen-cs --git https://github.com/90-008/uniffi-bindgen-cs + + - name: generate command parser bindings + run: | + cargo -Z unstable-options build --package commands --lib --release --artifact-dir obj/ + uniffi-bindgen-cs "obj/libcommands.so" --library --out-dir="./PluralKit.Bot" + cargo run --package commands --bin write_cs_glue -- "./PluralKit.Bot"/commandtypes.cs + - name: Run automated tests run: dotnet test --configuration Release diff --git a/.github/workflows/rust-docker.yml b/.github/workflows/rust-docker.yml index a46adf67..cde407f7 100644 --- a/.github/workflows/rust-docker.yml +++ b/.github/workflows/rust-docker.yml @@ -1,22 +1,22 @@ name: Build and push Rust service Docker images on: + workflow_dispatch: push: paths: - - 'crates/**' - - '.dockerignore' - - '.github/workflows/rust.yml' - - 'ci/Dockerfile.rust' - - 'ci/rust-docker-target.sh' - - 'Cargo.toml' - - 'Cargo.lock' + - "crates/**" + - ".dockerignore" + - ".github/workflows/rust.yml" + - "ci/Dockerfile.rust" + - "ci/rust-docker-target.sh" + - "Cargo.toml" + - "Cargo.lock" jobs: build: - name: 'rust docker build' + name: "rust docker build" runs-on: ubuntu-latest permissions: packages: write - if: github.repository == 'PluralKit/PluralKit' steps: - uses: docker/login-action@v1 if: ${{ !env.ACT }} @@ -35,7 +35,7 @@ jobs: # https://github.com/docker/build-push-action/issues/378 context: . file: ci/Dockerfile.rust - push: false + push: false cache-from: type=registry,ref=ghcr.io/pluralkit/docker-cache:rust cache-to: type=registry,ref=ghcr.io/pluralkit/docker-cache:rust,mode=max outputs: .docker-bin diff --git a/.gitignore b/.gitignore index e088092e..b72e1aaa 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ target/ .idea/ .run/ .vscode/ +.zed/ .mono/ tags/ .DS_Store @@ -31,6 +32,8 @@ logs/ .version recipe.json .docker-bin/ +PluralKit.Bot/commands.cs +PluralKit.Bot/commandtypes.cs # nix .nix-process-compose diff --git a/Cargo.lock b/Cargo.lock index 0bb714cb..c8afef88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,18 +4,18 @@ version = 4 [[package]] name = "addr2line" -version = "0.24.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ "gimli", ] [[package]] name = "adler2" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "ahash" @@ -23,21 +23,21 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", "once_cell", "version_check", ] [[package]] name = "ahash" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "once_cell", "version_check", - "zerocopy 0.7.35", + "zerocopy", ] [[package]] @@ -55,12 +55,6 @@ 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" @@ -72,9 +66,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.97" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "api" @@ -83,14 +77,14 @@ dependencies = [ "anyhow", "axum 0.8.4", "fred", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-util", "lazy_static", "libpk", "metrics", "pk_macros", "pluralkit_models", - "reqwest 0.12.15", + "reqwest 0.12.23", "reverse-proxy-service", "serde", "serde_json", @@ -99,7 +93,7 @@ dependencies = [ "subtle", "tokio", "tower 0.4.13", - "tower-http", + "tower-http 0.5.2", "tracing", "twilight-http", ] @@ -137,10 +131,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] -name = "async-trait" -version = "0.1.88" +name = "askama" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7" +dependencies = [ + "askama_derive", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn", +] + +[[package]] +name = "askama_parser" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", @@ -180,9 +216,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "avatars" @@ -196,7 +232,7 @@ dependencies = [ "gif", "image", "libpk", - "reqwest 0.12.15", + "reqwest 0.12.23", "rust-s3", "serde", "sha2", @@ -217,7 +253,7 @@ dependencies = [ "attohttpc", "dirs", "log", - "quick-xml", + "quick-xml 0.26.0", "rust-ini 0.18.0", "serde", "thiserror 1.0.69", @@ -227,9 +263,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.13.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b756939cb2f8dc900aa6dcd505e6e2428e9cae7ff7b028c49e3946efa70878" +checksum = "879b6c89592deb404ba4dc0ae6b58ffd1795c78991cbb5b8bc441c48a070440d" dependencies = [ "aws-lc-sys", "zeroize", @@ -237,15 +273,16 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.28.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9f7720b74ed28ca77f90769a71fd8c637a0137f6fae4ae947e1050229cff57f" +checksum = "ee74396bee4da70c2e27cf94762714c911725efe69d9e2672f998512a67a4ce4" dependencies = [ "bindgen", "cc", "cmake", "dunce", "fs_extra", + "libloading", ] [[package]] @@ -301,7 +338,7 @@ dependencies = [ "http 1.3.1", "http-body 1.0.1", "http-body-util", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-util", "itoa", "matchit 0.8.4", @@ -360,9 +397,9 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.74" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", "cfg-if", @@ -370,7 +407,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets 0.52.6", + "windows-link 0.2.0", ] [[package]] @@ -393,31 +430,37 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.7.3" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] [[package]] name = "bindgen" -version = "0.69.5" +version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "cexpr", "clang-sys", "itertools", - "lazy_static", - "lazycell", "log", "prettyplease", "proc-macro2", "quote", "regex", - "rustc-hash 1.1.0", + "rustc-hash", "shlex", "syn", - "which", ] [[package]] @@ -428,9 +471,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.0" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" dependencies = [ "serde", ] @@ -445,16 +488,25 @@ dependencies = [ ] [[package]] -name = "bumpalo" -version = "3.17.0" +name = "borsh" +version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" +checksum = "ad8646f98db542e39fc66e68a20b2144f6a732636df7c2354e74645faaa433ce" +dependencies = [ + "cfg_aliases", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "bytemuck" -version = "1.22.0" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540" +checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" [[package]] name = "byteorder" @@ -485,11 +537,44 @@ dependencies = [ ] [[package]] -name = "cc" -version = "1.2.17" +name = "camino" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fcb57c740ae1daf453ae85f16e37396f672b039e00d9d866e07ddb24e328e3a" +checksum = "e1de8bc0aa9e9385ceb3bf0c152e3a9b9544f6c4a912c8ae504e80c1f0368603" dependencies = [ + "serde_core", +] + +[[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.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.16", +] + +[[package]] +name = "cc" +version = "1.2.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1354349954c6fc9cb0deab020f27f783cf0b604e8bb754dc4658ecf0d29c35f" +dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -512,9 +597,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "cfg_aliases" @@ -524,17 +609,16 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.40" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.0", ] [[package]] @@ -563,6 +647,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + [[package]] name = "combine" version = "4.6.7" @@ -573,6 +667,37 @@ dependencies = [ "memchr", ] +[[package]] +name = "command_definitions" +version = "0.1.0" +dependencies = [ + "command_parser", +] + +[[package]] +name = "command_parser" +version = "0.1.0" +dependencies = [ + "lazy_static", + "log", + "ordermap", + "regex", + "smol_str", + "strsim", +] + +[[package]] +name = "commands" +version = "0.1.0" +dependencies = [ + "command_definitions", + "command_parser", + "lazy_static", + "log", + "simple_logger", + "uniffi", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -597,7 +722,7 @@ dependencies = [ "rust-ini 0.20.0", "serde", "serde_json", - "toml", + "toml 0.8.23", "yaml-rust2", ] @@ -622,7 +747,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", "once_cell", "tiny-keccak", ] @@ -654,9 +779,9 @@ dependencies = [ [[package]] name = "core-foundation" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ "core-foundation-sys", "libc", @@ -679,9 +804,9 @@ dependencies = [ [[package]] name = "crc" -version = "3.2.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" dependencies = [ "crc-catalog", ] @@ -700,18 +825,18 @@ checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff" [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] [[package]] name = "croner" -version = "2.1.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38fd53511eaf0b00a185613875fee58b208dfce016577d0ad4bb548e1c4fb3ee" +checksum = "c344b0690c1ad1c7176fe18eb173e0c927008fdaaa256e40dfd43ddd149c0843" dependencies = [ "chrono", ] @@ -742,9 +867,9 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" @@ -806,9 +931,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.8.0" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "575f75dfd25738df5b91b8e43e14d44bda14637a58fae779fd2b064f8bf3e010" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" [[package]] name = "debugid" @@ -822,9 +947,9 @@ dependencies = [ [[package]] name = "der" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", "pem-rfc7468", @@ -833,12 +958,12 @@ dependencies = [ [[package]] name = "deranged" -version = "0.4.1" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28cfac68e08048ae1883171632c2aef3ebc555621ae56fbccce1cbf22dd7f058" +checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" dependencies = [ "powerfmt", - "serde", + "serde_core", ] [[package]] @@ -881,7 +1006,7 @@ dependencies = [ "axum 0.8.4", "hickory-client", "libpk", - "reqwest 0.12.15", + "reqwest 0.12.23", "serde", "serde_json", "tokio", @@ -971,12 +1096,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.11" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.1", ] [[package]] @@ -992,9 +1117,9 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", @@ -1007,6 +1132,26 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -1016,6 +1161,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" + [[package]] name = "findshlibs" version = "0.10.2" @@ -1030,9 +1181,9 @@ dependencies = [ [[package]] name = "flate2" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" dependencies = [ "crc32fast", "miniz_oxide", @@ -1072,9 +1223,9 @@ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -1099,7 +1250,7 @@ dependencies = [ "redis-protocol", "semver", "sha-1", - "socket2 0.5.9", + "socket2 0.5.10", "tokio", "tokio-stream", "tokio-util", @@ -1119,6 +1270,15 @@ dependencies = [ "syn", ] +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1238,7 +1398,7 @@ dependencies = [ "lazy_static", "libpk", "metrics", - "reqwest 0.12.15", + "reqwest 0.12.23", "serde", "serde_json", "serde_variant", @@ -1278,36 +1438,36 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasi 0.14.7+wasi-0.2.4", "wasm-bindgen", ] [[package]] name = "gif" -version = "0.13.1" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" dependencies = [ "color_quant", "weezl", @@ -1315,21 +1475,32 @@ dependencies = [ [[package]] name = "gimli" -version = "0.31.1" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "glob" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] [[package]] name = "h2" -version = "0.3.26" +version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" dependencies = [ "bytes", "fnv", @@ -1346,9 +1517,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.8" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5017294ff4bb30944501348f6f8e42e6ad28f42c8bbef7a74029aff064a4e3c2" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" dependencies = [ "atomic-waker", "bytes", @@ -1363,6 +1534,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +dependencies = [ + "cfg-if", + "crunchy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1378,21 +1559,27 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "ahash 0.8.11", + "ahash 0.8.12", "allocator-api2", ] [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + [[package]] name = "hashlink" version = "0.8.4" @@ -1408,7 +1595,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.15.2", + "hashbrown 0.15.5", ] [[package]] @@ -1425,9 +1612,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.3.9" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "hex" @@ -1507,13 +1694,13 @@ dependencies = [ [[package]] name = "hostname" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9c7c7c8ac16c798734b8a24560c1362120597c40d5e1459f09498f8f6c8f2ba" +checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" dependencies = [ "cfg-if", "libc", - "windows", + "windows-link 0.1.3", ] [[package]] @@ -1594,14 +1781,14 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2 0.3.26", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", "httparse", "httpdate", "itoa", "pin-project-lite", - "socket2 0.5.9", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -1610,20 +1797,22 @@ dependencies = [ [[package]] name = "hyper" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", - "h2 0.4.8", + "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", @@ -1645,38 +1834,41 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.5" +version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "futures-util", "http 1.3.1", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-util", - "rustls 0.23.25", + "rustls 0.23.32", "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.2", + "tokio-rustls 0.26.3", "tower-service", - "webpki-roots 0.26.8", + "webpki-roots 1.0.2", ] [[package]] name = "hyper-util" -version = "0.1.11" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ + "base64 0.22.1", "bytes", "futures-channel", + "futures-core", "futures-util", "http 1.3.1", "http-body 1.0.1", - "hyper 1.6.0", + "hyper 1.7.0", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", - "socket2 0.5.9", + "socket2 0.6.0", "tokio", "tower-service", "tracing", @@ -1684,9 +1876,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1694,7 +1886,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.0", + "windows-core", ] [[package]] @@ -1708,21 +1900,22 @@ dependencies = [ [[package]] name = "icu_collections" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" dependencies = [ "displaydoc", + "potential_utf", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locid" -version = "1.5.0" +name = "icu_locale_core" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" dependencies = [ "displaydoc", "litemap", @@ -1731,31 +1924,11 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" - [[package]] name = "icu_normalizer" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" dependencies = [ "displaydoc", "icu_collections", @@ -1763,67 +1936,54 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", - "utf16_iter", - "utf8_iter", - "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "1.5.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" [[package]] name = "icu_properties" -version = "1.5.1" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" dependencies = [ "displaydoc", "icu_collections", - "icu_locid_transform", + "icu_locale_core", "icu_properties_data", "icu_provider", - "tinystr", + "potential_utf", + "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "1.5.1" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" [[package]] name = "icu_provider" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" dependencies = [ "displaydoc", - "icu_locid", - "icu_provider_macros", + "icu_locale_core", "stable_deref_trait", "tinystr", "writeable", "yoke", "zerofrom", + "zerotrie", "zerovec", ] -[[package]] -name = "icu_provider_macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "ident_case" version = "1.0.1" @@ -1832,9 +1992,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -1843,9 +2003,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -1853,15 +2013,16 @@ dependencies = [ [[package]] name = "image" -version = "0.25.6" +version = "0.25.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" +checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" dependencies = [ "bytemuck", "byteorder-lite", "color_quant", "gif", "image-webp", + "moxcms", "num-traits", "png", "tiff", @@ -1881,19 +2042,19 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.8.0" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3954d50fe15b02142bf25d3b8bdadb634ec3948f103d04ffe3031bc8fe9d7058" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.15.2", + "hashbrown 0.16.0", ] [[package]] name = "inherent" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c38228f24186d9cc68c729accb4d413be9eaed6ad07ff79e0270d9e56f3de13" +checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" dependencies = [ "proc-macro2", "quote", @@ -1906,7 +2067,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "cfg-if", "libc", ] @@ -1918,10 +2079,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] -name = "itertools" -version = "0.12.1" +name = "iri-string" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] @@ -1956,25 +2127,19 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "jobserver" -version = "0.1.33" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.3.3", "libc", ] -[[package]] -name = "jpeg-decoder" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" - [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" dependencies = [ "once_cell", "wasm-bindgen", @@ -1982,15 +2147,15 @@ dependencies = [ [[package]] name = "json-subscriber" -version = "0.2.4" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a04191d0d2a8409d99dccd5642abf91197a53fd661c6611ddb1f87751802d449" +checksum = "39891c6f2a8e066540984e1050747baa3ad274fd416d2e79dec79f91c6221c33" dependencies = [ "serde", "serde_json", "tracing", "tracing-core", - "tracing-serde 0.1.3", + "tracing-serde", "tracing-subscriber", "uuid", ] @@ -2015,33 +2180,27 @@ dependencies = [ "spin 0.9.8", ] -[[package]] -name = "lazycell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" - [[package]] name = "libc" -version = "0.2.175" +version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" [[package]] name = "libloading" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" +checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.52.6", + "windows-targets 0.53.4", ] [[package]] name = "libm" -version = "0.2.11" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libpk" @@ -2069,12 +2228,13 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.3" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "libc", + "redox_syscall", ] [[package]] @@ -2099,27 +2259,21 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.4.15" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - -[[package]] -name = "linux-raw-sys" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe7db12097d22ec582439daf8618b8fdd1a7bef6270e9af3b1ebcd30893cf413" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" dependencies = [ "autocfg", "scopeguard", @@ -2127,9 +2281,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "matchers" @@ -2181,17 +2341,17 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "memchr" -version = "2.7.4" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "metrics" -version = "0.23.0" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "884adb57038347dfbaf2d5065887b6cf4312330dc8e94bc30a1a839bd79d3261" +checksum = "3045b4193fbdc5b5681f32f11070da9be3609f189a79f3390706d42587f46bb5" dependencies = [ - "ahash 0.8.11", + "ahash 0.8.12", "portable-atomic", ] @@ -2203,7 +2363,7 @@ checksum = "b4f0c8427b39666bf970460908b213ec09b3b350f20c0c2eabcbba51704a08e6" dependencies = [ "base64 0.22.1", "http-body-util", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-util", "indexmap", "ipnet", @@ -2255,9 +2415,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.7" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff70ce3e48ae43fa075863cef62e8b43b71a4f2382229920e0df362592919430" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", "simd-adler32", @@ -2265,13 +2425,23 @@ dependencies = [ [[package]] name = "mio" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "moxcms" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd32fa8935aeadb8a8a6b6b351e40225570a37c43de67690383d87ef170cd08" +dependencies = [ + "num-traits", + "pxfm", ] [[package]] @@ -2367,19 +2537,28 @@ dependencies = [ [[package]] name = "num_cpus" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ "hermit-abi", "libc", ] [[package]] -name = "object" -version = "0.36.7" +name = "num_threads" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "memchr", ] @@ -2426,12 +2605,22 @@ dependencies = [ ] [[package]] -name = "os_info" -version = "3.10.0" +name = "ordermap" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a604e53c24761286860eba4e2c8b23a0161526476b1de520139d69cdb85a6b5" +checksum = "b100f7dd605611822d30e182214d3c02fdefce2d801d23993f6b6ba6ca1392af" +dependencies = [ + "indexmap", +] + +[[package]] +name = "os_info" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0e1ac5fde8d43c34139135df8ea9ee9465394b2d8d20f032d38998f64afffc3" dependencies = [ "log", + "plist", "serde", "windows-sys 0.52.0", ] @@ -2444,9 +2633,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.3" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" dependencies = [ "lock_api", "parking_lot_core", @@ -2454,9 +2643,9 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", @@ -2482,26 +2671,26 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" +checksum = "21e0a3a33733faeaf8651dfee72dd0f388f0c8e5ad496a3478fa5a922f49cfa8" dependencies = [ "memchr", - "thiserror 2.0.12", + "thiserror 2.0.16", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d725d9cfd79e87dccc9341a2ef39d1b6f6353d68c4b33c177febbe1a402c97c5" +checksum = "bc58706f770acb1dbd0973e6530a3cff4746fb721207feb3a8a6064cd0b6c663" dependencies = [ "pest", "pest_generator", @@ -2509,9 +2698,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db7d01726be8ab66ab32f9df467ae8b1148906685bbe75c82d1e65d7f5b3f841" +checksum = "6d4f36811dfe07f7b8573462465d5cb8965fffc2e71ae377a33aecf14c2c9a2f" dependencies = [ "pest", "pest_meta", @@ -2522,11 +2711,10 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0" +checksum = "42919b05089acbd0a5dcd5405fb304d17d1053847b81163d09c4ad18ce8e8420" dependencies = [ - "once_cell", "pest", "sha2", ] @@ -2600,6 +2788,25 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap", + "quick-xml 0.38.3", + "serde", + "time", +] + [[package]] name = "pluralkit_models" version = "0.1.0" @@ -2615,11 +2822,11 @@ dependencies = [ [[package]] name = "png" -version = "0.17.16" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.9.4", "crc32fast", "fdeflate", "flate2", @@ -2628,9 +2835,18 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "potential_utf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +dependencies = [ + "zerovec", +] [[package]] name = "powerfmt" @@ -2644,14 +2860,14 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy 0.8.24", + "zerocopy", ] [[package]] name = "prettyplease" -version = "0.2.36" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff24dfcda44452b9816fff4cd4227e1bb73ff5a2f1bc1105aa92fb8565ce44d2" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", "syn", @@ -2659,24 +2875,33 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.94" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] [[package]] -name = "quanta" -version = "0.12.5" +name = "pxfm" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bd1fe6824cea6538803de3ff1bc0cf3949024db3d43c9643024bfb33a807c0e" +checksum = "83f9b339b02259ada5c0f4a389b7fb472f933aa17ce176fd2ad98f28bb401fde" +dependencies = [ + "num-traits", +] + +[[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.0+wasi-snapshot-preview1", + "wasi 0.11.1+wasi-snapshot-preview1", "web-sys", "winapi", ] @@ -2698,20 +2923,29 @@ dependencies = [ ] [[package]] -name = "quinn" -version = "0.11.7" +name = "quick-xml" +version = "0.38.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3bd15a6f2967aef83887dcb9fec0014580467e33720d073560cf015a5683012" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.1", - "rustls 0.23.25", - "socket2 0.5.9", - "thiserror 2.0.12", + "rustc-hash", + "rustls 0.23.32", + "socket2 0.6.0", + "thiserror 2.0.16", "tokio", "tracing", "web-time", @@ -2719,19 +2953,20 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.10" +version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b820744eb4dc9b57a3398183639c511b5a26d2ed702cedd3febaa1393caa22cc" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ "bytes", - "getrandom 0.3.2", - "rand 0.9.0", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.2", "ring 0.17.14", - "rustc-hash 2.1.1", - "rustls 0.23.25", + "rustc-hash", + "rustls 0.23.32", "rustls-pki-types", "slab", - "thiserror 2.0.12", + "thiserror 2.0.16", "tinyvec", "tracing", "web-time", @@ -2739,16 +2974,16 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.11" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "541d0f57c6ec747a90738a52741d3221f7960e8ac2f0ff4b1a63680e033b4ab5" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.9", + "socket2 0.6.0", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -2762,9 +2997,9 @@ dependencies = [ [[package]] name = "r-efi" -version = "5.2.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "radix_trie" @@ -2789,13 +3024,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.0" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", - "zerocopy 0.8.24", ] [[package]] @@ -2824,7 +3058,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", ] [[package]] @@ -2833,16 +3067,16 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.3.3", ] [[package]] name = "raw-cpuid" -version = "11.5.0" +version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6df7ab838ed27997ba19a4664507e6f82b41fe6e20be42929332156e5e85146" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", ] [[package]] @@ -2861,11 +3095,11 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.10" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", ] [[package]] @@ -2874,16 +3108,16 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.2.16", "libredox", "thiserror 1.0.69", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" dependencies = [ "aho-corasick", "memchr", @@ -2893,9 +3127,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" dependencies = [ "aho-corasick", "memchr", @@ -2904,9 +3138,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] name = "reqwest" @@ -2919,7 +3153,7 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2 0.3.26", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", "hyper 0.14.32", @@ -2932,7 +3166,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "rustls 0.21.12", - "rustls-pemfile 1.0.4", + "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", @@ -2953,9 +3187,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.15" +version = "0.12.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" +checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "base64 0.22.1", "bytes", @@ -2965,34 +3199,30 @@ dependencies = [ "http 1.3.1", "http-body 1.0.1", "http-body-util", - "hyper 1.6.0", - "hyper-rustls 0.27.5", + "hyper 1.7.0", + "hyper-rustls 0.27.7", "hyper-util", - "ipnet", "js-sys", "log", - "mime", - "once_cell", "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.25", - "rustls-pemfile 2.2.0", + "rustls 0.23.32", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper 1.0.2", "tokio", - "tokio-rustls 0.26.2", + "tokio-rustls 0.26.3", "tower 0.5.2", + "tower-http 0.6.6", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 0.26.8", - "windows-registry", + "webpki-roots 1.0.2", ] [[package]] @@ -3032,7 +3262,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.15", + "getrandom 0.2.16", "libc", "untrusted 0.9.0", "windows-sys 0.52.0", @@ -3045,7 +3275,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" dependencies = [ "base64 0.21.7", - "bitflags 2.9.0", + "bitflags 2.9.4", "serde", "serde_derive", ] @@ -3110,7 +3340,7 @@ dependencies = [ "maybe-async", "md5", "percent-encoding", - "quick-xml", + "quick-xml 0.26.0", "reqwest 0.11.27", "serde", "serde_derive", @@ -3124,15 +3354,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.24" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" - -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc-hash" @@ -3151,28 +3375,15 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.44" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "errno", "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustix" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" -dependencies = [ - "bitflags 2.9.0", - "errno", - "libc", - "linux-raw-sys 0.9.3", - "windows-sys 0.59.0", + "linux-raw-sys", + "windows-sys 0.61.1", ] [[package]] @@ -3201,16 +3412,16 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.25" +version = "0.23.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "822ee9188ac4ec04a2f0531e55d035fb2de73f18b41a63c70c2712503b6fb13c" +checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" dependencies = [ "aws-lc-rs", "log", "once_cell", "ring 0.17.14", "rustls-pki-types", - "rustls-webpki 0.103.1", + "rustls-webpki 0.103.6", "subtle", "zeroize", ] @@ -3236,42 +3447,34 @@ dependencies = [ "base64 0.21.7", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ "web-time", + "zeroize", ] [[package]] name = "rustls-platform-verifier" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5467026f437b4cb2a533865eaa73eb840019a0916f4b9ec563c6e617e086c9" +checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" dependencies = [ - "core-foundation 0.10.0", + "core-foundation 0.10.1", "core-foundation-sys", "jni", "log", "once_cell", - "rustls 0.23.25", + "rustls 0.23.32", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki 0.103.1", + "rustls-webpki 0.103.6", "security-framework", "security-framework-sys", - "webpki-root-certs", + "webpki-root-certs 0.26.11", "windows-sys 0.59.0", ] @@ -3293,9 +3496,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.1" +version = "0.103.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fef8b8769aaccf73098557a87cd1816b4f9c7c16811c9c77142aa695c16f2c03" +checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" dependencies = [ "aws-lc-rs", "ring 0.17.14", @@ -3305,9 +3508,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -3326,11 +3529,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.1", ] [[package]] @@ -3345,7 +3548,7 @@ dependencies = [ "libpk", "metrics", "num-format", - "reqwest 0.12.15", + "reqwest 0.12.23", "serde", "serde_json", "sqlx", @@ -3360,6 +3563,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sct" version = "0.7.1" @@ -3372,9 +3595,9 @@ dependencies = [ [[package]] name = "sea-query" -version = "0.32.3" +version = "0.32.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a24d8b9fcd2674a6c878a3d871f4f1380c6c43cc3718728ac96864d888458e" +checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c" dependencies = [ "inherent", "sea-query-derive", @@ -3391,17 +3614,17 @@ dependencies = [ "proc-macro2", "quote", "syn", - "thiserror 2.0.12", + "thiserror 2.0.16", ] [[package]] name = "security-framework" -version = "3.2.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" +checksum = "cc198e42d9b7510827939c9a15f5062a0c913f3371d765977e586d2fe6c16f4a" dependencies = [ - "bitflags 2.9.0", - "core-foundation 0.10.0", + "bitflags 2.9.4", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3409,9 +3632,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.14.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" dependencies = [ "core-foundation-sys", "libc", @@ -3419,9 +3642,13 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "sentry" @@ -3430,8 +3657,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a7332159e544e34db06b251b1eda5e546bd90285c3f58d9c8ff8450b484e0da" dependencies = [ "httpdate", - "reqwest 0.12.15", - "rustls 0.23.25", + "reqwest 0.12.23", + "rustls 0.23.32", "sentry-backtrace", "sentry-contexts", "sentry-core", @@ -3440,7 +3667,7 @@ dependencies = [ "sentry-tracing", "tokio", "ureq", - "webpki-roots 0.26.8", + "webpki-roots 0.26.11", ] [[package]] @@ -3534,10 +3761,11 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.219" +version = "1.0.227" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "80ece43fc6fbed4eb5392ab50c07334d3e577cbf40997ee896fe7af40bba4245" dependencies = [ + "serde_core", "serde_derive", ] @@ -3552,10 +3780,19 @@ dependencies = [ ] [[package]] -name = "serde_derive" -version = "1.0.219" +name = "serde_core" +version = "1.0.227" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "7a576275b607a2c86ea29e410193df32bc680303c82f31e275bbfcafe8b33be5" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.227" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e694923b8824cf0e9b382adf0f60d4e05f348f357b38833a3fa5ed7c2ede04" dependencies = [ "proc-macro2", "quote", @@ -3564,25 +3801,27 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "indexmap", "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] name = "serde_path_to_error" -version = "0.1.17" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ "itoa", "serde", + "serde_core", ] [[package]] @@ -3598,9 +3837,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.8" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" dependencies = [ "serde", ] @@ -3656,9 +3895,9 @@ checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", @@ -3682,9 +3921,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.2" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" dependencies = [ "libc", ] @@ -3711,6 +3950,24 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple_logger" +version = "4.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e7e46c8c90251d47d08b28b8a419ffb4aede0f87c2eea95e17d1d5bacbf3ef1" +dependencies = [ + "colored", + "log", + "time", + "windows-sys 0.48.0", +] + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "sketches-ddsketch" version = "0.2.2" @@ -3719,27 +3976,40 @@ checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c" [[package]] name = "slab" -version = "0.4.9" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "smallvec" -version = "1.14.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" dependencies = [ "serde", ] [[package]] -name = "socket2" -version = "0.5.9" +name = "smawk" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + +[[package]] +name = "smol_str" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9676b89cd56310a87b93dec47b11af744f34d5fc9f367b829474eec0a891350d" +dependencies = [ + "borsh", + "serde", +] + +[[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", @@ -3782,9 +4052,9 @@ dependencies = [ [[package]] name = "sqlx" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4410e73b3c0d8442c5f99b425d7a435b5ee0ae4167b3196771dd3f7a01be745f" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ "sqlx-core", "sqlx-macros", @@ -3795,10 +4065,11 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a007b6936676aa9ab40207cde35daab0a04b823be8ae004368c0793b96a61e0" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ + "base64 0.22.1", "bytes", "chrono", "crc", @@ -3809,7 +4080,7 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.15.2", + "hashbrown 0.15.5", "hashlink 0.10.0", "indexmap", "log", @@ -3820,7 +4091,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror 2.0.12", + "thiserror 2.0.16", "time", "tokio", "tokio-stream", @@ -3831,9 +4102,9 @@ dependencies = [ [[package]] name = "sqlx-macros" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3112e2ad78643fef903618d78cf0aec1cb3134b019730edb039b69eaf531f310" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" dependencies = [ "proc-macro2", "quote", @@ -3844,9 +4115,9 @@ dependencies = [ [[package]] name = "sqlx-macros-core" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e9f90acc5ab146a99bf5061a7eb4976b573f560bc898ef3bf8435448dd5e7ad" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ "dotenvy", "either", @@ -3863,20 +4134,19 @@ dependencies = [ "sqlx-postgres", "sqlx-sqlite", "syn", - "tempfile", "tokio", "url", ] [[package]] name = "sqlx-mysql" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4560278f0e00ce64938540546f59f590d60beee33fffbd3b9cd47851e5fff233" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.9.0", + "bitflags 2.9.4", "byteorder", "bytes", "chrono", @@ -3906,7 +4176,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.12", + "thiserror 2.0.16", "time", "tracing", "uuid", @@ -3915,13 +4185,13 @@ dependencies = [ [[package]] name = "sqlx-postgres" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5b98a57f363ed6764d5b3a12bfedf62f07aa16e1856a7ddc2a0bb190a959613" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.9.0", + "bitflags 2.9.4", "byteorder", "chrono", "crc", @@ -3946,7 +4216,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.12", + "thiserror 2.0.16", "time", "tracing", "uuid", @@ -3955,9 +4225,9 @@ dependencies = [ [[package]] name = "sqlx-sqlite" -version = "0.8.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f85ca71d3a5b24e64e1d08dd8fe36c6c95c339a896cc33068148906784620540" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", "chrono", @@ -3973,6 +4243,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", + "thiserror 2.0.16", "time", "tracing", "url", @@ -3985,6 +4256,12 @@ 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 = "stringprep" version = "0.1.5" @@ -3996,6 +4273,12 @@ dependencies = [ "unicode-properties", ] +[[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" @@ -4004,9 +4287,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.104" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -4030,9 +4313,9 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", @@ -4062,15 +4345,24 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.19.1" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", - "getrandom 0.3.2", + "getrandom 0.3.3", "once_cell", - "rustix 1.0.5", - "windows-sys 0.59.0", + "rustix", + "windows-sys 0.61.1", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", ] [[package]] @@ -4084,11 +4376,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.16", ] [[package]] @@ -4104,9 +4396,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", @@ -4115,34 +4407,38 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ "cfg-if", - "once_cell", ] [[package]] name = "tiff" -version = "0.9.1" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" dependencies = [ + "fax", "flate2", - "jpeg-decoder", + "half", + "quick-error", "weezl", + "zune-jpeg", ] [[package]] name = "time" -version = "0.3.41" +version = "0.3.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde", "time-core", @@ -4151,15 +4447,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" dependencies = [ "num-conv", "time-core", @@ -4176,9 +4472,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.7.6" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" dependencies = [ "displaydoc", "zerovec", @@ -4186,9 +4482,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" dependencies = [ "tinyvec_macros", ] @@ -4242,11 +4538,11 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd" dependencies = [ - "rustls 0.23.25", + "rustls 0.23.32", "tokio", ] @@ -4263,9 +4559,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.14" +version = "0.7.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" dependencies = [ "bytes", "futures-core", @@ -4276,9 +4572,9 @@ dependencies = [ [[package]] name = "tokio-websockets" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbc46f9dc832c663a5db08513162001a29ac820913275d58943f942c2bc1c435" +checksum = "9fcaf159b4e7a376b05b5bfd77bfd38f3324f5fce751b4213bfc7eaa47affb4e" dependencies = [ "base64 0.22.1", "bytes", @@ -4293,15 +4589,24 @@ dependencies = [ "sha1_smol", "simdutf8", "tokio", - "tokio-rustls 0.26.2", + "tokio-rustls 0.26.3", "tokio-util", ] [[package]] name = "toml" -version = "0.8.20" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", @@ -4311,26 +4616,33 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] [[package]] name = "toml_edit" -version = "0.22.24" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" +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.4.13" @@ -4369,7 +4681,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "bytes", "futures-util", "http 1.3.1", @@ -4381,6 +4693,24 @@ dependencies = [ "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.4", + "bytes", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "iri-string", + "pin-project-lite", + "tower 0.5.2", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -4407,9 +4737,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.28" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", @@ -4418,9 +4748,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", "valuable", @@ -4437,16 +4767,6 @@ dependencies = [ "tracing-core", ] -[[package]] -name = "tracing-serde" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc6b213177105856957181934e4920de57730fc69bf42c37ee5bb664d406d9e1" -dependencies = [ - "serde", - "tracing-core", -] - [[package]] name = "tracing-serde" version = "0.2.0" @@ -4475,7 +4795,7 @@ dependencies = [ "tracing", "tracing-core", "tracing-log", - "tracing-serde 0.2.0", + "tracing-serde", ] [[package]] @@ -4489,7 +4809,7 @@ name = "twilight-cache-inmemory" version = "0.16.0" source = "git+https://github.com/pluralkit/twilight?branch=pluralkit-7f08d95#054a2aa5d29fb46220af1cd5df568b73511cdb26" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "dashmap", "serde", "twilight-model", @@ -4501,7 +4821,7 @@ name = "twilight-gateway" version = "0.16.0" source = "git+https://github.com/pluralkit/twilight?branch=pluralkit-7f08d95#054a2aa5d29fb46220af1cd5df568b73511cdb26" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "fastrand", "futures-core", "futures-sink", @@ -4533,11 +4853,11 @@ dependencies = [ "fastrand", "http 1.3.1", "http-body-util", - "hyper 1.6.0", - "hyper-rustls 0.27.5", + "hyper 1.7.0", + "hyper-rustls 0.27.7", "hyper-util", "percent-encoding", - "rustls 0.23.25", + "rustls 0.23.32", "serde", "serde_json", "tokio", @@ -4561,7 +4881,7 @@ name = "twilight-model" version = "0.16.0" source = "git+https://github.com/pluralkit/twilight?branch=pluralkit-7f08d95#054a2aa5d29fb46220af1cd5df568b73511cdb26" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.4", "serde", "serde-value", "serde_repr", @@ -4614,9 +4934,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unicode-normalization" @@ -4639,6 +4959,137 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "uniffi" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6d968cb62160c11f2573e6be724ef8b1b18a277aededd17033f8a912d73e2b4" +dependencies = [ + "anyhow", + "cargo_metadata", + "uniffi_bindgen", + "uniffi_build", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b39ef1acbe1467d5d210f274fae344cb6f8766339330cb4c9688752899bf6b" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck 0.5.0", + "indexmap", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml 0.5.11", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_build" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6683e6b665423cddeacd89a3f97312cf400b2fb245a26f197adaf65c45d505b2" +dependencies = [ + "anyhow", + "camino", + "uniffi_bindgen", +] + +[[package]] +name = "uniffi_core" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d990b553d6b9a7ee9c3ae71134674739913d52350b56152b0e613595bb5a6f" +dependencies = [ + "anyhow", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f4f224becf14885c10e6e400b95cc4d1985738140cb194ccc2044563f8a56b" +dependencies = [ + "anyhow", + "indexmap", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "uniffi_macros" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b481d385af334871d70904e6a5f129be7cd38c18fcf8dd8fd1f646b426a56d58" +dependencies = [ + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn", + "toml 0.5.11", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10f817868a3b171bb7bf259e882138d104deafde65684689b4694c846d322491" +dependencies = [ + "anyhow", + "siphasher", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b147e133ad7824e32426b90bc41fda584363563f2ba747f590eca1fd6fd14e6" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caed654fb73da5abbc7a7e9c741532284532ba4762d6fe5071372df22a41730a" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + [[package]] name = "untrusted" version = "0.7.1" @@ -4660,17 +5111,17 @@ dependencies = [ "base64 0.22.1", "log", "once_cell", - "rustls 0.23.25", + "rustls 0.23.32", "rustls-pki-types", "url", - "webpki-roots 0.26.8", + "webpki-roots 0.26.11", ] [[package]] name = "url" -version = "2.5.4" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", @@ -4684,12 +5135,6 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" -[[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -4698,12 +5143,14 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.16.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.3.3", + "js-sys", "serde", + "wasm-bindgen", ] [[package]] @@ -4745,17 +5192,26 @@ dependencies = [ [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.2+wasi-0.2.4" +version = "0.14.7+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" dependencies = [ - "wit-bindgen-rt", + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", ] [[package]] @@ -4766,21 +5222,22 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" dependencies = [ "bumpalo", "log", @@ -4792,9 +5249,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" dependencies = [ "cfg-if", "js-sys", @@ -4805,9 +5262,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4815,9 +5272,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" dependencies = [ "proc-macro2", "quote", @@ -4828,9 +5285,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" dependencies = [ "unicode-ident", ] @@ -4850,9 +5307,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" dependencies = [ "js-sys", "wasm-bindgen", @@ -4890,9 +5347,18 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "0.26.8" +version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09aed61f5e8d2c18344b3faa33a4c837855fe56642757754775548fee21386c4" +checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" +dependencies = [ + "webpki-root-certs 1.0.2", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4ffd8df1c57e87c325000a3d6ef93db75279dc3a231125aac571650f22b12a" dependencies = [ "rustls-pki-types", ] @@ -4914,38 +5380,44 @@ checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" [[package]] name = "webpki-roots" -version = "0.26.8" +version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2210b291f7ea53617fbafcc4939f10914214ec15aace5ba62293a668f322c5c9" +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 = "weezl" -version = "0.1.8" +name = "weedle2" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" - -[[package]] -name = "which" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" dependencies = [ - "either", - "home", - "once_cell", - "rustix 0.38.44", + "nom", ] [[package]] -name = "whoami" -version = "1.6.0" +name = "weezl" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7" +checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" dependencies = [ - "redox_syscall", + "libredox", "wasite", ] @@ -4967,11 +5439,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.1", ] [[package]] @@ -4980,43 +5452,24 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" -dependencies = [ - "windows-core 0.52.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows-core" -version = "0.52.0" +version = "0.62.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-core" -version = "0.61.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" +checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.2.0", "windows-result", - "windows-strings 0.4.0", + "windows-strings", ] [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.60.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "edb307e42a74fb6de9bf3a02d9712678b22399c87e6fa869d6dfcd8c1b7754e0" dependencies = [ "proc-macro2", "quote", @@ -5025,9 +5478,9 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "c0abd1ddbc6964ac14db11c7213d6532ef34bd9aa042c2e5935f59d7908b46a5" dependencies = [ "proc-macro2", "quote", @@ -5036,46 +5489,32 @@ dependencies = [ [[package]] name = "windows-link" -version = "0.1.1" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] -name = "windows-registry" -version = "0.4.0" +name = "windows-link" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" -dependencies = [ - "windows-result", - "windows-strings 0.3.1", - "windows-targets 0.53.0", -] +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" [[package]] name = "windows-result" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" dependencies = [ - "windows-link", + "windows-link 0.2.0", +] + +[[package]] +name = "windows-strings" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +dependencies = [ + "windows-link 0.2.0", ] [[package]] @@ -5114,6 +5553,24 @@ 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.4", +] + +[[package]] +name = "windows-sys" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" +dependencies = [ + "windows-link 0.2.0", +] + [[package]] name = "windows-targets" version = "0.42.2" @@ -5162,10 +5619,11 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.0" +version = "0.53.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" +checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" dependencies = [ + "windows-link 0.2.0", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -5358,9 +5816,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "winnow" -version = "0.7.4" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e97b544156e9bebe1a0ffbc03484fc1ffe3100cbce3ffb17eac35f7cdd7ab36" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" dependencies = [ "memchr", ] @@ -5376,25 +5834,16 @@ dependencies = [ ] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags 2.9.0", -] - -[[package]] -name = "write16" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" [[package]] name = "yaml-rust2" @@ -5409,9 +5858,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" dependencies = [ "serde", "stable_deref_trait", @@ -5421,9 +5870,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", @@ -5433,38 +5882,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.7.35" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" dependencies = [ - "zerocopy-derive 0.7.35", -] - -[[package]] -name = "zerocopy" -version = "0.8.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" -dependencies = [ - "zerocopy-derive 0.8.24", + "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.35" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", @@ -5499,10 +5928,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" [[package]] -name = "zerovec" -version = "0.10.4" +name = "zerotrie" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +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", @@ -5511,9 +5951,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.10.3" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", @@ -5531,9 +5971,9 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "2.0.15+zstd.1.5.7" +version = "2.0.16+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index 69a9048a..c97fe43a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [workspace] +resolver = "2" members = [ "./crates/*" ] -resolver = "2" [workspace.dependencies] anyhow = "1" diff --git a/PluralKit.Bot/CommandMeta/CommandParseErrors.cs b/PluralKit.Bot/CommandMeta/CommandParseErrors.cs index 334a271d..ab9c9fae 100644 --- a/PluralKit.Bot/CommandMeta/CommandParseErrors.cs +++ b/PluralKit.Bot/CommandMeta/CommandParseErrors.cs @@ -1,58 +1,25 @@ -using Humanizer; - using Myriad.Types; -using PluralKit.Core; - namespace PluralKit.Bot; public partial class CommandTree { - private async Task PrintCommandNotFoundError(Context ctx, params Command[] potentialCommands) + private async Task PrintCommandList(Context ctx, string subject, string commands) { - var commandListStr = CreatePotentialCommandList(ctx.DefaultPrefix, potentialCommands); - await ctx.Reply( - $"{Emojis.Error} Unknown command `{ctx.DefaultPrefix}{ctx.FullCommand().Truncate(100)}`. Perhaps you meant to use one of the following commands?\n{commandListStr}\n\nFor a full list of possible commands, see ."); - } - - private async Task PrintCommandExpectedError(Context ctx, params Command[] potentialCommands) - { - var commandListStr = CreatePotentialCommandList(ctx.DefaultPrefix, potentialCommands); - await ctx.Reply( - $"{Emojis.Error} You need to pass a command. Perhaps you meant to use one of the following commands?\n{commandListStr}\n\nFor a full list of possible commands, see ."); - } - - private static string CreatePotentialCommandList(string prefix, params Command[] potentialCommands) - { - return string.Join("\n", potentialCommands.Select(cmd => $"- **{prefix}{cmd.Usage}** - *{cmd.Description}*")); - } - - private async Task PrintCommandList(Context ctx, string subject, params Command[] commands) - { - var str = CreatePotentialCommandList(ctx.DefaultPrefix, commands); - await ctx.Reply( - $"Here is a list of commands related to {subject}:", - embed: new Embed() - { - Description = $"{str}\nFor a full list of possible commands, see .", - Color = DiscordUtils.Blue, - } - ); - } - - private async Task CreateSystemNotFoundError(Context ctx) - { - var input = ctx.PopArgument(); - if (input.TryParseMention(out var id)) + if (commands.Length == 0) { - // Try to resolve the user ID to find the associated account, - // so we can print their username. - var user = await ctx.Rest.GetUser(id); - if (user != null) - return $"Account **{user.Username}#{user.Discriminator}** does not have a system registered."; - return $"Account with ID `{id}` not found."; + await ctx.Reply($"No commands related to `{subject}` was found. For the full list of commands, see the website: "); + return; } - return $"System with ID {input.AsCode()} not found."; + await ctx.Reply( + components: [ + new MessageComponent() + { + Type = ComponentType.Text, + Content = $"Here is a list of commands related to `{subject}`:\n{commands}\nFor a full list of possible commands, see .", + } + ] + ); } } \ No newline at end of file diff --git a/PluralKit.Bot/CommandMeta/CommandTree.cs b/PluralKit.Bot/CommandMeta/CommandTree.cs index d1ab6da0..20cfb7d2 100644 --- a/PluralKit.Bot/CommandMeta/CommandTree.cs +++ b/PluralKit.Bot/CommandMeta/CommandTree.cs @@ -4,646 +4,326 @@ namespace PluralKit.Bot; public partial class CommandTree { - public Task ExecuteCommand(Context ctx) + public Task ExecuteCommand(Context ctx, Commands command) { - if (ctx.Match("system", "s", "account", "acc")) - return HandleSystemCommand(ctx); - if (ctx.Match("member", "m")) - return HandleMemberCommand(ctx); - if (ctx.Match("group", "g")) - return HandleGroupCommand(ctx); - if (ctx.Match("switch", "sw")) - return HandleSwitchCommand(ctx); - if (ctx.Match("commands", "cmd", "c")) - return CommandHelpRoot(ctx); - if (ctx.Match("ap", "autoproxy", "auto")) - return HandleAutoproxyCommand(ctx); - if (ctx.Match("config", "cfg", "configure")) - return HandleConfigCommand(ctx); - if (ctx.Match("serverconfig", "guildconfig", "scfg")) - return HandleServerConfigCommand(ctx); - if (ctx.Match("list", "find", "members", "search", "query", "l", "f", "fd", "ls")) - return ctx.Execute(SystemList, m => m.MemberList(ctx, ctx.System)); - if (ctx.Match("link")) - return ctx.Execute(Link, m => m.LinkSystem(ctx)); - if (ctx.Match("unlink")) - return ctx.Execute(Unlink, m => m.UnlinkAccount(ctx)); - if (ctx.Match("token")) - if (ctx.Match("refresh", "renew", "invalidate", "reroll", "regen")) - return ctx.Execute(TokenRefresh, m => m.RefreshToken(ctx)); - else - return ctx.Execute(TokenGet, m => m.GetToken(ctx)); - if (ctx.Match("import")) - return ctx.Execute(Import, m => m.Import(ctx)); - if (ctx.Match("export")) - return ctx.Execute(Export, m => m.Export(ctx)); - if (ctx.Match("help", "h")) - if (ctx.Match("commands")) - return ctx.Reply("For the list of commands, see the website: "); - else if (ctx.Match("proxy")) - return ctx.Reply( - "The proxy help page has been moved! See the website: https://pluralkit.me/guide#proxying"); - else return ctx.Execute(Help, m => m.HelpRoot(ctx)); - if (ctx.Match("explain")) - return ctx.Execute(Explain, m => m.Explain(ctx)); - if (ctx.Match("message", "msg", "messageinfo")) - return ctx.Execute(Message, m => m.GetMessage(ctx)); - if (ctx.Match("edit", "e")) - return ctx.Execute(MessageEdit, m => m.EditMessage(ctx, false)); - if (ctx.Match("x")) - return ctx.Execute(MessageEdit, m => m.EditMessage(ctx, true)); - if (ctx.Match("reproxy", "rp", "crimes", "crime")) - return ctx.Execute(MessageReproxy, m => m.ReproxyMessage(ctx)); - if (ctx.Match("log")) - if (ctx.Match("channel")) - return ctx.Execute(LogChannel, m => m.SetLogChannel(ctx), true); - else if (ctx.Match("enable", "on")) - return ctx.Execute(LogEnable, m => m.SetLogEnabled(ctx, true), true); - else if (ctx.Match("disable", "off")) - return ctx.Execute(LogDisable, m => m.SetLogEnabled(ctx, false), true); - else if (ctx.Match("list", "show")) - return ctx.Execute(LogShow, m => m.ShowLogDisabledChannels(ctx), true); - else - return ctx.Reply($"{Emojis.Warn} Message logging commands have moved to `{ctx.DefaultPrefix}serverconfig`."); - if (ctx.Match("logclean")) - return ctx.Execute(ServerConfigLogClean, m => m.SetLogCleanup(ctx), true); - if (ctx.Match("blacklist", "bl")) - if (ctx.Match("enable", "on", "add", "deny")) - return ctx.Execute(BlacklistAdd, m => m.SetProxyBlacklisted(ctx, true), true); - else if (ctx.Match("disable", "off", "remove", "allow")) - return ctx.Execute(BlacklistRemove, m => m.SetProxyBlacklisted(ctx, false), true); - else if (ctx.Match("list", "show")) - return ctx.Execute(BlacklistShow, m => m.ShowProxyBlacklisted(ctx), true); - else - return ctx.Reply($"{Emojis.Warn} Blacklist commands have moved to `{ctx.DefaultPrefix}serverconfig`."); - if (ctx.Match("proxy")) - if (ctx.Match("debug")) - return ctx.Execute(ProxyCheck, m => m.MessageProxyCheck(ctx)); - else - return ctx.Execute(SystemProxy, m => m.SystemProxy(ctx)); - if (ctx.Match("invite")) return ctx.Execute(Invite, m => m.Invite(ctx)); - if (ctx.Match("mn")) return ctx.Execute(null, m => m.Mn(ctx)); - if (ctx.Match("fire")) return ctx.Execute(null, m => m.Fire(ctx)); - if (ctx.Match("thunder")) return ctx.Execute(null, m => m.Thunder(ctx)); - if (ctx.Match("freeze")) return ctx.Execute(null, m => m.Freeze(ctx)); - if (ctx.Match("starstorm")) return ctx.Execute(null, m => m.Starstorm(ctx)); - if (ctx.Match("flash")) return ctx.Execute(null, m => m.Flash(ctx)); - if (ctx.Match("rool")) return ctx.Execute(null, m => m.Rool(ctx)); - if (ctx.Match("sus")) return ctx.Execute(null, m => m.Sus(ctx)); - if (ctx.Match("error")) return ctx.Execute(null, m => m.Error(ctx)); - if (ctx.Match("stats", "status")) return ctx.Execute(null, m => m.Stats(ctx)); - if (ctx.Match("permcheck")) - return ctx.Execute(PermCheck, m => m.PermCheckGuild(ctx)); - if (ctx.Match("proxycheck")) - return ctx.Execute(ProxyCheck, m => m.MessageProxyCheck(ctx)); - if (ctx.Match("debug")) - return HandleDebugCommand(ctx); - if (ctx.Match("admin")) - return HandleAdminCommand(ctx); - if (ctx.Match("random", "rand", "r")) - if (ctx.Match("group", "g") || ctx.MatchFlag("group", "g")) - return ctx.Execute(GroupRandom, r => r.Group(ctx, ctx.System)); - else - return ctx.Execute(MemberRandom, m => m.Member(ctx, ctx.System)); - if (ctx.Match("dashboard", "dash")) - return ctx.Execute(Dashboard, m => m.Dashboard(ctx)); - - // don't send an "invalid command" response if the guild has those turned off - if (ctx.GuildConfig != null && ctx.GuildConfig!.InvalidCommandResponseEnabled != true) - return Task.CompletedTask; - - // remove compiler warning - return ctx.Reply( - $"{Emojis.Error} Unknown command {ctx.PeekArgument().AsCode()}. For a list of possible commands, see ."); - } - - private async Task HandleAdminAbuseLogCommand(Context ctx) - { - ctx.AssertBotAdmin(); - - if (ctx.Match("n", "new", "create")) - await ctx.Execute(Admin, a => a.AbuseLogCreate(ctx)); - else + return command switch { - AbuseLog? abuseLog = null!; - var account = await ctx.MatchUser(); - if (account != null) + Commands.CommandsList(var param, _) => PrintCommandList(ctx, param.subject, Parameters.GetRelatedCommands(ctx.DefaultPrefix, param.subject)), + Commands.Dashboard => ctx.Execute(Dashboard, m => m.Dashboard(ctx)), + Commands.Explain => ctx.Execute(Explain, m => m.Explain(ctx)), + Commands.Help(_, var flags) => ctx.Execute(Help, m => m.HelpRoot(ctx, flags.show_embed)), + Commands.HelpCommands => ctx.Reply( + "For the list of commands, see the website: "), + Commands.HelpProxy => ctx.Reply( + "The proxy help page has been moved! See the website: https://pluralkit.me/guide#proxying"), + Commands.Invite => ctx.Execute(Invite, m => m.Invite(ctx)), + Commands.Stats => ctx.Execute(null, m => m.Stats(ctx)), + Commands.MemberShow(var param, var flags) => ctx.Execute(MemberInfo, m => m.ViewMember(ctx, param.target, flags.show_embed)), + Commands.MemberNew(var param, var flags) => ctx.Execute(MemberNew, m => m.NewMember(ctx, param.name, flags.yes)), + Commands.MemberSoulscream(var param, _) => ctx.Execute(MemberInfo, m => m.Soulscream(ctx, param.target)), + Commands.MemberAvatarShow(var param, var flags) => ctx.Execute(MemberAvatar, m => m.ShowAvatar(ctx, param.target, flags.GetReplyFormat())), + Commands.MemberAvatarClear(var param, var flags) => ctx.Execute(MemberAvatar, m => m.ClearAvatar(ctx, param.target, flags.yes)), + Commands.MemberAvatarUpdate(var param, _) => ctx.Execute(MemberAvatar, m => m.ChangeAvatar(ctx, param.target, param.avatar)), + Commands.MemberWebhookAvatarShow(var param, var flags) => ctx.Execute(MemberAvatar, m => m.ShowWebhookAvatar(ctx, param.target, flags.GetReplyFormat())), + Commands.MemberWebhookAvatarClear(var param, var flags) => ctx.Execute(MemberAvatar, m => m.ClearWebhookAvatar(ctx, param.target, flags.yes)), + Commands.MemberWebhookAvatarUpdate(var param, _) => ctx.Execute(MemberAvatar, m => m.ChangeWebhookAvatar(ctx, param.target, param.avatar)), + Commands.MemberServerAvatarShow(var param, var flags) => ctx.Execute(MemberAvatar, m => m.ShowServerAvatar(ctx, param.target, flags.GetReplyFormat())), + Commands.MemberServerAvatarClear(var param, var flags) => ctx.Execute(MemberAvatar, m => m.ClearServerAvatar(ctx, param.target, flags.yes)), + Commands.MemberServerAvatarUpdate(var param, _) => ctx.Execute(MemberAvatar, m => m.ChangeServerAvatar(ctx, param.target, param.avatar)), + Commands.MemberPronounsShow(var param, var flags) => ctx.Execute(MemberPronouns, m => m.ShowPronouns(ctx, param.target, flags.GetReplyFormat())), + Commands.MemberPronounsClear(var param, var flags) => ctx.Execute(MemberPronouns, m => m.ClearPronouns(ctx, param.target, flags.yes)), + Commands.MemberPronounsUpdate(var param, _) => ctx.Execute(MemberPronouns, m => m.ChangePronouns(ctx, param.target, param.pronouns)), + Commands.MemberDescShow(var param, var flags) => ctx.Execute(MemberDesc, m => m.ShowDescription(ctx, param.target, flags.GetReplyFormat())), + Commands.MemberDescClear(var param, var flags) => ctx.Execute(MemberDesc, m => m.ClearDescription(ctx, param.target, flags.yes)), + Commands.MemberDescUpdate(var param, _) => ctx.Execute(MemberDesc, m => m.ChangeDescription(ctx, param.target, param.description)), + Commands.MemberNameShow(var param, var flags) => ctx.Execute(MemberInfo, m => m.ShowName(ctx, param.target, flags.GetReplyFormat())), + Commands.MemberNameUpdate(var param, var flags) => ctx.Execute(MemberInfo, m => m.ChangeName(ctx, param.target, param.name, flags.yes)), + Commands.MemberBannerShow(var param, var flags) => ctx.Execute(MemberBannerImage, m => m.ShowBannerImage(ctx, param.target, flags.GetReplyFormat())), + Commands.MemberBannerClear(var param, var flags) => ctx.Execute(MemberBannerImage, m => m.ClearBannerImage(ctx, param.target, flags.yes)), + Commands.MemberBannerUpdate(var param, _) => ctx.Execute(MemberBannerImage, m => m.ChangeBannerImage(ctx, param.target, param.banner)), + Commands.MemberColorShow(var param, var flags) => ctx.Execute(MemberColor, m => m.ShowColor(ctx, param.target, flags.GetReplyFormat())), + Commands.MemberColorClear(var param, var flags) => ctx.Execute(MemberColor, m => m.ClearColor(ctx, param.target, flags.yes)), + Commands.MemberColorUpdate(var param, _) => ctx.Execute(MemberColor, m => m.ChangeColor(ctx, param.target, param.color)), + Commands.MemberBirthdayShow(var param, var flags) => ctx.Execute(MemberBirthday, m => m.ShowBirthday(ctx, param.target, flags.GetReplyFormat())), + Commands.MemberBirthdayClear(var param, var flags) => ctx.Execute(MemberBirthday, m => m.ClearBirthday(ctx, param.target, flags.yes)), + Commands.MemberBirthdayUpdate(var param, _) => ctx.Execute(MemberBirthday, m => m.ChangeBirthday(ctx, param.target, param.birthday)), + Commands.MemberDisplaynameShow(var param, var flags) => ctx.Execute(MemberDisplayName, m => m.ShowDisplayName(ctx, param.target, flags.GetReplyFormat())), + Commands.MemberDisplaynameClear(var param, var flags) => ctx.Execute(MemberDisplayName, m => m.ClearDisplayName(ctx, param.target, flags.yes)), + Commands.MemberDisplaynameUpdate(var param, _) => ctx.Execute(MemberDisplayName, m => m.ChangeDisplayName(ctx, param.target, param.name)), + Commands.MemberServernameShow(var param, var flags) => ctx.Execute(MemberServerName, m => m.ShowServerName(ctx, param.target, flags.GetReplyFormat())), + Commands.MemberServernameClear(var param, var flags) => ctx.Execute(MemberServerName, m => m.ClearServerName(ctx, param.target, flags.yes)), + Commands.MemberServernameUpdate(var param, _) => ctx.Execute(MemberServerName, m => m.ChangeServerName(ctx, param.target, param.name)), + Commands.MemberKeepproxyShow(var param, _) => ctx.Execute(MemberKeepProxy, m => m.ShowKeepProxy(ctx, param.target)), + Commands.MemberKeepproxyUpdate(var param, _) => ctx.Execute(MemberKeepProxy, m => m.ChangeKeepProxy(ctx, param.target, param.value)), + Commands.MemberServerKeepproxyShow(var param, _) => ctx.Execute(MemberServerKeepProxy, m => m.ShowServerKeepProxy(ctx, param.target)), + Commands.MemberServerKeepproxyUpdate(var param, _) => ctx.Execute(MemberServerKeepProxy, m => m.ChangeServerKeepProxy(ctx, param.target, param.value)), + Commands.MemberServerKeepproxyClear(var param, var flags) => ctx.Execute(MemberServerKeepProxy, m => m.ClearServerKeepProxy(ctx, param.target, flags.yes)), + Commands.MemberProxyShow(var param, _) => ctx.Execute(MemberProxy, m => m.ShowProxy(ctx, param.target)), + Commands.MemberProxyClear(var param, var flags) => ctx.Execute(MemberProxy, m => m.ClearProxy(ctx, param.target, flags.yes)), + Commands.MemberProxyAdd(var param, var flags) => ctx.Execute(MemberProxy, m => m.AddProxy(ctx, param.target, param.tag, flags.yes)), + Commands.MemberProxyRemove(var param, _) => ctx.Execute(MemberProxy, m => m.RemoveProxy(ctx, param.target, param.tag)), + Commands.MemberProxySet(var param, var flags) => ctx.Execute(MemberProxy, m => m.SetProxy(ctx, param.target, param.tags, flags.yes)), + Commands.MemberTtsShow(var param, _) => ctx.Execute(MemberTts, m => m.ShowTts(ctx, param.target)), + Commands.MemberTtsUpdate(var param, _) => ctx.Execute(MemberTts, m => m.ChangeTts(ctx, param.target, param.value)), + Commands.MemberAutoproxyShow(var param, _) => ctx.Execute(MemberAutoproxy, m => m.ShowAutoproxy(ctx, param.target)), + Commands.MemberAutoproxyUpdate(var param, _) => ctx.Execute(MemberAutoproxy, m => m.ChangeAutoproxy(ctx, param.target, param.value)), + Commands.MemberDelete(var param, _) => ctx.Execute(MemberDelete, m => m.Delete(ctx, param.target)), + Commands.MemberPrivacyShow(var param, _) => ctx.Execute(MemberPrivacy, m => m.ShowPrivacy(ctx, param.target)), + Commands.MemberPrivacyUpdate(var param, _) => ctx.Execute(MemberPrivacy, m => m.ChangePrivacy(ctx, param.target, param.member_privacy_target, param.new_privacy_level)), + Commands.MemberGroupAdd(var param, _) => ctx.Execute(MemberGroupAdd, m => m.AddRemoveGroups(ctx, param.target, param.groups, Groups.AddRemoveOperation.Add)), + Commands.MemberGroupRemove(var param, _) => ctx.Execute(MemberGroupRemove, m => m.AddRemoveGroups(ctx, param.target, param.groups, Groups.AddRemoveOperation.Remove)), + Commands.MemberId(var param, _) => ctx.Execute(MemberId, m => m.DisplayId(ctx, param.target)), + Commands.CfgShow => ctx.Execute(null, m => m.ShowConfig(ctx)), + Commands.CfgApAccountShow => ctx.Execute(null, m => m.ViewAutoproxyAccount(ctx)), + Commands.CfgApAccountUpdate(var param, _) => ctx.Execute(null, m => m.EditAutoproxyAccount(ctx, param.toggle)), + Commands.CfgApTimeoutShow => ctx.Execute(null, m => m.ViewAutoproxyTimeout(ctx)), + Commands.CfgApTimeoutOff => ctx.Execute(null, m => m.DisableAutoproxyTimeout(ctx)), + Commands.CfgApTimeoutReset => ctx.Execute(null, m => m.ResetAutoproxyTimeout(ctx)), + Commands.CfgApTimeoutUpdate(var param, _) => ctx.Execute(null, m => m.EditAutoproxyTimeout(ctx, param.timeout)), + Commands.CfgTimezoneShow => ctx.Execute(null, m => m.ViewSystemTimezone(ctx)), + Commands.CfgTimezoneReset => ctx.Execute(null, m => m.ResetSystemTimezone(ctx)), + Commands.CfgTimezoneUpdate(var param, var flags) => ctx.Execute(null, m => m.EditSystemTimezone(ctx, param.timezone, flags.yes)), + Commands.CfgPingShow => ctx.Execute(null, m => m.ViewSystemPing(ctx)), + Commands.CfgPingUpdate(var param, _) => ctx.Execute(null, m => m.EditSystemPing(ctx, param.toggle)), + Commands.CfgMemberPrivacyShow => ctx.Execute(null, m => m.ViewMemberDefaultPrivacy(ctx)), + Commands.CfgMemberPrivacyUpdate(var param, _) => ctx.Execute(null, m => m.EditMemberDefaultPrivacy(ctx, param.toggle)), + Commands.CfgGroupPrivacyShow => ctx.Execute(null, m => m.ViewGroupDefaultPrivacy(ctx)), + Commands.CfgGroupPrivacyUpdate(var param, _) => ctx.Execute(null, m => m.EditGroupDefaultPrivacy(ctx, param.toggle)), + Commands.CfgShowPrivateInfoShow => ctx.Execute(null, m => m.ViewShowPrivateInfo(ctx)), + Commands.CfgShowPrivateInfoUpdate(var param, _) => ctx.Execute(null, m => m.EditShowPrivateInfo(ctx, param.toggle)), + Commands.CfgCaseSensitiveProxyTagsShow => ctx.Execute(null, m => m.ViewCaseSensitiveProxyTags(ctx)), + Commands.CfgCaseSensitiveProxyTagsUpdate(var param, _) => ctx.Execute(null, m => m.EditCaseSensitiveProxyTags(ctx, param.toggle)), + Commands.CfgProxyErrorMessageShow => ctx.Execute(null, m => m.ViewProxyErrorMessageEnabled(ctx)), + Commands.CfgProxyErrorMessageUpdate(var param, _) => ctx.Execute(null, m => m.EditProxyErrorMessageEnabled(ctx, param.toggle)), + Commands.CfgHidSplitShow => ctx.Execute(null, m => m.ViewHidDisplaySplit(ctx)), + Commands.CfgHidSplitUpdate(var param, _) => ctx.Execute(null, m => m.EditHidDisplaySplit(ctx, param.toggle)), + Commands.CfgHidCapsShow => ctx.Execute(null, m => m.ViewHidDisplayCaps(ctx)), + Commands.CfgHidCapsUpdate(var param, _) => ctx.Execute(null, m => m.EditHidDisplayCaps(ctx, param.toggle)), + Commands.CfgHidPaddingShow => ctx.Execute(null, m => m.ViewHidListPadding(ctx)), + Commands.CfgHidPaddingUpdate(var param, _) => ctx.Execute(null, m => m.EditHidListPadding(ctx, param.padding)), + Commands.CfgCardShowColorHexShow => ctx.Execute(null, m => m.ViewCardShowColorHex(ctx)), + Commands.CfgCardShowColorHexUpdate(var param, _) => ctx.Execute(null, m => m.EditCardShowColorHex(ctx, param.toggle)), + Commands.CfgProxySwitchShow => ctx.Execute(null, m => m.ViewProxySwitch(ctx)), + Commands.CfgProxySwitchUpdate(var param, _) => ctx.Execute(null, m => m.EditProxySwitch(ctx, param.proxy_switch_action)), + Commands.CfgNameFormatShow => ctx.Execute(null, m => m.ViewNameFormat(ctx)), + Commands.CfgNameFormatReset => ctx.Execute(null, m => m.ResetNameFormat(ctx)), + Commands.CfgNameFormatUpdate(var param, _) => ctx.Execute(null, m => m.EditNameFormat(ctx, param.format)), + Commands.CfgServerNameFormatShow(_, var flags) => ctx.Execute(null, m => m.ViewServerNameFormat(ctx, flags.GetReplyFormat())), + Commands.CfgServerNameFormatReset => ctx.Execute(null, m => m.ResetServerNameFormat(ctx)), + Commands.CfgServerNameFormatUpdate(var param, _) => ctx.Execute(null, m => m.EditServerNameFormat(ctx, param.format)), + Commands.CfgLimitsUpdate => ctx.Execute(null, m => m.LimitUpdate(ctx)), + Commands.FunThunder => ctx.Execute(null, m => m.Thunder(ctx)), + Commands.FunMeow => ctx.Execute(null, m => m.Meow(ctx)), + Commands.FunPokemon => ctx.Execute(null, m => m.Mn(ctx)), + Commands.FunFire => ctx.Execute(null, m => m.Fire(ctx)), + Commands.FunFreeze => ctx.Execute(null, m => m.Freeze(ctx)), + Commands.FunStarstorm => ctx.Execute(null, m => m.Starstorm(ctx)), + Commands.FunFlash => ctx.Execute(null, m => m.Flash(ctx)), + Commands.FunRool => ctx.Execute(null, m => m.Rool(ctx)), + Commands.Amogus => ctx.Execute(null, m => m.Sus(ctx)), + Commands.FunError => ctx.Execute(null, m => m.Error(ctx)), + Commands.SystemInfo(var param, var flags) => ctx.Execute(SystemInfo, m => m.Query(ctx, param.target ?? ctx.System, flags.all, flags.@public, flags.@private)), + Commands.SystemNew(var param, _) => ctx.Execute(SystemNew, m => m.New(ctx, param.name)), + Commands.SystemShowName(var param, var flags) => ctx.Execute(SystemRename, m => m.ShowName(ctx, param.target ?? ctx.System, flags.GetReplyFormat())), + Commands.SystemRename(var param, _) => ctx.Execute(SystemRename, m => m.Rename(ctx, ctx.System, param.name)), + Commands.SystemShowServerName(var param, var flags) => ctx.Execute(SystemServerName, m => m.ShowServerName(ctx, param.target ?? ctx.System, flags.GetReplyFormat())), + Commands.SystemClearServerName(var param, var flags) => ctx.Execute(SystemServerName, m => m.ClearServerName(ctx, ctx.System, flags.yes)), + Commands.SystemRenameServerName(var param, _) => ctx.Execute(SystemServerName, m => m.RenameServerName(ctx, ctx.System, param.name)), + Commands.SystemShowDescription(var param, var flags) => ctx.Execute(SystemDesc, m => m.ShowDescription(ctx, param.target ?? ctx.System, flags.GetReplyFormat())), + Commands.SystemClearDescription(var param, var flags) => ctx.Execute(SystemDesc, m => m.ClearDescription(ctx, ctx.System, flags.yes)), + Commands.SystemChangeDescription(var param, _) => ctx.Execute(SystemDesc, m => m.ChangeDescription(ctx, ctx.System, param.description)), + Commands.SystemShowColor(var param, var flags) => ctx.Execute(SystemColor, m => m.ShowColor(ctx, param.target ?? ctx.System, flags.GetReplyFormat())), + Commands.SystemClearColor(var param, var flags) => ctx.Execute(SystemColor, m => m.ClearColor(ctx, ctx.System, flags.yes)), + Commands.SystemChangeColor(var param, _) => ctx.Execute(SystemColor, m => m.ChangeColor(ctx, ctx.System, param.color)), + Commands.SystemShowTag(var param, var flags) => ctx.Execute(SystemTag, m => m.ShowTag(ctx, param.target ?? ctx.System, flags.GetReplyFormat())), + Commands.SystemClearTag(var param, var flags) => ctx.Execute(SystemTag, m => m.ClearTag(ctx, ctx.System, flags.yes)), + Commands.SystemChangeTag(var param, _) => ctx.Execute(SystemTag, m => m.ChangeTag(ctx, ctx.System, param.tag)), + Commands.SystemShowServerTag(var param, var flags) => ctx.Execute(SystemServerTag, m => m.ShowServerTag(ctx, param.target ?? ctx.System, flags.GetReplyFormat())), + Commands.SystemClearServerTag(var param, var flags) => ctx.Execute(SystemServerTag, m => m.ClearServerTag(ctx, ctx.System, flags.yes)), + Commands.SystemChangeServerTag(var param, _) => ctx.Execute(SystemServerTag, m => m.ChangeServerTag(ctx, ctx.System, param.tag)), + Commands.SystemShowPronouns(var param, var flags) => ctx.Execute(SystemPronouns, m => m.ShowPronouns(ctx, param.target ?? ctx.System, flags.GetReplyFormat())), + Commands.SystemClearPronouns(var param, var flags) => ctx.Execute(SystemPronouns, m => m.ClearPronouns(ctx, ctx.System, flags.yes)), + Commands.SystemChangePronouns(var param, _) => ctx.Execute(SystemPronouns, m => m.ChangePronouns(ctx, ctx.System, param.pronouns)), + Commands.SystemShowAvatar(var param, var flags) => ((Func)(() => { - abuseLog = await ctx.Repository.GetAbuseLogByAccount(account.Id); - } - else - { - abuseLog = await ctx.Repository.GetAbuseLogByGuid(new Guid(ctx.PopArgument())); - } - - if (abuseLog == null) - { - await ctx.Reply($"{Emojis.Error} Could not find an existing abuse log entry for that query."); - return; - } - - if (!ctx.HasNext()) - await ctx.Execute(Admin, a => a.AbuseLogShow(ctx, abuseLog)); - else if (ctx.Match("au", "adduser")) - await ctx.Execute(Admin, a => a.AbuseLogAddUser(ctx, abuseLog)); - else if (ctx.Match("ru", "removeuser")) - await ctx.Execute(Admin, a => a.AbuseLogRemoveUser(ctx, abuseLog)); - else if (ctx.Match("desc", "description")) - await ctx.Execute(Admin, a => a.AbuseLogDescription(ctx, abuseLog)); - else if (ctx.Match("deny", "deny-bot-usage")) - await ctx.Execute(Admin, a => a.AbuseLogFlagDeny(ctx, abuseLog)); - else if (ctx.Match("yeet", "remove", "delete")) - await ctx.Execute(Admin, a => a.AbuseLogDelete(ctx, abuseLog)); - else - await ctx.Reply($"{Emojis.Error} Unknown subcommand {ctx.PeekArgument().AsCode()}."); - } - } - - private async Task HandleAdminCommand(Context ctx) - { - if (ctx.Match("usid", "updatesystemid")) - await ctx.Execute(Admin, a => a.UpdateSystemId(ctx)); - else if (ctx.Match("umid", "updatememberid")) - await ctx.Execute(Admin, a => a.UpdateMemberId(ctx)); - else if (ctx.Match("ugid", "updategroupid")) - await ctx.Execute(Admin, a => a.UpdateGroupId(ctx)); - else if (ctx.Match("rsid", "rerollsystemid")) - await ctx.Execute(Admin, a => a.RerollSystemId(ctx)); - else if (ctx.Match("rmid", "rerollmemberid")) - await ctx.Execute(Admin, a => a.RerollMemberId(ctx)); - else if (ctx.Match("rgid", "rerollgroupid")) - await ctx.Execute(Admin, a => a.RerollGroupId(ctx)); - else if (ctx.Match("uml", "updatememberlimit")) - await ctx.Execute(Admin, a => a.SystemMemberLimit(ctx)); - else if (ctx.Match("ugl", "updategrouplimit")) - await ctx.Execute(Admin, a => a.SystemGroupLimit(ctx)); - else if (ctx.Match("sr", "systemrecover")) - await ctx.Execute(Admin, a => a.SystemRecover(ctx)); - else if (ctx.Match("sd", "systemdelete")) - await ctx.Execute(Admin, a => a.SystemDelete(ctx)); - else if (ctx.Match("sendmsg", "sendmessage")) - await ctx.Execute(Admin, a => a.SendAdminMessage(ctx)); - else if (ctx.Match("al", "abuselog")) - await HandleAdminAbuseLogCommand(ctx); - else - await ctx.Reply($"{Emojis.Error} Unknown command."); - } - - private async Task HandleDebugCommand(Context ctx) - { - var availableCommandsStr = "Available debug targets: `permissions`, `proxying`"; - - if (ctx.Match("permissions", "perms", "permcheck")) - if (ctx.Match("channel", "ch")) - await ctx.Execute(PermCheck, m => m.PermCheckChannel(ctx)); - else - await ctx.Execute(PermCheck, m => m.PermCheckGuild(ctx)); - else if (ctx.Match("channel")) - await ctx.Execute(PermCheck, m => m.PermCheckChannel(ctx)); - else if (ctx.Match("proxy", "proxying", "proxycheck")) - await ctx.Execute(ProxyCheck, m => m.MessageProxyCheck(ctx)); - else if (!ctx.HasNext()) - await ctx.Reply($"{Emojis.Error} You need to pass a command. {availableCommandsStr}"); - else - await ctx.Reply( - $"{Emojis.Error} Unknown debug command {ctx.PeekArgument().AsCode()}. {availableCommandsStr}"); - } - - private async Task HandleSystemCommand(Context ctx) - { - // these commands never take a system target - if (ctx.Match("new", "create", "make", "add", "register", "init", "n")) - await ctx.Execute(SystemNew, m => m.New(ctx)); - else if (ctx.Match("commands", "help")) - await PrintCommandList(ctx, "systems", SystemCommands); - - // todo: these aren't deprecated but also shouldn't be here - else if (ctx.Match("webhook", "hook")) - await ctx.Execute(null, m => m.SystemWebhook(ctx)); - else if (ctx.Match("proxy")) - await ctx.Execute(SystemProxy, m => m.SystemProxy(ctx)); - - // finally, parse commands that *can* take a system target - else - { - // try matching a system ID - var target = await ctx.MatchSystem(); - var previousPtr = ctx.Parameters._ptr; - - // if we have a parsed target and no more commands, don't bother with the command flow - // we skip the `target != null` check here since the argument isn't be popped if it's not a system - if (!ctx.HasNext()) - { - await ctx.Execute(SystemInfo, m => m.Query(ctx, target ?? ctx.System)); - return; - } - - // hacky, but we need to CheckSystem(target) which throws a PKError - // normally PKErrors are only handled in ctx.Execute - try - { - await HandleSystemCommandTargeted(ctx, target ?? ctx.System); - } - catch (PKError e) - { - await ctx.Reply($"{Emojis.Error} {e.Message}"); - return; - } - - // if we *still* haven't matched anything, the user entered an invalid command name or system reference - if (ctx.Parameters._ptr == previousPtr) - { - if (!ctx.Parameters.Peek().TryParseHid(out _) && !ctx.Parameters.Peek().TryParseMention(out _)) + if (param.target == null) { - await PrintCommandNotFoundError(ctx, SystemCommands); - return; + // we want to change avatar if an attached image is passed + // we can't have a separate parsed command for this since the parser can't be aware of any attachments + var attachedImage = ctx.ExtractImageFromAttachment(); + if (attachedImage is { } image) + return ctx.Execute(SystemAvatar, m => m.ChangeAvatar(ctx, ctx.System, image)); } - - var list = CreatePotentialCommandList(ctx.DefaultPrefix, SystemCommands); - await ctx.Reply($"{Emojis.Error} {await CreateSystemNotFoundError(ctx)}\n\n" - + $"Perhaps you meant to use one of the following commands?\n{list}"); - } - } - } - - private async Task HandleSystemCommandTargeted(Context ctx, PKSystem target) - { - if (ctx.Match("name", "rename", "changename", "rn")) - await ctx.CheckSystem(target).Execute(SystemRename, m => m.Name(ctx, target)); - else if (ctx.Match("servername", "sn", "sname", "snick", "snickname", "servernick", "servernickname", - "serverdisplayname", "guildname", "guildnick", "guildnickname", "serverdn")) - await ctx.Execute(SystemServerName, m => m.ServerName(ctx, target)); - else if (ctx.Match("tag", "t")) - await ctx.CheckSystem(target).Execute(SystemTag, m => m.Tag(ctx, target)); - else if (ctx.Match("servertag", "st", "stag", "deer")) - await ctx.CheckSystem(target).Execute(SystemServerTag, m => m.ServerTag(ctx, target)); - else if (ctx.Match("description", "desc", "describe", "d", "bio", "info", "text", "intro")) - await ctx.CheckSystem(target).Execute(SystemDesc, m => m.Description(ctx, target)); - else if (ctx.Match("pronouns", "pronoun", "prns", "pn")) - await ctx.CheckSystem(target).Execute(SystemPronouns, m => m.Pronouns(ctx, target)); - else if (ctx.Match("color", "colour")) - await ctx.CheckSystem(target).Execute(SystemColor, m => m.Color(ctx, target)); - else if (ctx.Match("banner", "splash", "cover")) - await ctx.CheckSystem(target).Execute(SystemBannerImage, m => m.BannerImage(ctx, target)); - else if (ctx.Match("avatar", "picture", "icon", "image", "pic", "pfp")) - await ctx.CheckSystem(target).Execute(SystemAvatar, m => m.Avatar(ctx, target)); - else if (ctx.Match("serveravatar", "sa", "servericon", "serverimage", "serverpfp", "serverpic", "savatar", "spic", - "guildavatar", "guildpic", "guildicon", "sicon", "spfp")) - await ctx.CheckSystem(target).Execute(SystemServerAvatar, m => m.ServerAvatar(ctx, target)); - else if (ctx.Match("list", "l", "members", "ls")) - await ctx.CheckSystem(target).Execute(SystemList, m => m.MemberList(ctx, target)); - else if (ctx.Match("find", "search", "query", "fd", "s")) - await ctx.CheckSystem(target).Execute(SystemFind, m => m.MemberList(ctx, target)); - else if (ctx.Match("f", "front", "fronter", "fronters")) - { - if (ctx.Match("h", "history")) - await ctx.CheckSystem(target).Execute(SystemFrontHistory, m => m.SystemFrontHistory(ctx, target)); - else if (ctx.Match("p", "percent", "%")) - await ctx.CheckSystem(target).Execute(SystemFrontPercent, m => m.FrontPercent(ctx, system: target)); - else - await ctx.CheckSystem(target).Execute(SystemFronter, m => m.SystemFronter(ctx, target)); - } - else if (ctx.Match("fh", "fronthistory", "history", "switches")) - await ctx.CheckSystem(target).Execute(SystemFrontHistory, m => m.SystemFrontHistory(ctx, target)); - else if (ctx.Match("fp", "frontpercent", "front%", "frontbreakdown")) - await ctx.CheckSystem(target).Execute(SystemFrontPercent, m => m.FrontPercent(ctx, system: target)); - else if (ctx.Match("info", "view", "show")) - await ctx.CheckSystem(target).Execute(SystemInfo, m => m.Query(ctx, target)); - else if (ctx.Match("groups", "gs")) - await ctx.CheckSystem(target).Execute(GroupList, g => g.ListSystemGroups(ctx, target)); - else if (ctx.Match("privacy")) - await ctx.CheckSystem(target).Execute(SystemPrivacy, m => m.SystemPrivacy(ctx, target)); - else if (ctx.Match("delete", "remove", "destroy", "erase", "yeet")) - await ctx.CheckSystem(target).Execute(SystemDelete, m => m.Delete(ctx, target)); - else if (ctx.Match("id")) - await ctx.CheckSystem(target).Execute(SystemId, m => m.DisplayId(ctx, target)); - else if (ctx.Match("random", "rand", "r")) - if (ctx.Match("group", "g") || ctx.MatchFlag("group", "g")) - await ctx.CheckSystem(target).Execute(GroupRandom, r => r.Group(ctx, target)); - else - await ctx.CheckSystem(target).Execute(MemberRandom, m => m.Member(ctx, target)); - } - - private async Task HandleMemberCommand(Context ctx) - { - if (ctx.Match("new", "n", "add", "create", "register")) - await ctx.Execute(MemberNew, m => m.NewMember(ctx)); - else if (ctx.Match("list")) - await ctx.Execute(SystemList, m => m.MemberList(ctx, ctx.System)); - else if (ctx.Match("commands", "help")) - await PrintCommandList(ctx, "members", MemberCommands); - else if (await ctx.MatchMember() is PKMember target) - await HandleMemberCommandTargeted(ctx, target); - else if (!ctx.HasNext()) - await PrintCommandExpectedError(ctx, MemberNew, MemberInfo, MemberRename, MemberDisplayName, - MemberServerName, MemberDesc, MemberPronouns, - MemberColor, MemberBirthday, MemberProxy, MemberDelete, MemberAvatar); - else - await ctx.Reply($"{Emojis.Error} {ctx.CreateNotFoundError("Member", ctx.PopArgument())}"); - } - - private async Task HandleMemberCommandTargeted(Context ctx, PKMember target) - { - // Commands that have a member target (eg. pk;member delete) - if (ctx.Match("rename", "name", "changename", "setname", "rn")) - await ctx.Execute(MemberRename, m => m.Name(ctx, target)); - else if (ctx.Match("description", "desc", "describe", "d", "bio", "info", "text", "intro")) - await ctx.Execute(MemberDesc, m => m.Description(ctx, target)); - else if (ctx.Match("pronouns", "pronoun", "prns", "pn")) - await ctx.Execute(MemberPronouns, m => m.Pronouns(ctx, target)); - else if (ctx.Match("color", "colour")) - await ctx.Execute(MemberColor, m => m.Color(ctx, target)); - else if (ctx.Match("birthday", "birth", "bday", "birthdate", "cakeday", "bdate", "bd")) - await ctx.Execute(MemberBirthday, m => m.Birthday(ctx, target)); - else if (ctx.Match("proxy", "tags", "proxytags", "brackets")) - await ctx.Execute(MemberProxy, m => m.Proxy(ctx, target)); - else if (ctx.Match("delete", "remove", "destroy", "erase", "yeet")) - await ctx.Execute(MemberDelete, m => m.Delete(ctx, target)); - else if (ctx.Match("avatar", "profile", "picture", "icon", "image", "pfp", "pic")) - await ctx.Execute(MemberAvatar, m => m.Avatar(ctx, target)); - else if (ctx.Match("proxyavatar", "proxypfp", "webhookavatar", "webhookpfp", "pa", "pavatar", "ppfp")) - await ctx.Execute(MemberAvatar, m => m.WebhookAvatar(ctx, target)); - else if (ctx.Match("banner", "splash", "cover")) - await ctx.Execute(MemberBannerImage, m => m.BannerImage(ctx, target)); - else if (ctx.Match("group", "groups", "g")) - if (ctx.Match("add", "a")) - await ctx.Execute(MemberGroupAdd, - m => m.AddRemoveGroups(ctx, target, Groups.AddRemoveOperation.Add)); - else if (ctx.Match("remove", "rem")) - await ctx.Execute(MemberGroupRemove, - m => m.AddRemoveGroups(ctx, target, Groups.AddRemoveOperation.Remove)); - else - await ctx.Execute(MemberGroups, m => m.ListMemberGroups(ctx, target)); - else if (ctx.Match("serveravatar", "sa", "servericon", "serverimage", "serverpfp", "serverpic", "savatar", "spic", - "guildavatar", "guildpic", "guildicon", "sicon", "spfp")) - await ctx.Execute(MemberServerAvatar, m => m.ServerAvatar(ctx, target)); - else if (ctx.Match("displayname", "dn", "dname", "nick", "nickname", "dispname")) - await ctx.Execute(MemberDisplayName, m => m.DisplayName(ctx, target)); - else if (ctx.Match("servername", "sn", "sname", "snick", "snickname", "servernick", "servernickname", - "serverdisplayname", "guildname", "guildnick", "guildnickname", "serverdn")) - await ctx.Execute(MemberServerName, m => m.ServerName(ctx, target)); - else if (ctx.Match("autoproxy", "ap")) - await ctx.Execute(MemberAutoproxy, m => m.MemberAutoproxy(ctx, target)); - else if (ctx.Match("keepproxy", "keeptags", "showtags", "kp")) - await ctx.Execute(MemberKeepProxy, m => m.KeepProxy(ctx, target)); - else if (ctx.Match("texttospeech", "text-to-speech", "tts")) - await ctx.Execute(MemberTts, m => m.Tts(ctx, target)); - else if (ctx.Match("serverkeepproxy", "servershowtags", "guildshowtags", "guildkeeptags", "serverkeeptags", "skp")) - await ctx.Execute(MemberServerKeepProxy, m => m.ServerKeepProxy(ctx, target)); - else if (ctx.Match("id")) - await ctx.Execute(MemberId, m => m.DisplayId(ctx, target)); - else if (ctx.Match("privacy")) - await ctx.Execute(MemberPrivacy, m => m.Privacy(ctx, target, null)); - else if (ctx.Match("private", "hidden", "hide")) - await ctx.Execute(MemberPrivacy, m => m.Privacy(ctx, target, PrivacyLevel.Private)); - else if (ctx.Match("public", "shown", "show", "unhide", "unhidden")) - await ctx.Execute(MemberPrivacy, m => m.Privacy(ctx, target, PrivacyLevel.Public)); - else if (ctx.Match("soulscream")) - await ctx.Execute(MemberInfo, m => m.Soulscream(ctx, target)); - else if (!ctx.HasNext()) // Bare command - await ctx.Execute(MemberInfo, m => m.ViewMember(ctx, target)); - else - await PrintCommandNotFoundError(ctx, MemberInfo, MemberRename, MemberDisplayName, MemberServerName, - MemberDesc, MemberPronouns, MemberColor, MemberBirthday, MemberProxy, MemberDelete, MemberAvatar, - SystemList); - } - - private async Task HandleGroupCommand(Context ctx) - { - // Commands with no group argument - if (ctx.Match("n", "new")) - await ctx.Execute(GroupNew, g => g.CreateGroup(ctx)); - else if (ctx.Match("list", "l")) - await ctx.Execute(GroupList, g => g.ListSystemGroups(ctx, null)); - else if (ctx.Match("commands", "help")) - await PrintCommandList(ctx, "groups", GroupCommands); - else if (await ctx.MatchGroup() is { } target) - { - // Commands with group argument - if (ctx.Match("rename", "name", "changename", "setname", "rn")) - await ctx.Execute(GroupRename, g => g.RenameGroup(ctx, target)); - else if (ctx.Match("nick", "dn", "displayname", "nickname")) - await ctx.Execute(GroupDisplayName, g => g.GroupDisplayName(ctx, target)); - else if (ctx.Match("description", "desc", "describe", "d", "bio", "info", "text", "intro")) - await ctx.Execute(GroupDesc, g => g.GroupDescription(ctx, target)); - else if (ctx.Match("add", "a")) - await ctx.Execute(GroupAdd, - g => g.AddRemoveMembers(ctx, target, Groups.AddRemoveOperation.Add)); - else if (ctx.Match("remove", "rem")) - await ctx.Execute(GroupRemove, - g => g.AddRemoveMembers(ctx, target, Groups.AddRemoveOperation.Remove)); - else if (ctx.Match("members", "list", "ms", "l", "ls")) - await ctx.Execute(GroupMemberList, g => g.ListGroupMembers(ctx, target)); - else if (ctx.Match("random", "rand", "r")) - await ctx.Execute(GroupMemberRandom, r => r.GroupMember(ctx, target)); - else if (ctx.Match("privacy")) - await ctx.Execute(GroupPrivacy, g => g.GroupPrivacy(ctx, target, null)); - else if (ctx.Match("public", "pub")) - await ctx.Execute(GroupPrivacy, g => g.GroupPrivacy(ctx, target, PrivacyLevel.Public)); - else if (ctx.Match("private", "priv")) - await ctx.Execute(GroupPrivacy, g => g.GroupPrivacy(ctx, target, PrivacyLevel.Private)); - else if (ctx.Match("delete", "destroy", "erase", "yeet")) - await ctx.Execute(GroupDelete, g => g.DeleteGroup(ctx, target)); - else if (ctx.Match("avatar", "picture", "icon", "image", "pic", "pfp")) - await ctx.Execute(GroupIcon, g => g.GroupIcon(ctx, target)); - else if (ctx.Match("banner", "splash", "cover")) - await ctx.Execute(GroupBannerImage, g => g.GroupBannerImage(ctx, target)); - else if (ctx.Match("fp", "frontpercent", "front%", "frontbreakdown")) - await ctx.Execute(GroupFrontPercent, g => g.FrontPercent(ctx, group: target)); - else if (ctx.Match("color", "colour")) - await ctx.Execute(GroupColor, g => g.GroupColor(ctx, target)); - else if (ctx.Match("id")) - await ctx.Execute(GroupId, g => g.DisplayId(ctx, target)); - else if (!ctx.HasNext()) - await ctx.Execute(GroupInfo, g => g.ShowGroupCard(ctx, target)); - else - await PrintCommandNotFoundError(ctx, GroupCommandsTargeted); - } - else if (!ctx.HasNext()) - await PrintCommandExpectedError(ctx, GroupCommands); - else - await ctx.Reply($"{Emojis.Error} {ctx.CreateNotFoundError("Group", ctx.PopArgument())}"); - } - - private async Task HandleSwitchCommand(Context ctx) - { - if (ctx.Match("out")) - await ctx.Execute(SwitchOut, m => m.SwitchOut(ctx)); - else if (ctx.Match("move", "m", "shift", "offset")) - await ctx.Execute(SwitchMove, m => m.SwitchMove(ctx)); - else if (ctx.Match("edit", "e", "replace")) - if (ctx.Match("out")) - await ctx.Execute(SwitchEditOut, m => m.SwitchEditOut(ctx)); - else - await ctx.Execute(SwitchEdit, m => m.SwitchEdit(ctx)); - else if (ctx.Match("delete", "remove", "erase", "cancel", "yeet")) - await ctx.Execute(SwitchDelete, m => m.SwitchDelete(ctx)); - else if (ctx.Match("copy", "add", "duplicate", "dupe")) - await ctx.Execute(SwitchCopy, m => m.SwitchEdit(ctx, true)); - else if (ctx.Match("commands", "help")) - await PrintCommandList(ctx, "switching", SwitchCommands); - else if (ctx.HasNext()) // there are following arguments - await ctx.Execute(Switch, m => m.SwitchDo(ctx)); - else - await PrintCommandNotFoundError(ctx, Switch, SwitchOut, SwitchMove, SwitchEdit, SwitchEditOut, - SwitchDelete, SwitchCopy, SystemFronter, SystemFrontHistory); - } - - private async Task CommandHelpRoot(Context ctx) - { - if (!ctx.HasNext()) - { - await ctx.Reply( - "Available command help targets: `system`, `member`, `group`, `switch`, `config`, `autoproxy`, `log`, `blacklist`." - + $"\n- **{ctx.DefaultPrefix}commands ** - *View commands related to a help target.*" - + "\n\nFor the full list of commands, see the website: "); - return; - } - - switch (ctx.PeekArgument()) - { - case "system": - case "systems": - case "s": - case "account": - case "acc": - await PrintCommandList(ctx, "systems", SystemCommands); - break; - case "member": - case "members": - case "m": - await PrintCommandList(ctx, "members", MemberCommands); - break; - case "group": - case "groups": - case "g": - await PrintCommandList(ctx, "groups", GroupCommands); - break; - case "switch": - case "switches": - case "switching": - case "sw": - await PrintCommandList(ctx, "switching", SwitchCommands); - break; - case "log": - await PrintCommandList(ctx, "message logging", LogCommands); - break; - case "blacklist": - case "bl": - await PrintCommandList(ctx, "channel blacklisting", BlacklistCommands); - break; - case "config": - case "cfg": - await PrintCommandList(ctx, "settings", ConfigCommands); - break; - case "serverconfig": - case "guildconfig": - case "scfg": - await PrintCommandList(ctx, "server settings", ServerConfigCommands); - break; - case "autoproxy": - case "ap": - await PrintCommandList(ctx, "autoproxy", AutoproxyCommands); - break; - default: - await ctx.Reply("For the full list of commands, see the website: "); - break; - } - } - - private Task HandleAutoproxyCommand(Context ctx) - { - // ctx.CheckSystem(); - // oops, that breaks stuff! PKErrors before ctx.Execute don't actually do anything. - // so we just emulate checking and throwing an error. - if (ctx.System == null) - return ctx.Reply($"{Emojis.Error} {Errors.NoSystemError(ctx.DefaultPrefix).Message}"); - - return ctx.Execute(AutoproxySet, m => m.SetAutoproxyMode(ctx)); - } - - private Task HandleConfigCommand(Context ctx) - { - if (ctx.System == null) - return ctx.Reply($"{Emojis.Error} {Errors.NoSystemError(ctx.DefaultPrefix).Message}"); - - if (!ctx.HasNext()) - return ctx.Execute(null, m => m.ShowConfig(ctx)); - - if (ctx.MatchMultiple(new[] { "autoproxy", "ap" }, new[] { "account", "ac" })) - return ctx.Execute(null, m => m.AutoproxyAccount(ctx)); - if (ctx.MatchMultiple(new[] { "autoproxy", "ap" }, new[] { "timeout", "tm" })) - return ctx.Execute(null, m => m.AutoproxyTimeout(ctx)); - if (ctx.Match("timezone", "zone", "tz")) - return ctx.Execute(null, m => m.SystemTimezone(ctx)); - if (ctx.Match("ping")) - return ctx.Execute(null, m => m.SystemPing(ctx)); - if (ctx.MatchMultiple(new[] { "private" }, new[] { "member" }) || ctx.Match("mp")) - return ctx.Execute(null, m => m.MemberDefaultPrivacy(ctx)); - if (ctx.MatchMultiple(new[] { "private" }, new[] { "group" }) || ctx.Match("gp")) - return ctx.Execute(null, m => m.GroupDefaultPrivacy(ctx)); - if (ctx.MatchMultiple(new[] { "show" }, new[] { "private" }) || ctx.Match("sp")) - return ctx.Execute(null, m => m.ShowPrivateInfo(ctx)); - if (ctx.MatchMultiple(new[] { "proxy" }, new[] { "case" })) - return ctx.Execute(null, m => m.CaseSensitiveProxyTags(ctx)); - if (ctx.MatchMultiple(new[] { "proxy" }, new[] { "error" }) || ctx.Match("pe")) - return ctx.Execute(null, m => m.ProxyErrorMessageEnabled(ctx)); - if (ctx.MatchMultiple(new[] { "split" }, new[] { "id", "ids" }) || ctx.Match("sid", "sids")) - return ctx.Execute(null, m => m.HidDisplaySplit(ctx)); - if (ctx.MatchMultiple(new[] { "cap", "caps", "capitalize", "capitalise" }, new[] { "id", "ids" }) || ctx.Match("capid", "capids")) - return ctx.Execute(null, m => m.HidDisplayCaps(ctx)); - if (ctx.MatchMultiple(new[] { "pad" }, new[] { "id", "ids" }) || ctx.MatchMultiple(new[] { "id" }, new[] { "pad", "padding" }) || ctx.Match("idpad", "padid", "padids")) - return ctx.Execute(null, m => m.HidListPadding(ctx)); - if (ctx.MatchMultiple(new[] { "show" }, new[] { "color", "colour", "colors", "colours" }) || ctx.Match("showcolor", "showcolour", "showcolors", "showcolours", "colorcode", "colorhex")) - return ctx.Execute(null, m => m.CardShowColorHex(ctx)); - if (ctx.MatchMultiple(new[] { "name" }, new[] { "format" }) || ctx.Match("nameformat", "nf")) - return ctx.Execute(null, m => m.NameFormat(ctx)); - if (ctx.MatchMultiple(new[] { "member", "group" }, new[] { "limit" }) || ctx.Match("limit")) - return ctx.Execute(null, m => m.LimitUpdate(ctx)); - if (ctx.MatchMultiple(new[] { "proxy" }, new[] { "switch" }) || ctx.Match("proxyswitch", "ps")) - return ctx.Execute(null, m => m.ProxySwitch(ctx)); - if (ctx.MatchMultiple(new[] { "server" }, new[] { "name" }, new[] { "format" }) || ctx.MatchMultiple(new[] { "server", "servername" }, new[] { "format", "nameformat", "nf" }) || ctx.Match("snf", "servernf", "servernameformat", "snameformat")) - return ctx.Execute(null, m => m.ServerNameFormat(ctx)); - - // todo: maybe add the list of configuration keys here? - return ctx.Reply($"{Emojis.Error} Could not find a setting with that name. Please see `{ctx.DefaultPrefix}commands config` for the list of possible config settings."); - } - - private Task HandleServerConfigCommand(Context ctx) - { - if (!ctx.HasNext()) - return ctx.Execute(null, m => m.ShowConfig(ctx)); - - if (ctx.MatchMultiple(new[] { "log" }, new[] { "cleanup", "clean" }) || ctx.Match("logclean")) - return ctx.Execute(null, m => m.SetLogCleanup(ctx)); - if (ctx.MatchMultiple(new[] { "invalid", "unknown" }, new[] { "command" }, new[] { "error", "response" }) || ctx.Match("invalidcommanderror", "unknowncommanderror")) - return ctx.Execute(null, m => m.InvalidCommandResponse(ctx)); - if (ctx.MatchMultiple(new[] { "require", "enforce" }, new[] { "tag", "systemtag" }) || ctx.Match("requiretag", "enforcetag")) - return ctx.Execute(null, m => m.RequireSystemTag(ctx)); - if (ctx.MatchMultiple(new[] { "suppress" }, new[] { "notifications" }) || ctx.Match("proxysilent")) - return ctx.Execute(null, m => m.SuppressNotifications(ctx)); - if (ctx.MatchMultiple(new[] { "log" }, new[] { "channel" })) - return ctx.Execute(null, m => m.SetLogChannel(ctx)); - if (ctx.MatchMultiple(new[] { "log" }, new[] { "blacklist" })) - { - if (ctx.Match("enable", "on", "add", "deny")) - return ctx.Execute(null, m => m.SetLogBlacklisted(ctx, true)); - else if (ctx.Match("disable", "off", "remove", "allow")) - return ctx.Execute(null, m => m.SetLogBlacklisted(ctx, false)); - else - return ctx.Execute(null, m => m.ShowLogDisabledChannels(ctx)); - } - if (ctx.MatchMultiple(new[] { "proxy", "proxying" }, new[] { "blacklist" })) - { - if (ctx.Match("enable", "on", "add", "deny")) - return ctx.Execute(null, m => m.SetProxyBlacklisted(ctx, true)); - else if (ctx.Match("disable", "off", "remove", "allow")) - return ctx.Execute(null, m => m.SetProxyBlacklisted(ctx, false)); - else - return ctx.Execute(null, m => m.ShowProxyBlacklisted(ctx)); - } - - // todo: maybe add the list of configuration keys here? - return ctx.Reply($"{Emojis.Error} Could not find a setting with that name. Please see `{ctx.DefaultPrefix}commands serverconfig` for the list of possible config settings."); + // if no attachment show the avatar like intended + return ctx.Execute(SystemAvatar, m => m.ShowAvatar(ctx, param.target ?? ctx.System, flags.GetReplyFormat())); + }))(), + Commands.SystemClearAvatar(var param, var flags) => ctx.Execute(SystemAvatar, m => m.ClearAvatar(ctx, ctx.System, flags.yes)), + Commands.SystemChangeAvatar(var param, _) => ctx.Execute(SystemAvatar, m => m.ChangeAvatar(ctx, ctx.System, param.avatar)), + Commands.SystemShowServerAvatar(var param, var flags) => ((Func)(() => + { + if (param.target == null) + { + // we want to change avatar if an attached image is passed + // we can't have a separate parsed command for this since the parser can't be aware of any attachments + var attachedImage = ctx.ExtractImageFromAttachment(); + if (attachedImage is { } image) + return ctx.Execute(SystemServerAvatar, m => m.ChangeServerAvatar(ctx, ctx.System, image)); + } + // if no attachment show the avatar like intended + return ctx.Execute(SystemServerAvatar, m => m.ShowServerAvatar(ctx, param.target ?? ctx.System, flags.GetReplyFormat())); + }))(), + Commands.SystemClearServerAvatar(var param, var flags) => ctx.Execute(SystemServerAvatar, m => m.ClearServerAvatar(ctx, ctx.System, flags.yes)), + Commands.SystemChangeServerAvatar(var param, _) => ctx.Execute(SystemServerAvatar, m => m.ChangeServerAvatar(ctx, ctx.System, param.avatar)), + Commands.SystemShowBanner(var param, var flags) => ((Func)(() => + { + if (param.target == null) + { + // we want to change banner if an attached image is passed + // we can't have a separate parsed command for this since the parser can't be aware of any attachments + var attachedImage = ctx.ExtractImageFromAttachment(); + if (attachedImage is { } image) + return ctx.Execute(SystemBannerImage, m => m.ChangeBannerImage(ctx, ctx.System, image)); + } + // if no attachment show the banner like intended + return ctx.Execute(SystemBannerImage, m => m.ShowBannerImage(ctx, param.target ?? ctx.System, flags.GetReplyFormat())); + }))(), + Commands.SystemClearBanner(var param, var flags) => ctx.Execute(SystemBannerImage, m => m.ClearBannerImage(ctx, ctx.System, flags.yes)), + Commands.SystemChangeBanner(var param, _) => ctx.Execute(SystemBannerImage, m => m.ChangeBannerImage(ctx, ctx.System, param.banner)), + Commands.SystemDelete(_, var flags) => ctx.Execute(SystemDelete, m => m.Delete(ctx, ctx.System, flags.no_export)), + Commands.SystemShowProxyCurrent(_, _) => ctx.Execute(SystemProxy, m => m.ShowSystemProxy(ctx, ctx.Guild)), + Commands.SystemShowProxy(var param, _) => ctx.Execute(SystemProxy, m => m.ShowSystemProxy(ctx, param.target)), + Commands.SystemToggleProxyCurrent(var param, _) => ctx.Execute(SystemProxy, m => m.ToggleSystemProxy(ctx, ctx.Guild, param.toggle)), + Commands.SystemToggleProxy(var param, _) => ctx.Execute(SystemProxy, m => m.ToggleSystemProxy(ctx, param.target, param.toggle)), + Commands.SystemShowPrivacy(var param, _) => ctx.Execute(SystemPrivacy, m => m.ShowSystemPrivacy(ctx, ctx.System)), + Commands.SystemChangePrivacyAll(var param, _) => ctx.Execute(SystemPrivacy, m => m.ChangeSystemPrivacyAll(ctx, ctx.System, param.level)), + Commands.SystemChangePrivacy(var param, _) => ctx.Execute(SystemPrivacy, m => m.ChangeSystemPrivacy(ctx, ctx.System, param.privacy, param.level)), + Commands.SwitchOut(_, _) => ctx.Execute(SwitchOut, m => m.SwitchOut(ctx)), + Commands.SwitchDo(var param, _) => ctx.Execute(Switch, m => m.SwitchDo(ctx, param.targets)), + Commands.SwitchMove(var param, var flags) => ctx.Execute(SwitchMove, m => m.SwitchMove(ctx, param.@string, flags.yes)), + Commands.SwitchEdit(var param, var flags) => ctx.Execute(SwitchEdit, m => m.SwitchEdit(ctx, param.targets, false, flags.first, flags.remove, flags.append, flags.prepend, flags.yes)), + Commands.SwitchEditOut(_, var flags) => ctx.Execute(SwitchEditOut, m => m.SwitchEditOut(ctx, flags.yes)), + Commands.SwitchDelete(var param, var flags) => ctx.Execute(SwitchDelete, m => m.SwitchDelete(ctx, flags.all, flags.yes)), + Commands.SwitchCopy(var param, var flags) => ctx.Execute(SwitchCopy, m => m.SwitchEdit(ctx, param.targets, true, flags.first, flags.remove, flags.append, flags.prepend, false)), + Commands.SystemFronter(var param, var flags) => ctx.Execute(SystemFronter, m => m.Fronter(ctx, param.target ?? ctx.System)), + Commands.SystemFronterHistory(var param, var flags) => ctx.Execute(SystemFrontHistory, m => m.FrontHistory(ctx, param.target ?? ctx.System, flags.clear)), + Commands.SystemFronterPercent(var param, var flags) => ctx.Execute(SystemFrontPercent, m => m.FrontPercent(ctx, param.target ?? ctx.System, flags.duration, flags.fronters_only, flags.flat)), + Commands.SystemDisplayId(var param, _) => ctx.Execute(SystemId, m => m.DisplayId(ctx, param.target ?? ctx.System)), + Commands.SystemWebhookShow => ctx.Execute(null, m => m.GetSystemWebhook(ctx)), + Commands.SystemWebhookClear(_, var flags) => ctx.Execute(null, m => m.ClearSystemWebhook(ctx, flags.yes)), + Commands.SystemWebhookSet(var param, _) => ctx.Execute(null, m => m.SetSystemWebhook(ctx, param.url)), + Commands.RandomSelf(_, var flags) => + flags.group + ? ctx.Execute(GroupRandom, m => m.Group(ctx, ctx.System, flags.all, flags.show_embed)) + : ctx.Execute(MemberRandom, m => m.Member(ctx, ctx.System, flags.all, flags.show_embed)), + Commands.RandomGroupSelf(_, var flags) => ctx.Execute(GroupRandom, m => m.Group(ctx, ctx.System, flags.all, flags.show_embed)), + Commands.RandomGroupMemberSelf(var param, var flags) => ctx.Execute(GroupMemberRandom, m => m.GroupMember(ctx, param.target, flags.all, flags.show_embed, flags)), + Commands.SystemRandom(var param, var flags) => + flags.group + ? ctx.Execute(GroupRandom, m => m.Group(ctx, param.target, flags.all, flags.show_embed)) + : ctx.Execute(MemberRandom, m => m.Member(ctx, param.target, flags.all, flags.show_embed)), + Commands.SystemRandomGroup(var param, var flags) => + ctx.Execute(GroupRandom, m => m.Group(ctx, param.target, flags.all, flags.show_embed)), + Commands.GroupRandomMember(var param, var flags) => ctx.Execute(GroupMemberRandom, m => m.GroupMember(ctx, param.target, flags.all, flags.show_embed, flags)), + Commands.SystemLink(var param, var flags) => ctx.Execute(Link, m => m.LinkSystem(ctx, param.account, flags.yes)), + Commands.SystemUnlink(var param, var flags) => ctx.Execute(Unlink, m => m.UnlinkAccount(ctx, param.account, flags.yes)), + Commands.SystemMembers(var param, var flags) => ctx.Execute(SystemList, m => m.MemberList(ctx, param.target ?? ctx.System, param.query, flags)), + Commands.MemberGroups(var param, var flags) => ctx.Execute(MemberGroups, m => m.ListMemberGroups(ctx, param.target, param.query, flags, flags.all)), + Commands.GroupMembers(var param, var flags) => ctx.Execute(GroupMemberList, m => m.ListGroupMembers(ctx, param.target, param.query, flags)), + Commands.SystemGroups(var param, var flags) => ctx.Execute(GroupList, g => g.ListSystemGroups(ctx, param.target ?? ctx.System, param.query, flags, flags.all)), + Commands.GroupsSelf(var param, var flags) => ctx.Execute(GroupList, g => g.ListSystemGroups(ctx, ctx.System, param.query, flags, flags.all)), + Commands.GroupNew(var param, var flags) => ctx.Execute(GroupNew, g => g.CreateGroup(ctx, param.name, flags.yes)), + Commands.GroupInfo(var param, var flags) => ctx.Execute(GroupInfo, g => g.ShowGroupCard(ctx, param.target, flags.show_embed, flags.all)), + Commands.GroupShowName(var param, var flags) => ctx.Execute(GroupRename, g => g.ShowGroupDisplayName(ctx, param.target, flags.GetReplyFormat())), + Commands.GroupClearName(var param, var flags) => ctx.Execute(GroupRename, g => g.RenameGroup(ctx, param.target, null)), + Commands.GroupRename(var param, var flags) => ctx.Execute(GroupRename, g => g.RenameGroup(ctx, param.target, param.name, flags.yes)), + Commands.GroupShowDisplayName(var param, var flags) => ctx.Execute(GroupDisplayName, g => g.ShowGroupDisplayName(ctx, param.target, flags.GetReplyFormat())), + Commands.GroupClearDisplayName(var param, var flags) => ctx.Execute(GroupDisplayName, g => g.ClearGroupDisplayName(ctx, param.target, flags.yes)), + Commands.GroupChangeDisplayName(var param, _) => ctx.Execute(GroupDisplayName, g => g.ChangeGroupDisplayName(ctx, param.target, param.name)), + Commands.GroupShowDescription(var param, var flags) => ctx.Execute(GroupDesc, g => g.ShowGroupDescription(ctx, param.target, flags.GetReplyFormat())), + Commands.GroupClearDescription(var param, var flags) => ctx.Execute(GroupDesc, g => g.ClearGroupDescription(ctx, param.target, flags.yes)), + Commands.GroupChangeDescription(var param, _) => ctx.Execute(GroupDesc, g => g.ChangeGroupDescription(ctx, param.target, param.description)), + Commands.GroupShowIcon(var param, var flags) => ctx.Execute(GroupIcon, g => g.ShowGroupIcon(ctx, param.target, flags.GetReplyFormat())), + Commands.GroupClearIcon(var param, var flags) => ctx.Execute(GroupIcon, g => g.ClearGroupIcon(ctx, param.target, flags.yes)), + Commands.GroupChangeIcon(var param, _) => ctx.Execute(GroupIcon, g => g.ChangeGroupIcon(ctx, param.target, param.icon)), + Commands.GroupShowBanner(var param, var flags) => ctx.Execute(GroupBannerImage, g => g.ShowGroupBanner(ctx, param.target, flags.GetReplyFormat())), + Commands.GroupClearBanner(var param, var flags) => ctx.Execute(GroupBannerImage, g => g.ClearGroupBanner(ctx, param.target, flags.yes)), + Commands.GroupChangeBanner(var param, _) => ctx.Execute(GroupBannerImage, g => g.ChangeGroupBanner(ctx, param.target, param.banner)), + Commands.GroupShowColor(var param, var flags) => ctx.Execute(GroupColor, g => g.ShowGroupColor(ctx, param.target, flags.GetReplyFormat())), + Commands.GroupClearColor(var param, var flags) => ctx.Execute(GroupColor, g => g.ClearGroupColor(ctx, param.target, flags.yes)), + Commands.GroupChangeColor(var param, _) => ctx.Execute(GroupColor, g => g.ChangeGroupColor(ctx, param.target, param.color)), + Commands.GroupAddMember(var param, var flags) => ctx.Execute(GroupAdd, g => g.AddRemoveMembers(ctx, param.target, param.targets, Groups.AddRemoveOperation.Add, flags.all)), + Commands.GroupRemoveMember(var param, var flags) => ctx.Execute(GroupRemove, g => g.AddRemoveMembers(ctx, param.target, param.targets, Groups.AddRemoveOperation.Remove, flags.all, flags.yes)), + Commands.GroupShowPrivacy(var param, _) => ctx.Execute(GroupPrivacy, g => g.ShowGroupPrivacy(ctx, param.target)), + Commands.GroupChangePrivacyAll(var param, _) => ctx.Execute(GroupPrivacy, g => g.SetAllGroupPrivacy(ctx, param.target, param.level)), + Commands.GroupChangePrivacy(var param, _) => ctx.Execute(GroupPrivacy, g => g.SetGroupPrivacy(ctx, param.target, param.privacy, param.level)), + Commands.GroupSetPublic(var param, _) => ctx.Execute(GroupPrivacy, g => g.SetAllGroupPrivacy(ctx, param.target, PrivacyLevel.Public)), + Commands.GroupSetPrivate(var param, _) => ctx.Execute(GroupPrivacy, g => g.SetAllGroupPrivacy(ctx, param.target, PrivacyLevel.Private)), + Commands.GroupDelete(var param, var flags) => ctx.Execute(GroupDelete, g => g.DeleteGroup(ctx, param.target)), + Commands.GroupId(var param, _) => ctx.Execute(GroupId, g => g.DisplayId(ctx, param.target)), + Commands.GroupFronterPercent(var param, var flags) => ctx.Execute(GroupFrontPercent, g => g.FrontPercent(ctx, null, flags.duration, flags.fronters_only, flags.flat, param.target)), + Commands.TokenDisplay => ctx.Execute(TokenGet, m => m.GetToken(ctx)), + Commands.TokenRefresh => ctx.Execute(TokenRefresh, m => m.RefreshToken(ctx)), + Commands.AutoproxyShow => ctx.Execute(AutoproxySet, m => m.SetAutoproxyMode(ctx, null)), + Commands.AutoproxyOff => ctx.Execute(AutoproxySet, m => m.SetAutoproxyMode(ctx, new Autoproxy.Mode.Off())), + Commands.AutoproxyLatch => ctx.Execute(AutoproxySet, m => m.SetAutoproxyMode(ctx, new Autoproxy.Mode.Latch())), + Commands.AutoproxyFront => ctx.Execute(AutoproxySet, m => m.SetAutoproxyMode(ctx, new Autoproxy.Mode.Front())), + Commands.AutoproxyMember(var param, _) => ctx.Execute(AutoproxySet, m => m.SetAutoproxyMode(ctx, new Autoproxy.Mode.Member(param.target))), + Commands.PermcheckChannel(var param, _) => ctx.Execute(PermCheck, m => m.PermCheckChannel(ctx, param.target)), + Commands.PermcheckGuild(var param, _) => ctx.Execute(PermCheck, m => m.PermCheckGuild(ctx, param.target)), + Commands.MessageProxyCheck(var param, _) => ctx.Execute(ProxyCheck, m => m.MessageProxyCheck(ctx, param.target)), + Commands.MessageInfo(var param, var flags) => ctx.Execute(Message, m => m.GetMessage(ctx, param.target, flags.GetReplyFormat(), flags.delete, flags.author, flags.show_embed)), + Commands.MessageAuthor(var param, var flags) => ctx.Execute(Message, m => m.GetMessage(ctx, param.target, flags.GetReplyFormat(), false, true, flags.show_embed)), + Commands.MessageDelete(var param, var flags) => ctx.Execute(Message, m => m.GetMessage(ctx, param.target, flags.GetReplyFormat(), true, false, flags.show_embed)), + Commands.MessageEdit(var param, var flags) => ctx.Execute(MessageEdit, m => m.EditMessage(ctx, param.target, param.new_content, flags.regex, flags.no_space, flags.append, flags.prepend, flags.clear_embeds, flags.clear_attachments)), + Commands.MessageReproxy(var param, _) => ctx.Execute(MessageReproxy, m => m.ReproxyMessage(ctx, param.msg, param.member)), + Commands.Import(var param, var flags) => ctx.Execute(Import, m => m.Import(ctx, param.url, flags.yes)), + Commands.Export(_, _) => ctx.Execute(Export, m => m.Export(ctx)), + Commands.ServerConfigShow => ctx.Execute(null, m => m.ShowConfig(ctx)), + Commands.ServerConfigLogChannelShow => ctx.Execute(null, m => m.ShowLogChannel(ctx)), + Commands.ServerConfigLogChannelSet(var param, _) => ctx.Execute(null, m => m.SetLogChannel(ctx, param.channel)), + Commands.ServerConfigLogChannelClear(_, var flags) => ctx.Execute(null, m => m.ClearLogChannel(ctx, flags.yes)), + Commands.ServerConfigLogCleanupShow => ctx.Execute(null, m => m.ShowLogCleanup(ctx)), + Commands.ServerConfigLogCleanupSet(var param, _) => ctx.Execute(null, m => m.SetLogCleanup(ctx, param.toggle)), + Commands.ServerConfigLogBlacklistShow => ctx.Execute(null, m => m.ShowLogBlacklist(ctx)), + Commands.ServerConfigLogBlacklistAdd(var param, var flags) => ctx.Execute(null, m => m.AddLogBlacklist(ctx, param.channel, flags.all)), + Commands.ServerConfigLogBlacklistRemove(var param, var flags) => ctx.Execute(null, m => m.RemoveLogBlacklist(ctx, param.channel, flags.all)), + Commands.ServerConfigProxyBlacklistShow => ctx.Execute(null, m => m.ShowProxyBlacklist(ctx)), + Commands.ServerConfigProxyBlacklistAdd(var param, var flags) => ctx.Execute(null, m => m.AddProxyBlacklist(ctx, param.channel, flags.all)), + Commands.ServerConfigProxyBlacklistRemove(var param, var flags) => ctx.Execute(null, m => m.RemoveProxyBlacklist(ctx, param.channel, flags.all)), + Commands.ServerConfigInvalidCommandResponseShow => ctx.Execute(null, m => m.ShowInvalidCommandResponse(ctx)), + Commands.ServerConfigInvalidCommandResponseSet(var param, _) => ctx.Execute(null, m => m.SetInvalidCommandResponse(ctx, param.toggle)), + Commands.ServerConfigRequireSystemTagShow => ctx.Execute(null, m => m.ShowRequireSystemTag(ctx)), + Commands.ServerConfigRequireSystemTagSet(var param, _) => ctx.Execute(null, m => m.SetRequireSystemTag(ctx, param.toggle)), + Commands.ServerConfigSuppressNotificationsShow => ctx.Execute(null, m => m.ShowSuppressNotifications(ctx)), + Commands.ServerConfigSuppressNotificationsSet(var param, _) => ctx.Execute(null, m => m.SetSuppressNotifications(ctx, param.toggle)), + Commands.AdminUpdateSystemId(var param, var flags) => ctx.Execute(null, m => m.UpdateSystemId(ctx, param.target, param.new_hid, flags.yes)), + Commands.AdminUpdateMemberId(var param, var flags) => ctx.Execute(null, m => m.UpdateMemberId(ctx, param.target, param.new_hid, flags.yes)), + Commands.AdminUpdateGroupId(var param, var flags) => ctx.Execute(null, m => m.UpdateGroupId(ctx, param.target, param.new_hid, flags.yes)), + Commands.AdminRerollSystemId(var param, var flags) => ctx.Execute(null, m => m.RerollSystemId(ctx, param.target, flags.yes)), + Commands.AdminRerollMemberId(var param, var flags) => ctx.Execute(null, m => m.RerollMemberId(ctx, param.target, flags.yes)), + Commands.AdminRerollGroupId(var param, var flags) => ctx.Execute(null, m => m.RerollGroupId(ctx, param.target, flags.yes)), + Commands.AdminSystemMemberLimit(var param, var flags) => ctx.Execute(null, m => m.SystemMemberLimit(ctx, param.target, param.limit, flags.yes)), + Commands.AdminSystemGroupLimit(var param, var flags) => ctx.Execute(null, m => m.SystemGroupLimit(ctx, param.target, param.limit, flags.yes)), + Commands.AdminSystemRecover(var param, var flags) => ctx.Execute(null, m => m.SystemRecover(ctx, param.token, param.account, flags.reroll_token, flags.yes)), + Commands.AdminSystemDelete(var param, _) => ctx.Execute(null, m => m.SystemDelete(ctx, param.target)), + Commands.AdminSendMessage(var param, _) => ctx.Execute(null, m => m.SendAdminMessage(ctx, param.account, param.content)), + Commands.AdminAbuselogCreate(var param, var flags) => ctx.Execute(null, m => m.AbuseLogCreate(ctx, param.account, flags.deny_boy_usage, param.description)), + Commands.AdminAbuselogShowAccount(var param, _) => ctx.Execute(null, m => m.AbuseLogShow(ctx, param.account, null)), + Commands.AdminAbuselogFlagDenyAccount(var param, _) => ctx.Execute(null, m => m.AbuseLogFlagDeny(ctx, param.account, null, param.value)), + Commands.AdminAbuselogDescriptionAccount(var param, var flags) => ctx.Execute(null, m => m.AbuseLogDescription(ctx, param.account, null, param.desc, flags.clear, flags.yes)), + Commands.AdminAbuselogAddUserAccount(var param, _) => ctx.Execute(null, m => m.AbuseLogAddUser(ctx, param.account, null, ctx.Author)), + Commands.AdminAbuselogRemoveUserAccount(var param, _) => ctx.Execute(null, m => m.AbuseLogRemoveUser(ctx, param.account, null, ctx.Author)), + Commands.AdminAbuselogDeleteAccount(var param, _) => ctx.Execute(null, m => m.AbuseLogDelete(ctx, param.account, null)), + Commands.AdminAbuselogShowLogId(var param, _) => ctx.Execute(null, m => m.AbuseLogShow(ctx, null, param.log_id)), + Commands.AdminAbuselogFlagDenyLogId(var param, _) => ctx.Execute(null, m => m.AbuseLogFlagDeny(ctx, null, param.log_id, param.value)), + Commands.AdminAbuselogDescriptionLogId(var param, var flags) => ctx.Execute(null, m => m.AbuseLogDescription(ctx, null, param.log_id, param.desc, flags.clear, flags.yes)), + Commands.AdminAbuselogAddUserLogId(var param, _) => ctx.Execute(null, m => m.AbuseLogAddUser(ctx, null, param.log_id, ctx.Author)), + Commands.AdminAbuselogRemoveUserLogId(var param, _) => ctx.Execute(null, m => m.AbuseLogRemoveUser(ctx, null, param.log_id, ctx.Author)), + Commands.AdminAbuselogDeleteLogId(var param, _) => ctx.Execute(null, m => m.AbuseLogDelete(ctx, null, param.log_id)), + _ => + // this should only ever occur when deving if commands are not implemented... + ctx.Reply( + $"{Emojis.Error} Parsed command {ctx.Parameters.Callback().AsCode()} not implemented in PluralKit.Bot!"), + }; } } \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/Context/Context.cs b/PluralKit.Bot/CommandSystem/Context/Context.cs index f155c8dc..3a8f61a2 100644 --- a/PluralKit.Bot/CommandSystem/Context/Context.cs +++ b/PluralKit.Bot/CommandSystem/Context/Context.cs @@ -26,11 +26,9 @@ public class Context private readonly IMetrics _metrics; private readonly CommandMessageService _commandMessageService; - private Command? _currentCommand; - public Context(ILifetimeScope provider, int shardId, Guild? guild, Channel channel, MessageCreateEvent message, int commandParseOffset, PKSystem senderSystem, SystemConfig config, - GuildConfig? guildConfig, string[] prefixes) + GuildConfig? guildConfig, string[] prefixes, Parameters parameters) { Message = (Message)message; ShardId = shardId; @@ -48,9 +46,9 @@ public class Context _commandMessageService = provider.Resolve(); CommandPrefix = message.Content?.Substring(0, commandParseOffset); DefaultPrefix = prefixes[0]; - Parameters = new Parameters(message.Content?.Substring(commandParseOffset)); Rest = provider.Resolve(); Cluster = provider.Resolve(); + Parameters = parameters; } public readonly IDiscordCache Cache; @@ -156,8 +154,6 @@ public class Context public async Task Execute(Command? commandDef, Func handler, bool deprecated = false) { - _currentCommand = commandDef; - if (deprecated && commandDef != null) { await Reply($"{Emojis.Warn} Server configuration has moved to `{DefaultPrefix}serverconfig`. The command you are trying to run is now `{DefaultPrefix}{commandDef.Key}`."); @@ -196,10 +192,11 @@ public class Context public LookupContext DirectLookupContextFor(SystemId systemId) => System?.Id == systemId ? LookupContext.ByOwner : LookupContext.ByNonOwner; - public LookupContext LookupContextFor(SystemId systemId) + public LookupContext LookupContextFor(SystemId systemId, bool? _hasPrivateOverride = null, bool? _hasPublicOverride = null) { - var hasPrivateOverride = this.MatchFlag("private", "priv"); - var hasPublicOverride = this.MatchFlag("public", "pub"); + // todo(dusk): these should be passed as a parameter ideally + bool hasPrivateOverride = _hasPrivateOverride ?? Parameters.HasFlag("private", "priv"); + bool hasPublicOverride = _hasPublicOverride ?? Parameters.HasFlag("public", "pub"); if (hasPrivateOverride && hasPublicOverride) throw new PKError("Cannot match both public and private flags at the same time."); diff --git a/PluralKit.Bot/CommandSystem/Context/ContextArgumentsExt.cs b/PluralKit.Bot/CommandSystem/Context/ContextArgumentsExt.cs index 982eec77..d04babb7 100644 --- a/PluralKit.Bot/CommandSystem/Context/ContextArgumentsExt.cs +++ b/PluralKit.Bot/CommandSystem/Context/ContextArgumentsExt.cs @@ -2,201 +2,30 @@ using System.Text.RegularExpressions; using Myriad.Types; -using PluralKit.Core; - namespace PluralKit.Bot; public static class ContextArgumentsExt { - public static string PopArgument(this Context ctx) => - ctx.Parameters.Pop(); - - public static string PeekArgument(this Context ctx) => - ctx.Parameters.Peek(); - - public static string RemainderOrNull(this Context ctx, bool skipFlags = true) => - ctx.Parameters.Remainder(skipFlags).Length == 0 ? null : ctx.Parameters.Remainder(skipFlags); - - public static bool HasNext(this Context ctx, bool skipFlags = true) => - ctx.RemainderOrNull(skipFlags) != null; - - public static string FullCommand(this Context ctx) => - ctx.Parameters.FullCommand; - - /// - /// Checks if the next parameter is equal to one of the given keywords and pops it from the stack. Case-insensitive. - /// - public static bool Match(this Context ctx, ref string used, params string[] potentialMatches) - { - var arg = ctx.PeekArgument(); - foreach (var match in potentialMatches) - if (arg.Equals(match, StringComparison.InvariantCultureIgnoreCase)) - { - used = ctx.PopArgument(); - return true; - } - - return false; - } - - /// - /// Checks if the next parameter is equal to one of the given keywords. Case-insensitive. - /// - public static bool Match(this Context ctx, params string[] potentialMatches) - { - string used = null; // Unused and unreturned, we just yeet it - return ctx.Match(ref used, potentialMatches); - } - - /// - /// Checks if the next parameter (starting from `ptr`) is equal to one of the given keywords, and leaves it on the stack. Case-insensitive. - /// - public static bool PeekMatch(this Context ctx, ref int ptr, string[] potentialMatches) - { - var arg = ctx.Parameters.PeekWithPtr(ref ptr); - foreach (var match in potentialMatches) - if (arg.Equals(match, StringComparison.InvariantCultureIgnoreCase)) - return true; - - return false; - } - - /// - /// Matches the next *n* parameters against each parameter consecutively. - ///
- /// Note that this is handled differently than single-parameter Match: - /// each method parameter is an array of potential matches for the *n*th command string parameter. - ///
- public static bool MatchMultiple(this Context ctx, params string[][] potentialParametersMatches) - { - int ptr = ctx.Parameters._ptr; - - foreach (var param in potentialParametersMatches) - if (!ctx.PeekMatch(ref ptr, param)) return false; - - ctx.Parameters._ptr = ptr; - - return true; - } - - public static bool MatchFlag(this Context ctx, params string[] potentialMatches) - { - // Flags are *ALWAYS PARSED LOWERCASE*. This means we skip out on a "ToLower" call here. - // Can assume the caller array only contains lowercase *and* the set below only contains lowercase - - var flags = ctx.Parameters.Flags(); - return potentialMatches.Any(potentialMatch => flags.Contains(potentialMatch)); - } - - public static bool MatchClear(this Context ctx) - => ctx.Match("clear", "reset", "default") || ctx.MatchFlag("c", "clear"); - - public static ReplyFormat MatchFormat(this Context ctx) - { - if (ctx.Match("r", "raw") || ctx.MatchFlag("r", "raw")) return ReplyFormat.Raw; - if (ctx.Match("pt", "plaintext") || ctx.MatchFlag("pt", "plaintext")) return ReplyFormat.Plaintext; - return ReplyFormat.Standard; - } - - public static ReplyFormat PeekMatchFormat(this Context ctx) - { - int ptr1 = ctx.Parameters._ptr; - int ptr2 = ctx.Parameters._ptr; - if (ctx.PeekMatch(ref ptr1, new[] { "r", "raw" }) || ctx.MatchFlag("r", "raw")) return ReplyFormat.Raw; - if (ctx.PeekMatch(ref ptr2, new[] { "pt", "plaintext" }) || ctx.MatchFlag("pt", "plaintext")) return ReplyFormat.Plaintext; - return ReplyFormat.Standard; - } - - public static bool MatchToggle(this Context ctx, bool? defaultValue = null) - { - var value = ctx.MatchToggleOrNull(defaultValue); - if (value == null) throw new PKError("You must pass either \"on\" or \"off\" to this command."); - return value.Value; - } - - public static bool? MatchToggleOrNull(this Context ctx, bool? defaultValue = null) - { - if (defaultValue != null && ctx.MatchClear()) - return defaultValue.Value; - - var yesToggles = new[] { "yes", "on", "enable", "enabled", "true" }; - var noToggles = new[] { "no", "off", "disable", "disabled", "false" }; - - if (ctx.Match(yesToggles) || ctx.MatchFlag(yesToggles)) - return true; - else if (ctx.Match(noToggles) || ctx.MatchFlag(noToggles)) - return false; - else return null; - } - - public static (ulong? messageId, ulong? channelId) MatchMessage(this Context ctx, bool parseRawMessageId) + public static Message.Reference? GetRepliedTo(this Context ctx) { if (ctx.Message.Type == Message.MessageType.Reply && ctx.Message.MessageReference?.MessageId != null) - return (ctx.Message.MessageReference.MessageId, ctx.Message.MessageReference.ChannelId); + return ctx.Message.MessageReference; + return null; + } - var word = ctx.PeekArgument(); - if (word == null) - return (null, null); - - if (parseRawMessageId && ulong.TryParse(word, out var mid)) + public static (ulong? messageId, ulong? channelId) ParseMessage(this Context ctx, string maybeMessageRef, bool parseRawMessageId) + { + if (parseRawMessageId && ulong.TryParse(maybeMessageRef, out var mid)) return (mid, null); - var match = Regex.Match(word, "https://(?:\\w+.)?discord(?:app)?.com/channels/\\d+/(\\d+)/(\\d+)"); + var match = Regex.Match(maybeMessageRef, "https://(?:\\w+.)?discord(?:app)?.com/channels/\\d+/(\\d+)/(\\d+)"); if (!match.Success) return (null, null); var channelId = ulong.Parse(match.Groups[1].Value); var messageId = ulong.Parse(match.Groups[2].Value); - ctx.PopArgument(); return (messageId, channelId); } - - public static async Task> ParseMemberList(this Context ctx, SystemId? restrictToSystem) - { - var members = new List(); - - // Loop through all the given arguments - while (ctx.HasNext()) - { - // and attempt to match a member - var member = await ctx.MatchMember(restrictToSystem); - - if (member == null) - // if we can't, big error. Every member name must be valid. - throw new PKError(ctx.CreateNotFoundError("Member", ctx.PopArgument())); - - members.Add(member); // Then add to the final output list - } - - if (members.Count == 0) throw new PKSyntaxError("You must input at least one member."); - - return members; - } - - public static async Task> ParseGroupList(this Context ctx, SystemId? restrictToSystem) - { - var groups = new List(); - - // Loop through all the given arguments - while (ctx.HasNext()) - { - // and attempt to match a group - var group = await ctx.MatchGroup(restrictToSystem); - if (group == null) - // if we can't, big error. Every group name must be valid. - throw new PKError(ctx.CreateNotFoundError("Group", ctx.PopArgument())); - - // todo: remove this, the database query enforces the restriction - if (restrictToSystem != null && group.System != restrictToSystem) - throw Errors.NotOwnGroupError; // TODO: name *which* group? - - groups.Add(group); // Then add to the final output list - } - - if (groups.Count == 0) throw new PKSyntaxError("You must input at least one group."); - - return groups; - } } public enum ReplyFormat diff --git a/PluralKit.Bot/CommandSystem/Context/ContextAvatarExt.cs b/PluralKit.Bot/CommandSystem/Context/ContextAvatarExt.cs index 3501b53b..21d24c4f 100644 --- a/PluralKit.Bot/CommandSystem/Context/ContextAvatarExt.cs +++ b/PluralKit.Bot/CommandSystem/Context/ContextAvatarExt.cs @@ -6,34 +6,8 @@ namespace PluralKit.Bot; public static class ContextAvatarExt { - public static async Task MatchImage(this Context ctx) + public static ParsedImage? ExtractImageFromAttachment(this Context ctx) { - // If we have a user @mention/ID, use their avatar - if (await ctx.MatchUser() is { } user) - { - var url = user.AvatarUrl("png", 256); - return new ParsedImage { Url = url, Source = AvatarSource.User, SourceUser = user }; - } - - // If we have raw or plaintext, don't try to parse as a URL - if (ctx.PeekMatchFormat() != ReplyFormat.Standard) - return null; - - // If we have a positional argument, try to parse it as a URL - var arg = ctx.RemainderOrNull(); - if (arg != null) - { - // Allow surrounding the URL with to "de-embed" - if (arg.StartsWith("<") && arg.EndsWith(">")) - arg = arg.Substring(1, arg.Length - 2); - - if (!Core.MiscUtils.TryMatchUri(arg, out var uri)) - throw Errors.InvalidUrl; - - // ToString URL-decodes, which breaks URLs to spaces; AbsoluteUri doesn't - return new ParsedImage { Url = uri.AbsoluteUri, Source = AvatarSource.Url }; - } - // If we have an attachment, use that if (ctx.Message.Attachments.FirstOrDefault() is { } attachment) { @@ -51,6 +25,29 @@ public static class ContextAvatarExt // and if there are no attachments (which would have been caught just before) return null; } + public static async Task GetUserPfp(this Context ctx, string arg) + { + // If we have a user @mention/ID, use their avatar + if (await ctx.ParseUser(arg) is { } user) + { + var url = user.AvatarUrl("png", 256); + return new ParsedImage { Url = url, Source = AvatarSource.User, SourceUser = user }; + } + + return null; + } + public static ParsedImage ParseImage(this Context ctx, string arg) + { + // Allow surrounding the URL with to "de-embed" + if (arg.StartsWith("<") && arg.EndsWith(">")) + arg = arg.Substring(1, arg.Length - 2); + + if (!Core.MiscUtils.TryMatchUri(arg, out var uri)) + throw Errors.InvalidUrl; + + // ToString URL-decodes, which breaks URLs to spaces; AbsoluteUri doesn't + return new ParsedImage { Url = uri.AbsoluteUri, Source = AvatarSource.Url }; + } } public struct ParsedImage diff --git a/PluralKit.Bot/CommandSystem/Context/ContextEntityArgumentsExt.cs b/PluralKit.Bot/CommandSystem/Context/ContextEntityArgumentsExt.cs index 533e374f..5c429c96 100644 --- a/PluralKit.Bot/CommandSystem/Context/ContextEntityArgumentsExt.cs +++ b/PluralKit.Bot/CommandSystem/Context/ContextEntityArgumentsExt.cs @@ -1,52 +1,22 @@ -using System.Text.RegularExpressions; - using Myriad.Extensions; using Myriad.Types; -using PluralKit.Bot.Utils; using PluralKit.Core; namespace PluralKit.Bot; public static class ContextEntityArgumentsExt { - public static async Task MatchUser(this Context ctx) + public static async Task ParseUser(this Context ctx, string arg) { - var text = ctx.PeekArgument(); - if (text.TryParseMention(out var id)) - { - var user = await ctx.Cache.GetOrFetchUser(ctx.Rest, id); - if (user != null) ctx.PopArgument(); - return user; - } + if (arg.TryParseMention(out var id)) + return await ctx.Cache.GetOrFetchUser(ctx.Rest, id); return null; } - public static bool MatchUserRaw(this Context ctx, out ulong id) + public static async Task ParseSystem(this Context ctx, string input) { - id = 0; - - var text = ctx.PeekArgument(); - if (text.TryParseMention(out var mentionId)) - id = mentionId; - - return id != 0; - } - - public static Task PeekSystem(this Context ctx) => ctx.MatchSystemInner(); - - public static async Task MatchSystem(this Context ctx) - { - var system = await ctx.MatchSystemInner(); - if (system != null) ctx.PopArgument(); - return system; - } - - private static async Task MatchSystemInner(this Context ctx) - { - var input = ctx.PeekArgument(); - // System references can take three forms: // - The direct user ID of an account connected to the system // - A @mention of an account connected to the system (<@uid>) @@ -63,10 +33,8 @@ public static class ContextEntityArgumentsExt return null; } - public static async Task PeekMember(this Context ctx, SystemId? restrictToSystem = null) + public static async Task ParseMember(this Context ctx, string input, bool byId) { - var input = ctx.PeekArgument(); - // Member references can have one of three forms, depending on // whether you're in a system or not: // - A member hid @@ -75,7 +43,7 @@ public static class ContextEntityArgumentsExt // Skip name / display name matching if the user does not have a system // or if they specifically request by-HID matching - if (ctx.System != null && !ctx.MatchFlag("id", "by-id")) + if (ctx.System != null && !byId) { // First, try finding by member name in system if (await ctx.Repository.GetMemberByName(ctx.System.Id, input) is PKMember memberByName) @@ -98,55 +66,25 @@ public static class ContextEntityArgumentsExt // If we are supposed to restrict it to a system anyway we can just do that PKMember memberByHid = null; - if (restrictToSystem != null) - { - memberByHid = await ctx.Repository.GetMemberByHid(hid, restrictToSystem); - if (memberByHid != null) - return memberByHid; - } - // otherwise we try the querier's system and if that doesn't work we do global - else - { - memberByHid = await ctx.Repository.GetMemberByHid(hid, ctx.System?.Id); - if (memberByHid != null) - return memberByHid; + memberByHid = await ctx.Repository.GetMemberByHid(hid, ctx.System?.Id); + if (memberByHid != null) + return memberByHid; - // ff ctx.System was null then this would be a duplicate of above and we don't want to run it again - if (ctx.System != null) - { - memberByHid = await ctx.Repository.GetMemberByHid(hid); - if (memberByHid != null) - return memberByHid; - } + // ff ctx.System was null then this would be a duplicate of above and we don't want to run it again + if (ctx.System != null) + { + memberByHid = await ctx.Repository.GetMemberByHid(hid); + if (memberByHid != null) + return memberByHid; } // We didn't find anything, so we return null. return null; } - /// - /// Attempts to pop a member descriptor from the stack, returning it if present. If a member could not be - /// resolved by the next word in the argument stack, does *not* touch the stack, and returns null. - /// - public static async Task MatchMember(this Context ctx, SystemId? restrictToSystem = null) + public static async Task ParseGroup(this Context ctx, string input, bool byId, SystemId? restrictToSystem = null) { - // First, peek a member - var member = await ctx.PeekMember(restrictToSystem); - - // If the peek was successful, we've used up the next argument, so we pop that just to get rid of it. - if (member != null) ctx.PopArgument(); - - // Finally, we return the member value. - return member; - } - - public static async Task PeekGroup(this Context ctx, SystemId? restrictToSystem = null) - { - var input = ctx.PeekArgument(); - - // see PeekMember for an explanation of the logic used here - - if (ctx.System != null && !ctx.MatchFlag("id", "by-id")) + if (ctx.System != null && !byId) { if (await ctx.Repository.GetGroupByName(ctx.System.Id, input) is { } byName) return byName; @@ -163,16 +101,9 @@ public static class ContextEntityArgumentsExt return null; } - public static async Task MatchGroup(this Context ctx, SystemId? restrictToSystem = null) + public static string CreateNotFoundError(this Context ctx, string entity, string input, bool byId = false) { - var group = await ctx.PeekGroup(restrictToSystem); - if (group != null) ctx.PopArgument(); - return group; - } - - public static string CreateNotFoundError(this Context ctx, string entity, string input) - { - var isIDOnlyQuery = ctx.System == null || ctx.MatchFlag("id", "by-id"); + var isIDOnlyQuery = ctx.System == null || byId; var inputIsHid = HidUtils.ParseHid(input) != null; if (isIDOnlyQuery) @@ -186,35 +117,4 @@ public static class ContextEntityArgumentsExt return $"{entity} with ID or name \"{input}\" not found."; return $"{entity} with name \"{input}\" not found. Note that a {entity.ToLower()} ID is 5 or 6 characters long."; } - - public static async Task MatchChannel(this Context ctx) - { - if (!MentionUtils.TryParseChannel(ctx.PeekArgument(), out var id)) - return null; - - // todo: match channels in other guilds - var channel = await ctx.Cache.TryGetChannel(ctx.Guild!.Id, id); - if (channel == null) - channel = await ctx.Rest.GetChannelOrNull(id); - if (channel == null) - return null; - - if (!DiscordUtils.IsValidGuildChannel(channel)) - return null; - - ctx.PopArgument(); - return channel; - } - - public static async Task MatchGuild(this Context ctx) - { - if (!ulong.TryParse(ctx.PeekArgument(), out var id)) - return null; - - var guild = await ctx.Rest.GetGuildOrNull(id); - if (guild != null) - ctx.PopArgument(); - - return guild; - } } \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/Context/ContextFlagsExt.cs b/PluralKit.Bot/CommandSystem/Context/ContextFlagsExt.cs new file mode 100644 index 00000000..cf636322 --- /dev/null +++ b/PluralKit.Bot/CommandSystem/Context/ContextFlagsExt.cs @@ -0,0 +1,54 @@ +using PluralKit.Core; + +namespace PluralKit.Bot; + +public static class ContextFlagsExt +{ + public static async Task FlagResolveOpaque(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveFlag( + ctx, param_name, + param => (param as Parameter.Opaque)?.value + ); + } + + public static async Task FlagResolveMember(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveFlag( + ctx, param_name, + param => (param as Parameter.MemberRef)?.member + ); + } + + public static async Task FlagResolveSystem(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveFlag( + ctx, param_name, + param => (param as Parameter.SystemRef)?.system + ); + } + + public static async Task FlagResolveMemberPrivacyTarget(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveFlag( + ctx, param_name, + param => (param as Parameter.MemberPrivacyTarget)?.target + ); + } + + public static async Task FlagResolvePrivacyLevel(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveFlag( + ctx, param_name, + param => (param as Parameter.PrivacyLevel)?.level + ); + } + + public static async Task FlagResolveToggle(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveFlag( + ctx, param_name, + param => (param as Parameter.Toggle)?.value + ); + } +} \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/Context/ContextParametersExt.cs b/PluralKit.Bot/CommandSystem/Context/ContextParametersExt.cs new file mode 100644 index 00000000..61086919 --- /dev/null +++ b/PluralKit.Bot/CommandSystem/Context/ContextParametersExt.cs @@ -0,0 +1,151 @@ +using PluralKit.Core; +using Myriad.Types; + +namespace PluralKit.Bot; + +public static class ContextParametersExt +{ + public static async Task ParamResolveOpaque(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveParameter( + ctx, param_name, + param => (param as Parameter.Opaque)?.value + ); + } + + public static async Task ParamResolveNumber(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveParameter( + ctx, param_name, + param => (param as Parameter.Number)?.value + ); + } + + public static async Task ParamResolveMember(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveParameter( + ctx, param_name, + param => (param as Parameter.MemberRef)?.member + ); + } + + public static async Task?> ParamResolveMembers(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveParameter( + ctx, param_name, + param => (param as Parameter.MemberRefs)?.members + ); + } + + public static async Task ParamResolveGroup(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveParameter( + ctx, param_name, + param => (param as Parameter.GroupRef)?.group + ); + } + + public static async Task?> ParamResolveGroups(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveParameter( + ctx, param_name, + param => (param as Parameter.GroupRefs)?.groups + ); + } + + public static async Task ParamResolveSystem(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveParameter( + ctx, param_name, + param => (param as Parameter.SystemRef)?.system + ); + } + + public static async Task ParamResolveUser(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveParameter( + ctx, param_name, + param => (param as Parameter.UserRef)?.user + ); + } + + public static async Task ParamResolveMemberPrivacyTarget(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveParameter( + ctx, param_name, + param => (param as Parameter.MemberPrivacyTarget)?.target + ); + } + + public static async Task ParamResolveGroupPrivacyTarget(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveParameter( + ctx, param_name, + param => (param as Parameter.GroupPrivacyTarget)?.target + ); + } + + public static async Task ParamResolveSystemPrivacyTarget(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveParameter( + ctx, param_name, + param => (param as Parameter.SystemPrivacyTarget)?.target + ); + } + + public static async Task ParamResolvePrivacyLevel(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveParameter( + ctx, param_name, + param => (param as Parameter.PrivacyLevel)?.level + ); + } + + public static async Task ParamResolveProxySwitchAction(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveParameter( + ctx, param_name, + param => (param as Parameter.ProxySwitchAction)?.action + ); + } + + public static async Task ParamResolveToggle(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveParameter( + ctx, param_name, + param => (param as Parameter.Toggle)?.value + ); + } + + public static async Task ParamResolveAvatar(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveParameter( + ctx, param_name, + param => (param as Parameter.Avatar)?.avatar + ); + } + + public static async Task ParamResolveMessage(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveParameter( + ctx, param_name, + param => (param as Parameter.MessageRef)?.message + ); + } + + public static async Task ParamResolveChannel(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveParameter( + ctx, param_name, + param => (param as Parameter.ChannelRef)?.channel + ); + } + + public static async Task ParamResolveGuild(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveParameter( + ctx, param_name, + param => (param as Parameter.GuildRef)?.guild + ); + } +} \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/Context/ContextPrivacyExt.cs b/PluralKit.Bot/CommandSystem/Context/ContextPrivacyExt.cs deleted file mode 100644 index 2f120f8a..00000000 --- a/PluralKit.Bot/CommandSystem/Context/ContextPrivacyExt.cs +++ /dev/null @@ -1,51 +0,0 @@ -using PluralKit.Core; - -namespace PluralKit.Bot; - -public static class ContextPrivacyExt -{ - public static PrivacyLevel PopPrivacyLevel(this Context ctx) - { - if (ctx.Match("public", "pub", "show", "shown", "visible", "unhide", "unhidden")) - return PrivacyLevel.Public; - - if (ctx.Match("private", "priv", "hide", "hidden")) - return PrivacyLevel.Private; - - if (!ctx.HasNext()) - throw new PKSyntaxError("You must pass a privacy level (`public` or `private`)"); - - throw new PKSyntaxError( - $"Invalid privacy level {ctx.PopArgument().AsCode()} (must be `public` or `private`)."); - } - - public static SystemPrivacySubject PopSystemPrivacySubject(this Context ctx) - { - if (!SystemPrivacyUtils.TryParseSystemPrivacy(ctx.PeekArgument(), out var subject)) - throw new PKSyntaxError( - $"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `description`, `members`, `front`, `fronthistory`, `groups`, or `all`)."); - - ctx.PopArgument(); - return subject; - } - - public static MemberPrivacySubject PopMemberPrivacySubject(this Context ctx) - { - if (!MemberPrivacyUtils.TryParseMemberPrivacy(ctx.PeekArgument(), out var subject)) - throw new PKSyntaxError( - $"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `name`, `description`, `avatar`, `birthday`, `pronouns`, `proxy`, `metadata`, `visibility`, or `all`)."); - - ctx.PopArgument(); - return subject; - } - - public static GroupPrivacySubject PopGroupPrivacySubject(this Context ctx) - { - if (!GroupPrivacyUtils.TryParseGroupPrivacy(ctx.PeekArgument(), out var subject)) - throw new PKSyntaxError( - $"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `name`, `description`, `icon`, `metadata`, `visibility`, or `all`)."); - - ctx.PopArgument(); - return subject; - } -} \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/Parameters.cs b/PluralKit.Bot/CommandSystem/Parameters.cs index 05e1bdb5..0278f661 100644 --- a/PluralKit.Bot/CommandSystem/Parameters.cs +++ b/PluralKit.Bot/CommandSystem/Parameters.cs @@ -1,185 +1,205 @@ +using Humanizer; +using Myriad.Types; +using Myriad.Extensions; +using PluralKit.Core; +using uniffi.commands; + namespace PluralKit.Bot; +// corresponds to the ffi Paramater type, but with stricter types (also avoiding exposing ffi types!) +public abstract record Parameter() +{ + public record MemberRef(PKMember member): Parameter; + public record MemberRefs(List members): Parameter; + public record GroupRef(PKGroup group): Parameter; + public record GroupRefs(List groups): Parameter; + public record SystemRef(PKSystem system): Parameter; + public record UserRef(User user): Parameter; + public record MessageRef(Message.Reference message): Parameter; + public record ChannelRef(Channel channel): Parameter; + public record GuildRef(Guild guild): Parameter; + public record MemberPrivacyTarget(MemberPrivacySubject target): Parameter; + public record GroupPrivacyTarget(GroupPrivacySubject target): Parameter; + public record SystemPrivacyTarget(SystemPrivacySubject target): Parameter; + public record PrivacyLevel(Core.PrivacyLevel level): Parameter; + public record Toggle(bool value): Parameter; + public record Opaque(string value): Parameter; + public record Number(int value): Parameter; + public record Avatar(ParsedImage avatar): Parameter; + public record ProxySwitchAction(SystemConfig.ProxySwitchAction action): Parameter; +} + public class Parameters { - // Dictionary of (left, right) quote pairs - // Each char in the string is an individual quote, multi-char strings imply "one of the following chars" - private static readonly Dictionary _quotePairs = new() + private string _cb { get; init; } + private Dictionary _flags { get; init; } + private Dictionary _params { get; init; } + + // just used for errors, temporarily + public string FullCommand { get; init; } + + public Parameters(string prefix, string cmd) { - // Basic - { "'", "'" }, // ASCII single quotes - { "\"", "\"" }, // ASCII double quotes - - // "Smart quotes" - // Specifically ignore the left/right status of the quotes and match any combination of them - // Left string also includes "low" quotes to allow for the low-high style used in some locales - { "\u201C\u201D\u201F\u201E", "\u201C\u201D\u201F" }, // double quotes - { "\u2018\u2019\u201B\u201A", "\u2018\u2019\u201B" }, // single quotes - - // Chevrons (normal and "fullwidth" variants) - { "\u00AB\u300A", "\u00BB\u300B" }, // double chevrons, pointing away (<>) - { "\u00BB\u300B", "\u00AB\u300A" }, // double chevrons, pointing together (>>text<<) - { "\u2039\u3008", "\u203A\u3009" }, // single chevrons, pointing away () - { "\u203A\u3009", "\u2039\u3008" }, // single chevrons, pointing together (>text<) - - // Other - { "\u300C\u300E", "\u300D\u300F" } // corner brackets (Japanese/Chinese) - }; - - private ISet _flags; // Only parsed when requested first time - public int _ptr; - - public string FullCommand { get; } - - private struct WordPosition - { - // Start of the word - internal readonly int startPos; - - // End of the word - internal readonly int endPos; - - // How much to advance word pointer afterwards to point at the start of the *next* word - internal readonly int advanceAfterWord; - - internal readonly bool wasQuoted; - - public WordPosition(int startPos, int endPos, int advanceAfterWord, bool wasQuoted) + FullCommand = cmd; + var result = CommandsMethods.ParseCommand(prefix, cmd); + if (result is CommandResult.Ok) { - this.startPos = startPos; - this.endPos = endPos; - this.advanceAfterWord = advanceAfterWord; - this.wasQuoted = wasQuoted; + var command = ((CommandResult.Ok)result).@command; + _cb = command.@commandRef; + _flags = command.@flags; + _params = command.@params; + } + else + { + throw new PKError(((CommandResult.Err)result).@error); } } - public Parameters(string cmd) + public static string GetRelatedCommands(string prefix, string subject) { - // This is a SUPER dirty hack to avoid having to match both spaces and newlines in the word detection below - // Instead, we just add a space before every newline (which then gets stripped out later). - FullCommand = cmd.Replace("\n", " \n"); - _ptr = 0; + return CommandsMethods.GetRelatedCommands(prefix, subject); } - private void ParseFlags() + public string Callback() { - _flags = new HashSet(); + return _cb; + } - var ptr = 0; - while (NextWordPosition(ptr) is { } wp) + public bool HasFlag(params string[] potentialMatches) + { + return potentialMatches.Any(_flags.ContainsKey); + } + + private async Task ResolveFfiParam(Context ctx, uniffi.commands.Parameter ffi_param) + { + var byId = HasFlag("id", "by-id"); // this is added as a hidden flag to all command definitions + switch (ffi_param) { - ptr = wp.endPos + wp.advanceAfterWord; + case uniffi.commands.Parameter.MemberRef memberRef: + return new Parameter.MemberRef( + await ctx.ParseMember(memberRef.member, byId) + ?? throw new PKError(ctx.CreateNotFoundError("Member", memberRef.member, byId)) + ); + case uniffi.commands.Parameter.MemberRefs memberRefs: + return new Parameter.MemberRefs( + await memberRefs.members.ToAsyncEnumerable().SelectAwait(async m => + await ctx.ParseMember(m, byId) + ?? throw new PKError(ctx.CreateNotFoundError("Member", m, byId)) + ).ToListAsync() + ); + case uniffi.commands.Parameter.GroupRef groupRef: + return new Parameter.GroupRef( + await ctx.ParseGroup(groupRef.group, byId) + ?? throw new PKError(ctx.CreateNotFoundError("Group", groupRef.group)) + ); + case uniffi.commands.Parameter.GroupRefs groupRefs: + return new Parameter.GroupRefs( + await groupRefs.groups.ToAsyncEnumerable().SelectAwait(async g => + await ctx.ParseGroup(g, byId) + ?? throw new PKError(ctx.CreateNotFoundError("Group", g, byId)) + ).ToListAsync() + ); + case uniffi.commands.Parameter.SystemRef systemRef: + // todo: do we need byId here? + return new Parameter.SystemRef( + await ctx.ParseSystem(systemRef.system) + ?? throw new PKError(ctx.CreateNotFoundError("System", systemRef.system)) + ); + case uniffi.commands.Parameter.UserRef(var userId): + return new Parameter.UserRef( + await ctx.Cache.GetOrFetchUser(ctx.Rest, userId) + ?? throw new PKError(ctx.CreateNotFoundError("User", userId.ToString())) + ); + // todo(dusk): ideally generate enums for these from rust code in the cs glue + case uniffi.commands.Parameter.MemberPrivacyTarget memberPrivacyTarget: + // this should never really fail... + if (!MemberPrivacyUtils.TryParseMemberPrivacy(memberPrivacyTarget.target, out var memberPrivacy)) + throw new PKError($"Invalid member privacy target {memberPrivacyTarget.target}"); + return new Parameter.MemberPrivacyTarget(memberPrivacy); + case uniffi.commands.Parameter.GroupPrivacyTarget groupPrivacyTarget: + // this should never really fail... + if (!GroupPrivacyUtils.TryParseGroupPrivacy(groupPrivacyTarget.target, out var groupPrivacy)) + throw new PKError($"Invalid group privacy target {groupPrivacyTarget.target}"); + return new Parameter.GroupPrivacyTarget(groupPrivacy); + case uniffi.commands.Parameter.SystemPrivacyTarget systemPrivacyTarget: + // this should never really fail... + if (!SystemPrivacyUtils.TryParseSystemPrivacy(systemPrivacyTarget.target, out var systemPrivacy)) + throw new PKError($"Invalid system privacy target {systemPrivacyTarget.target}"); + return new Parameter.SystemPrivacyTarget(systemPrivacy); + case uniffi.commands.Parameter.PrivacyLevel privacyLevel: + return new Parameter.PrivacyLevel(privacyLevel.level == "public" ? PrivacyLevel.Public : privacyLevel.level == "private" ? PrivacyLevel.Private : throw new PKError($"Invalid privacy level {privacyLevel.level}")); + case uniffi.commands.Parameter.ProxySwitchAction(var action): + SystemConfig.ProxySwitchAction newVal; - // Is this word a *flag* (as in, starts with a - AND is not quoted) - if (FullCommand[wp.startPos] != '-' || wp.wasQuoted) continue; // (if not, carry on w/ next word) + if (action.Equals("off", StringComparison.InvariantCultureIgnoreCase)) + newVal = SystemConfig.ProxySwitchAction.Off; + else if (action.Equals("new", StringComparison.InvariantCultureIgnoreCase) || action.Equals("n", StringComparison.InvariantCultureIgnoreCase) || action.Equals("on", StringComparison.InvariantCultureIgnoreCase)) + newVal = SystemConfig.ProxySwitchAction.New; + else if (action.Equals("add", StringComparison.InvariantCultureIgnoreCase) || action.Equals("a", StringComparison.InvariantCultureIgnoreCase)) + newVal = SystemConfig.ProxySwitchAction.Add; + else + throw new PKError("You must pass either \"new\", \"add\", or \"off\" to this command."); - // Find the *end* of the flag start (technically allowing arbitrary amounts of dashes) - var flagNameStart = wp.startPos; - while (flagNameStart < FullCommand.Length && FullCommand[flagNameStart] == '-') - flagNameStart++; - - // Then add the word to the flag set - var word = FullCommand.Substring(flagNameStart, wp.endPos - flagNameStart).Trim(); - if (word.Length > 0) - _flags.Add(word.ToLowerInvariant()); + return new Parameter.ProxySwitchAction(newVal); + case uniffi.commands.Parameter.Toggle toggle: + return new Parameter.Toggle(toggle.toggle); + case uniffi.commands.Parameter.OpaqueString opaque: + return new Parameter.Opaque(opaque.raw); + case uniffi.commands.Parameter.OpaqueInt number: + return new Parameter.Number(number.raw); + case uniffi.commands.Parameter.Avatar avatar: + return new Parameter.Avatar(await ctx.GetUserPfp(avatar.avatar) ?? ctx.ParseImage(avatar.avatar)); + case uniffi.commands.Parameter.MessageRef(var guildId, var channelId, var messageId): + return new Parameter.MessageRef(new Message.Reference(guildId, channelId, messageId)); + case uniffi.commands.Parameter.ChannelRef(var channelId): + return new Parameter.ChannelRef(await ctx.Rest.GetChannelOrNull(channelId) ?? throw new PKError($"Channel {channelId} not found")); + case uniffi.commands.Parameter.GuildRef(var guildId): + return new Parameter.GuildRef(await ctx.Rest.GetGuildOrNull(guildId) ?? throw new PKError($"Guild {guildId} not found")); + case uniffi.commands.Parameter.Null: + return null; } + return null; } - public string Pop() + // resolves a single flag with value + private async Task ResolveFlag(Context ctx, string flag_name) { - // Loop to ignore and skip past flags - while (NextWordPosition(_ptr) is { } pos) - { - _ptr = pos.endPos + pos.advanceAfterWord; - if (FullCommand[pos.startPos] == '-' && !pos.wasQuoted) continue; - return FullCommand.Substring(pos.startPos, pos.endPos - pos.startPos).Trim(); - } - - return ""; + if (!HasFlag(flag_name)) return null; + var flag_value = _flags[flag_name]; + if (flag_value == null) return null; + var resolved = await ResolveFfiParam(ctx, flag_value); + if (resolved != null) return resolved; + // this should never happen, types are handled rust side + return null; } - public string Peek() + // resolves a single parameter + private async Task ResolveParameter(Context ctx, string param_name) { - // temp ptr so we don't move the real ptr - int ptr = _ptr; - - return PeekWithPtr(ref ptr); + if (!_params.ContainsKey(param_name)) return null; + var resolved = await ResolveFfiParam(ctx, _params[param_name]); + if (resolved != null) return resolved; + // this should never happen, types are handled rust side + return null; } - public string PeekWithPtr(ref int ptr) + public async Task ResolveFlag(Context ctx, string flag_name, Func extract_func) { - // Loop to ignore and skip past flags - while (NextWordPosition(ptr) is { } pos) - { - ptr = pos.endPos + pos.advanceAfterWord; - if (FullCommand[pos.startPos] == '-' && !pos.wasQuoted) continue; - return FullCommand.Substring(pos.startPos, pos.endPos - pos.startPos).Trim(); - } - - return ""; + var param = await ResolveFlag(ctx, flag_name); + // todo: i think this should return null for everything...? + if (param == null) return default; + return extract_func(param) + // this should never happen unless codegen somehow uses a wrong name + ?? throw new PKError($"Flag {flag_name.AsCode()} was not found or did not have a value defined for command {Callback().AsCode()} -- this is a bug!!"); } - public ISet Flags() + public async Task ResolveParameter(Context ctx, string param_name, Func extract_func) { - if (_flags == null) ParseFlags(); - return _flags; - } - - public string Remainder(bool skipFlags = true) - { - if (skipFlags) - // Skip all *leading* flags when taking the remainder - while (NextWordPosition(_ptr) is { } wp) - { - if (FullCommand[wp.startPos] != '-' || wp.wasQuoted) break; - _ptr = wp.endPos + wp.advanceAfterWord; - } - - // *Then* get the remainder - return FullCommand.Substring(Math.Min(_ptr, FullCommand.Length)).Trim(); - } - - private WordPosition? NextWordPosition(int position) - { - // Skip leading spaces before actual content - while (position < FullCommand.Length && FullCommand[position] == ' ') position++; - - // Is this the end of the string? - if (FullCommand.Length <= position) return null; - - // Is this a quoted word? - if (TryCheckQuote(FullCommand[position], out var endQuotes)) - { - // We found a quoted word - find an instance of one of the corresponding end quotes - var endQuotePosition = -1; - for (var i = position + 1; i < FullCommand.Length; i++) - if (endQuotePosition == -1 && endQuotes.Contains(FullCommand[i])) - endQuotePosition = i; // need a break; don't feel like brackets tho lol - - // Position after the end quote should be EOL or a space - // Otherwise we fallthrough to the unquoted word handler below - if (FullCommand.Length == endQuotePosition + 1 || FullCommand[endQuotePosition + 1] == ' ') - return new WordPosition(position + 1, endQuotePosition, 2, true); - } - - // Not a quoted word, just find the next space and return if it's the end of the command - var wordEnd = FullCommand.IndexOf(' ', position + 1); - - return wordEnd == -1 - ? new WordPosition(position, FullCommand.Length, 0, false) - : new WordPosition(position, wordEnd, 1, false); - } - - private bool TryCheckQuote(char potentialLeftQuote, out string correspondingRightQuotes) - { - foreach (var (left, right) in _quotePairs) - if (left.Contains(potentialLeftQuote)) - { - correspondingRightQuotes = right; - return true; - } - - correspondingRightQuotes = null; - return false; + var param = await ResolveParameter(ctx, param_name); + // todo: i think this should return null for everything...? + if (param == null) return default; + return extract_func(param) + // this should never happen unless codegen somehow uses a wrong name + ?? throw new PKError($"Parameter {param_name.AsCode()} was not found for command {Callback().AsCode()} -- this is a bug!!"); } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Admin.cs b/PluralKit.Bot/Commands/Admin.cs index 44171345..7726466b 100644 --- a/PluralKit.Bot/Commands/Admin.cs +++ b/PluralKit.Bot/Commands/Admin.cs @@ -1,5 +1,3 @@ -using System.Text.RegularExpressions; - using Humanizer; using Dapper; using SqlKata; @@ -113,43 +111,27 @@ public class Admin return eb.Build(); } - public async Task UpdateSystemId(Context ctx) + public async Task UpdateSystemId(Context ctx, PKSystem target, string newHid, bool confirmYes) { ctx.AssertBotAdmin(); - var target = await ctx.MatchSystem(); - if (target == null) - throw new PKError("Unknown system."); - - var input = ctx.PopArgument(); - if (!input.TryParseHid(out var newHid)) - throw new PKError($"Invalid new system ID `{input}`."); - var existingSystem = await ctx.Repository.GetSystemByHid(newHid); if (existingSystem != null) throw new PKError($"Another system already exists with ID `{newHid}`."); await ctx.Reply(null, await CreateEmbed(ctx, target)); - if (!await ctx.PromptYesNo($"Change system ID of `{target.Hid}` to `{newHid}`?", "Change")) + if (!await ctx.PromptYesNo($"Change system ID of `{target.Hid}` to `{newHid}`?", "Change", flagValue: confirmYes)) throw new PKError("ID change cancelled."); await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Hid = newHid }); await ctx.Reply($"{Emojis.Success} System ID updated (`{target.Hid}` -> `{newHid}`)."); } - public async Task UpdateMemberId(Context ctx) + public async Task UpdateMemberId(Context ctx, PKMember target, string newHid, bool confirmYes) { ctx.AssertBotAdmin(); - var target = await ctx.MatchMember(); - if (target == null) - throw new PKError("Unknown member."); - - var input = ctx.PopArgument(); - if (!input.TryParseHid(out var newHid)) - throw new PKError($"Invalid new member ID `{input}`."); - var existingMember = await ctx.Repository.GetMemberByHid(newHid); if (existingMember != null) throw new PKError($"Another member already exists with ID `{newHid}`."); @@ -159,7 +141,7 @@ public class Admin if (!await ctx.PromptYesNo( $"Change member ID of **{target.NameFor(LookupContext.ByNonOwner)}** (`{target.Hid}`) to `{newHid}`?", - "Change" + "Change", flagValue: confirmYes )) throw new PKError("ID change cancelled."); @@ -167,18 +149,10 @@ public class Admin await ctx.Reply($"{Emojis.Success} Member ID updated (`{target.Hid}` -> `{newHid}`)."); } - public async Task UpdateGroupId(Context ctx) + public async Task UpdateGroupId(Context ctx, PKGroup target, string newHid, bool confirmYes) { ctx.AssertBotAdmin(); - var target = await ctx.MatchGroup(); - if (target == null) - throw new PKError("Unknown group."); - - var input = ctx.PopArgument(); - if (!input.TryParseHid(out var newHid)) - throw new PKError($"Invalid new group ID `{input}`."); - var existingGroup = await ctx.Repository.GetGroupByHid(newHid); if (existingGroup != null) throw new PKError($"Another group already exists with ID `{newHid}`."); @@ -187,7 +161,7 @@ public class Admin await ctx.Reply(null, await CreateEmbed(ctx, system)); if (!await ctx.PromptYesNo($"Change group ID of **{target.Name}** (`{target.Hid}`) to `{newHid}`?", - "Change" + "Change", flagValue: confirmYes )) throw new PKError("ID change cancelled."); @@ -195,17 +169,13 @@ public class Admin await ctx.Reply($"{Emojis.Success} Group ID updated (`{target.Hid}` -> `{newHid}`)."); } - public async Task RerollSystemId(Context ctx) + public async Task RerollSystemId(Context ctx, PKSystem target, bool confirmYes) { ctx.AssertBotAdmin(); - var target = await ctx.MatchSystem(); - if (target == null) - throw new PKError("Unknown system."); - await ctx.Reply(null, await CreateEmbed(ctx, target)); - if (!await ctx.PromptYesNo($"Reroll system ID `{target.Hid}`?", "Reroll")) + if (!await ctx.PromptYesNo($"Reroll system ID `{target.Hid}`?", "Reroll", flagValue: confirmYes)) throw new PKError("ID change cancelled."); var query = new Query("systems").AsUpdate(new @@ -218,20 +188,16 @@ public class Admin await ctx.Reply($"{Emojis.Success} System ID updated (`{target.Hid}` -> `{newHid}`)."); } - public async Task RerollMemberId(Context ctx) + public async Task RerollMemberId(Context ctx, PKMember target, bool confirmYes) { ctx.AssertBotAdmin(); - var target = await ctx.MatchMember(); - if (target == null) - throw new PKError("Unknown member."); - var system = await ctx.Repository.GetSystem(target.System); await ctx.Reply(null, await CreateEmbed(ctx, system)); if (!await ctx.PromptYesNo( $"Reroll member ID for **{target.NameFor(LookupContext.ByNonOwner)}** (`{target.Hid}`)?", - "Reroll" + "Reroll", flagValue: confirmYes )) throw new PKError("ID change cancelled."); @@ -245,19 +211,15 @@ public class Admin await ctx.Reply($"{Emojis.Success} Member ID updated (`{target.Hid}` -> `{newHid}`)."); } - public async Task RerollGroupId(Context ctx) + public async Task RerollGroupId(Context ctx, PKGroup target, bool confirmYes) { ctx.AssertBotAdmin(); - var target = await ctx.MatchGroup(); - if (target == null) - throw new PKError("Unknown group."); - var system = await ctx.Repository.GetSystem(target.System); await ctx.Reply(null, await CreateEmbed(ctx, system)); if (!await ctx.PromptYesNo($"Reroll group ID for **{target.Name}** (`{target.Hid}`)?", - "Change" + "Change", flagValue: confirmYes )) throw new PKError("ID change cancelled."); @@ -271,71 +233,52 @@ public class Admin await ctx.Reply($"{Emojis.Success} Group ID updated (`{target.Hid}` -> `{newHid}`)."); } - public async Task SystemMemberLimit(Context ctx) + public async Task SystemMemberLimit(Context ctx, PKSystem target, int? newLimit, bool confirmYes) { ctx.AssertBotAdmin(); - var target = await ctx.MatchSystem(); - if (target == null) - throw new PKError("Unknown system."); - var config = await ctx.Repository.GetSystemConfig(target.Id); var currentLimit = config.MemberLimitOverride ?? Limits.MaxMemberCount; - if (!ctx.HasNext()) + if (newLimit == null) { await ctx.Reply(null, await CreateEmbed(ctx, target)); return; } - var newLimitStr = ctx.PopArgument().ToLower().Replace(",", null).Replace("k", "000"); - if (!int.TryParse(newLimitStr, out var newLimit)) - throw new PKError($"Couldn't parse `{newLimitStr}` as number."); - await ctx.Reply(null, await CreateEmbed(ctx, target)); - if (!await ctx.PromptYesNo($"Update member limit from **{currentLimit}** to **{newLimit}**?", "Update")) + if (!await ctx.PromptYesNo($"Update member limit from **{currentLimit}** to **{newLimit}**?", "Update", flagValue: confirmYes)) throw new PKError("Member limit change cancelled."); await ctx.Repository.UpdateSystemConfig(target.Id, new SystemConfigPatch { MemberLimitOverride = newLimit }); await ctx.Reply($"{Emojis.Success} Member limit updated."); } - public async Task SystemGroupLimit(Context ctx) + public async Task SystemGroupLimit(Context ctx, PKSystem target, int? newLimit, bool confirmYes) { ctx.AssertBotAdmin(); - var target = await ctx.MatchSystem(); - if (target == null) - throw new PKError("Unknown system."); - var config = await ctx.Repository.GetSystemConfig(target.Id); var currentLimit = config.GroupLimitOverride ?? Limits.MaxGroupCount; - if (!ctx.HasNext()) + if (newLimit == null) { await ctx.Reply(null, await CreateEmbed(ctx, target)); return; } - var newLimitStr = ctx.PopArgument().ToLower().Replace(",", null).Replace("k", "000"); - if (!int.TryParse(newLimitStr, out var newLimit)) - throw new PKError($"Couldn't parse `{newLimitStr}` as number."); - await ctx.Reply(null, await CreateEmbed(ctx, target)); - if (!await ctx.PromptYesNo($"Update group limit from **{currentLimit}** to **{newLimit}**?", "Update")) + if (!await ctx.PromptYesNo($"Update group limit from **{currentLimit}** to **{newLimit}**?", "Update", flagValue: confirmYes)) throw new PKError("Group limit change cancelled."); await ctx.Repository.UpdateSystemConfig(target.Id, new SystemConfigPatch { GroupLimitOverride = newLimit }); await ctx.Reply($"{Emojis.Success} Group limit updated."); } - public async Task SystemRecover(Context ctx) + public async Task SystemRecover(Context ctx, string systemToken, User account, bool rerollToken, bool confirmYes) { ctx.AssertBotAdmin(); - var rerollToken = ctx.MatchFlag("rt", "reroll-token"); - - var systemToken = ctx.PopArgument(); var systemId = await ctx.Database.Execute(conn => conn.QuerySingleOrDefaultAsync( "select id from systems where token = @token", new { token = systemToken } @@ -344,10 +287,6 @@ public class Admin if (systemId == null) throw new PKError("Could not retrieve a system with that token."); - var account = await ctx.MatchUser(); - if (account == null) - throw new PKError("You must pass an account to associate the system with (either ID or @mention)."); - var existingAccount = await ctx.Repository.GetSystemByAccount(account.Id); if (existingAccount != null) throw Errors.AccountInOtherSystem(existingAccount, ctx.Config, ctx.DefaultPrefix); @@ -355,7 +294,7 @@ public class Admin var system = await ctx.Repository.GetSystem(systemId.Value!); await ctx.Reply(null, await CreateEmbed(ctx, system)); - if (!await ctx.PromptYesNo($"Associate account {account.NameAndMention()} with system `{system.Hid}`?", "Recover account")) + if (!await ctx.PromptYesNo($"Associate account {account.NameAndMention()} with system `{system.Hid}`?", "Recover account", flagValue: confirmYes)) throw new PKError("System recovery cancelled."); await ctx.Repository.AddAccount(system.Id, account.Id); @@ -378,14 +317,10 @@ public class Admin }); } - public async Task SystemDelete(Context ctx) + public async Task SystemDelete(Context ctx, PKSystem target) { ctx.AssertBotAdmin(); - var target = await ctx.MatchSystem(); - if (target == null) - throw new PKError("Unknown system."); - await ctx.Reply($"To delete the following system, reply with the system's UUID: `{target.Uuid.ToString()}`", await CreateEmbed(ctx, target)); if (!await ctx.ConfirmWithReply(target.Uuid.ToString())) @@ -396,18 +331,11 @@ public class Admin await ctx.Reply($"{Emojis.Success} System deletion succesful."); } - public async Task AbuseLogCreate(Context ctx) + public async Task AbuseLogCreate(Context ctx, User account, bool denyBotUsage, string? description) { - var denyBotUsage = ctx.MatchFlag("deny", "deny-bot-usage"); - var account = await ctx.MatchUser(); - if (account == null) - throw new PKError("You must pass an account to associate the abuse log with (either ID or @mention)."); + ctx.AssertBotAdmin(); - string? desc = null!; - if (ctx.HasNext(false)) - desc = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); - - var abuseLog = await ctx.Repository.CreateAbuseLog(desc, denyBotUsage); + var abuseLog = await ctx.Repository.CreateAbuseLog(description, denyBotUsage); await ctx.Repository.AddAbuseLogAccount(abuseLog.Id, account.Id); await ctx.Reply( @@ -415,14 +343,49 @@ public class Admin await CreateAbuseLogEmbed(ctx, abuseLog)); } - public async Task AbuseLogShow(Context ctx, AbuseLog abuseLog) + public async Task GetAbuseLog(Context ctx, User? account, string? id) { + ctx.AssertBotAdmin(); + + AbuseLog? abuseLog = null!; + if (account != null) + { + abuseLog = await ctx.Repository.GetAbuseLogByAccount(account.Id); + } + else + { + abuseLog = await ctx.Repository.GetAbuseLogByGuid(new Guid(id)); + } + + if (abuseLog == null) + { + await ctx.Reply($"{Emojis.Error} Could not find an existing abuse log entry for that query."); + return null; + } + + return abuseLog; + } + + public async Task AbuseLogShow(Context ctx, User? account, string? id) + { + ctx.AssertBotAdmin(); + + AbuseLog? abuseLog = await GetAbuseLog(ctx, account, id); + if (abuseLog == null) + return; + await ctx.Reply(null, await CreateAbuseLogEmbed(ctx, abuseLog)); } - public async Task AbuseLogFlagDeny(Context ctx, AbuseLog abuseLog) + public async Task AbuseLogFlagDeny(Context ctx, User? account, string? id, bool? value) { - if (!ctx.HasNext()) + ctx.AssertBotAdmin(); + + AbuseLog? abuseLog = await GetAbuseLog(ctx, account, id); + if (abuseLog == null) + return; + + if (value == null) { await ctx.Reply( $"Bot usage is currently {(abuseLog.DenyBotUsage ? "denied" : "allowed")} " @@ -430,27 +393,31 @@ public class Admin } else { - var value = ctx.MatchToggle(true); if (abuseLog.DenyBotUsage != value) - await ctx.Repository.UpdateAbuseLog(abuseLog.Id, new AbuseLogPatch { DenyBotUsage = value }); + await ctx.Repository.UpdateAbuseLog(abuseLog.Id, new AbuseLogPatch { DenyBotUsage = value.Value }); await ctx.Reply( - $"Bot usage is now **{(value ? "denied" : "allowed")}** " + $"Bot usage is now **{(value.Value ? "denied" : "allowed")}** " + $"for accounts associated with abuse log `{abuseLog.Uuid}`."); } } - public async Task AbuseLogDescription(Context ctx, AbuseLog abuseLog) + public async Task AbuseLogDescription(Context ctx, User? account, string? id, string? description, bool clear, bool confirmClear) { - if (ctx.MatchClear() && await ctx.ConfirmClear("this abuse log description")) + ctx.AssertBotAdmin(); + + AbuseLog? abuseLog = await GetAbuseLog(ctx, account, id); + if (abuseLog == null) + return; + + if (clear && await ctx.ConfirmClear("this abuse log description", confirmClear)) { await ctx.Repository.UpdateAbuseLog(abuseLog.Id, new AbuseLogPatch { Description = null }); await ctx.Reply($"{Emojis.Success} Abuse log description cleared."); } - else if (ctx.HasNext()) + else if (description != null) { - var desc = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); - await ctx.Repository.UpdateAbuseLog(abuseLog.Id, new AbuseLogPatch { Description = desc }); + await ctx.Repository.UpdateAbuseLog(abuseLog.Id, new AbuseLogPatch { Description = description }); await ctx.Reply($"{Emojis.Success} Abuse log description updated."); } else @@ -461,11 +428,13 @@ public class Admin } } - public async Task AbuseLogAddUser(Context ctx, AbuseLog abuseLog) + public async Task AbuseLogAddUser(Context ctx, User? accountToFind, string? id, User account) { - var account = await ctx.MatchUser(); - if (account == null) - throw new PKError("You must pass an account to associate the abuse log with (either ID or @mention)."); + ctx.AssertBotAdmin(); + + AbuseLog? abuseLog = await GetAbuseLog(ctx, accountToFind, id); + if (abuseLog == null) + return; await ctx.Repository.AddAbuseLogAccount(abuseLog.Id, account.Id); await ctx.Reply( @@ -473,11 +442,13 @@ public class Admin await CreateAbuseLogEmbed(ctx, abuseLog)); } - public async Task AbuseLogRemoveUser(Context ctx, AbuseLog abuseLog) + public async Task AbuseLogRemoveUser(Context ctx, User? accountToFind, string? id, User account) { - var account = await ctx.MatchUser(); - if (account == null) - throw new PKError("You must pass an account to remove from the abuse log (either ID or @mention)."); + ctx.AssertBotAdmin(); + + AbuseLog? abuseLog = await GetAbuseLog(ctx, accountToFind, id); + if (abuseLog == null) + return; await ctx.Repository.UpdateAccount(account.Id, new() { @@ -489,8 +460,14 @@ public class Admin await CreateAbuseLogEmbed(ctx, abuseLog)); } - public async Task AbuseLogDelete(Context ctx, AbuseLog abuseLog) + public async Task AbuseLogDelete(Context ctx, User? account, string? id) { + ctx.AssertBotAdmin(); + + AbuseLog? abuseLog = await GetAbuseLog(ctx, account, id); + if (abuseLog == null) + return; + if (!await ctx.PromptYesNo($"Really delete abuse log entry `{abuseLog.Uuid}`?", "Delete", matchFlag: false)) { await ctx.Reply($"{Emojis.Error} Deletion cancelled."); @@ -501,17 +478,10 @@ public class Admin await ctx.Reply($"{Emojis.Success} Successfully deleted abuse log entry."); } - public async Task SendAdminMessage(Context ctx) + public async Task SendAdminMessage(Context ctx, User account, string content) { ctx.AssertBotAdmin(); - var account = await ctx.MatchUser(); - if (account == null) - throw new PKError("You must pass an account to send an admin message to (either ID or @mention)."); - if (!ctx.HasNext()) - throw new PKError("You must provide a message to send."); - - var content = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); var messageContent = $"## [Admin Message]\n\n{content}\n\nWe cannot read replies sent to this DM. If you wish to contact the staff team, please join the support server () or send us an email at ."; try diff --git a/PluralKit.Bot/Commands/Api.cs b/PluralKit.Bot/Commands/Api.cs index 626bcd4a..380e8cf1 100644 --- a/PluralKit.Bot/Commands/Api.cs +++ b/PluralKit.Bot/Commands/Api.cs @@ -115,28 +115,32 @@ public class Api } } - public async Task SystemWebhook(Context ctx) + public async Task GetSystemWebhook(Context ctx) { ctx.CheckSystem().CheckDMContext(); - if (!ctx.HasNext(false)) - { - if (ctx.System.WebhookUrl == null) - await ctx.Reply($"Your system does not have a webhook URL set. Set one with `{ctx.DefaultPrefix}system webhook `!"); - else - await ctx.Reply($"Your system's webhook URL is <{ctx.System.WebhookUrl}>."); + if (ctx.System.WebhookUrl == null) + await ctx.Reply($"Your system does not have a webhook URL set. Set one with `{ctx.DefaultPrefix}system webhook `!"); + else + await ctx.Reply($"Your system's webhook URL is <{ctx.System.WebhookUrl}>."); + } + + public async Task ClearSystemWebhook(Context ctx, bool confirmYes) + { + ctx.CheckSystem().CheckDMContext(); + + if (!await ctx.ConfirmClear("your system's webhook URL", confirmYes)) return; - } - if (ctx.MatchClear() && await ctx.ConfirmClear("your system's webhook URL")) - { - await ctx.Repository.UpdateSystem(ctx.System.Id, new SystemPatch { WebhookUrl = null, WebhookToken = null }); + await ctx.Repository.UpdateSystem(ctx.System.Id, new SystemPatch { WebhookUrl = null, WebhookToken = null }); - await ctx.Reply($"{Emojis.Success} System webhook URL removed."); - return; - } + await ctx.Reply($"{Emojis.Success} System webhook URL removed."); + } + + public async Task SetSystemWebhook(Context ctx, string newUrl) + { + ctx.CheckSystem().CheckDMContext(); - var newUrl = ctx.RemainderOrNull(); if (!await DispatchExt.ValidateUri(newUrl)) throw new PKError($"The URL {newUrl.AsCode()} is invalid or I cannot access it. Are you sure this is a valid, publicly accessible URL?"); diff --git a/PluralKit.Bot/Commands/Autoproxy.cs b/PluralKit.Bot/Commands/Autoproxy.cs index ddff335b..39e57e42 100644 --- a/PluralKit.Bot/Commands/Autoproxy.cs +++ b/PluralKit.Bot/Commands/Autoproxy.cs @@ -11,37 +11,51 @@ public class Autoproxy { private readonly IClock _clock; + public abstract record Mode() + { + public record Off(): Mode; + public record Latch(): Mode; + public record Front(): Mode; + public record Member(PKMember member): Mode; + } + public Autoproxy(IClock clock) { _clock = clock; } - public async Task SetAutoproxyMode(Context ctx) + public async Task SetAutoproxyMode(Context ctx, Mode? mode = null) { - // no need to check account here, it's already done at CommandTree - ctx.CheckGuildContext(); + ctx.CheckSystem().CheckGuildContext(); // for now, just for guild // this also creates settings if there are none present var settings = await ctx.Repository.GetAutoproxySettings(ctx.System.Id, ctx.Guild.Id, null); - if (ctx.Match("off", "stop", "cancel", "no", "disable", "remove")) - await AutoproxyOff(ctx, settings); - else if (ctx.Match("latch", "last", "proxy", "stick", "sticky", "l")) - await AutoproxyLatch(ctx, settings); - else if (ctx.Match("front", "fronter", "switch", "f")) - await AutoproxyFront(ctx, settings); - else if (ctx.Match("member")) - throw new PKSyntaxError($"Member-mode autoproxy must target a specific member. Use the `{ctx.DefaultPrefix}autoproxy ` command, where `member` is the name or ID of a member in your system."); - else if (await ctx.MatchMember() is PKMember member) - await AutoproxyMember(ctx, member); - else if (!ctx.HasNext()) - await ctx.Reply(embed: await CreateAutoproxyStatusEmbed(ctx, settings)); - else - throw new PKSyntaxError($"Invalid autoproxy mode {ctx.PopArgument().AsCode()}."); + if (mode == null) + { + await AutoproxyShow(ctx, settings); + return; + } + + switch (mode) + { + case Mode.Off: + await AutoproxyOff(ctx, settings); + break; + case Mode.Latch: + await AutoproxyLatch(ctx, settings); + break; + case Mode.Front: + await AutoproxyFront(ctx, settings); + break; + case Mode.Member(var member): + await AutoproxyMember(ctx, member); + break; + } } - private async Task AutoproxyOff(Context ctx, AutoproxySettings settings) + public async Task AutoproxyOff(Context ctx, AutoproxySettings settings) { if (settings.AutoproxyMode == AutoproxyMode.Off) { @@ -54,7 +68,7 @@ public class Autoproxy } } - private async Task AutoproxyLatch(Context ctx, AutoproxySettings settings) + public async Task AutoproxyLatch(Context ctx, AutoproxySettings settings) { if (settings.AutoproxyMode == AutoproxyMode.Latch) { @@ -67,7 +81,7 @@ public class Autoproxy } } - private async Task AutoproxyFront(Context ctx, AutoproxySettings settings) + public async Task AutoproxyFront(Context ctx, AutoproxySettings settings) { if (settings.AutoproxyMode == AutoproxyMode.Front) { @@ -80,7 +94,7 @@ public class Autoproxy } } - private async Task AutoproxyMember(Context ctx, PKMember member) + public async Task AutoproxyMember(Context ctx, PKMember member) { ctx.CheckOwnMember(member); @@ -90,6 +104,11 @@ public class Autoproxy await ctx.Reply($"{Emojis.Success} Autoproxy set to **{member.NameFor(ctx)}** in this server."); } + public async Task AutoproxyShow(Context ctx, AutoproxySettings settings) + { + await ctx.Reply(embed: await CreateAutoproxyStatusEmbed(ctx, settings)); + } + private async Task CreateAutoproxyStatusEmbed(Context ctx, AutoproxySettings settings) { var commandList = $"**{ctx.DefaultPrefix}autoproxy latch** - Autoproxies as last-proxied member" diff --git a/PluralKit.Bot/Commands/Checks.cs b/PluralKit.Bot/Commands/Checks.cs index fa2f6e37..8134cbba 100644 --- a/PluralKit.Bot/Commands/Checks.cs +++ b/PluralKit.Bot/Commands/Checks.cs @@ -36,37 +36,11 @@ public class Checks _cache = cache; } - public async Task PermCheckGuild(Context ctx) + public async Task PermCheckGuild(Context ctx, Guild guild) { - Guild guild; - GuildMemberPartial senderGuildUser = null; - - if (ctx.Guild != null && !ctx.HasNext()) - { - guild = ctx.Guild; - senderGuildUser = ctx.Member; - } - else - { - var guildIdStr = ctx.RemainderOrNull() ?? - throw new PKSyntaxError("You must pass a server ID or run this command in a server."); - if (!ulong.TryParse(guildIdStr, out var guildId)) - throw new PKSyntaxError($"Could not parse {guildIdStr.AsCode()} as an ID."); - - try - { - guild = await _rest.GetGuild(guildId); - } - catch (ForbiddenException) - { - throw Errors.GuildNotFound(guildId); - } - - if (guild != null) - senderGuildUser = await _rest.GetGuildMember(guildId, ctx.Author.Id); - if (guild == null || senderGuildUser == null) - throw Errors.GuildNotFound(guildId); - } + var senderGuildUser = await _rest.GetGuildMember(guild.Id, ctx.Author.Id); + if (senderGuildUser == null) + throw Errors.GuildNotFound(guild.Id); var guildMember = await _rest.GetGuildMember(guild.Id, _botConfig.ClientId); @@ -135,17 +109,13 @@ public class Checks await ctx.Reply(embed: eb.Build()); } - public async Task PermCheckChannel(Context ctx) + public async Task PermCheckChannel(Context ctx, Channel channel) { - if (!ctx.HasNext()) - throw new PKSyntaxError("You need to specify a channel."); - var error = "Channel not found or you do not have permissions to access it."; // todo: this breaks if channel is not in cache and bot does not have View Channel permissions // with new cache it breaks if channel is not in current guild - var channel = await ctx.MatchChannel(); - if (channel == null || channel.GuildId == null) + if (channel.GuildId == null) throw new PKError(error); var guild = await _rest.GetGuildOrNull(channel.GuildId.Value); @@ -189,15 +159,16 @@ public class Checks await ctx.Reply(embed: eb.Build()); } - public async Task MessageProxyCheck(Context ctx) + public async Task MessageProxyCheck(Context ctx, Message.Reference? messageReference) { - if (!ctx.HasNext() && ctx.Message.MessageReference == null) + if (messageReference == null && ctx.Message.MessageReference == null) throw new PKSyntaxError("You need to specify a message."); var failedToGetMessage = "Could not find a valid message to check, was not able to fetch the message, or the message was not sent by you."; - var (messageId, channelId) = ctx.MatchMessage(false); + messageReference = ctx.GetRepliedTo(); + var (messageId, channelId) = (messageReference?.MessageId, messageReference?.ChannelId); if (messageId == null || channelId == null) throw new PKError(failedToGetMessage); diff --git a/PluralKit.Bot/Commands/Config.cs b/PluralKit.Bot/Commands/Config.cs index 24cfb43f..9d8b175c 100644 --- a/PluralKit.Bot/Commands/Config.cs +++ b/PluralKit.Bot/Commands/Config.cs @@ -197,17 +197,16 @@ public class Config } private string EnabledDisabled(bool value) => value ? "enabled" : "disabled"; - public async Task AutoproxyAccount(Context ctx) + public async Task ViewAutoproxyAccount(Context ctx) { var allowAutoproxy = await ctx.Repository.GetAutoproxyEnabled(ctx.Author.Id); - if (!ctx.HasNext()) - { - await ctx.Reply($"Autoproxy is currently **{EnabledDisabled(allowAutoproxy)}** for account <@{ctx.Author.Id}>."); - return; - } + await ctx.Reply($"Autoproxy is currently **{EnabledDisabled(allowAutoproxy)}** for account <@{ctx.Author.Id}>."); + } - var allow = ctx.MatchToggle(true); + public async Task EditAutoproxyAccount(Context ctx, bool allow) + { + var allowAutoproxy = await ctx.Repository.GetAutoproxyEnabled(ctx.Author.Id); var statusString = EnabledDisabled(allow); if (allowAutoproxy == allow) @@ -220,80 +219,87 @@ public class Config await ctx.Reply($"{Emojis.Success} Autoproxy {statusString} for account <@{ctx.Author.Id}>."); } - - public async Task AutoproxyTimeout(Context ctx) + public async Task ViewAutoproxyTimeout(Context ctx) { - if (!ctx.HasNext()) - { - var timeout = ctx.Config.LatchTimeout.HasValue - ? Duration.FromSeconds(ctx.Config.LatchTimeout.Value) - : (Duration?)null; + var timeout = ctx.Config.LatchTimeout.HasValue + ? Duration.FromSeconds(ctx.Config.LatchTimeout.Value) + : (Duration?)null; - if (timeout == null) - await ctx.Reply($"You do not have a custom autoproxy timeout duration set. The default latch timeout duration is {ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize(4)}."); - else if (timeout == Duration.Zero) - await ctx.Reply("Latch timeout is currently **disabled** for your system. Latch mode autoproxy will never time out."); - else - await ctx.Reply($"The current latch timeout duration for your system is {timeout.Value.ToTimeSpan().Humanize(4)}."); - return; - } - - Duration? newTimeout; - Duration overflow = Duration.Zero; - if (ctx.Match("off", "stop", "cancel", "no", "disable", "remove")) newTimeout = Duration.Zero; - else if (ctx.MatchClear()) newTimeout = null; + if (timeout == null) + await ctx.Reply($"You do not have a custom autoproxy timeout duration set. The default latch timeout duration is {ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize(4)}."); + else if (timeout == Duration.Zero) + await ctx.Reply("Latch timeout is currently **disabled** for your system. Latch mode autoproxy will never time out."); else + await ctx.Reply($"The current latch timeout duration for your system is {timeout.Value.ToTimeSpan().Humanize(4)}."); + } + + public async Task DisableAutoproxyTimeout(Context ctx) + { + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { LatchTimeout = (int)Duration.Zero.TotalSeconds }); + + await ctx.Reply($"{Emojis.Success} Latch timeout disabled. Latch mode autoproxy will never time out."); + } + + public async Task ResetAutoproxyTimeout(Context ctx) + { + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { LatchTimeout = null }); + + await ctx.Reply($"{Emojis.Success} Latch timeout reset to default ({ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize(4)})."); + } + + public async Task EditAutoproxyTimeout(Context ctx, string timeout) + { + Duration newTimeout; + Duration overflow = Duration.Zero; + // todo: we should parse date in the command parser + var timeoutStr = timeout; + var timeoutPeriod = DateUtils.ParsePeriod(timeoutStr) + ?? throw new PKError($"Could not parse '{timeoutStr}' as a valid duration. Try using a syntax such as \"3h5m\" (i.e. 3 hours and 5 minutes)."); + if (timeoutPeriod.TotalHours > 100000) { - var timeoutStr = ctx.RemainderOrNull(); - var timeoutPeriod = DateUtils.ParsePeriod(timeoutStr); - if (timeoutPeriod == null) throw new PKError($"Could not parse '{timeoutStr}' as a valid duration. Try using a syntax such as \"3h5m\" (i.e. 3 hours and 5 minutes)."); - if (timeoutPeriod.Value.TotalHours > 100000) - { - // sanity check to prevent seconds overflow if someone types in 999999999 - overflow = timeoutPeriod.Value; - newTimeout = Duration.Zero; - } - else newTimeout = timeoutPeriod; + // sanity check to prevent seconds overflow if someone types in 999999999 + overflow = timeoutPeriod; + newTimeout = Duration.Zero; } + else newTimeout = timeoutPeriod; - await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { LatchTimeout = (int?)newTimeout?.TotalSeconds }); + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { LatchTimeout = (int?)newTimeout.TotalSeconds }); - if (newTimeout == null) - await ctx.Reply($"{Emojis.Success} Latch timeout reset to default ({ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize(4)})."); - else if (newTimeout == Duration.Zero && overflow != Duration.Zero) + if (newTimeout == Duration.Zero && overflow != Duration.Zero) await ctx.Reply($"{Emojis.Success} Latch timeout disabled. Latch mode autoproxy will never time out. ({overflow.ToTimeSpan().Humanize(4)} is too long)"); else if (newTimeout == Duration.Zero) await ctx.Reply($"{Emojis.Success} Latch timeout disabled. Latch mode autoproxy will never time out."); else - await ctx.Reply($"{Emojis.Success} Latch timeout set to {newTimeout.Value!.ToTimeSpan().Humanize(4)}."); + await ctx.Reply($"{Emojis.Success} Latch timeout set to {newTimeout.ToTimeSpan().Humanize(4)}."); } - public async Task SystemTimezone(Context ctx) + public async Task ViewSystemTimezone(Context ctx) { if (ctx.System == null) throw Errors.NoSystemError(ctx.DefaultPrefix); - if (ctx.MatchClear()) - { - await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { UiTz = "UTC" }); + await ctx.Reply( + $"Your current system time zone is set to **{ctx.Config.UiTz}**. It is currently **{SystemClock.Instance.GetCurrentInstant().FormatZoned(ctx.Config.Zone)}** in that time zone. To change your system time zone, type `{ctx.DefaultPrefix}config tz `."); + } - await ctx.Reply($"{Emojis.Success} System time zone cleared (set to UTC)."); - return; - } + public async Task ResetSystemTimezone(Context ctx) + { + if (ctx.System == null) throw Errors.NoSystemError(ctx.DefaultPrefix); - var zoneStr = ctx.RemainderOrNull(); - if (zoneStr == null) - { - await ctx.Reply( - $"Your current system time zone is set to **{ctx.Config.UiTz}**. It is currently **{SystemClock.Instance.GetCurrentInstant().FormatZoned(ctx.Config.Zone)}** in that time zone. To change your system time zone, type `{ctx.DefaultPrefix}config tz `."); - return; - } + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { UiTz = "UTC" }); + + await ctx.Reply($"{Emojis.Success} System time zone cleared (set to UTC)."); + } + + public async Task EditSystemTimezone(Context ctx, string zoneStr, bool confirmYes = false) + { + if (ctx.System == null) throw Errors.NoSystemError(ctx.DefaultPrefix); var zone = await FindTimeZone(ctx, zoneStr); if (zone == null) throw Errors.InvalidTimeZone(zoneStr); var currentTime = SystemClock.Instance.GetCurrentInstant().InZone(zone); var msg = $"This will change the system time zone to **{zone.Id}**. The current time is **{currentTime.FormatZoned()}**. Is this correct?"; - if (!await ctx.PromptYesNo(msg, "Change Timezone")) throw Errors.TimezoneChangeCancelled; + if (!await ctx.PromptYesNo(msg, "Change Timezone", flagValue: confirmYes)) throw Errors.TimezoneChangeCancelled; await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { UiTz = zone.Id }); @@ -360,27 +366,24 @@ public class Config }); } - public async Task SystemPing(Context ctx) + public async Task ViewSystemPing(Context ctx) { // note: this is here because this is also used in `pk;system ping`, which does not CheckSystem ctx.CheckSystem(); - // todo: move all the other config settings to this format + await ctx.Reply($"Reaction pings are currently **{EnabledDisabled(ctx.Config.PingsEnabled)}** for your system. " + + $"To {EnabledDisabled(!ctx.Config.PingsEnabled)[..^1]} reaction pings, type `{ctx.DefaultPrefix}config ping {EnabledDisabled(!ctx.Config.PingsEnabled)[..^1]}`."); + } - String Response(bool isError, bool val) - => $"Reaction pings are {(isError ? "already" : "currently")} **{EnabledDisabled(val)}** for your system. " - + $"To {EnabledDisabled(!val)[..^1]} reaction pings, type `{ctx.DefaultPrefix}config ping {EnabledDisabled(!val)[..^1]}`."; - - if (!ctx.HasNext()) - { - await ctx.Reply(Response(false, ctx.Config.PingsEnabled)); - return; - } - - var value = ctx.MatchToggle(true); + public async Task EditSystemPing(Context ctx, bool value) + { + ctx.CheckSystem(); if (ctx.Config.PingsEnabled == value) - await ctx.Reply(Response(true, ctx.Config.PingsEnabled)); + { + await ctx.Reply($"Reaction pings are already **{EnabledDisabled(ctx.Config.PingsEnabled)}** for your system. " + + $"To {EnabledDisabled(!value)[..^1]} reaction pings, type `{ctx.DefaultPrefix}config ping {EnabledDisabled(!value)[..^1]}`."); + } else { await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { PingsEnabled = value }); @@ -388,230 +391,182 @@ public class Config } } - public async Task MemberDefaultPrivacy(Context ctx) + public async Task ViewMemberDefaultPrivacy(Context ctx) { - if (!ctx.HasNext()) - { - if (ctx.Config.MemberDefaultPrivate) { await ctx.Reply($"Newly created members will currently have their privacy settings set to private. To change this, type `{ctx.DefaultPrefix}config private member off`"); } - else { await ctx.Reply($"Newly created members will currently have their privacy settings set to public. To automatically set new members' privacy settings to private, type `{ctx.DefaultPrefix}config private member on`"); } - } + if (ctx.Config.MemberDefaultPrivate) + await ctx.Reply($"Newly created members will currently have their privacy settings set to private. To change this, type `{ctx.DefaultPrefix}config private member off`"); else - { - if (ctx.MatchToggle(false)) - { - await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { MemberDefaultPrivate = true }); - - await ctx.Reply("Newly created members will now have their privacy settings set to private."); - } - else - { - await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { MemberDefaultPrivate = false }); - - await ctx.Reply("Newly created members will now have their privacy settings set to public."); - } - } + await ctx.Reply($"Newly created members will currently have their privacy settings set to public. To automatically set new members' privacy settings to private, type `{ctx.DefaultPrefix}config private member on`"); } - public async Task GroupDefaultPrivacy(Context ctx) + public async Task EditMemberDefaultPrivacy(Context ctx, bool value) { - if (!ctx.HasNext()) - { - if (ctx.Config.GroupDefaultPrivate) { await ctx.Reply($"Newly created groups will currently have their privacy settings set to private. To change this, type `{ctx.DefaultPrefix}config private group off`"); } - else { await ctx.Reply($"Newly created groups will currently have their privacy settings set to public. To automatically set new groups' privacy settings to private, type `{ctx.DefaultPrefix}config private group on`"); } - } + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { MemberDefaultPrivate = value }); + + if (value) + await ctx.Reply("Newly created members will now have their privacy settings set to private."); else - { - if (ctx.MatchToggle(false)) - { - await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { GroupDefaultPrivate = true }); - - await ctx.Reply("Newly created groups will now have their privacy settings set to private."); - } - else - { - await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { GroupDefaultPrivate = false }); - - await ctx.Reply("Newly created groups will now have their privacy settings set to public."); - } - } + await ctx.Reply("Newly created members will now have their privacy settings set to public."); } - public async Task ShowPrivateInfo(Context ctx) + public async Task ViewGroupDefaultPrivacy(Context ctx) { - if (!ctx.HasNext()) - { - if (ctx.Config.ShowPrivateInfo) await ctx.Reply("Private information is currently **shown** when looking up your own info. Use the `-public` flag to hide it."); - else await ctx.Reply("Private information is currently **hidden** when looking up your own info. Use the `-private` flag to show it."); - return; - } + if (ctx.Config.GroupDefaultPrivate) + await ctx.Reply($"Newly created groups will currently have their privacy settings set to private. To change this, type `{ctx.DefaultPrefix}config private group off`"); + else + await ctx.Reply($"Newly created groups will currently have their privacy settings set to public. To automatically set new groups' privacy settings to private, type `{ctx.DefaultPrefix}config private group on`"); + } - if (ctx.MatchToggle(true)) - { - await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { ShowPrivateInfo = true }); + public async Task EditGroupDefaultPrivacy(Context ctx, bool value) + { + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { GroupDefaultPrivate = value }); + if (value) + await ctx.Reply("Newly created groups will now have their privacy settings set to private."); + else + await ctx.Reply("Newly created groups will now have their privacy settings set to public."); + } + + public async Task ViewShowPrivateInfo(Context ctx) + { + if (ctx.Config.ShowPrivateInfo) + await ctx.Reply("Private information is currently **shown** when looking up your own info. Use the `-public` flag to hide it."); + else + await ctx.Reply("Private information is currently **hidden** when looking up your own info. Use the `-private` flag to show it."); + } + + public async Task EditShowPrivateInfo(Context ctx, bool value) + { + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { ShowPrivateInfo = value }); + + if (value) await ctx.Reply("Private information will now be **shown** when looking up your own info. Use the `-public` flag to hide it."); - } else - { - await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { ShowPrivateInfo = false }); - await ctx.Reply("Private information will now be **hidden** when looking up your own info. Use the `-private` flag to show it."); - } } - public async Task CaseSensitiveProxyTags(Context ctx) + public async Task ViewCaseSensitiveProxyTags(Context ctx) { - if (!ctx.HasNext()) - { - if (ctx.Config.CaseSensitiveProxyTags) { await ctx.Reply("Proxy tags are currently case **sensitive**."); } - else { await ctx.Reply("Proxy tags are currently case **insensitive**."); } - return; - } + if (ctx.Config.CaseSensitiveProxyTags) + await ctx.Reply("Proxy tags are currently case **sensitive**."); + else + await ctx.Reply("Proxy tags are currently case **insensitive**."); + } - if (ctx.MatchToggle(true)) - { - await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { CaseSensitiveProxyTags = true }); + public async Task EditCaseSensitiveProxyTags(Context ctx, bool value) + { + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { CaseSensitiveProxyTags = value }); + if (value) await ctx.Reply("Proxy tags are now case sensitive."); - } else - { - await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { CaseSensitiveProxyTags = false }); - await ctx.Reply("Proxy tags are now case insensitive."); - } } - public async Task ProxyErrorMessageEnabled(Context ctx) + public async Task ViewProxyErrorMessageEnabled(Context ctx) { - if (!ctx.HasNext()) - { - if (ctx.Config.ProxyErrorMessageEnabled) { await ctx.Reply("Proxy error messages are currently **enabled**."); } - else { await ctx.Reply("Proxy error messages are currently **disabled**. Messages that fail to proxy (due to message or attachment size) will not throw an error message."); } - return; - } - - if (ctx.MatchToggle(true)) - { - await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { ProxyErrorMessageEnabled = true }); - - await ctx.Reply("Proxy error messages are now enabled."); - } + if (ctx.Config.ProxyErrorMessageEnabled) + await ctx.Reply("Proxy error messages are currently **enabled**."); else - { - await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { ProxyErrorMessageEnabled = false }); + await ctx.Reply("Proxy error messages are currently **disabled**. Messages that fail to proxy (due to message or attachment size) will not throw an error message."); + } + public async Task EditProxyErrorMessageEnabled(Context ctx, bool value) + { + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { ProxyErrorMessageEnabled = value }); + + if (value) + await ctx.Reply("Proxy error messages are now enabled."); + else await ctx.Reply("Proxy error messages are now disabled. Messages that fail to proxy (due to message or attachment size) will not throw an error message."); - } } - public async Task HidDisplaySplit(Context ctx) + public async Task ViewHidDisplaySplit(Context ctx) { - if (!ctx.HasNext()) - { - var msg = $"Splitting of 6-character IDs with a hyphen is currently **{EnabledDisabled(ctx.Config.HidDisplaySplit)}**."; - await ctx.Reply(msg); - return; - } - - var newVal = ctx.MatchToggle(false); - await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { HidDisplaySplit = newVal }); - await ctx.Reply($"Splitting of 6-character IDs with a hyphen is now {EnabledDisabled(newVal)}."); + await ctx.Reply($"Splitting of 6-character IDs with a hyphen is currently **{EnabledDisabled(ctx.Config.HidDisplaySplit)}**."); } - public async Task HidDisplayCaps(Context ctx) + public async Task EditHidDisplaySplit(Context ctx, bool value) { - if (!ctx.HasNext()) - { - var msg = $"Displaying IDs as capital letters is currently **{EnabledDisabled(ctx.Config.HidDisplayCaps)}**."; - await ctx.Reply(msg); - return; - } - - var newVal = ctx.MatchToggle(false); - await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { HidDisplayCaps = newVal }); - await ctx.Reply($"Displaying IDs as capital letters is now {EnabledDisabled(newVal)}."); + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { HidDisplaySplit = value }); + await ctx.Reply($"Splitting of 6-character IDs with a hyphen is now {EnabledDisabled(value)}."); } - public async Task HidListPadding(Context ctx) + public async Task ViewHidDisplayCaps(Context ctx) { - if (!ctx.HasNext()) - { - string message; - switch (ctx.Config.HidListPadding) - { - case SystemConfig.HidPadFormat.None: message = "Padding 5-character IDs in lists is currently disabled."; break; - case SystemConfig.HidPadFormat.Left: message = "5-character IDs displayed in lists will have a padding space added to the beginning."; break; - case SystemConfig.HidPadFormat.Right: message = "5-character IDs displayed in lists will have a padding space added to the end."; break; - default: throw new Exception("unreachable"); - } - await ctx.Reply(message); - return; - } + await ctx.Reply($"Displaying IDs as capital letters is currently **{EnabledDisabled(ctx.Config.HidDisplayCaps)}**."); + } + public async Task EditHidDisplayCaps(Context ctx, bool value) + { + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { HidDisplayCaps = value }); + await ctx.Reply($"Displaying IDs as capital letters is now {EnabledDisabled(value)}."); + } + + public async Task ViewHidListPadding(Context ctx) + { + string message = ctx.Config.HidListPadding switch + { + SystemConfig.HidPadFormat.None => "Padding 5-character IDs in lists is currently disabled.", + SystemConfig.HidPadFormat.Left => "5-character IDs displayed in lists will have a padding space added to the beginning.", + SystemConfig.HidPadFormat.Right => "5-character IDs displayed in lists will have a padding space added to the end.", + _ => throw new Exception("unreachable") + }; + await ctx.Reply(message); + } + + public async Task EditHidListPadding(Context ctx, string padding) + { var badInputError = "Valid padding settings are `left`, `right`, or `off`."; - var toggleOff = ctx.MatchToggleOrNull(false); - - switch (toggleOff) + if (padding.Equals("off", StringComparison.InvariantCultureIgnoreCase)) { - case true: throw new PKError(badInputError); - case false: - { - await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { HidListPadding = SystemConfig.HidPadFormat.None }); - await ctx.Reply("Padding 5-character IDs in lists has been disabled."); - return; - } + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { HidListPadding = SystemConfig.HidPadFormat.None }); + await ctx.Reply("Padding 5-character IDs in lists has been disabled."); } - - if (ctx.Match("left", "l")) + else if (padding.Equals("left", StringComparison.InvariantCultureIgnoreCase) || padding.Equals("l", StringComparison.InvariantCultureIgnoreCase)) { await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { HidListPadding = SystemConfig.HidPadFormat.Left }); await ctx.Reply("5-character IDs displayed in lists will now have a padding space added to the beginning."); } - else if (ctx.Match("right", "r")) + else if (padding.Equals("right", StringComparison.InvariantCultureIgnoreCase) || padding.Equals("r", StringComparison.InvariantCultureIgnoreCase)) { await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { HidListPadding = SystemConfig.HidPadFormat.Right }); await ctx.Reply("5-character IDs displayed in lists will now have a padding space added to the end."); } - else throw new PKError(badInputError); + else + { + throw new PKError(badInputError); + } } - public async Task CardShowColorHex(Context ctx) + public async Task ViewCardShowColorHex(Context ctx) { - if (!ctx.HasNext()) - { - var msg = $"Showing color codes on system/member/group cards is currently **{EnabledDisabled(ctx.Config.CardShowColorHex)}**."; - await ctx.Reply(msg); - return; - } - - var newVal = ctx.MatchToggle(false); - await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { CardShowColorHex = newVal }); - await ctx.Reply($"Showing color codes on system/member/group cards is now {EnabledDisabled(newVal)}."); + await ctx.Reply($"Showing color codes on system/member/group cards is currently **{EnabledDisabled(ctx.Config.CardShowColorHex)}**."); } - public async Task ProxySwitch(Context ctx) + public async Task EditCardShowColorHex(Context ctx, bool value) { - if (!ctx.HasNext()) + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { CardShowColorHex = value }); + await ctx.Reply($"Showing color codes on system/member/group cards is now {EnabledDisabled(value)}."); + } + + public async Task ViewProxySwitch(Context ctx) + { + string msg = ctx.Config.ProxySwitch switch { - string msg = ctx.Config.ProxySwitch switch - { - SystemConfig.ProxySwitchAction.Off => "Currently, when you proxy as a member, no switches are logged or changed.", - SystemConfig.ProxySwitchAction.New => "When you proxy as a member, currently it makes a new switch.", - SystemConfig.ProxySwitchAction.Add => "When you proxy as a member, currently it adds them to the current switch.", - _ => throw new Exception("unreachable"), - }; - await ctx.Reply(msg); - return; - } + SystemConfig.ProxySwitchAction.Off => "Currently, when you proxy as a member, no switches are logged or changed.", + SystemConfig.ProxySwitchAction.New => "When you proxy as a member, currently it makes a new switch.", + SystemConfig.ProxySwitchAction.Add => "When you proxy as a member, currently it adds them to the current switch.", + _ => throw new Exception("unreachable"), + }; + await ctx.Reply(msg); + } - // toggle = false means off, toggle = true means new, otherwise if they said add that means add or if they said new they mean new. If none of those, error - var toggle = ctx.MatchToggleOrNull(false); - var newVal = toggle == false ? SystemConfig.ProxySwitchAction.Off : toggle == true ? SystemConfig.ProxySwitchAction.New : ctx.Match("add", "a") ? SystemConfig.ProxySwitchAction.Add : ctx.Match("new", "n") ? SystemConfig.ProxySwitchAction.New : throw new PKError("You must pass either \"new\", \"add\", or \"off\" to this command."); - - await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { ProxySwitch = newVal }); - switch (newVal) + public async Task EditProxySwitch(Context ctx, SystemConfig.ProxySwitchAction action) + { + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { ProxySwitch = action }); + switch (action) { case SystemConfig.ProxySwitchAction.Off: await ctx.Reply("Now when you proxy as a member, no switches are logged or changed."); break; case SystemConfig.ProxySwitchAction.New: await ctx.Reply("When you proxy as a member, it now makes a new switch."); break; @@ -620,65 +575,61 @@ public class Config } } - public async Task NameFormat(Context ctx) + public async Task ViewNameFormat(Context ctx) { - var clearFlag = ctx.MatchClear(); - if (!ctx.HasNext() && !clearFlag) - { - await ctx.Reply($"Member names are currently formatted as `{ctx.Config.NameFormat ?? ProxyMember.DefaultFormat}`"); - return; - } + await ctx.Reply($"Member names are currently formatted as `{ctx.Config.NameFormat ?? ProxyMember.DefaultFormat}`"); + } - string formatString; - if (clearFlag) - formatString = ProxyMember.DefaultFormat; - else - formatString = ctx.RemainderOrNull(); + public async Task ResetNameFormat(Context ctx) + { + await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { NameFormat = ProxyMember.DefaultFormat }); + await ctx.Reply($"Member names are now formatted as `{ProxyMember.DefaultFormat}`"); + } + public async Task EditNameFormat(Context ctx, string formatString) + { await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { NameFormat = formatString }); await ctx.Reply($"Member names are now formatted as `{formatString}`"); } - public async Task ServerNameFormat(Context ctx) + public async Task ViewServerNameFormat(Context ctx, ReplyFormat format) { ctx.CheckGuildContext(); - var clearFlag = ctx.MatchClear(); - var format = ctx.MatchFormat(); var guildCfg = await ctx.Repository.GetSystemGuild(ctx.Guild.Id, ctx.System.Id); - // if there's nothing next or what's next is raw/plaintext and we're not clearing, it's a query - if ((!ctx.HasNext() || format != ReplyFormat.Standard) && !clearFlag) - { - if (guildCfg.NameFormat == null) - await ctx.Reply("You do not have a specific name format set for this server and member names are formatted with your global name format."); - else - switch (format) - { - case ReplyFormat.Raw: - await ctx.Reply($"`{guildCfg.NameFormat}`"); - break; - case ReplyFormat.Plaintext: - var eb = new EmbedBuilder() - .Description($"Showing guild Name Format for system {ctx.System.DisplayHid(ctx.Config)}"); - await ctx.Reply(guildCfg.NameFormat, eb.Build()); - break; - default: - await ctx.Reply($"Your member names in this server are currently formatted as `{guildCfg.NameFormat}`"); - break; - } - return; - } - - string? formatString = null; - if (!clearFlag) - { - formatString = ctx.RemainderOrNull(); - } - await ctx.Repository.UpdateSystemGuild(ctx.System.Id, ctx.Guild.Id, new() { NameFormat = formatString }); - if (formatString == null) - await ctx.Reply($"Member names are now formatted with your global name format in this server."); + if (guildCfg.NameFormat == null) + await ctx.Reply("You do not have a specific name format set for this server and member names are formatted with your global name format."); else - await ctx.Reply($"Member names are now formatted as `{formatString}` in this server."); + switch (format) + { + case ReplyFormat.Raw: + await ctx.Reply($"`{guildCfg.NameFormat}`"); + break; + case ReplyFormat.Plaintext: + var eb = new EmbedBuilder() + .Description($"Showing guild Name Format for system {ctx.System.DisplayHid(ctx.Config)}"); + await ctx.Reply(guildCfg.NameFormat, eb.Build()); + break; + default: + await ctx.Reply($"Your member names in this server are currently formatted as `{guildCfg.NameFormat}`"); + break; + } + } + + public async Task ResetServerNameFormat(Context ctx) + { + ctx.CheckGuildContext(); + + await ctx.Repository.UpdateSystemGuild(ctx.System.Id, ctx.Guild.Id, new() { NameFormat = null }); + await ctx.Reply($"Member names are now formatted with your global name format in this server."); + } + + public async Task EditServerNameFormat(Context ctx, string formatString) + { + ctx.CheckGuildContext(); + + await ctx.Repository.UpdateSystemGuild(ctx.System.Id, ctx.Guild.Id, new() { NameFormat = formatString }); + await ctx.Reply($"Member names are now formatted as `{formatString}` in this server."); } public Task LimitUpdate(Context ctx) diff --git a/PluralKit.Bot/Commands/Fun.cs b/PluralKit.Bot/Commands/Fun.cs index b1ab53e0..609fffd5 100644 --- a/PluralKit.Bot/Commands/Fun.cs +++ b/PluralKit.Bot/Commands/Fun.cs @@ -34,20 +34,19 @@ public class Fun public Task Sus(Context ctx) => ctx.Reply("\U0001F4EE"); - public Task Error(Context ctx) - { - if (ctx.Match("message")) - return ctx.Reply("> **Error code:** `50f3c7b439d111ecab2023a5431fffbd`", new EmbedBuilder() - .Color(0xE74C3C) - .Title("Internal error occurred") - .Description( - "For support, please send the error code above in **#bug-reports-and-errors** on **[the support server *(click to join)*](https://discord.gg/PczBt78)** with a description of what you were doing at the time.") - .Footer(new Embed.EmbedFooter("50f3c7b439d111ecab2023a5431fffbd")) - .Timestamp(SystemClock.Instance.GetCurrentInstant().ToDateTimeOffset().ToString("O")) - .Build() - ); + public Task Meow(Context ctx) => + ctx.Reply("*mrrp :3*"); - return ctx.Reply( - $"{Emojis.Error} Unknown command {"error".AsCode()}. For a list of possible commands, see ."); - } + public Task ErrorMessage(Context ctx) => ctx.Reply("> **Error code:** `50f3c7b439d111ecab2023a5431fffbd`", new EmbedBuilder() + .Color(0xE74C3C) + .Title("Internal error occurred") + .Description( + "For support, please send the error code above in **#bug-reports-and-errors** on **[the support server *(click to join)*](https://discord.gg/PczBt78)** with a description of what you were doing at the time.") + .Footer(new Embed.EmbedFooter("50f3c7b439d111ecab2023a5431fffbd")) + .Timestamp(SystemClock.Instance.GetCurrentInstant().ToDateTimeOffset().ToString("O")) + .Build() + ); + + public Task Error(Context ctx) => ctx.Reply( + $"{Emojis.Error} Unknown command {"error".AsCode()}. For a list of possible commands, see ."); } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/GroupMember.cs b/PluralKit.Bot/Commands/GroupMember.cs index b30abb24..9dc007f8 100644 --- a/PluralKit.Bot/Commands/GroupMember.cs +++ b/PluralKit.Bot/Commands/GroupMember.cs @@ -10,11 +10,11 @@ namespace PluralKit.Bot; public class GroupMember { - public async Task AddRemoveGroups(Context ctx, PKMember target, Groups.AddRemoveOperation op) + public async Task AddRemoveGroups(Context ctx, PKMember target, List _groups, Groups.AddRemoveOperation op) { ctx.CheckSystem().CheckOwnMember(target); - var groups = (await ctx.ParseGroupList(ctx.System.Id)) + var groups = _groups.FindAll(g => g.System == ctx.System.Id) .Select(g => g.Id) .Distinct() .ToList(); @@ -51,11 +51,12 @@ public class GroupMember groups.Count - toAction.Count)); } - public async Task ListMemberGroups(Context ctx, PKMember target) + public async Task ListMemberGroups(Context ctx, PKMember target, string? query, IHasListOptions flags, bool all) { var targetSystem = await ctx.Repository.GetSystem(target.System); - var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(target.System), ctx.LookupContextFor(target.System)); + var opts = flags.GetListOptions(ctx, target.System); opts.MemberFilter = target.Id; + opts.Search = query; var title = new StringBuilder($"Groups containing {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`) in "); if (ctx.Guild != null) @@ -79,15 +80,15 @@ public class GroupMember title.Append($" matching **{opts.Search.Truncate(100)}**"); await ctx.RenderGroupList(ctx.LookupContextFor(target.System), target.System, title.ToString(), - target.Color, opts); + target.Color, opts, all); } - public async Task AddRemoveMembers(Context ctx, PKGroup target, Groups.AddRemoveOperation op) + public async Task AddRemoveMembers(Context ctx, PKGroup target, List? _members, Groups.AddRemoveOperation op, bool all, bool confirmYes = false) { ctx.CheckOwnGroup(target); List members; - if (ctx.MatchFlag("all", "a")) + if (all) { members = (await ctx.Database.Execute(conn => conn.QueryMemberList(target.System, new DatabaseViewsExt.ListQueryOptions { }))) @@ -97,10 +98,14 @@ public class GroupMember } else { - members = (await ctx.ParseMemberList(ctx.System.Id)) - .Select(m => m.Id) - .Distinct() - .ToList(); + if (_members == null) + throw new PKError("Please provide a list of members to add/remove."); + + members = _members + .FindAll(m => m.System == ctx.System.Id) + .Select(m => m.Id) + .Distinct() + .ToList(); } var existingMembersInGroup = (await ctx.Database.Execute(conn => conn.QueryMemberList(target.System, @@ -124,7 +129,7 @@ public class GroupMember .Where(m => existingMembersInGroup.Contains(m.Value)) .ToList(); - if (ctx.MatchFlag("all", "a") && !await ctx.PromptYesNo($"Are you sure you want to remove all members from group {target.Reference(ctx)}?", "Empty Group")) throw Errors.GenericCancelled(); + if (all && !await ctx.PromptYesNo($"Are you sure you want to remove all members from group {target.Reference(ctx)}?", "Empty Group", flagValue: confirmYes)) throw Errors.GenericCancelled(); await ctx.Repository.RemoveMembersFromGroup(target.Id, toAction); } @@ -137,15 +142,16 @@ public class GroupMember members.Count - toAction.Count)); } - public async Task ListGroupMembers(Context ctx, PKGroup target) + public async Task ListGroupMembers(Context ctx, PKGroup target, string? query, IHasListOptions flags) { // see global system list for explanation of how privacy settings are used here var targetSystem = await GetGroupSystem(ctx, target); ctx.CheckSystemPrivacy(targetSystem.Id, target.ListPrivacy); - var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(target.System), ctx.LookupContextFor(target.System)); + var opts = flags.GetListOptions(ctx, target.System); opts.GroupFilter = target.Id; + opts.Search = query; var title = new StringBuilder($"Members of {target.DisplayName ?? target.Name} (`{target.DisplayHid(ctx.Config)}`) in "); if (ctx.Guild != null) diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index b18764d2..5b672176 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -32,12 +32,11 @@ public class Groups _avatarHosting = avatarHosting; } - public async Task CreateGroup(Context ctx) + public async Task CreateGroup(Context ctx, string groupName, bool confirmYes = false) { ctx.CheckSystem(); // Check group name length - var groupName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a group name."); if (groupName.Length > Limits.MaxGroupNameLength) throw new PKError($"Group name too long ({groupName.Length}/{Limits.MaxGroupNameLength} characters)."); @@ -54,7 +53,7 @@ public class Groups { var msg = $"{Emojis.Warn} You already have a group in your system with the name \"{existingGroup.Name}\" (with ID `{existingGroup.DisplayHid(ctx.Config)}`). Do you want to create another group with the same name?"; - if (!await ctx.PromptYesNo(msg, "Create")) + if (!await ctx.PromptYesNo(msg, "Create", flagValue: confirmYes)) throw new PKError("Group creation cancelled."); } @@ -99,12 +98,11 @@ public class Groups await ctx.Reply(replyStr, eb.Build()); } - public async Task RenameGroup(Context ctx, PKGroup target) + public async Task RenameGroup(Context ctx, PKGroup target, string? newName, bool confirmYes = false) { ctx.CheckOwnGroup(target); // Check group name length - var newName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a new group name."); if (newName.Length > Limits.MaxGroupNameLength) throw new PKError( $"New group name too long ({newName.Length}/{Limits.MaxMemberNameLength} characters)."); @@ -115,7 +113,7 @@ public class Groups { var msg = $"{Emojis.Warn} You already have a group in your system with the name \"{existingGroup.Name}\" (with ID `{existingGroup.DisplayHid(ctx.Config)}`). Do you want to rename this group to that name too?"; - if (!await ctx.PromptYesNo(msg, "Rename")) + if (!await ctx.PromptYesNo(msg, "Rename", flagValue: confirmYes)) throw new PKError("Group rename cancelled."); } @@ -124,7 +122,7 @@ public class Groups await ctx.Reply($"{Emojis.Success} Group name changed from **{target.Name}** to **{newName}** (using {newName.Length}/{Limits.MaxGroupNameLength} characters)."); } - public async Task GroupDisplayName(Context ctx, PKGroup target) + public async Task ShowGroupDisplayName(Context ctx, PKGroup target, ReplyFormat format) { var noDisplayNameSetMessage = "This group does not have a display name set" + (ctx.System?.Id == target.System @@ -134,8 +132,6 @@ public class Groups // Whether displayname is shown or not should depend on if group name privacy is set. // If name privacy is on then displayname should look like name. - var format = ctx.MatchFormat(); - // if we're doing a raw or plaintext query check for null if (format != ReplyFormat.Standard) if (target.DisplayName == null || !target.NamePrivacy.CanAccess(ctx.DirectLookupContextFor(target.System))) @@ -157,69 +153,65 @@ public class Groups return; } - if (!ctx.HasNext(false)) - { - var showDisplayName = target.NamePrivacy.CanAccess(ctx.LookupContextFor(target.System)) && target.DisplayName != null; + var showDisplayName = target.NamePrivacy.CanAccess(ctx.LookupContextFor(target.System)) && target.DisplayName != null; - var eb = new EmbedBuilder() - .Title("Group names") - .Field(new Embed.Field("Name", target.NameFor(ctx))) - .Field(new Embed.Field("Display Name", showDisplayName ? target.DisplayName : "*(no displayname set or name is private)*")); + var eb2 = new EmbedBuilder() + .Title("Group names") + .Field(new Embed.Field("Name", target.NameFor(ctx))) + .Field(new Embed.Field("Display Name", showDisplayName ? target.DisplayName : "*(no displayname set or name is private)*")); - var reference = target.Reference(ctx); + var reference = target.Reference(ctx); - if (ctx.System?.Id == target.System) - eb.Description( - $"To change display name, type `{ctx.DefaultPrefix}group {reference} displayname `.\n" - + $"To clear it, type `{ctx.DefaultPrefix}group {reference} displayname -clear`.\n" - + $"To print the raw display name, type `{ctx.DefaultPrefix}group {reference} displayname -raw`."); + if (ctx.System?.Id == target.System) + eb2.Description( + $"To change display name, type `{ctx.DefaultPrefix}group {reference} displayname `.\n" + + $"To clear it, type `{ctx.DefaultPrefix}group {reference} displayname -clear`.\n" + + $"To print the raw display name, type `{ctx.DefaultPrefix}group {reference} displayname -raw`."); - if (ctx.System?.Id == target.System && showDisplayName) - eb.Footer(new Embed.EmbedFooter($"Using {target.DisplayName.Length}/{Limits.MaxGroupNameLength} characters.")); + if (ctx.System?.Id == target.System && showDisplayName) + eb2.Footer(new Embed.EmbedFooter($"Using {target.DisplayName.Length}/{Limits.MaxGroupNameLength} characters.")); - await ctx.Reply(embed: eb.Build()); - - return; - } - - ctx.CheckOwnGroup(target); - - if (ctx.MatchClear() && await ctx.ConfirmClear("this group's display name")) - { - var patch = new GroupPatch { DisplayName = Partial.Null() }; - await ctx.Repository.UpdateGroup(target.Id, patch); - - var replyStr = $"{Emojis.Success} Group display name cleared."; - if (target.NamePrivacy == PrivacyLevel.Private) - replyStr += $"\n{Emojis.Warn} Since this group no longer has a display name set, their name privacy **can no longer take effect**."; - await ctx.Reply(replyStr); - } - else - { - var newDisplayName = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); - if (newDisplayName.Length > Limits.MaxGroupNameLength) - throw new PKError($"Group name too long ({newDisplayName.Length}/{Limits.MaxGroupNameLength} characters)."); - - var patch = new GroupPatch { DisplayName = Partial.Present(newDisplayName) }; - await ctx.Repository.UpdateGroup(target.Id, patch); - - await ctx.Reply($"{Emojis.Success} Group display name changed (using {newDisplayName.Length}/{Limits.MaxGroupNameLength} characters)."); - } + await ctx.Reply(embed: eb2.Build()); } - public async Task GroupDescription(Context ctx, PKGroup target) + public async Task ClearGroupDisplayName(Context ctx, PKGroup target, bool confirmYes = false) { - ctx.CheckSystemPrivacy(target.System, target.DescriptionPrivacy); + ctx.CheckOwnGroup(target); - var noDescriptionSetMessage = "This group does not have a description set."; - if (ctx.System?.Id == target.System) - noDescriptionSetMessage += - $" To set one, type `{ctx.DefaultPrefix}group {target.Reference(ctx)} description `."; + if (!await ctx.ConfirmClear("this group's display name", confirmYes)) + return; - var format = ctx.MatchFormat(); + var patch = new GroupPatch { DisplayName = Partial.Null() }; + await ctx.Repository.UpdateGroup(target.Id, patch); - // if there's nothing next or what's next is "raw"/"plaintext" we're doing a query, so check for null - if (!ctx.HasNext(false) || format != ReplyFormat.Standard) + var replyStr = $"{Emojis.Success} Group display name cleared."; + if (target.NamePrivacy == PrivacyLevel.Private) + replyStr += $"\n{Emojis.Warn} Since this group no longer has a display name set, their name privacy **can no longer take effect**."; + await ctx.Reply(replyStr); + } + + public async Task ChangeGroupDisplayName(Context ctx, PKGroup target, string newDisplayName) + { + ctx.CheckOwnGroup(target); + + if (newDisplayName.Length > Limits.MaxGroupNameLength) + throw new PKError($"Group name too long ({newDisplayName.Length}/{Limits.MaxGroupNameLength} characters)."); + + var patch = new GroupPatch { DisplayName = Partial.Present(newDisplayName) }; + await ctx.Repository.UpdateGroup(target.Id, patch); + + await ctx.Reply($"{Emojis.Success} Group display name changed (using {newDisplayName.Length}/{Limits.MaxGroupNameLength} characters)."); + } + + public async Task ShowGroupDescription(Context ctx, PKGroup target, ReplyFormat format) + { + var noDescriptionSetMessage = "This group does not have a description set" + + (ctx.System?.Id == target.System + ? $". To set one, type `{ctx.DefaultPrefix}group {target.Reference(ctx)} description `." + : "."); + + // if we're doing a raw or plaintext query check for null + if (format != ReplyFormat.Standard) if (target.Description == null) { await ctx.Reply(noDescriptionSetMessage); @@ -239,246 +231,291 @@ public class Groups return; } - if (!ctx.HasNext(false)) + if (target.Description == null) { - await ctx.Reply(embed: new EmbedBuilder() - .Title("Group description") - .Description(target.Description) - .Field(new Embed.Field("\u200B", - $"To print the description with formatting, type `{ctx.DefaultPrefix}group {target.Reference(ctx)} description -raw`." - + (ctx.System?.Id == target.System - ? $" To clear it, type `{ctx.DefaultPrefix}group {target.Reference(ctx)} description -clear`." - : "") - + $" Using {target.Description.Length}/{Limits.MaxDescriptionLength} characters.")) - .Build()); + await ctx.Reply(noDescriptionSetMessage); return; } + var eb2 = new EmbedBuilder() + .Title("Group description") + .Description(target.Description); + + var reference = target.Reference(ctx); + + if (ctx.System?.Id == target.System) + eb2.Field(new Embed.Field("\u200B", + $"To print the description with formatting, type `{ctx.DefaultPrefix}group {reference} description -raw`." + + $" To clear it, type `{ctx.DefaultPrefix}group {reference} description -clear`." + + $" Using {target.Description.Length}/{Limits.MaxDescriptionLength} characters.")); + else + eb2.Field(new Embed.Field("\u200B", + $"To print the description with formatting, type `{ctx.DefaultPrefix}group {reference} description -raw`." + + $" Using {target.Description.Length}/{Limits.MaxDescriptionLength} characters.")); + + await ctx.Reply(embed: eb2.Build()); + } + + public async Task ClearGroupDescription(Context ctx, PKGroup target, bool confirmYes = false) + { ctx.CheckOwnGroup(target); - if (ctx.MatchClear() && await ctx.ConfirmClear("this group's description")) - { - var patch = new GroupPatch { Description = Partial.Null() }; - await ctx.Repository.UpdateGroup(target.Id, patch); - await ctx.Reply($"{Emojis.Success} Group description cleared."); - } - else - { - var description = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); - if (description.IsLongerThan(Limits.MaxDescriptionLength)) - throw Errors.StringTooLongError("Description", description.Length, Limits.MaxDescriptionLength); + if (!await ctx.ConfirmClear("this group's description", confirmYes)) + return; - var patch = new GroupPatch { Description = Partial.Present(description) }; - await ctx.Repository.UpdateGroup(target.Id, patch); + var patch = new GroupPatch { Description = Partial.Null() }; + await ctx.Repository.UpdateGroup(target.Id, patch); - await ctx.Reply($"{Emojis.Success} Group description changed (using {description.Length}/{Limits.MaxDescriptionLength} characters)."); - } + await ctx.Reply($"{Emojis.Success} Group description cleared."); } - public async Task GroupIcon(Context ctx, PKGroup target) + public async Task ChangeGroupDescription(Context ctx, PKGroup target, string newDescription) { - async Task ClearIcon() - { - await ctx.ConfirmClear("this group's icon"); - ctx.CheckOwnGroup(target); + ctx.CheckOwnGroup(target); - await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Icon = null }); - await ctx.Reply($"{Emojis.Success} Group icon cleared."); - } + if (newDescription.IsLongerThan(Limits.MaxDescriptionLength)) + throw Errors.StringTooLongError("Description", newDescription.Length, Limits.MaxDescriptionLength); - async Task SetIcon(ParsedImage img) - { - ctx.CheckOwnGroup(target); + var patch = new GroupPatch { Description = Partial.Present(newDescription) }; + await ctx.Repository.UpdateGroup(target.Id, patch); - img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System); - await _avatarHosting.VerifyAvatarOrThrow(img.Url); + await ctx.Reply($"{Emojis.Success} Group description changed (using {newDescription.Length}/{Limits.MaxDescriptionLength} characters)."); + } - await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Icon = img.CleanUrl ?? img.Url }); + public async Task ShowGroupIcon(Context ctx, PKGroup target, ReplyFormat format) + { + var noIconSetMessage = "This group does not have an avatar set" + + (ctx.System?.Id == target.System + ? ". Set one by attaching an image to this command, or by passing an image URL or @mention." + : "."); - var msg = img.Source switch + ctx.CheckSystemPrivacy(target.System, target.IconPrivacy); + + // if we're doing a raw or plaintext query check for null + if (format != ReplyFormat.Standard) + if ((target.Icon?.Trim() ?? "").Length == 0) { - AvatarSource.User => - $"{Emojis.Success} Group icon changed to {img.SourceUser?.Username}'s avatar!\n{Emojis.Warn} If {img.SourceUser?.Username} changes their avatar, the group icon will need to be re-set.", - AvatarSource.Url => $"{Emojis.Success} Group icon changed to the image at the given URL.", - AvatarSource.HostedCdn => $"{Emojis.Success} Group icon changed to attached image.", - AvatarSource.Attachment => - $"{Emojis.Success} Group icon changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the group icon will stop working.", - _ => throw new ArgumentOutOfRangeException() - }; + await ctx.Reply(noIconSetMessage); + return; + } - // The attachment's already right there, no need to preview it. - var hasEmbed = img.Source != AvatarSource.Attachment && img.Source != AvatarSource.HostedCdn; - await (hasEmbed - ? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build()) - : ctx.Reply(msg)); - } - - async Task ShowIcon() + if (format == ReplyFormat.Raw) { - ctx.CheckSystemPrivacy(target.System, target.IconPrivacy); - - if ((target.Icon?.Trim() ?? "").Length > 0) - switch (ctx.MatchFormat()) - { - case ReplyFormat.Raw: - await ctx.Reply($"`{target.Icon.TryGetCleanCdnUrl()}`"); - break; - case ReplyFormat.Plaintext: - var ebP = new EmbedBuilder() - .Description($"Showing avatar for group {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)"); - await ctx.Reply(text: $"<{target.Icon.TryGetCleanCdnUrl()}>", embed: ebP.Build()); - break; - default: - var ebS = new EmbedBuilder() - .Title("Group icon") - .Image(new Embed.EmbedImage(target.Icon.TryGetCleanCdnUrl())); - if (target.System == ctx.System?.Id) - ebS.Description($"To clear, use `{ctx.DefaultPrefix}group {target.Reference(ctx)} icon -clear`."); - await ctx.Reply(embed: ebS.Build()); - break; - } - else - throw new PKSyntaxError( - "This group does not have an avatar set. Set one by attaching an image to this command, or by passing an image URL or @mention."); + await ctx.Reply($"`{target.Icon.TryGetCleanCdnUrl()}`"); + return; } - - if (ctx.MatchClear()) - await ClearIcon(); - else if (await ctx.MatchImage() is { } img) - await SetIcon(img); - else - await ShowIcon(); - } - - public async Task GroupBannerImage(Context ctx, PKGroup target) - { - async Task ClearBannerImage() + if (format == ReplyFormat.Plaintext) { - ctx.CheckOwnGroup(target); - await ctx.ConfirmClear("this group's banner image"); - - await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { BannerImage = null }); - await ctx.Reply($"{Emojis.Success} Group banner image cleared."); - } - - async Task SetBannerImage(ParsedImage img) - { - ctx.CheckOwnGroup(target); - - img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Banner, ctx.Author.Id, ctx.System); - await _avatarHosting.VerifyAvatarOrThrow(img.Url, true); - - await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { BannerImage = img.CleanUrl ?? img.Url }); - - var msg = img.Source switch - { - AvatarSource.Url => $"{Emojis.Success} Group banner image changed to the image at the given URL.", - AvatarSource.HostedCdn => $"{Emojis.Success} Group banner image changed to attached image.", - AvatarSource.Attachment => - $"{Emojis.Success} Group banner image changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the banner image will stop working.", - AvatarSource.User => throw new PKError("Cannot set a banner image to an user's avatar."), - _ => throw new ArgumentOutOfRangeException() - }; - - // The attachment's already right there, no need to preview it. - var hasEmbed = img.Source != AvatarSource.Attachment && img.Source != AvatarSource.HostedCdn; - await (hasEmbed - ? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build()) - : ctx.Reply(msg)); - } - - async Task ShowBannerImage() - { - ctx.CheckSystemPrivacy(target.System, target.BannerPrivacy); - - if ((target.BannerImage?.Trim() ?? "").Length > 0) - switch (ctx.MatchFormat()) - { - case ReplyFormat.Raw: - await ctx.Reply($"`{target.BannerImage.TryGetCleanCdnUrl()}`"); - break; - case ReplyFormat.Plaintext: - var ebP = new EmbedBuilder() - .Description($"Showing banner for group {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)"); - await ctx.Reply(text: $"<{target.BannerImage.TryGetCleanCdnUrl()}>", embed: ebP.Build()); - break; - default: - var ebS = new EmbedBuilder() - .Title("Group banner image") - .Image(new Embed.EmbedImage(target.BannerImage.TryGetCleanCdnUrl())); - if (target.System == ctx.System?.Id) - ebS.Description($"To clear, use `{ctx.DefaultPrefix}group {target.Reference(ctx)} banner clear`."); - await ctx.Reply(embed: ebS.Build()); - break; - } - else - throw new PKSyntaxError( - "This group does not have a banner image set. Set one by attaching an image to this command, or by passing an image URL or @mention."); - } - - if (ctx.MatchClear()) - await ClearBannerImage(); - else if (await ctx.MatchImage() is { } img) - await SetBannerImage(img); - else - await ShowBannerImage(); - } - - public async Task GroupColor(Context ctx, PKGroup target) - { - var isOwnSystem = ctx.System?.Id == target.System; - var matchedFormat = ctx.MatchFormat(); - var matchedClear = ctx.MatchClear(); - - if (!isOwnSystem || !(ctx.HasNext() || matchedClear)) - { - if (target.Color == null) - await ctx.Reply( - "This group does not have a color set." + (isOwnSystem ? $" To set one, type `{ctx.DefaultPrefix}group {target.Reference(ctx)} color `." : "")); - else if (matchedFormat == ReplyFormat.Raw) - await ctx.Reply("```\n#" + target.Color + "\n```"); - else if (matchedFormat == ReplyFormat.Plaintext) - await ctx.Reply(target.Color); - else - await ctx.Reply(embed: new EmbedBuilder() - .Title("Group color") - .Color(target.Color.ToDiscordColor()) - .Thumbnail(new Embed.EmbedThumbnail($"attachment://color.gif")) - .Description($"This group's color is **#{target.Color}**." - + (isOwnSystem ? $" To clear it, type `{ctx.DefaultPrefix}group {target.Reference(ctx)} color -clear`." : "")) - .Build(), - files: [MiscUtils.GenerateColorPreview(target.Color)]); + var ebP = new EmbedBuilder() + .Description($"Showing avatar for group {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)"); + await ctx.Reply(text: $"<{target.Icon.TryGetCleanCdnUrl()}>", embed: ebP.Build()); return; } - ctx.CheckSystem().CheckOwnGroup(target); - - if (matchedClear) + if ((target.Icon?.Trim() ?? "").Length == 0) { - await ctx.Repository.UpdateGroup(target.Id, new() { Color = Partial.Null() }); - - await ctx.Reply($"{Emojis.Success} Group color cleared."); + await ctx.Reply(noIconSetMessage); + return; } - else - { - var color = ctx.RemainderOrNull(); - if (color.StartsWith("#")) color = color.Substring(1); - if (!Regex.IsMatch(color, "^[0-9a-fA-F]{6}$")) throw Errors.InvalidColorError(color); - - var patch = new GroupPatch { Color = Partial.Present(color.ToLowerInvariant()) }; - await ctx.Repository.UpdateGroup(target.Id, patch); - - await ctx.Reply(embed: new EmbedBuilder() - .Title($"{Emojis.Success} Group color changed.") - .Color(color.ToDiscordColor()) - .Thumbnail(new Embed.EmbedThumbnail($"attachment://color.gif")) - .Build(), - files: [MiscUtils.GenerateColorPreview(color)]); - } + var ebS = new EmbedBuilder() + .Title("Group icon") + .Image(new Embed.EmbedImage(target.Icon.TryGetCleanCdnUrl())); + if (target.System == ctx.System?.Id) + ebS.Description($"To clear, use `{ctx.DefaultPrefix}group {target.Reference(ctx)} icon -clear`."); + await ctx.Reply(embed: ebS.Build()); } - public async Task ListSystemGroups(Context ctx, PKSystem system) + public async Task ClearGroupIcon(Context ctx, PKGroup target, bool confirmYes) + { + ctx.CheckOwnGroup(target); + await ctx.ConfirmClear("this group's icon", confirmYes); + + await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Icon = null }); + await ctx.Reply($"{Emojis.Success} Group icon cleared."); + } + + public async Task ChangeGroupIcon(Context ctx, PKGroup target, ParsedImage img) + { + ctx.CheckOwnGroup(target); + + img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System); + await _avatarHosting.VerifyAvatarOrThrow(img.Url); + + await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Icon = img.CleanUrl ?? img.Url }); + + var msg = img.Source switch + { + AvatarSource.User => + $"{Emojis.Success} Group icon changed to {img.SourceUser?.Username}'s avatar!\n{Emojis.Warn} If {img.SourceUser?.Username} changes their avatar, the group icon will need to be re-set.", + AvatarSource.Url => $"{Emojis.Success} Group icon changed to the image at the given URL.", + AvatarSource.HostedCdn => $"{Emojis.Success} Group icon changed to attached image.", + AvatarSource.Attachment => + $"{Emojis.Success} Group icon changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the group icon will stop working.", + _ => throw new ArgumentOutOfRangeException() + }; + + // The attachment's already right there, no need to preview it. + var hasEmbed = img.Source != AvatarSource.Attachment && img.Source != AvatarSource.HostedCdn; + await (hasEmbed + ? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build()) + : ctx.Reply(msg)); + } + + public async Task ShowGroupBanner(Context ctx, PKGroup target, ReplyFormat format) + { + var noBannerSetMessage = "This group does not have a banner image set" + + (ctx.System?.Id == target.System + ? ". Set one by attaching an image to this command, or by passing an image URL or @mention." + : "."); + + ctx.CheckSystemPrivacy(target.System, target.BannerPrivacy); + + // if we're doing a raw or plaintext query check for null + if (format != ReplyFormat.Standard) + if ((target.BannerImage?.Trim() ?? "").Length == 0) + { + await ctx.Reply(noBannerSetMessage); + return; + } + + if (format == ReplyFormat.Raw) + { + await ctx.Reply($"`{target.BannerImage.TryGetCleanCdnUrl()}`"); + return; + } + if (format == ReplyFormat.Plaintext) + { + var ebP = new EmbedBuilder() + .Description($"Showing banner for group {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)"); + await ctx.Reply(text: $"<{target.BannerImage.TryGetCleanCdnUrl()}>", embed: ebP.Build()); + return; + } + + if ((target.BannerImage?.Trim() ?? "").Length == 0) + { + await ctx.Reply(noBannerSetMessage); + return; + } + + var ebS = new EmbedBuilder() + .Title("Group banner image") + .Image(new Embed.EmbedImage(target.BannerImage.TryGetCleanCdnUrl())); + if (target.System == ctx.System?.Id) + ebS.Description($"To clear, use `{ctx.DefaultPrefix}group {target.Reference(ctx)} banner clear`."); + await ctx.Reply(embed: ebS.Build()); + } + + public async Task ClearGroupBanner(Context ctx, PKGroup target, bool confirmYes) + { + ctx.CheckOwnGroup(target); + await ctx.ConfirmClear("this group's banner image", confirmYes); + + await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { BannerImage = null }); + await ctx.Reply($"{Emojis.Success} Group banner image cleared."); + } + + public async Task ChangeGroupBanner(Context ctx, PKGroup target, ParsedImage img) + { + ctx.CheckOwnGroup(target); + + img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Banner, ctx.Author.Id, ctx.System); + await _avatarHosting.VerifyAvatarOrThrow(img.Url, true); + + await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { BannerImage = img.CleanUrl ?? img.Url }); + + var msg = img.Source switch + { + AvatarSource.Url => $"{Emojis.Success} Group banner image changed to the image at the given URL.", + AvatarSource.HostedCdn => $"{Emojis.Success} Group banner image changed to attached image.", + AvatarSource.Attachment => + $"{Emojis.Success} Group banner image changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the banner image will stop working.", + AvatarSource.User => throw new PKError("Cannot set a banner image to an user's avatar."), + _ => throw new ArgumentOutOfRangeException() + }; + + // The attachment's already right there, no need to preview it. + var hasEmbed = img.Source != AvatarSource.Attachment && img.Source != AvatarSource.HostedCdn; + await (hasEmbed + ? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build()) + : ctx.Reply(msg)); + } + + public async Task ShowGroupColor(Context ctx, PKGroup target, ReplyFormat format) + { + var noColorSetMessage = "This group does not have a color set" + + (ctx.System?.Id == target.System + ? $". To set one, type `{ctx.DefaultPrefix}group {target.Reference(ctx)} color `." + : "."); + + // if we're doing a raw or plaintext query check for null + if (format != ReplyFormat.Standard) + if (target.Color == null) + { + await ctx.Reply(noColorSetMessage); + return; + } + + if (format == ReplyFormat.Raw) + { + await ctx.Reply("```\n#" + target.Color + "\n```"); + return; + } + if (format == ReplyFormat.Plaintext) + { + await ctx.Reply(target.Color); + return; + } + + if (target.Color == null) + { + await ctx.Reply(noColorSetMessage); + return; + } + + var eb = new EmbedBuilder() + .Title("Group color") + .Color(target.Color.ToDiscordColor()) + .Thumbnail(new Embed.EmbedThumbnail($"attachment://color.gif")) + .Description($"This group's color is **#{target.Color}**."); + + if (ctx.System?.Id == target.System) + eb.Description(eb.Build().Description + $" To clear it, type `{ctx.DefaultPrefix}group {target.Reference(ctx)} color -clear`."); + + await ctx.Reply(embed: eb.Build(), files: [MiscUtils.GenerateColorPreview(target.Color)]); + } + + public async Task ClearGroupColor(Context ctx, PKGroup target, bool confirmYes = false) + { + ctx.CheckOwnGroup(target); + + if (!await ctx.ConfirmClear("this group's color", confirmYes)) + return; + + await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Color = Partial.Null() }); + + await ctx.Reply($"{Emojis.Success} Group color cleared."); + } + + public async Task ChangeGroupColor(Context ctx, PKGroup target, string color) + { + ctx.CheckOwnGroup(target); + + if (color.StartsWith("#")) color = color.Substring(1); + if (!Regex.IsMatch(color, "^[0-9a-fA-F]{6}$")) throw Errors.InvalidColorError(color); + + var patch = new GroupPatch { Color = Partial.Present(color.ToLowerInvariant()) }; + await ctx.Repository.UpdateGroup(target.Id, patch); + + await ctx.Reply(embed: new EmbedBuilder() + .Title($"{Emojis.Success} Group color changed.") + .Color(color.ToDiscordColor()) + .Thumbnail(new Embed.EmbedThumbnail($"attachment://color.gif")) + .Build(), + files: [MiscUtils.GenerateColorPreview(color)]); + } + + public async Task ListSystemGroups(Context ctx, PKSystem system, string? query, IHasListOptions flags, bool all) { if (system == null) { @@ -492,13 +529,16 @@ public class Groups // - ParseListOptions checks list access privacy and sets the privacy filter (which members show up in list) // - RenderGroupList checks the indivual privacy for each member (NameFor, etc) // the own system is always allowed to look up their list - var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(system.Id), ctx.LookupContextFor(system.Id)); + var opts = flags.GetListOptions(ctx, system.Id); + opts.Search = query; + await ctx.RenderGroupList( ctx.LookupContextFor(system.Id), system.Id, GetEmbedTitle(ctx, system, opts), system.Color, - opts + opts, + all ); } @@ -517,114 +557,109 @@ public class Groups return title.ToString(); } - public async Task ShowGroupCard(Context ctx, PKGroup target) + public async Task ShowGroupCard(Context ctx, PKGroup target, bool showEmbed, bool all) { var system = await GetGroupSystem(ctx, target); - if (ctx.MatchFlag("show-embed", "se")) + if (showEmbed) { - await ctx.Reply(text: EmbedService.LEGACY_EMBED_WARNING, embed: await _embeds.CreateGroupEmbed(ctx, system, target)); + await ctx.Reply(text: EmbedService.LEGACY_EMBED_WARNING, embed: await _embeds.CreateGroupEmbed(ctx, system, target, all)); return; } - await ctx.Reply(components: await _embeds.CreateGroupMessageComponents(ctx, system, target)); + await ctx.Reply(components: await _embeds.CreateGroupMessageComponents(ctx, system, target, all)); } - public async Task GroupPrivacy(Context ctx, PKGroup target, PrivacyLevel? newValueFromCommand) + public async Task ShowGroupPrivacy(Context ctx, PKGroup target) { ctx.CheckSystem().CheckOwnGroup(target); - // Display privacy settings - if (!ctx.HasNext() && newValueFromCommand == null) - { - await ctx.Reply(embed: new EmbedBuilder() - .Title($"Current privacy settings for {target.Name}") - .Field(new Embed.Field("Name", target.NamePrivacy.Explanation())) - .Field(new Embed.Field("Description", target.DescriptionPrivacy.Explanation())) - .Field(new Embed.Field("Banner", target.BannerPrivacy.Explanation())) - .Field(new Embed.Field("Icon", target.IconPrivacy.Explanation())) - .Field(new Embed.Field("Member list", target.ListPrivacy.Explanation())) - .Field(new Embed.Field("Metadata (creation date)", target.MetadataPrivacy.Explanation())) - .Field(new Embed.Field("Visibility", target.Visibility.Explanation())) - .Description( - $"To edit privacy settings, use the command:\n> {ctx.DefaultPrefix}group **{target.Reference(ctx)}** privacy **** ****\n\n- `subject` is one of `name`, `description`, `banner`, `icon`, `members`, `metadata`, `visibility`, or `all`\n- `level` is either `public` or `private`.") - .Build()); - return; - } - async Task SetAll(PrivacyLevel level) - { - await ctx.Repository.UpdateGroup(target.Id, new GroupPatch().WithAllPrivacy(level)); + await ctx.Reply(embed: new EmbedBuilder() + .Title($"Current privacy settings for {target.Name}") + .Field(new Embed.Field("Name", target.NamePrivacy.Explanation())) + .Field(new Embed.Field("Description", target.DescriptionPrivacy.Explanation())) + .Field(new Embed.Field("Banner", target.BannerPrivacy.Explanation())) + .Field(new Embed.Field("Icon", target.IconPrivacy.Explanation())) + .Field(new Embed.Field("Member list", target.ListPrivacy.Explanation())) + .Field(new Embed.Field("Metadata (creation date)", target.MetadataPrivacy.Explanation())) + .Field(new Embed.Field("Visibility", target.Visibility.Explanation())) + .Description( + $"To edit privacy settings, use the command:\n> {ctx.DefaultPrefix}group **{target.Reference(ctx)}** privacy **** ****\n\n- `subject` is one of `name`, `description`, `banner`, `icon`, `members`, `metadata`, `visibility`, or `all`\n- `level` is either `public` or `private`.") + .Build()); + } - if (level == PrivacyLevel.Private) - await ctx.Reply( - $"{Emojis.Success} All {target.Name}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see nothing on the group card."); - else - await ctx.Reply( - $"{Emojis.Success} All {target.Name}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see everything on the group card."); - } + public async Task SetAllGroupPrivacy(Context ctx, PKGroup target, PrivacyLevel level) + { + ctx.CheckOwnGroup(target); - async Task SetLevel(GroupPrivacySubject subject, PrivacyLevel level) - { - await ctx.Repository.UpdateGroup(target.Id, new GroupPatch().WithPrivacy(subject, level)); + await ctx.Repository.UpdateGroup(target.Id, new GroupPatch().WithAllPrivacy(level)); - var subjectName = subject switch - { - GroupPrivacySubject.Name => "name privacy", - GroupPrivacySubject.Description => "description privacy", - GroupPrivacySubject.Banner => "banner privacy", - GroupPrivacySubject.Icon => "icon privacy", - GroupPrivacySubject.List => "member list", - GroupPrivacySubject.Metadata => "metadata", - GroupPrivacySubject.Visibility => "visibility", - _ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}") - }; - - var explanation = (subject, level) switch - { - (GroupPrivacySubject.Name, PrivacyLevel.Private) => - "This group's name is now hidden from other systems, and will be replaced by the group's display name.", - (GroupPrivacySubject.Description, PrivacyLevel.Private) => - "This group's description is now hidden from other systems.", - (GroupPrivacySubject.Banner, PrivacyLevel.Private) => - "This group's banner is now hidden from other systems.", - (GroupPrivacySubject.Icon, PrivacyLevel.Private) => - "This group's icon is now hidden from other systems.", - (GroupPrivacySubject.Visibility, PrivacyLevel.Private) => - "This group is now hidden from group lists and member cards.", - (GroupPrivacySubject.Metadata, PrivacyLevel.Private) => - "This group's metadata (eg. creation date) is now hidden from other systems.", - (GroupPrivacySubject.List, PrivacyLevel.Private) => - "This group's member list is now hidden from other systems.", - - (GroupPrivacySubject.Name, PrivacyLevel.Public) => - "This group's name is no longer hidden from other systems.", - (GroupPrivacySubject.Description, PrivacyLevel.Public) => - "This group's description is no longer hidden from other systems.", - (GroupPrivacySubject.Banner, PrivacyLevel.Public) => - "This group's banner is no longer hidden from other systems.", - (GroupPrivacySubject.Icon, PrivacyLevel.Public) => - "This group's icon is no longer hidden from other systems.", - (GroupPrivacySubject.Visibility, PrivacyLevel.Public) => - "This group is no longer hidden from group lists and member cards.", - (GroupPrivacySubject.Metadata, PrivacyLevel.Public) => - "This group's metadata (eg. creation date) is no longer hidden from other systems.", - (GroupPrivacySubject.List, PrivacyLevel.Public) => - "This group's member list is no longer hidden from other systems.", - - _ => throw new InvalidOperationException($"Invalid subject/level tuple ({subject}, {level})") - }; - - var replyStr = $"{Emojis.Success} {target.Name}'s **{subjectName}** has been set to **{level.LevelName()}**. {explanation}"; - - if (subject == GroupPrivacySubject.Name && level == PrivacyLevel.Private && target.DisplayName == null) - replyStr += $"\n{Emojis.Warn} This group does not have a display name set, and name privacy **will not take effect**."; - - await ctx.Reply(replyStr); - } - - if (ctx.Match("all") || newValueFromCommand != null) - await SetAll(newValueFromCommand ?? ctx.PopPrivacyLevel()); + if (level == PrivacyLevel.Private) + await ctx.Reply( + $"{Emojis.Success} All {target.Name}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see nothing on the group card."); else - await SetLevel(ctx.PopGroupPrivacySubject(), ctx.PopPrivacyLevel()); + await ctx.Reply( + $"{Emojis.Success} All {target.Name}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see everything on the group card."); + } + + public async Task SetGroupPrivacy(Context ctx, PKGroup target, GroupPrivacySubject subject, PrivacyLevel level) + { + ctx.CheckOwnGroup(target); + + await ctx.Repository.UpdateGroup(target.Id, new GroupPatch().WithPrivacy(subject, level)); + + var subjectName = subject switch + { + GroupPrivacySubject.Name => "name privacy", + GroupPrivacySubject.Description => "description privacy", + GroupPrivacySubject.Banner => "banner privacy", + GroupPrivacySubject.Icon => "icon privacy", + GroupPrivacySubject.List => "member list", + GroupPrivacySubject.Metadata => "metadata", + GroupPrivacySubject.Visibility => "visibility", + _ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}") + }; + + var explanation = (subject, level) switch + { + (GroupPrivacySubject.Name, PrivacyLevel.Private) => + "This group's name is now hidden from other systems, and will be replaced by the group's display name.", + (GroupPrivacySubject.Description, PrivacyLevel.Private) => + "This group's description is now hidden from other systems.", + (GroupPrivacySubject.Banner, PrivacyLevel.Private) => + "This group's banner is now hidden from other systems.", + (GroupPrivacySubject.Icon, PrivacyLevel.Private) => + "This group's icon is now hidden from other systems.", + (GroupPrivacySubject.Visibility, PrivacyLevel.Private) => + "This group is now hidden from group lists and member cards.", + (GroupPrivacySubject.Metadata, PrivacyLevel.Private) => + "This group's metadata (eg. creation date) is now hidden from other systems.", + (GroupPrivacySubject.List, PrivacyLevel.Private) => + "This group's member list is now hidden from other systems.", + + (GroupPrivacySubject.Name, PrivacyLevel.Public) => + "This group's name is no longer hidden from other systems.", + (GroupPrivacySubject.Description, PrivacyLevel.Public) => + "This group's description is no longer hidden from other systems.", + (GroupPrivacySubject.Banner, PrivacyLevel.Public) => + "This group's banner is no longer hidden from other systems.", + (GroupPrivacySubject.Icon, PrivacyLevel.Public) => + "This group's icon is no longer hidden from other systems.", + (GroupPrivacySubject.Visibility, PrivacyLevel.Public) => + "This group is no longer hidden from group lists and member cards.", + (GroupPrivacySubject.Metadata, PrivacyLevel.Public) => + "This group's metadata (eg. creation date) is no longer hidden from other systems.", + (GroupPrivacySubject.List, PrivacyLevel.Public) => + "This group's member list is no longer hidden from other systems.", + + _ => throw new InvalidOperationException($"Invalid subject/level tuple ({subject}, {level})") + }; + + var replyStr = $"{Emojis.Success} {target.Name}'s **{subjectName}** has been set to **{level.LevelName()}**. {explanation}"; + + if (subject == GroupPrivacySubject.Name && level == PrivacyLevel.Private && target.DisplayName == null) + replyStr += $"\n{Emojis.Warn} This group does not have a display name set, and name privacy **will not take effect**."; + + await ctx.Reply(replyStr); } public async Task DeleteGroup(Context ctx, PKGroup target) diff --git a/PluralKit.Bot/Commands/Help.cs b/PluralKit.Bot/Commands/Help.cs index 333f4997..36aa73f7 100644 --- a/PluralKit.Bot/Commands/Help.cs +++ b/PluralKit.Bot/Commands/Help.cs @@ -7,9 +7,9 @@ namespace PluralKit.Bot; public class Help { - public Task HelpRoot(Context ctx) + public Task HelpRoot(Context ctx, bool showEmbed = false) { - if (ctx.MatchFlag("show-embed", "se")) + if (showEmbed) return HelpRootOld(ctx); return ctx.Reply(BuildComponents(ctx.Author.Id, Help.Description.Replace("{prefix}", ctx.DefaultPrefix), -1)); diff --git a/PluralKit.Bot/Commands/ImportExport.cs b/PluralKit.Bot/Commands/ImportExport.cs index d626c70d..76118afe 100644 --- a/PluralKit.Bot/Commands/ImportExport.cs +++ b/PluralKit.Bot/Commands/ImportExport.cs @@ -31,9 +31,9 @@ public class ImportExport _dmCache = dmCache; } - public async Task Import(Context ctx) + public async Task Import(Context ctx, string? inputUrl, bool confirmYes) { - var inputUrl = ctx.RemainderOrNull() ?? ctx.Message.Attachments.FirstOrDefault()?.Url; + inputUrl = inputUrl ?? ctx.Message.Attachments.FirstOrDefault()?.Url; if (inputUrl == null) throw Errors.NoImportFilePassed; if (!Core.MiscUtils.TryMatchUri(inputUrl, out var url)) @@ -77,7 +77,7 @@ public class ImportExport async Task ConfirmImport(string message) { var msg = $"{message}\n\nDo you want to proceed with the import?"; - if (!await ctx.PromptYesNo(msg, "Proceed")) + if (!await ctx.PromptYesNo(msg, "Proceed", flagValue: confirmYes)) throw Errors.ImportCancelled; } @@ -86,7 +86,7 @@ public class ImportExport && data.Value("accounts").Contains(ctx.Author.Id.ToString())) { var msg = $"{Emojis.Warn} You seem to importing a system profile belonging to another account. Are you sure you want to proceed?"; - if (!await ctx.PromptYesNo(msg, "Import")) throw Errors.ImportCancelled; + if (!await ctx.PromptYesNo(msg, "Import", flagValue: confirmYes)) throw Errors.ImportCancelled; } var result = await _dataFiles.ImportSystem(ctx.Author.Id, ctx.System, data, ConfirmImport); diff --git a/PluralKit.Bot/Commands/Lists/ContextListExt.cs b/PluralKit.Bot/Commands/Lists/ContextListExt.cs index 15257218..bdd1b034 100644 --- a/PluralKit.Bot/Commands/Lists/ContextListExt.cs +++ b/PluralKit.Bot/Commands/Lists/ContextListExt.cs @@ -9,95 +9,13 @@ using PluralKit.Core; namespace PluralKit.Bot; +public interface IHasListOptions +{ + ListOptions GetListOptions(Context ctx, SystemId system); +} + public static class ContextListExt { - public static ListOptions ParseListOptions(this Context ctx, LookupContext directLookupCtx, LookupContext lookupContext) - { - var p = new ListOptions(); - - // Short or long list? (parse this first, as it can potentially take a positional argument) - var isFull = ctx.Match("f", "full", "big", "details", "long") || ctx.MatchFlag("f", "full"); - p.Type = isFull ? ListType.Long : ListType.Short; - - // Search query - if (ctx.HasNext()) - p.Search = ctx.RemainderOrNull(); - - // Include description in search? - if (ctx.MatchFlag( - "search-description", - "filter-description", - "in-description", - "sd", - "description", - "desc" - )) - p.SearchDescription = true; - - // Sort property (default is by name, but adding a flag anyway, 'cause why not) - if (ctx.MatchFlag("by-name", "bn")) p.SortProperty = SortProperty.Name; - if (ctx.MatchFlag("by-display-name", "bdn")) p.SortProperty = SortProperty.DisplayName; - if (ctx.MatchFlag("by-id", "bid")) p.SortProperty = SortProperty.Hid; - if (ctx.MatchFlag("by-message-count", "bmc")) p.SortProperty = SortProperty.MessageCount; - if (ctx.MatchFlag("by-created", "bc", "bcd")) p.SortProperty = SortProperty.CreationDate; - if (ctx.MatchFlag("by-last-fronted", "by-last-front", "by-last-switch", "blf", "bls")) - p.SortProperty = SortProperty.LastSwitch; - if (ctx.MatchFlag("by-last-message", "blm", "blp")) p.SortProperty = SortProperty.LastMessage; - if (ctx.MatchFlag("by-birthday", "by-birthdate", "bbd")) p.SortProperty = SortProperty.Birthdate; - if (ctx.MatchFlag("random", "rand")) p.SortProperty = SortProperty.Random; - - // Sort reverse? - if (ctx.MatchFlag("r", "rev", "reverse")) - p.Reverse = true; - - // Privacy filter (default is public only) - if (ctx.MatchFlag("a", "all")) p.PrivacyFilter = null; - if (ctx.MatchFlag("private-only", "po")) p.PrivacyFilter = PrivacyLevel.Private; - - // PERM CHECK: If we're trying to access non-public members of another system, error - if (p.PrivacyFilter != PrivacyLevel.Public && directLookupCtx != LookupContext.ByOwner) - // TODO: should this just return null instead of throwing or something? >.> - throw Errors.NotOwnInfo; - - //this is for searching - p.Context = lookupContext; - - // Additional fields to include in the search results - if (ctx.MatchFlag("with-last-switch", "with-last-fronted", "with-last-front", "wls", "wlf")) - p.IncludeLastSwitch = true; - if (ctx.MatchFlag("with-last-message", "with-last-proxy", "wlm", "wlp")) - p.IncludeLastMessage = true; - if (ctx.MatchFlag("with-message-count", "wmc")) - p.IncludeMessageCount = true; - if (ctx.MatchFlag("with-created", "wc")) - p.IncludeCreated = true; - if (ctx.MatchFlag("with-avatar", "with-image", "with-icon", "wa", "wi", "ia", "ii", "img")) - p.IncludeAvatar = true; - if (ctx.MatchFlag("with-pronouns", "wp", "wprns")) - p.IncludePronouns = true; - if (ctx.MatchFlag("with-displayname", "wdn")) - p.IncludeDisplayName = true; - if (ctx.MatchFlag("with-birthday", "wbd", "wb")) - p.IncludeBirthday = true; - - // Always show the sort property, too (unless this is the short list and we are already showing something else) - if (p.Type != ListType.Short || p.includedCount == 0) - { - if (p.SortProperty == SortProperty.DisplayName) p.IncludeDisplayName = true; - if (p.SortProperty == SortProperty.MessageCount) p.IncludeMessageCount = true; - if (p.SortProperty == SortProperty.CreationDate) p.IncludeCreated = true; - if (p.SortProperty == SortProperty.LastSwitch) p.IncludeLastSwitch = true; - if (p.SortProperty == SortProperty.LastMessage) p.IncludeLastMessage = true; - if (p.SortProperty == SortProperty.Birthdate) p.IncludeBirthday = true; - } - - // Make sure the options are valid - p.AssertIsValid(); - - // Done! - return p; - } - public static async Task RenderMemberList(this Context ctx, LookupContext lookupCtx, SystemId system, string embedTitle, string color, ListOptions opts) { @@ -212,7 +130,7 @@ public static class ContextListExt } public static async Task RenderGroupList(this Context ctx, LookupContext lookupCtx, - SystemId system, string embedTitle, string color, ListOptions opts) + SystemId system, string embedTitle, string color, ListOptions opts, bool all) { // We take an IDatabase instead of a IPKConnection so we don't keep the handle open for the entire runtime // We wanna release it as soon as the member list is actually *fetched*, instead of potentially minutes later (paginate timeout) @@ -286,7 +204,7 @@ public static class ContextListExt { if (g.ListPrivacy == PrivacyLevel.Public || lookupCtx == LookupContext.ByOwner) { - if (ctx.MatchFlag("all", "a")) + if (all) { ret += $"({"member".ToQuantity(g.TotalMemberCount)})"; } @@ -324,7 +242,7 @@ public static class ContextListExt if (g.ListPrivacy == PrivacyLevel.Public || lookupCtx == LookupContext.ByOwner) { - if (ctx.MatchFlag("all", "a") && ctx.DirectLookupContextFor(system) == LookupContext.ByOwner) + if (all && ctx.DirectLookupContextFor(system) == LookupContext.ByOwner) profile.Append($"\n**Member Count:** {g.TotalMemberCount}"); else profile.Append($"\n**Member Count:** {g.PublicMemberCount}"); diff --git a/PluralKit.Bot/Commands/Lists/ListOptions.cs b/PluralKit.Bot/Commands/Lists/ListOptions.cs index 991b0a8e..f225da75 100644 --- a/PluralKit.Bot/Commands/Lists/ListOptions.cs +++ b/PluralKit.Bot/Commands/Lists/ListOptions.cs @@ -184,6 +184,7 @@ public static class ListOptionsExt // the check for multiple *sorting* property flags is done in SortProperty setter } + } public enum SortProperty diff --git a/PluralKit.Bot/Commands/Member.cs b/PluralKit.Bot/Commands/Member.cs index 37ab9d18..6d5ee812 100644 --- a/PluralKit.Bot/Commands/Member.cs +++ b/PluralKit.Bot/Commands/Member.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Reflection.Metadata; using System.Web; using Dapper; @@ -27,10 +28,10 @@ public class Member _avatarHosting = avatarHosting; } - public async Task NewMember(Context ctx) + public async Task NewMember(Context ctx, string? memberName, bool confirmYes = false) { if (ctx.System == null) throw Errors.NoSystemError(ctx.DefaultPrefix); - var memberName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a member name."); + memberName = memberName ?? throw new PKSyntaxError("You must pass a member name."); // Hard name length cap if (memberName.Length > Limits.MaxMemberNameLength) @@ -41,7 +42,7 @@ public class Member if (existingMember != null) { var msg = $"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.NameFor(ctx)}\" (with ID `{existingMember.DisplayHid(ctx.Config)}`). Do you want to create another member with the same name?"; - if (!await ctx.PromptYesNo(msg, "Create")) throw new PKError("Member creation cancelled."); + if (!await ctx.PromptYesNo(msg, "Create", flagValue: confirmYes)) throw new PKError("Member creation cancelled."); } await using var conn = await ctx.Database.Obtain(); @@ -119,10 +120,10 @@ public class Member await ctx.Reply(replyStr); } - public async Task ViewMember(Context ctx, PKMember target) + public async Task ViewMember(Context ctx, PKMember target, bool showEmbed = false) { var system = await ctx.Repository.GetSystem(target.System); - if (ctx.MatchFlag("show-embed", "se")) + if (showEmbed) { await ctx.Reply( text: EmbedService.LEGACY_EMBED_WARNING, diff --git a/PluralKit.Bot/Commands/MemberAvatar.cs b/PluralKit.Bot/Commands/MemberAvatar.cs index 26eb310e..81500a0e 100644 --- a/PluralKit.Bot/Commands/MemberAvatar.cs +++ b/PluralKit.Bot/Commands/MemberAvatar.cs @@ -17,8 +17,11 @@ public class MemberAvatar _avatarHosting = avatarHosting; } - private async Task AvatarClear(MemberAvatarLocation location, Context ctx, PKMember target, MemberGuildSettings? mgs) + private async Task AvatarClear(MemberAvatarLocation location, Context ctx, PKMember target, MemberGuildSettings? mgs, bool confirmYes) { + ctx.CheckSystem().CheckOwnMember(target); + await ctx.ConfirmClear("this member's " + location.Name(), confirmYes); + await UpdateAvatar(location, ctx, target, null); if (location == MemberAvatarLocation.Server) { @@ -47,7 +50,7 @@ public class MemberAvatar } private async Task AvatarShow(MemberAvatarLocation location, Context ctx, PKMember target, - MemberGuildSettings? guildData) + MemberGuildSettings? guildData, ReplyFormat format) { // todo: this privacy code is really confusing // for now, we skip privacy flag/config parsing for this, but it would be good to fix that at some point @@ -86,7 +89,6 @@ public class MemberAvatar if (location == MemberAvatarLocation.Server) field += $" (for {ctx.Guild.Name})"; - var format = ctx.MatchFormat(); if (format == ReplyFormat.Raw) { await ctx.Reply($"`{currentValue?.TryGetCleanCdnUrl()}`"); @@ -110,58 +112,89 @@ public class MemberAvatar else throw new PKError("Format Not Recognized"); } - public async Task ServerAvatar(Context ctx, PKMember target) + private async Task AvatarChange(MemberAvatarLocation location, Context ctx, PKMember target, + MemberGuildSettings? guildData, ParsedImage avatar) { - ctx.CheckGuildContext(); - var guildData = await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id); - await AvatarCommandTree(MemberAvatarLocation.Server, ctx, target, guildData); - } - - public async Task Avatar(Context ctx, PKMember target) - { - var guildData = ctx.Guild != null - ? await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id) - : null; - - await AvatarCommandTree(MemberAvatarLocation.Member, ctx, target, guildData); - } - - public async Task WebhookAvatar(Context ctx, PKMember target) - { - var guildData = ctx.Guild != null - ? await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id) - : null; - - await AvatarCommandTree(MemberAvatarLocation.MemberWebhook, ctx, target, guildData); - } - - private async Task AvatarCommandTree(MemberAvatarLocation location, Context ctx, PKMember target, - MemberGuildSettings? guildData) - { - // First, see if we need to *clear* - if (ctx.MatchClear()) - { - ctx.CheckSystem().CheckOwnMember(target); - await ctx.ConfirmClear("this member's " + location.Name()); - await AvatarClear(location, ctx, target, guildData); - return; - } - - // Then, parse an image from the command (from various sources...) - var avatarArg = await ctx.MatchImage(); - if (avatarArg == null) - { - // If we didn't get any, just show the current avatar - await AvatarShow(location, ctx, target, guildData); - return; - } - ctx.CheckSystem().CheckOwnMember(target); - avatarArg = await _avatarHosting.TryRehostImage(avatarArg.Value, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System); - await _avatarHosting.VerifyAvatarOrThrow(avatarArg.Value.Url); - await UpdateAvatar(location, ctx, target, avatarArg.Value.CleanUrl ?? avatarArg.Value.Url); - await PrintResponse(location, ctx, target, avatarArg.Value, guildData); + avatar = await _avatarHosting.TryRehostImage(avatar, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System); + await _avatarHosting.VerifyAvatarOrThrow(avatar.Url); + await UpdateAvatar(location, ctx, target, avatar.CleanUrl ?? avatar.Url); + await PrintResponse(location, ctx, target, avatar, guildData); + } + + private Task GetServerAvatarGuildData(Context ctx, PKMember target) + { + ctx.CheckGuildContext(); + return ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id); + } + + private async Task GetAvatarGuildData(Context ctx, PKMember target) + { + return ctx.Guild != null + ? await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id) + : null; + } + + private async Task GetWebhookAvatarGuildData(Context ctx, PKMember target) + { + return ctx.Guild != null + ? await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id) + : null; + } + + public async Task ShowServerAvatar(Context ctx, PKMember target, ReplyFormat format) + { + var guildData = await GetServerAvatarGuildData(ctx, target); + await AvatarShow(MemberAvatarLocation.Server, ctx, target, guildData, format); + } + + public async Task ClearServerAvatar(Context ctx, PKMember target, bool confirmYes) + { + var guildData = await GetServerAvatarGuildData(ctx, target); + await AvatarClear(MemberAvatarLocation.Server, ctx, target, guildData, confirmYes); + } + + public async Task ChangeServerAvatar(Context ctx, PKMember target, ParsedImage avatar) + { + var guildData = await GetServerAvatarGuildData(ctx, target); + await AvatarChange(MemberAvatarLocation.Server, ctx, target, guildData, avatar); + } + + public async Task ShowAvatar(Context ctx, PKMember target, ReplyFormat format) + { + var guildData = await GetAvatarGuildData(ctx, target); + await AvatarShow(MemberAvatarLocation.Member, ctx, target, guildData, format); + } + + public async Task ClearAvatar(Context ctx, PKMember target, bool confirmYes) + { + var guildData = await GetAvatarGuildData(ctx, target); + await AvatarClear(MemberAvatarLocation.Member, ctx, target, guildData, confirmYes); + } + + public async Task ChangeAvatar(Context ctx, PKMember target, ParsedImage avatar) + { + var guildData = await GetAvatarGuildData(ctx, target); + await AvatarChange(MemberAvatarLocation.Member, ctx, target, guildData, avatar); + } + + public async Task ShowWebhookAvatar(Context ctx, PKMember target, ReplyFormat format) + { + var guildData = await GetWebhookAvatarGuildData(ctx, target); + await AvatarShow(MemberAvatarLocation.MemberWebhook, ctx, target, guildData, format); + } + + public async Task ClearWebhookAvatar(Context ctx, PKMember target, bool confirmYes) + { + var guildData = await GetWebhookAvatarGuildData(ctx, target); + await AvatarClear(MemberAvatarLocation.MemberWebhook, ctx, target, guildData, confirmYes); + } + + public async Task ChangeWebhookAvatar(Context ctx, PKMember target, ParsedImage avatar) + { + var guildData = await GetWebhookAvatarGuildData(ctx, target); + await AvatarChange(MemberAvatarLocation.MemberWebhook, ctx, target, guildData, avatar); } private Task PrintResponse(MemberAvatarLocation location, Context ctx, PKMember target, ParsedImage avatar, diff --git a/PluralKit.Bot/Commands/MemberEdit.cs b/PluralKit.Bot/Commands/MemberEdit.cs index 525d60ab..e13dc919 100644 --- a/PluralKit.Bot/Commands/MemberEdit.cs +++ b/PluralKit.Bot/Commands/MemberEdit.cs @@ -21,38 +21,33 @@ public class MemberEdit _avatarHosting = avatarHosting; } - public async Task Name(Context ctx, PKMember target) + public async Task ShowName(Context ctx, PKMember target, ReplyFormat format) { - var format = ctx.MatchFormat(); - - if (!ctx.HasNext() || format != ReplyFormat.Standard) + var lctx = ctx.DirectLookupContextFor(target.System); + switch (format) { - var lctx = ctx.DirectLookupContextFor(target.System); - switch (format) - { - case ReplyFormat.Raw: - await ctx.Reply($"```{target.NameFor(lctx)}```"); - break; - case ReplyFormat.Plaintext: - var eb = new EmbedBuilder() - .Description($"Showing name for member {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)"); - await ctx.Reply(target.NameFor(lctx), embed: eb.Build()); - break; - default: - var replyStrQ = $"Name for member {target.DisplayHid(ctx.Config)} is **{target.NameFor(lctx)}**."; - if (target.System == ctx.System?.Id) - replyStrQ += $"\nTo rename {target.DisplayHid(ctx.Config)} type `{ctx.DefaultPrefix}member {target.NameFor(ctx)} rename `." - + $" Using {target.NameFor(lctx).Length}/{Limits.MaxMemberNameLength} characters."; - await ctx.Reply(replyStrQ); - break; - } - return; + case ReplyFormat.Raw: + await ctx.Reply($"```{target.NameFor(lctx)}```"); + break; + case ReplyFormat.Plaintext: + var eb = new EmbedBuilder() + .Description($"Showing name for member {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)"); + await ctx.Reply(target.NameFor(lctx), embed: eb.Build()); + break; + default: + var replyStrQ = $"Name for member {target.DisplayHid(ctx.Config)} is **{target.NameFor(lctx)}**."; + if (target.System == ctx.System?.Id) + replyStrQ += $"\nTo rename {target.DisplayHid(ctx.Config)} type `{ctx.DefaultPrefix}member {target.NameFor(ctx)} rename `." + + $" Using {target.NameFor(lctx).Length}/{Limits.MaxMemberNameLength} characters."; + await ctx.Reply(replyStrQ); + break; } + } + public async Task ChangeName(Context ctx, PKMember target, string newName, bool confirmYes) + { ctx.CheckSystem().CheckOwnMember(target); - var newName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a new name for the member."); - // Hard name length cap if (newName.Length > Limits.MaxMemberNameLength) throw Errors.StringTooLongError("Member name", newName.Length, Limits.MaxMemberNameLength); @@ -63,7 +58,7 @@ public class MemberEdit { var msg = $"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.NameFor(ctx)}\" (`{existingMember.DisplayHid(ctx.Config)}`). Do you want to rename this member to that name too?"; - if (!await ctx.PromptYesNo(msg, "Rename")) throw new PKError("Member renaming cancelled."); + if (!await ctx.PromptYesNo(msg, "Rename", flagValue: confirmYes)) throw new PKError("Member renaming cancelled."); } // Rename the member @@ -85,7 +80,7 @@ public class MemberEdit await ctx.Reply(replyStr); } - public async Task Description(Context ctx, PKMember target) + public async Task ShowDescription(Context ctx, PKMember target, ReplyFormat format) { ctx.CheckSystemPrivacy(target.System, target.DescriptionPrivacy); @@ -94,10 +89,8 @@ public class MemberEdit noDescriptionSetMessage += $" To set one, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} description `."; - var format = ctx.MatchFormat(); - // if there's nothing next or what's next is "raw"/"plaintext" we're doing a query, so check for null - if (!ctx.HasNext(false) || format != ReplyFormat.Standard) + if (format != ReplyFormat.Standard) if (target.Description == null) { await ctx.Reply(noDescriptionSetMessage); @@ -117,59 +110,60 @@ public class MemberEdit return; } - if (!ctx.HasNext(false)) - { - await ctx.Reply(embed: new EmbedBuilder() - .Title("Member description") - .Description(target.Description) - .Field(new Embed.Field("\u200B", - $"To print the description with formatting, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} description -raw`." - + (ctx.System?.Id == target.System - ? $" To clear it, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} description -clear`." - + $" Using {target.Description.Length}/{Limits.MaxDescriptionLength} characters." - : ""))) - .Build()); - return; - } + await ctx.Reply(embed: new EmbedBuilder() + .Title("Member description") + .Description(target.Description) + .Field(new Embed.Field("\u200B", + $"To print the description with formatting, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} description -raw`." + + (ctx.System?.Id == target.System + ? $" To clear it, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} description -clear`." + + $" Using {target.Description.Length}/{Limits.MaxDescriptionLength} characters." + : ""))) + .Build()); + } + public async Task ClearDescription(Context ctx, PKMember target, bool confirmYes) + { + ctx.CheckSystemPrivacy(target.System, target.DescriptionPrivacy); ctx.CheckOwnMember(target); - if (ctx.MatchClear() && await ctx.ConfirmClear("this member's description")) + if (await ctx.ConfirmClear("this member's description", confirmYes)) { var patch = new MemberPatch { Description = Partial.Null() }; await ctx.Repository.UpdateMember(target.Id, patch); await ctx.Reply($"{Emojis.Success} Member description cleared."); } - else - { - var description = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); - if (description.IsLongerThan(Limits.MaxDescriptionLength)) - throw Errors.StringTooLongError("Description", description.Length, Limits.MaxDescriptionLength); - - var patch = new MemberPatch { Description = Partial.Present(description) }; - await ctx.Repository.UpdateMember(target.Id, patch); - - await ctx.Reply($"{Emojis.Success} Member description changed (using {description.Length}/{Limits.MaxDescriptionLength} characters)."); - } } - public async Task Pronouns(Context ctx, PKMember target) + public async Task ChangeDescription(Context ctx, PKMember target, string _description) { + ctx.CheckSystemPrivacy(target.System, target.DescriptionPrivacy); + ctx.CheckOwnMember(target); + + var description = _description.NormalizeLineEndSpacing(); + if (description.IsLongerThan(Limits.MaxDescriptionLength)) + throw Errors.StringTooLongError("Description", description.Length, Limits.MaxDescriptionLength); + + var patch = new MemberPatch { Description = Partial.Present(description) }; + await ctx.Repository.UpdateMember(target.Id, patch); + + await ctx.Reply($"{Emojis.Success} Member description changed (using {description.Length}/{Limits.MaxDescriptionLength} characters)."); + } + + public async Task ShowPronouns(Context ctx, PKMember target, ReplyFormat format) + { + ctx.CheckSystemPrivacy(target.System, target.PronounPrivacy); + var noPronounsSetMessage = "This member does not have pronouns set."; if (ctx.System?.Id == target.System) noPronounsSetMessage += $" To set some, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} pronouns `."; - ctx.CheckSystemPrivacy(target.System, target.PronounPrivacy); - - var format = ctx.MatchFormat(); - - // if there's nothing next or what's next is "raw"/"plaintext" we're doing a query, so check for null - if (!ctx.HasNext(false) || format != ReplyFormat.Standard) - if (target.Pronouns == null) - { - await ctx.Reply(noPronounsSetMessage); - return; - } + // check for null since we are doing a query + if (target.Pronouns == null) + { + await ctx.Reply(noPronounsSetMessage); + return; + } if (format == ReplyFormat.Raw) { @@ -184,210 +178,240 @@ public class MemberEdit return; } - if (!ctx.HasNext(false)) - { - await ctx.Reply( - $"**{target.NameFor(ctx)}**'s pronouns are **{target.Pronouns}**.\nTo print the pronouns with formatting, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} pronouns -raw`." - + (ctx.System?.Id == target.System - ? $" To clear them, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} pronouns -clear`." - + $" Using {target.Pronouns.Length}/{Limits.MaxPronounsLength} characters." - : "")); - return; - } + await ctx.Reply( + $"**{target.NameFor(ctx)}**'s pronouns are **{target.Pronouns}**.\nTo print the pronouns with formatting, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} pronouns -raw`." + + (ctx.System?.Id == target.System + ? $" To clear them, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} pronouns -clear`." + + $" Using {target.Pronouns.Length}/{Limits.MaxPronounsLength} characters." + : "")); + } + public async Task ClearPronouns(Context ctx, PKMember target, bool confirmYes) + { ctx.CheckOwnMember(target); - if (ctx.MatchClear() && await ctx.ConfirmClear("this member's pronouns")) + if (await ctx.ConfirmClear("this member's pronouns", confirmYes)) { var patch = new MemberPatch { Pronouns = Partial.Null() }; await ctx.Repository.UpdateMember(target.Id, patch); await ctx.Reply($"{Emojis.Success} Member pronouns cleared."); } - else - { - var pronouns = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); - if (pronouns.IsLongerThan(Limits.MaxPronounsLength)) - throw Errors.StringTooLongError("Pronouns", pronouns.Length, Limits.MaxPronounsLength); - - var patch = new MemberPatch { Pronouns = Partial.Present(pronouns) }; - await ctx.Repository.UpdateMember(target.Id, patch); - - await ctx.Reply($"{Emojis.Success} Member pronouns changed (using {pronouns.Length}/{Limits.MaxPronounsLength} characters)."); - } } - public async Task BannerImage(Context ctx, PKMember target) + public async Task ChangePronouns(Context ctx, PKMember target, string pronouns) { - async Task ClearBannerImage() - { - ctx.CheckOwnMember(target); - await ctx.ConfirmClear("this member's banner image"); + ctx.CheckOwnMember(target); - await ctx.Repository.UpdateMember(target.Id, new MemberPatch { BannerImage = null }); - await ctx.Reply($"{Emojis.Success} Member banner image cleared."); - } + pronouns = pronouns.NormalizeLineEndSpacing(); + if (pronouns.IsLongerThan(Limits.MaxPronounsLength)) + throw Errors.StringTooLongError("Pronouns", pronouns.Length, Limits.MaxPronounsLength); - async Task SetBannerImage(ParsedImage img) - { - ctx.CheckOwnMember(target); - img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Banner, ctx.Author.Id, ctx.System); - await _avatarHosting.VerifyAvatarOrThrow(img.Url, true); + var patch = new MemberPatch { Pronouns = Partial.Present(pronouns) }; + await ctx.Repository.UpdateMember(target.Id, patch); - await ctx.Repository.UpdateMember(target.Id, new MemberPatch { BannerImage = img.CleanUrl ?? img.Url }); + await ctx.Reply($"{Emojis.Success} Member pronouns changed (using {pronouns.Length}/{Limits.MaxPronounsLength} characters)."); + } - var msg = img.Source switch + public async Task ShowBannerImage(Context ctx, PKMember target, ReplyFormat format) + { + ctx.CheckSystemPrivacy(target.System, target.BannerPrivacy); + + var noBannerSetMessage = "This member does not have a banner image set."; + if (ctx.System?.Id == target.System) + noBannerSetMessage += $" To set one, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} banner ` or attach an image."; + + if (format != ReplyFormat.Standard) + if (string.IsNullOrWhiteSpace(target.BannerImage)) { - AvatarSource.Url => $"{Emojis.Success} Member banner image changed to the image at the given URL.", - AvatarSource.HostedCdn => $"{Emojis.Success} Member banner image changed to attached image.", - AvatarSource.Attachment => - $"{Emojis.Success} Member banner image changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the banner image will stop working.", - AvatarSource.User => throw new PKError("Cannot set a banner image to an user's avatar."), - _ => throw new ArgumentOutOfRangeException() - }; + await ctx.Reply(noBannerSetMessage); + return; + } - // The attachment's already right there, no need to preview it. - var hasEmbed = img.Source != AvatarSource.Attachment && img.Source != AvatarSource.HostedCdn; - await (hasEmbed - ? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build()) - : ctx.Reply(msg)); - } - - async Task ShowBannerImage() + if (format == ReplyFormat.Raw) { - ctx.CheckSystemPrivacy(target.System, target.BannerPrivacy); - - if ((target.BannerImage?.Trim() ?? "").Length > 0) - switch (ctx.MatchFormat()) - { - case ReplyFormat.Raw: - await ctx.Reply($"`{target.BannerImage.TryGetCleanCdnUrl()}`"); - break; - case ReplyFormat.Plaintext: - var ebP = new EmbedBuilder() - .Description($"Showing banner for member {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)"); - await ctx.Reply(text: $"<{target.BannerImage.TryGetCleanCdnUrl()}>", embed: ebP.Build()); - break; - default: - var ebS = new EmbedBuilder() - .Title($"{target.NameFor(ctx)}'s banner image") - .Image(new Embed.EmbedImage(target.BannerImage.TryGetCleanCdnUrl())); - if (target.System == ctx.System?.Id) - ebS.Description($"To clear, use `{ctx.DefaultPrefix}member {target.Reference(ctx)} banner clear`."); - await ctx.Reply(embed: ebS.Build()); - break; - } - else - throw new PKSyntaxError( - "This member does not have a banner image set." + ((target.System == ctx.System?.Id) ? " Set one by attaching an image to this command, or by passing an image URL." : "")); + await ctx.Reply($"```\n{target.BannerImage.TryGetCleanCdnUrl()}\n```"); + return; } - - if (ctx.MatchClear()) - await ClearBannerImage(); - else if (await ctx.MatchImage() is { } img) - await SetBannerImage(img); - else - await ShowBannerImage(); - } - - public async Task Color(Context ctx, PKMember target) - { - var isOwnSystem = ctx.System?.Id == target.System; - var matchedFormat = ctx.MatchFormat(); - var matchedClear = ctx.MatchClear(); - - if (!isOwnSystem || !(ctx.HasNext() || matchedClear)) + if (format == ReplyFormat.Plaintext) { - if (target.Color == null) - await ctx.Reply( - "This member does not have a color set." + (isOwnSystem ? $" To set one, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} color `." : "")); - else if (matchedFormat == ReplyFormat.Raw) - await ctx.Reply("```\n#" + target.Color + "\n```"); - else if (matchedFormat == ReplyFormat.Plaintext) - await ctx.Reply(target.Color); - else - await ctx.Reply(embed: new EmbedBuilder() - .Title("Member color") - .Color(target.Color.ToDiscordColor()) - .Thumbnail(new Embed.EmbedThumbnail($"attachment://color.gif")) - .Description($"This member's color is **#{target.Color}**." - + (isOwnSystem ? $" To clear it, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} color -clear`." : "")) - .Build(), - files: [MiscUtils.GenerateColorPreview(target.Color)]); + var eb = new EmbedBuilder() + .Description($"Showing banner for member {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)"); + await ctx.Reply(text: $"<{target.BannerImage.TryGetCleanCdnUrl()}>", embed: eb.Build()); return; } - ctx.CheckSystem().CheckOwnMember(target); + var embed = new EmbedBuilder() + .Title($"{target.NameFor(ctx)}'s banner image") + .Image(new Embed.EmbedImage(target.BannerImage.TryGetCleanCdnUrl())); + if (target.System == ctx.System?.Id) + embed.Description($"To clear, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} banner -clear`."); + await ctx.Reply(embed: embed.Build()); + } - if (matchedClear) + public async Task ClearBannerImage(Context ctx, PKMember target, bool confirmYes) + { + ctx.CheckOwnMember(target); + + if (await ctx.ConfirmClear("this member's banner image", confirmYes)) { - await ctx.Repository.UpdateMember(target.Id, new() { Color = Partial.Null() }); - - await ctx.Reply($"{Emojis.Success} Member color cleared."); - } - else - { - var color = ctx.RemainderOrNull(); - - if (color.StartsWith("#")) color = color.Substring(1); - if (!Regex.IsMatch(color, "^[0-9a-fA-F]{6}$")) throw Errors.InvalidColorError(color); - - var patch = new MemberPatch { Color = Partial.Present(color.ToLowerInvariant()) }; - await ctx.Repository.UpdateMember(target.Id, patch); - - await ctx.Reply(embed: new EmbedBuilder() - .Title($"{Emojis.Success} Member color changed.") - .Color(color.ToDiscordColor()) - .Thumbnail(new Embed.EmbedThumbnail($"attachment://color.gif")) - .Build(), - files: [MiscUtils.GenerateColorPreview(color)]); + await ctx.Repository.UpdateMember(target.Id, new MemberPatch { BannerImage = null }); + await ctx.Reply($"{Emojis.Success} Member banner image cleared."); } } - public async Task Birthday(Context ctx, PKMember target) + public async Task ChangeBannerImage(Context ctx, PKMember target, ParsedImage img) { - if (ctx.MatchClear() && await ctx.ConfirmClear("this member's birthday")) - { - ctx.CheckOwnMember(target); + ctx.CheckOwnMember(target); + img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Banner, ctx.Author.Id, ctx.System); + await _avatarHosting.VerifyAvatarOrThrow(img.Url, true); + + await ctx.Repository.UpdateMember(target.Id, new MemberPatch { BannerImage = img.CleanUrl ?? img.Url }); + + var msg = img.Source switch + { + AvatarSource.Url => $"{Emojis.Success} Member banner image changed to the image at the given URL.", + AvatarSource.HostedCdn => $"{Emojis.Success} Member banner image changed to attached image.", + AvatarSource.Attachment => + $"{Emojis.Success} Member banner image changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the banner image will stop working.", + AvatarSource.User => throw new PKError("Cannot set a banner image to an user's avatar."), + _ => throw new ArgumentOutOfRangeException() + }; + + // The attachment's already right there, no need to preview it. + var hasEmbed = img.Source != AvatarSource.Attachment && img.Source != AvatarSource.HostedCdn; + await (hasEmbed + ? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build()) + : ctx.Reply(msg)); + } + + public async Task ShowColor(Context ctx, PKMember target, ReplyFormat format) + { + if (target.Color == null) + { + await ctx.Reply( + "This member does not have a color set." + (ctx.System?.Id == target.System ? $" To set one, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} color `." : "")); + return; + } + + if (format == ReplyFormat.Raw) + { + await ctx.Reply("```\n#" + target.Color + "\n```"); + return; + } + + if (format == ReplyFormat.Plaintext) + { + await ctx.Reply(target.Color); + return; + } + + await ctx.Reply(embed: new EmbedBuilder() + .Title("Member color") + .Color(target.Color.ToDiscordColor()) + .Thumbnail(new Embed.EmbedThumbnail($"attachment://color.gif")) + .Description($"This member's color is **#{target.Color}**." + + (ctx.System?.Id == target.System ? $" To clear it, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} color -clear`." : "")) + .Build(), + files: [MiscUtils.GenerateColorPreview(target.Color)]); + } + + public async Task ClearColor(Context ctx, PKMember target, bool confirmYes) + { + ctx.CheckSystem().CheckOwnMember(target); + + if (await ctx.ConfirmClear("this member's color", confirmYes)) + { + await ctx.Repository.UpdateMember(target.Id, new() { Color = Partial.Null() }); + await ctx.Reply($"{Emojis.Success} Member color cleared."); + } + } + + public async Task ChangeColor(Context ctx, PKMember target, string color) + { + ctx.CheckSystem().CheckOwnMember(target); + + if (color.StartsWith("#")) + color = color.Substring(1); + + if (!Regex.IsMatch(color, "^[0-9a-fA-F]{6}$")) + throw Errors.InvalidColorError(color); + + var patch = new MemberPatch { Color = Partial.Present(color.ToLowerInvariant()) }; + await ctx.Repository.UpdateMember(target.Id, patch); + + await ctx.Reply(embed: new EmbedBuilder() + .Title($"{Emojis.Success} Member color changed.") + .Color(color.ToDiscordColor()) + .Thumbnail(new Embed.EmbedThumbnail($"attachment://color.gif")) + .Build(), + files: [MiscUtils.GenerateColorPreview(color)]); + } + + public async Task ShowBirthday(Context ctx, PKMember target, ReplyFormat format) + { + ctx.CheckSystemPrivacy(target.System, target.BirthdayPrivacy); + + var noBirthdaySetMessage = "This member does not have a birthdate set."; + if (ctx.System?.Id == target.System) + noBirthdaySetMessage += $" To set one, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} birthday `."; + + // if what's next is "raw"/"plaintext" we need to check for null + if (format != ReplyFormat.Standard) + if (target.Birthday == null) + { + await ctx.Reply(noBirthdaySetMessage); + return; + } + + if (format == ReplyFormat.Raw) + { + await ctx.Reply($"```\n{target.Birthday}\n```"); + return; + } + if (format == ReplyFormat.Plaintext) + { + var eb = new EmbedBuilder() + .Description($"Showing birthday for member {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)"); + await ctx.Reply(target.BirthdayString, embed: eb.Build()); + return; + } + + await ctx.Reply($"This member's birthdate is **{target.BirthdayString}**." + + (ctx.System?.Id == target.System + ? $" To clear it, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} birthday -clear`." + : "")); + } + + public async Task ClearBirthday(Context ctx, PKMember target, bool confirmYes) + { + ctx.CheckOwnMember(target); + + if (await ctx.ConfirmClear("this member's birthday", confirmYes)) + { var patch = new MemberPatch { Birthday = Partial.Null() }; await ctx.Repository.UpdateMember(target.Id, patch); - await ctx.Reply($"{Emojis.Success} Member birthdate cleared."); } - else if (!ctx.HasNext()) - { - ctx.CheckSystemPrivacy(target.System, target.BirthdayPrivacy); + } - if (target.Birthday == null) - await ctx.Reply("This member does not have a birthdate set." - + (ctx.System?.Id == target.System - ? $" To set one, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} birthdate `." - : "")); - else - await ctx.Reply($"This member's birthdate is **{target.BirthdayString}**." - + (ctx.System?.Id == target.System - ? $" To clear it, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} birthdate -clear`." - : "")); - } + public async Task ChangeBirthday(Context ctx, PKMember target, string birthdayStr) + { + ctx.CheckOwnMember(target); + + LocalDate? birthday; + if (birthdayStr == "today" || birthdayStr == "now") + birthday = SystemClock.Instance.InZone(ctx.Zone).GetCurrentDate(); else - { - ctx.CheckOwnMember(target); + birthday = DateUtils.ParseDate(birthdayStr, true); - var birthdayStr = ctx.RemainderOrNull(); + if (birthday == null) + throw Errors.BirthdayParseError(birthdayStr); - LocalDate? birthday; - if (birthdayStr == "today" || birthdayStr == "now") - birthday = SystemClock.Instance.InZone(ctx.Zone).GetCurrentDate(); - else - birthday = DateUtils.ParseDate(birthdayStr, true); + var patch = new MemberPatch { Birthday = Partial.Present(birthday) }; + await ctx.Repository.UpdateMember(target.Id, patch); - if (birthday == null) throw Errors.BirthdayParseError(birthdayStr); - - var patch = new MemberPatch { Birthday = Partial.Present(birthday) }; - await ctx.Repository.UpdateMember(target.Id, patch); - - await ctx.Reply($"{Emojis.Success} Member birthdate changed."); - } + await ctx.Reply($"{Emojis.Success} Member birthdate changed."); } private string boldIf(string str, bool condition) => condition ? $"**{str}**" : str; @@ -429,11 +453,54 @@ public class MemberEdit return eb; } - public async Task DisplayName(Context ctx, PKMember target) + public async Task ShowDisplayName(Context ctx, PKMember target, ReplyFormat format) { - async Task PrintSuccess(string text) + var isOwner = ctx.System?.Id == target.System; + var noDisplayNameSetMessage = $"This member does not have a display name set{(isOwner ? "" : " or name is private")}." + + (isOwner ? $" To set one, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} displayname `." : ""); + + // Whether displayname is shown or not should depend on if member name privacy is set. + // If name privacy is on then displayname should look like name. + if (target.DisplayName == null || !target.NamePrivacy.CanAccess(ctx.DirectLookupContextFor(target.System))) { - var successStr = text; + await ctx.Reply(noDisplayNameSetMessage); + return; + } + + if (format == ReplyFormat.Raw) + { + await ctx.Reply($"```\n{target.DisplayName}\n```"); + return; + } + if (format == ReplyFormat.Plaintext) + { + var _eb = new EmbedBuilder() + .Description($"Showing displayname for member {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)"); + await ctx.Reply(target.DisplayName, embed: _eb.Build()); + return; + } + + var eb = await CreateMemberNameInfoEmbed(ctx, target); + var reference = target.Reference(ctx); + if (ctx.System?.Id == target.System) + eb.Description( + $"To change display name, type `{ctx.DefaultPrefix}member {reference} displayname `.\n" + + $"To clear it, type `{ctx.DefaultPrefix}member {reference} displayname -clear`.\n" + + $"To print the raw display name, type `{ctx.DefaultPrefix}member {reference} displayname -raw`."); + await ctx.Reply(embed: eb.Build()); + } + + public async Task ClearDisplayName(Context ctx, PKMember target, bool confirmYes) + { + ctx.CheckOwnMember(target); + + if (await ctx.ConfirmClear("this member's display name", confirmYes)) + { + var patch = new MemberPatch { DisplayName = Partial.Null() }; + await ctx.Repository.UpdateMember(target.Id, patch); + + var successStr = $"{Emojis.Success} Member display name cleared. This member will now be proxied using their member name \"{target.Name}\"."; + if (ctx.Guild != null) { var memberGuildConfig = await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id); @@ -443,80 +510,37 @@ public class MemberEdit } await ctx.Reply(successStr); - } - - var isOwner = ctx.System?.Id == target.System; - var noDisplayNameSetMessage = $"This member does not have a display name set{(isOwner ? "" : " or name is private")}." - + (isOwner ? $" To set one, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} displayname `." : ""); - - // Whether displayname is shown or not should depend on if member name privacy is set. - // If name privacy is on then displayname should look like name. - - var format = ctx.MatchFormat(); - - // if what's next is "raw"/"plaintext" we need to check for null - if (format != ReplyFormat.Standard) - if (target.DisplayName == null || !target.NamePrivacy.CanAccess(ctx.DirectLookupContextFor(target.System))) - { - await ctx.Reply(noDisplayNameSetMessage); - return; - } - - if (format == ReplyFormat.Raw) - { - await ctx.Reply($"```\n{target.DisplayName}\n```"); - return; - } - if (format == ReplyFormat.Plaintext) - { - var eb = new EmbedBuilder() - .Description($"Showing displayname for member {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)"); - await ctx.Reply(target.DisplayName, embed: eb.Build()); - return; - } - - if (!ctx.HasNext(false)) - { - var eb = await CreateMemberNameInfoEmbed(ctx, target); - var reference = target.Reference(ctx); - if (ctx.System?.Id == target.System) - eb.Description( - $"To change display name, type `{ctx.DefaultPrefix}member {reference} displayname `.\n" - + $"To clear it, type `{ctx.DefaultPrefix}member {reference} displayname -clear`.\n" - + $"To print the raw display name, type `{ctx.DefaultPrefix}member {reference} displayname -raw`."); - await ctx.Reply(embed: eb.Build()); - return; - } - - ctx.CheckOwnMember(target); - - if (ctx.MatchClear() && await ctx.ConfirmClear("this member's display name")) - { - var patch = new MemberPatch { DisplayName = Partial.Null() }; - await ctx.Repository.UpdateMember(target.Id, patch); - - await PrintSuccess( - $"{Emojis.Success} Member display name cleared. This member will now be proxied using their member name \"{target.Name}\"."); if (target.NamePrivacy == PrivacyLevel.Private) await ctx.Reply($"{Emojis.Warn} Since this member no longer has a display name set, their name privacy **can no longer take effect**."); } - else - { - var newDisplayName = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); - - if (newDisplayName.Length > Limits.MaxMemberNameLength) - throw Errors.StringTooLongError("Member display name", newDisplayName.Length, Limits.MaxMemberNameLength); - - var patch = new MemberPatch { DisplayName = Partial.Present(newDisplayName) }; - await ctx.Repository.UpdateMember(target.Id, patch); - - await PrintSuccess( - $"{Emojis.Success} Member display name changed (using {newDisplayName.Length}/{Limits.MaxMemberNameLength} characters). This member will now be proxied using the name \"{newDisplayName}\"."); - } } - public async Task ServerName(Context ctx, PKMember target) + public async Task ChangeDisplayName(Context ctx, PKMember target, string newDisplayName) + { + ctx.CheckOwnMember(target); + + newDisplayName = newDisplayName.NormalizeLineEndSpacing(); + if (newDisplayName.Length > Limits.MaxMemberNameLength) + throw Errors.StringTooLongError("Member display name", newDisplayName.Length, Limits.MaxMemberNameLength); + + var patch = new MemberPatch { DisplayName = Partial.Present(newDisplayName) }; + await ctx.Repository.UpdateMember(target.Id, patch); + + var successStr = $"{Emojis.Success} Member display name changed (using {newDisplayName.Length}/{Limits.MaxMemberNameLength} characters). This member will now be proxied using the name \"{newDisplayName}\"."; + + if (ctx.Guild != null) + { + var memberGuildConfig = await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id); + if (memberGuildConfig.DisplayName != null) + successStr += + $" However, this member has a server name set in this server ({ctx.Guild.Name}), and will be proxied using that name, \"{memberGuildConfig.DisplayName}\", here."; + } + + await ctx.Reply(successStr); + } + + public async Task ShowServerName(Context ctx, PKMember target, ReplyFormat format) { ctx.CheckGuildContext(); @@ -525,12 +549,8 @@ public class MemberEdit noServerNameSetMessage += $" To set one, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} servername `."; - // No perms check, display name isn't covered by member privacy var memberGuildConfig = await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id); - var format = ctx.MatchFormat(); - - // if what's next is "raw"/"plaintext" we need to check for null if (format != ReplyFormat.Standard) if (memberGuildConfig.DisplayName == null) { @@ -545,26 +565,26 @@ public class MemberEdit } if (format == ReplyFormat.Plaintext) { - var eb = new EmbedBuilder() + var _eb = new EmbedBuilder() .Description($"Showing servername for member {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)"); - await ctx.Reply(memberGuildConfig.DisplayName, embed: eb.Build()); + await ctx.Reply(memberGuildConfig.DisplayName, embed: _eb.Build()); return; } - if (!ctx.HasNext(false)) - { - var eb = await CreateMemberNameInfoEmbed(ctx, target); - var reference = target.Reference(ctx); - if (ctx.System?.Id == target.System) - eb.Description( - $"To change server name, type `{ctx.DefaultPrefix}member {reference} servername `.\nTo clear it, type `{ctx.DefaultPrefix}member {reference} servername -clear`.\nTo print the raw server name, type `{ctx.DefaultPrefix}member {reference} servername -raw`."); - await ctx.Reply(embed: eb.Build()); - return; - } + var eb = await CreateMemberNameInfoEmbed(ctx, target); + var reference = target.Reference(ctx); + if (ctx.System?.Id == target.System) + eb.Description( + $"To change server name, type `{ctx.DefaultPrefix}member {reference} servername `.\nTo clear it, type `{ctx.DefaultPrefix}member {reference} servername -clear`.\nTo print the raw server name, type `{ctx.DefaultPrefix}member {reference} servername -raw`."); + await ctx.Reply(embed: eb.Build()); + } + public async Task ClearServerName(Context ctx, PKMember target, bool confirmYes) + { + ctx.CheckGuildContext(); ctx.CheckOwnMember(target); - if (ctx.MatchClear() && await ctx.ConfirmClear("this member's server name")) + if (await ctx.ConfirmClear("this member's server name", confirmYes)) { await ctx.Repository.UpdateMemberGuild(target.Id, ctx.Guild.Id, new MemberGuildPatch { DisplayName = null }); @@ -575,59 +595,52 @@ public class MemberEdit await ctx.Reply( $"{Emojis.Success} Member server name cleared. This member will now be proxied using their member name \"{target.NameFor(ctx)}\" in this server ({ctx.Guild.Name})."); } - else - { - var newServerName = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); - - await ctx.Repository.UpdateMemberGuild(target.Id, ctx.Guild.Id, - new MemberGuildPatch { DisplayName = newServerName }); - - await ctx.Reply( - $"{Emojis.Success} Member server name changed (using {newServerName.Length}/{Limits.MaxMemberNameLength} characters). This member will now be proxied using the name \"{newServerName}\" in this server ({ctx.Guild.Name})."); - } } - public async Task KeepProxy(Context ctx, PKMember target) + public async Task ChangeServerName(Context ctx, PKMember target, string newServerName) { - ctx.CheckSystem().CheckOwnMember(target); - MemberGuildSettings? memberGuildConfig = null; + ctx.CheckGuildContext(); + ctx.CheckOwnMember(target); + + newServerName = newServerName.NormalizeLineEndSpacing(); + if (newServerName.Length > Limits.MaxMemberNameLength) + throw Errors.StringTooLongError("Server name", newServerName.Length, Limits.MaxMemberNameLength); + + await ctx.Repository.UpdateMemberGuild(target.Id, ctx.Guild.Id, + new MemberGuildPatch { DisplayName = newServerName }); + + await ctx.Reply( + $"{Emojis.Success} Member server name changed (using {newServerName.Length}/{Limits.MaxMemberNameLength} characters). This member will now be proxied using the name \"{newServerName}\" in this server ({ctx.Guild.Name})."); + } + + public async Task ShowKeepProxy(Context ctx, PKMember target) + { + string keepProxyStatusMessage = ""; + + if (target.KeepProxy) + keepProxyStatusMessage += "This member has keepproxy **enabled**. Proxy tags will be **included** in the resulting message when proxying."; + else + keepProxyStatusMessage += "This member has keepproxy **disabled**. Proxy tags will **not** be included in the resulting message when proxying."; + if (ctx.Guild != null) { - memberGuildConfig = await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id); + var memberGuildConfig = await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id); + + if (memberGuildConfig?.KeepProxy.HasValue == true) + { + if (memberGuildConfig.KeepProxy.Value) + keepProxyStatusMessage += $"\n{Emojis.Warn} This member has keepproxy **enabled in this server**, which means proxy tags will **always** be included when proxying in this server, regardless of the global keepproxy. To clear this setting in this server, type `{ctx.DefaultPrefix}m serverkeepproxy clear`."; + else + keepProxyStatusMessage += $"\n{Emojis.Warn} This member has keepproxy **disabled in this server**, which means proxy tags will **never** be included when proxying in this server, regardless of the global keepproxy. To clear this setting in this server, type `{ctx.DefaultPrefix}m serverkeepproxy clear`."; + } } - bool newValue; - if (ctx.Match("on", "enabled", "true", "yes")) - { - newValue = true; - } - else if (ctx.Match("off", "disabled", "false", "no")) - { - newValue = false; - } - else if (ctx.HasNext()) - { - throw new PKSyntaxError("You must pass either \"on\" or \"off\"."); - } - else - { - string keepProxyStatusMessage = ""; + await ctx.Reply(keepProxyStatusMessage); + } - if (target.KeepProxy) - keepProxyStatusMessage += "This member has keepproxy **enabled**. Proxy tags will be **included** in the resulting message when proxying."; - else - keepProxyStatusMessage += "This member has keepproxy **disabled**. Proxy tags will **not** be included in the resulting message when proxying."; - - if (memberGuildConfig != null && memberGuildConfig.KeepProxy.HasValue && memberGuildConfig.KeepProxy.Value) - keepProxyStatusMessage += $"\n{Emojis.Warn} This member has keepproxy **enabled in this server**, which means proxy tags will **always** be included when proxying in this server, regardless of the global keepproxy. To clear this setting in this server, type `{ctx.DefaultPrefix}m serverkeepproxy clear`."; - else if (memberGuildConfig != null && memberGuildConfig.KeepProxy.HasValue && !memberGuildConfig.KeepProxy.Value) - keepProxyStatusMessage += $"\n{Emojis.Warn} This member has keepproxy **disabled in this server**, which means proxy tags will **never** be included when proxying in this server, regardless of the global keepproxy. To clear this setting in this server, type `{ctx.DefaultPrefix}m serverkeepproxy clear`."; - - await ctx.Reply(keepProxyStatusMessage); - return; - } - - ; + public async Task ChangeKeepProxy(Context ctx, PKMember target, bool newValue) + { + ctx.CheckSystem().CheckOwnMember(target); var patch = new MemberPatch { KeepProxy = Partial.Present(newValue) }; await ctx.Repository.UpdateMember(target.Id, patch); @@ -639,74 +652,58 @@ public class MemberEdit else keepProxyUpdateMessage += $"{Emojis.Success} this member now has keepproxy **disabled**. Member proxy tags will **not** be included in the resulting message when proxying."; - if (memberGuildConfig != null && memberGuildConfig.KeepProxy.HasValue && memberGuildConfig.KeepProxy.Value) - keepProxyUpdateMessage += $"\n{Emojis.Warn} This member has keepproxy **enabled in this server**, which means proxy tags will **always** be included when proxying in this server, regardless of the global keepproxy. To clear this setting in this server, type `{ctx.DefaultPrefix}m serverkeepproxy clear`."; - else if (memberGuildConfig != null && memberGuildConfig.KeepProxy.HasValue && !memberGuildConfig.KeepProxy.Value) - keepProxyUpdateMessage += $"\n{Emojis.Warn} This member has keepproxy **disabled in this server**, which means proxy tags will **never** be included when proxying in this server, regardless of the global keepproxy. To clear this setting in this server, type `{ctx.DefaultPrefix}m serverkeepproxy clear`."; + if (ctx.Guild != null) + { + var memberGuildConfig = await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id); + + if (memberGuildConfig?.KeepProxy.HasValue == true) + { + if (memberGuildConfig.KeepProxy.Value) + keepProxyUpdateMessage += $"\n{Emojis.Warn} This member has keepproxy **enabled in this server**, which means proxy tags will **always** be included when proxying in this server, regardless of the global keepproxy. To clear this setting in this server, type `{ctx.DefaultPrefix}m serverkeepproxy clear`."; + else + keepProxyUpdateMessage += $"\n{Emojis.Warn} This member has keepproxy **disabled in this server**, which means proxy tags will **never** be included when proxying in this server, regardless of the global keepproxy. To clear this setting in this server, type `{ctx.DefaultPrefix}m serverkeepproxy clear`."; + } + } await ctx.Reply(keepProxyUpdateMessage); } - public async Task ServerKeepProxy(Context ctx, PKMember target) + public async Task ShowServerKeepProxy(Context ctx, PKMember target) + { + ctx.CheckGuildContext(); + + var memberGuildConfig = await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id); + + if (memberGuildConfig.KeepProxy.HasValue) + { + if (memberGuildConfig.KeepProxy.Value) + await ctx.Reply($"This member has keepproxy **enabled** in the current server, which means proxy tags will be **included** in the resulting message when proxying. To clear this setting in this server, type `{ctx.DefaultPrefix}m serverkeepproxy clear`."); + else + await ctx.Reply($"This member has keepproxy **disabled** in the current server, which means proxy tags will **not** be included in the resulting message when proxying. To clear this setting in this server, type `{ctx.DefaultPrefix}m serverkeepproxy clear`."); + } + else + { + var noServerKeepProxySetMessage = "This member does not have a server keepproxy override set."; + if (target.KeepProxy) + noServerKeepProxySetMessage += " The global keepproxy is **enabled**, which means proxy tags will be **included** when proxying."; + else + noServerKeepProxySetMessage += " The global keepproxy is **disabled**, which means proxy tags will **not** be included when proxying."; + + await ctx.Reply(noServerKeepProxySetMessage); + } + } + + public async Task ClearServerKeepProxy(Context ctx, PKMember target, bool confirmYes) { ctx.CheckGuildContext(); ctx.CheckSystem().CheckOwnMember(target); - var memberGuildConfig = await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id); + if (await ctx.ConfirmClear("this member's server keepproxy setting", confirmYes)) + { + var patch = new MemberGuildPatch { KeepProxy = Partial.Present(null) }; + await ctx.Repository.UpdateMemberGuild(target.Id, ctx.Guild.Id, patch); - bool? newValue; - if (ctx.Match("on", "enabled", "true", "yes")) - { - newValue = true; - } - else if (ctx.Match("off", "disabled", "false", "no")) - { - newValue = false; - } - else if (ctx.MatchClear()) - { - newValue = null; - } - else if (ctx.HasNext()) - { - throw new PKSyntaxError("You must pass either \"on\", \"off\" or \"clear\"."); - } - else - { - if (memberGuildConfig.KeepProxy.HasValue) - if (memberGuildConfig.KeepProxy.Value) - await ctx.Reply( - $"This member has keepproxy **enabled** in the current server, which means proxy tags will be **included** in the resulting message when proxying. To clear this setting in this server, type `{ctx.DefaultPrefix}m serverkeepproxy clear`."); - else - await ctx.Reply( - $"This member has keepproxy **disabled** in the current server, which means proxy tags will **not** be included in the resulting message when proxying. To clear this setting in this server, type `{ctx.DefaultPrefix}m serverkeepproxy clear`."); - else - { - var noServerKeepProxySetMessage = "This member does not have a server keepproxy override set."; - if (target.KeepProxy) - noServerKeepProxySetMessage += " The global keepproxy is **enabled**, which means proxy tags will be **included** when proxying."; - else - noServerKeepProxySetMessage += " The global keepproxy is **disabled**, which means proxy tags will **not** be included when proxying."; - - await ctx.Reply(noServerKeepProxySetMessage); - } - return; - } - - var patch = new MemberGuildPatch { KeepProxy = Partial.Present(newValue) }; - await ctx.Repository.UpdateMemberGuild(target.Id, ctx.Guild.Id, patch); - - if (newValue.HasValue) - if (newValue.Value) - await ctx.Reply( - $"{Emojis.Success} Member proxy tags will now be **included** in the resulting message when proxying **in the current server**. To clear this setting in this server, type `{ctx.DefaultPrefix}m serverkeepproxy clear`."); - else - await ctx.Reply( - $"{Emojis.Success} Member proxy tags will now **not** be included in the resulting message when proxying **in the current server**. To clear this setting in this server, type `{ctx.DefaultPrefix}m serverkeepproxy clear`."); - else - { var serverKeepProxyClearedMessage = $"{Emojis.Success} Cleared server keepproxy settings for this member."; - if (target.KeepProxy) serverKeepProxyClearedMessage += " Member proxy tags will now be **included** in the resulting message when proxying."; else @@ -716,35 +713,33 @@ public class MemberEdit } } - public async Task Tts(Context ctx, PKMember target) + public async Task ChangeServerKeepProxy(Context ctx, PKMember target, bool newValue) { + ctx.CheckGuildContext(); ctx.CheckSystem().CheckOwnMember(target); - bool newValue; - if (ctx.Match("on", "enabled", "true", "yes")) - { - newValue = true; - } - else if (ctx.Match("off", "disabled", "false", "no")) - { - newValue = false; - } - else if (ctx.HasNext()) - { - throw new PKSyntaxError("You must pass either \"on\" or \"off\"."); - } - else - { - if (target.Tts) - await ctx.Reply( - "This member has text-to-speech **enabled**, which means their messages **will be** sent as text-to-speech messages."); - else - await ctx.Reply( - "This member has text-to-speech **disabled**, which means their messages **will not** be sent as text-to-speech messages."); - return; - } + var patch = new MemberGuildPatch { KeepProxy = Partial.Present(newValue) }; + await ctx.Repository.UpdateMemberGuild(target.Id, ctx.Guild.Id, patch); - ; + if (newValue) + await ctx.Reply($"{Emojis.Success} Member proxy tags will now be **included** in the resulting message when proxying **in the current server**. To clear this setting in this server, type `{ctx.DefaultPrefix}m serverkeepproxy clear`."); + else + await ctx.Reply($"{Emojis.Success} Member proxy tags will now **not** be included in the resulting message when proxying **in the current server**. To clear this setting in this server, type `{ctx.DefaultPrefix}m serverkeepproxy clear`."); + } + + public async Task ShowTts(Context ctx, PKMember target) + { + if (target.Tts) + await ctx.Reply( + "This member has text-to-speech **enabled**, which means their messages **will be** sent as text-to-speech messages."); + else + await ctx.Reply( + "This member has text-to-speech **disabled**, which means their messages **will not** be sent as text-to-speech messages."); + } + + public async Task ChangeTts(Context ctx, PKMember target, bool newValue) + { + ctx.CheckSystem().CheckOwnMember(target); var patch = new MemberPatch { Tts = Partial.Present(newValue) }; await ctx.Repository.UpdateMember(target.Id, patch); @@ -757,23 +752,19 @@ public class MemberEdit $"{Emojis.Success} Member messages will no longer be sent as text-to-speech messages."); } - public async Task MemberAutoproxy(Context ctx, PKMember target) + public async Task ShowAutoproxy(Context ctx, PKMember target) { - if (ctx.System == null) throw Errors.NoSystemError(ctx.DefaultPrefix); - if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError; + if (target.AllowAutoproxy) + await ctx.Reply( + "Latch/front autoproxy are **enabled** for this member. This member will be automatically proxied when autoproxy is set to latch or front mode."); + else + await ctx.Reply( + "Latch/front autoproxy are **disabled** for this member. This member will not be automatically proxied when autoproxy is set to latch or front mode."); + } - if (!ctx.HasNext()) - { - if (target.AllowAutoproxy) - await ctx.Reply( - "Latch/front autoproxy are **enabled** for this member. This member will be automatically proxied when autoproxy is set to latch or front mode."); - else - await ctx.Reply( - "Latch/front autoproxy are **disabled** for this member. This member will not be automatically proxied when autoproxy is set to latch or front mode."); - return; - } - - var newValue = ctx.MatchToggle(); + public async Task ChangeAutoproxy(Context ctx, PKMember target, bool newValue) + { + ctx.CheckSystem().CheckOwnMember(target); var patch = new MemberPatch { AllowAutoproxy = Partial.Present(newValue) }; await ctx.Repository.UpdateMember(target.Id, patch); @@ -784,128 +775,118 @@ public class MemberEdit await ctx.Reply($"{Emojis.Success} Latch / front autoproxy have been **disabled** for this member."); } - public async Task Privacy(Context ctx, PKMember target, PrivacyLevel? newValueFromCommand) + public async Task ShowPrivacy(Context ctx, PKMember target) + { + await ctx.Reply(embed: new EmbedBuilder() + .Title($"Current privacy settings for {target.NameFor(ctx)}") + .Field(new Embed.Field("Name (replaces name with display name if member has one)", + target.NamePrivacy.Explanation())) + .Field(new Embed.Field("Description", target.DescriptionPrivacy.Explanation())) + .Field(new Embed.Field("Banner", target.BannerPrivacy.Explanation())) + .Field(new Embed.Field("Avatar", target.AvatarPrivacy.Explanation())) + .Field(new Embed.Field("Birthday", target.BirthdayPrivacy.Explanation())) + .Field(new Embed.Field("Pronouns", target.PronounPrivacy.Explanation())) + .Field(new Embed.Field("Proxy Tags", target.ProxyPrivacy.Explanation())) + .Field(new Embed.Field("Meta (creation date, message count, last front, last message)", + target.MetadataPrivacy.Explanation())) + .Field(new Embed.Field("Visibility", target.MemberVisibility.Explanation())) + .Description( + $"To edit privacy settings, use the command:\n`{ctx.DefaultPrefix}member privacy `\n\n- `subject` is one of `name`, `description`, `banner`, `avatar`, `birthday`, `pronouns`, `proxies`, `metadata`, `visibility`, or `all`\n- `level` is either `public` or `private`.") + .Build()); + } + + public async Task ChangeAllPrivacy(Context ctx, PKMember target, PrivacyLevel level) { ctx.CheckSystem().CheckOwnMember(target); - // Display privacy settings - if (!ctx.HasNext() && newValueFromCommand == null) - { - await ctx.Reply(embed: new EmbedBuilder() - .Title($"Current privacy settings for {target.NameFor(ctx)}") - .Field(new Embed.Field("Name (replaces name with display name if member has one)", - target.NamePrivacy.Explanation())) - .Field(new Embed.Field("Description", target.DescriptionPrivacy.Explanation())) - .Field(new Embed.Field("Banner", target.BannerPrivacy.Explanation())) - .Field(new Embed.Field("Avatar", target.AvatarPrivacy.Explanation())) - .Field(new Embed.Field("Birthday", target.BirthdayPrivacy.Explanation())) - .Field(new Embed.Field("Pronouns", target.PronounPrivacy.Explanation())) - .Field(new Embed.Field("Proxy Tags", target.ProxyPrivacy.Explanation())) - .Field(new Embed.Field("Meta (creation date, message count, last front, last message)", - target.MetadataPrivacy.Explanation())) - .Field(new Embed.Field("Visibility", target.MemberVisibility.Explanation())) - .Description( - $"To edit privacy settings, use the command:\n`{ctx.DefaultPrefix}member privacy `\n\n- `subject` is one of `name`, `description`, `banner`, `avatar`, `birthday`, `pronouns`, `proxies`, `metadata`, `visibility`, or `all`\n- `level` is either `public` or `private`.") - .Build()); - return; - } + await ctx.Repository.UpdateMember(target.Id, new MemberPatch().WithAllPrivacy(level)); - // Get guild settings (mostly for warnings and such) - MemberGuildSettings guildSettings = null; - if (ctx.Guild != null) - guildSettings = await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id); - - async Task SetAll(PrivacyLevel level) - { - await ctx.Repository.UpdateMember(target.Id, new MemberPatch().WithAllPrivacy(level)); - - if (level == PrivacyLevel.Private) - await ctx.Reply( - $"{Emojis.Success} All {target.NameFor(ctx)}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see nothing on the member card."); - else - await ctx.Reply( - $"{Emojis.Success} All {target.NameFor(ctx)}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see everything on the member card."); - } - - async Task SetLevel(MemberPrivacySubject subject, PrivacyLevel level) - { - await ctx.Repository.UpdateMember(target.Id, new MemberPatch().WithPrivacy(subject, level)); - - var subjectName = subject switch - { - MemberPrivacySubject.Name => "name privacy", - MemberPrivacySubject.Description => "description privacy", - MemberPrivacySubject.Banner => "banner privacy", - MemberPrivacySubject.Avatar => "avatar privacy", - MemberPrivacySubject.Pronouns => "pronoun privacy", - MemberPrivacySubject.Birthday => "birthday privacy", - MemberPrivacySubject.Proxy => "proxy tag privacy", - MemberPrivacySubject.Metadata => "metadata privacy", - MemberPrivacySubject.Visibility => "visibility", - _ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}") - }; - - var explanation = (subject, level) switch - { - (MemberPrivacySubject.Name, PrivacyLevel.Private) => - "This member's name is now hidden from other systems, and will be replaced by the member's display name.", - (MemberPrivacySubject.Description, PrivacyLevel.Private) => - "This member's description is now hidden from other systems.", - (MemberPrivacySubject.Banner, PrivacyLevel.Private) => - "This member's banner is now hidden from other systems.", - (MemberPrivacySubject.Avatar, PrivacyLevel.Private) => - "This member's avatar is now hidden from other systems.", - (MemberPrivacySubject.Birthday, PrivacyLevel.Private) => - "This member's birthday is now hidden from other systems.", - (MemberPrivacySubject.Pronouns, PrivacyLevel.Private) => - "This member's pronouns are now hidden from other systems.", - (MemberPrivacySubject.Proxy, PrivacyLevel.Private) => - "This member's proxy tags are now hidden from other systems.", - (MemberPrivacySubject.Metadata, PrivacyLevel.Private) => - "This member's metadata (eg. created timestamp, message count, etc) is now hidden from other systems.", - (MemberPrivacySubject.Visibility, PrivacyLevel.Private) => - "This member is now hidden from member lists.", - - (MemberPrivacySubject.Name, PrivacyLevel.Public) => - "This member's name is no longer hidden from other systems.", - (MemberPrivacySubject.Description, PrivacyLevel.Public) => - "This member's description is no longer hidden from other systems.", - (MemberPrivacySubject.Banner, PrivacyLevel.Public) => - "This member's banner is no longer hidden from other systems.", - (MemberPrivacySubject.Avatar, PrivacyLevel.Public) => - "This member's avatar is no longer hidden from other systems.", - (MemberPrivacySubject.Birthday, PrivacyLevel.Public) => - "This member's birthday is no longer hidden from other systems.", - (MemberPrivacySubject.Pronouns, PrivacyLevel.Public) => - "This member's pronouns are no longer hidden from other systems.", - (MemberPrivacySubject.Proxy, PrivacyLevel.Public) => - "This member's proxy tags are no longer hidden from other systems.", - (MemberPrivacySubject.Metadata, PrivacyLevel.Public) => - "This member's metadata (eg. created timestamp, message count, etc) is no longer hidden from other systems.", - (MemberPrivacySubject.Visibility, PrivacyLevel.Public) => - "This member is no longer hidden from member lists.", - - _ => throw new InvalidOperationException($"Invalid subject/level tuple ({subject}, {level})") - }; - - var replyStr = $"{Emojis.Success} {target.NameFor(ctx)}'s **{subjectName}** has been set to **{level.LevelName()}**. {explanation}"; - - // Name privacy only works given a display name - if (subject == MemberPrivacySubject.Name && level == PrivacyLevel.Private && target.DisplayName == null) - replyStr += $"\n{Emojis.Warn} This member does not have a display name set, and name privacy **will not take effect**."; - - // Avatar privacy doesn't apply when proxying if no server avatar is set - if (subject == MemberPrivacySubject.Avatar && level == PrivacyLevel.Private && - guildSettings?.AvatarUrl == null) - replyStr += $"\n{Emojis.Warn} This member does not have a server avatar set, so *proxying* will **still show the member avatar**. If you want to hide your avatar when proxying here, set a server avatar: `{ctx.DefaultPrefix}member {target.Reference(ctx)} serveravatar`"; - - await ctx.Reply(replyStr); - } - - if (ctx.Match("all") || newValueFromCommand != null) - await SetAll(newValueFromCommand ?? ctx.PopPrivacyLevel()); + if (level == PrivacyLevel.Private) + await ctx.Reply( + $"{Emojis.Success} All {target.NameFor(ctx)}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see nothing on the member card."); else - await SetLevel(ctx.PopMemberPrivacySubject(), ctx.PopPrivacyLevel()); + await ctx.Reply( + $"{Emojis.Success} All {target.NameFor(ctx)}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see everything on the member card."); + } + + public async Task ChangePrivacy(Context ctx, PKMember target, MemberPrivacySubject subject, PrivacyLevel level) + { + ctx.CheckSystem().CheckOwnMember(target); + + await ctx.Repository.UpdateMember(target.Id, new MemberPatch().WithPrivacy(subject, level)); + + var subjectName = subject switch + { + MemberPrivacySubject.Name => "name privacy", + MemberPrivacySubject.Description => "description privacy", + MemberPrivacySubject.Banner => "banner privacy", + MemberPrivacySubject.Avatar => "avatar privacy", + MemberPrivacySubject.Pronouns => "pronoun privacy", + MemberPrivacySubject.Birthday => "birthday privacy", + MemberPrivacySubject.Proxy => "proxy tag privacy", + MemberPrivacySubject.Metadata => "metadata privacy", + MemberPrivacySubject.Visibility => "visibility", + _ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}") + }; + + var explanation = (subject, level) switch + { + (MemberPrivacySubject.Name, PrivacyLevel.Private) => + "This member's name is now hidden from other systems, and will be replaced by the member's display name.", + (MemberPrivacySubject.Description, PrivacyLevel.Private) => + "This member's description is now hidden from other systems.", + (MemberPrivacySubject.Banner, PrivacyLevel.Private) => + "This member's banner is now hidden from other systems.", + (MemberPrivacySubject.Avatar, PrivacyLevel.Private) => + "This member's avatar is now hidden from other systems.", + (MemberPrivacySubject.Birthday, PrivacyLevel.Private) => + "This member's birthday is now hidden from other systems.", + (MemberPrivacySubject.Pronouns, PrivacyLevel.Private) => + "This member's pronouns are now hidden from other systems.", + (MemberPrivacySubject.Proxy, PrivacyLevel.Private) => + "This member's proxy tags are now hidden from other systems.", + (MemberPrivacySubject.Metadata, PrivacyLevel.Private) => + "This member's metadata (eg. created timestamp, message count, etc) is now hidden from other systems.", + (MemberPrivacySubject.Visibility, PrivacyLevel.Private) => + "This member is now hidden from member lists.", + + (MemberPrivacySubject.Name, PrivacyLevel.Public) => + "This member's name is no longer hidden from other systems.", + (MemberPrivacySubject.Description, PrivacyLevel.Public) => + "This member's description is no longer hidden from other systems.", + (MemberPrivacySubject.Banner, PrivacyLevel.Public) => + "This member's banner is no longer hidden from other systems.", + (MemberPrivacySubject.Avatar, PrivacyLevel.Public) => + "This member's avatar is no longer hidden from other systems.", + (MemberPrivacySubject.Birthday, PrivacyLevel.Public) => + "This member's birthday is no longer hidden from other systems.", + (MemberPrivacySubject.Pronouns, PrivacyLevel.Public) => + "This member's pronouns are no longer hidden from other systems.", + (MemberPrivacySubject.Proxy, PrivacyLevel.Public) => + "This member's proxy tags are no longer hidden from other systems.", + (MemberPrivacySubject.Metadata, PrivacyLevel.Public) => + "This member's metadata (eg. created timestamp, message count, etc) is no longer hidden from other systems.", + (MemberPrivacySubject.Visibility, PrivacyLevel.Public) => + "This member is no longer hidden from member lists.", + + _ => throw new InvalidOperationException($"Invalid subject/level tuple ({subject}, {level})") + }; + + var replyStr = $"{Emojis.Success} {target.NameFor(ctx)}'s **{subjectName}** has been set to **{level.LevelName()}**. {explanation}"; + + // Name privacy only works given a display name + if (subject == MemberPrivacySubject.Name && level == PrivacyLevel.Private && target.DisplayName == null) + replyStr += $"\n{Emojis.Warn} This member does not have a display name set, and name privacy **will not take effect**."; + + // Avatar privacy doesn't apply when proxying if no server avatar is set + if (subject == MemberPrivacySubject.Avatar && level == PrivacyLevel.Private) + { + var guildSettings = ctx.Guild != null ? await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id) : null; + if (guildSettings?.AvatarUrl == null) + replyStr += $"\n{Emojis.Warn} This member does not have a server avatar set, so *proxying* will **still show the member avatar**. If you want to hide your avatar when proxying here, set a server avatar: `{ctx.DefaultPrefix}member {target.Reference(ctx)} serveravatar`"; + } + + await ctx.Reply(replyStr); } public async Task Delete(Context ctx, PKMember target) diff --git a/PluralKit.Bot/Commands/MemberProxy.cs b/PluralKit.Bot/Commands/MemberProxy.cs index e1bcb19b..6e47dce0 100644 --- a/PluralKit.Bot/Commands/MemberProxy.cs +++ b/PluralKit.Bot/Commands/MemberProxy.cs @@ -6,133 +6,120 @@ namespace PluralKit.Bot; public class MemberProxy { - public async Task Proxy(Context ctx, PKMember target) + public async Task ShowProxy(Context ctx, PKMember target) + { + if (target.ProxyTags.Count == 0) + await ctx.Reply("This member does not have any proxy tags."); + else + await ctx.Reply($"This member's proxy tags are:\n{target.ProxyTagsString("\n")}"); + } + + public async Task ClearProxy(Context ctx, PKMember target, bool confirmYes = false) { ctx.CheckSystem().CheckOwnMember(target); - ProxyTag ParseProxyTags(string exampleProxy) + // If we already have multiple tags, this would clear everything, so prompt that + if (target.ProxyTags.Count > 1) { - // // Make sure there's one and only one instance of "text" in the example proxy given - var prefixAndSuffix = exampleProxy.Split("text"); - if (prefixAndSuffix.Length == 1) prefixAndSuffix = prefixAndSuffix[0].Split("TEXT"); - if (prefixAndSuffix.Length < 2) throw Errors.ProxyMustHaveText; - if (prefixAndSuffix.Length > 2) throw Errors.ProxyMultipleText; - return new ProxyTag(prefixAndSuffix[0], prefixAndSuffix[1]); - } - - async Task WarnOnConflict(ProxyTag newTag) - { - var query = "select * from (select *, (unnest(proxy_tags)).prefix as prefix, (unnest(proxy_tags)).suffix as suffix from members where system = @System) as _ where prefix is not distinct from @Prefix and suffix is not distinct from @Suffix and id != @Existing"; - var conflicts = (await ctx.Database.Execute(conn => conn.QueryAsync(query, - new { newTag.Prefix, newTag.Suffix, Existing = target.Id, system = target.System }))).ToList(); - - if (conflicts.Count <= 0) return true; - - var conflictList = conflicts.Select(m => $"- **{m.NameFor(ctx)}**"); - var msg = $"{Emojis.Warn} The following members have conflicting proxy tags:\n{string.Join('\n', conflictList)}\nDo you want to proceed anyway?"; - return await ctx.PromptYesNo(msg, "Proceed"); - } - - // "Sub"command: clear flag - if (ctx.MatchClear()) - { - // If we already have multiple tags, this would clear everything, so prompt that - if (target.ProxyTags.Count > 1) - { - var msg = $"{Emojis.Warn} You already have multiple proxy tags set: {target.ProxyTagsString()}\nDo you want to clear them all?"; - if (!await ctx.PromptYesNo(msg, "Clear")) - throw Errors.GenericCancelled(); - } - - var patch = new MemberPatch { ProxyTags = Partial.Present(new ProxyTag[0]) }; - await ctx.Repository.UpdateMember(target.Id, patch); - - await ctx.Reply($"{Emojis.Success} Proxy tags cleared."); - } - // "Sub"command: no arguments; will print proxy tags - else if (!ctx.HasNext(false)) - { - if (target.ProxyTags.Count == 0) - await ctx.Reply("This member does not have any proxy tags."); - else - await ctx.Reply($"This member's proxy tags are:\n{target.ProxyTagsString("\n")}"); - } - // Subcommand: "add" - else if (ctx.Match("add", "append")) - { - if (!ctx.HasNext(false)) - throw new PKSyntaxError("You must pass an example proxy to add (eg. `[text]` or `J:text`)."); - - var tagToAdd = ParseProxyTags(ctx.RemainderOrNull(false).NormalizeLineEndSpacing()); - if (tagToAdd.IsEmpty) throw Errors.EmptyProxyTags(target, ctx); - if (target.ProxyTags.Contains(tagToAdd)) - throw Errors.ProxyTagAlreadyExists(tagToAdd, target); - if (tagToAdd.ProxyString.Length > Limits.MaxProxyTagLength) - throw new PKError( - $"Proxy tag too long ({tagToAdd.ProxyString.Length} > {Limits.MaxProxyTagLength} characters)."); - - if (!await WarnOnConflict(tagToAdd)) + var msg = $"{Emojis.Warn} You already have multiple proxy tags set: {target.ProxyTagsString()}\nDo you want to clear them all?"; + if (!await ctx.PromptYesNo(msg, "Clear", flagValue: confirmYes)) throw Errors.GenericCancelled(); - - var newTags = target.ProxyTags.ToList(); - newTags.Add(tagToAdd); - var patch = new MemberPatch { ProxyTags = Partial.Present(newTags.ToArray()) }; - await ctx.Repository.UpdateMember(target.Id, patch); - - await ctx.Reply($"{Emojis.Success} Added proxy tags {tagToAdd.ProxyString.AsCode()} (using {tagToAdd.ProxyString.Length}/{Limits.MaxProxyTagLength} characters)."); } - // Subcommand: "remove" - else if (ctx.Match("remove", "delete")) + + var patch = new MemberPatch { ProxyTags = Partial.Present(new ProxyTag[0]) }; + await ctx.Repository.UpdateMember(target.Id, patch); + + await ctx.Reply($"{Emojis.Success} Proxy tags cleared."); + } + + public async Task AddProxy(Context ctx, PKMember target, string proxyString, bool confirmYes = false) + { + ctx.CheckSystem().CheckOwnMember(target); + + var tagToAdd = ParseProxyTag(proxyString); + if (tagToAdd.IsEmpty) throw Errors.EmptyProxyTags(target, ctx); + if (target.ProxyTags.Contains(tagToAdd)) + throw Errors.ProxyTagAlreadyExists(tagToAdd, target); + if (tagToAdd.ProxyString.Length > Limits.MaxProxyTagLength) + throw new PKError( + $"Proxy tag too long ({tagToAdd.ProxyString.Length} > {Limits.MaxProxyTagLength} characters)."); + + if (!await WarnOnConflict(ctx, target, tagToAdd, confirmYes)) + throw Errors.GenericCancelled(); + + var newTags = target.ProxyTags.ToList(); + newTags.Add(tagToAdd); + var patch = new MemberPatch { ProxyTags = Partial.Present(newTags.ToArray()) }; + await ctx.Repository.UpdateMember(target.Id, patch); + + await ctx.Reply($"{Emojis.Success} Added proxy tags {tagToAdd.ProxyString.AsCode()} (using {tagToAdd.ProxyString.Length}/{Limits.MaxProxyTagLength} characters)."); + } + + public async Task RemoveProxy(Context ctx, PKMember target, string proxyString) + { + ctx.CheckSystem().CheckOwnMember(target); + + var tagToRemove = ParseProxyTag(proxyString); + if (tagToRemove.IsEmpty) throw Errors.EmptyProxyTags(target, ctx); + if (!target.ProxyTags.Contains(tagToRemove)) + throw Errors.ProxyTagDoesNotExist(tagToRemove, target); + + var newTags = target.ProxyTags.ToList(); + newTags.Remove(tagToRemove); + var patch = new MemberPatch { ProxyTags = Partial.Present(newTags.ToArray()) }; + await ctx.Repository.UpdateMember(target.Id, patch); + + await ctx.Reply($"{Emojis.Success} Removed proxy tags {tagToRemove.ProxyString.AsCode()}."); + } + + public async Task SetProxy(Context ctx, PKMember target, string proxyString, bool confirmYes = false) + { + ctx.CheckSystem().CheckOwnMember(target); + + var requestedTag = ParseProxyTag(proxyString); + if (requestedTag.IsEmpty) throw Errors.EmptyProxyTags(target, ctx); + + if (target.ProxyTags.Count > 1) { - if (!ctx.HasNext(false)) - throw new PKSyntaxError("You must pass a proxy tag to remove (eg. `[text]` or `J:text`)."); - - var remainder = ctx.RemainderOrNull(false); - var tagToRemove = ParseProxyTags(remainder.NormalizeLineEndSpacing()); - if (tagToRemove.IsEmpty) throw Errors.EmptyProxyTags(target, ctx); - if (!target.ProxyTags.Contains(tagToRemove)) - { - // Legacy support for when line endings weren't normalized - tagToRemove = ParseProxyTags(remainder); - if (!target.ProxyTags.Contains(tagToRemove)) - throw Errors.ProxyTagDoesNotExist(tagToRemove, target); - } - - - var newTags = target.ProxyTags.ToList(); - newTags.Remove(tagToRemove); - var patch = new MemberPatch { ProxyTags = Partial.Present(newTags.ToArray()) }; - await ctx.Repository.UpdateMember(target.Id, patch); - - await ctx.Reply($"{Emojis.Success} Removed proxy tags {tagToRemove.ProxyString.AsCode()}."); - } - // Subcommand: bare proxy tag given - else - { - var requestedTag = ParseProxyTags(ctx.RemainderOrNull(false).NormalizeLineEndSpacing()); - if (requestedTag.IsEmpty) throw Errors.EmptyProxyTags(target, ctx); - - // This is mostly a legacy command, so it's gonna warn if there's - // already more than one proxy tag. - if (target.ProxyTags.Count > 1) - { - var msg = $"This member already has more than one proxy tag set: {target.ProxyTagsString()}\nDo you want to replace them?"; - if (!await ctx.PromptYesNo(msg, "Replace")) - throw Errors.GenericCancelled(); - } - - if (requestedTag.ProxyString.Length > Limits.MaxProxyTagLength) - throw new PKError( - $"Proxy tag too long ({requestedTag.ProxyString.Length} > {Limits.MaxProxyTagLength} characters)."); - - if (!await WarnOnConflict(requestedTag)) + var msg = $"This member already has more than one proxy tag set: {target.ProxyTagsString()}\nDo you want to replace them?"; + if (!await ctx.PromptYesNo(msg, "Replace", flagValue: confirmYes)) throw Errors.GenericCancelled(); - - var newTags = new[] { requestedTag }; - var patch = new MemberPatch { ProxyTags = Partial.Present(newTags) }; - await ctx.Repository.UpdateMember(target.Id, patch); - - await ctx.Reply($"{Emojis.Success} Member proxy tags set to {requestedTag.ProxyString.AsCode()} (using {requestedTag.ProxyString.Length}/{Limits.MaxProxyTagLength} characters)."); } + + if (requestedTag.ProxyString.Length > Limits.MaxProxyTagLength) + throw new PKError( + $"Proxy tag too long ({requestedTag.ProxyString.Length} > {Limits.MaxProxyTagLength} characters)."); + + if (!await WarnOnConflict(ctx, target, requestedTag, confirmYes)) + throw Errors.GenericCancelled(); + + var newTags = new[] { requestedTag }; + var patch = new MemberPatch { ProxyTags = Partial.Present(newTags) }; + await ctx.Repository.UpdateMember(target.Id, patch); + + await ctx.Reply($"{Emojis.Success} Member proxy tags set to {requestedTag.ProxyString.AsCode()} (using {requestedTag.ProxyString.Length}/{Limits.MaxProxyTagLength} characters)."); + } + + private ProxyTag ParseProxyTag(string proxyString) + { + // Make sure there's one and only one instance of "text" in the example proxy given + var prefixAndSuffix = proxyString.Split("text"); + if (prefixAndSuffix.Length == 1) prefixAndSuffix = prefixAndSuffix[0].Split("TEXT"); + if (prefixAndSuffix.Length < 2) throw Errors.ProxyMustHaveText; + if (prefixAndSuffix.Length > 2) throw Errors.ProxyMultipleText; + return new ProxyTag(prefixAndSuffix[0], prefixAndSuffix[1]); + } + + private async Task WarnOnConflict(Context ctx, PKMember target, ProxyTag newTag, bool confirmYes = false) + { + var query = "select * from (select *, (unnest(proxy_tags)).prefix as prefix, (unnest(proxy_tags)).suffix as suffix from members where system = @System) as _ where prefix is not distinct from @Prefix and suffix is not distinct from @Suffix and id != @Existing"; + var conflicts = (await ctx.Database.Execute(conn => conn.QueryAsync(query, + new { newTag.Prefix, newTag.Suffix, Existing = target.Id, system = target.System }))).ToList(); + + if (conflicts.Count <= 0) return true; + + var conflictList = conflicts.Select(m => $"- **{m.NameFor(ctx)}**"); + var msg = $"{Emojis.Warn} The following members have conflicting proxy tags:\n{string.Join('\n', conflictList)}\nDo you want to proceed anyway?"; + return await ctx.PromptYesNo(msg, "Proceed", flagValue: confirmYes); } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Message.cs b/PluralKit.Bot/Commands/Message.cs index c61dd88b..4a73d92c 100644 --- a/PluralKit.Bot/Commands/Message.cs +++ b/PluralKit.Bot/Commands/Message.cs @@ -58,16 +58,14 @@ public class ProxiedMessage _redisService = redisService; } - public async Task ReproxyMessage(Context ctx) + public async Task ReproxyMessage(Context ctx, Message.Reference? messageRef, PKMember target) { - var (msg, systemId) = await GetMessageToEdit(ctx, ReproxyTimeout, true); + var (msg, systemId) = await GetMessageToEdit(ctx, messageRef?.MessageId ?? ctx.GetRepliedTo()?.MessageId, ReproxyTimeout, true); if (ctx.System.Id != systemId) throw new PKError("Can't reproxy a message sent by a different system."); - // Get target member ID - var target = await ctx.MatchMember(restrictToSystem: ctx.System.Id); - if (target == null) + if (target == null || target.System != ctx.System.Id) throw new PKError("Could not find a member to reproxy the message with."); // Fetch members and get the ProxyMember for `target` @@ -93,9 +91,9 @@ public class ProxiedMessage } } - public async Task EditMessage(Context ctx, bool useRegex) + public async Task EditMessage(Context ctx, Message.Reference? messageRef, string? newContent, bool useRegex, bool noSpace, bool append, bool prepend, bool clearEmbeds, bool clearAttachments) { - var (msg, systemId) = await GetMessageToEdit(ctx, EditTimeout, false); + var (msg, systemId) = await GetMessageToEdit(ctx, messageRef?.MessageId ?? ctx.GetRepliedTo()?.MessageId, EditTimeout, false); if (ctx.System.Id != systemId) throw new PKError("Can't edit a message sent by a different system."); @@ -104,21 +102,12 @@ public class ProxiedMessage if (originalMsg == null) throw new PKError("Could not edit message."); - // Regex flag - useRegex = useRegex || ctx.MatchFlag("regex", "x"); - - // Check if we should append or prepend - var mutateSpace = ctx.MatchFlag("nospace", "ns") ? "" : " "; - var append = ctx.MatchFlag("append", "a"); - var prepend = ctx.MatchFlag("prepend", "p"); + var mutateSpace = noSpace ? "" : " "; // Grab the original message content and new message content var originalContent = originalMsg.Content; - var newContent = ctx.RemainderOrNull()?.NormalizeLineEndSpacing(); // Should we clear embeds? - var clearEmbeds = ctx.MatchFlag("clear-embed", "ce"); - var clearAttachments = ctx.MatchFlag("clear-attachments", "ca"); if ((clearEmbeds || clearAttachments) && newContent == null) newContent = originalMsg.Content!; @@ -249,14 +238,13 @@ public class ProxiedMessage } } - private async Task<(PKMessage, SystemId)> GetMessageToEdit(Context ctx, Duration timeout, bool isReproxy) + private async Task<(PKMessage, SystemId)> GetMessageToEdit(Context ctx, ulong? referencedMessage, Duration timeout, bool isReproxy) { var editType = isReproxy ? "reproxy" : "edit"; var editTypeAction = isReproxy ? "reproxied" : "edited"; PKMessage? msg = null; - var (referencedMessage, _) = ctx.MatchMessage(false); if (referencedMessage != null) { await using var conn = await ctx.Database.Obtain(); @@ -332,22 +320,20 @@ public class ProxiedMessage return lastMessage; } - public async Task GetMessage(Context ctx) + public async Task GetMessage(Context ctx, Message.Reference? messageRef, ReplyFormat format, bool isDelete, bool author, bool showEmbed) { - var (messageId, _) = ctx.MatchMessage(true); - if (messageId == null) + if (ctx.Message.Type == Message.MessageType.Reply && ctx.Message.MessageReference?.MessageId != null) + messageRef = ctx.Message.MessageReference; + + if (messageRef == null || messageRef.MessageId == null) { - if (!ctx.HasNext()) - throw new PKSyntaxError("You must pass a message ID or link."); - throw new PKSyntaxError($"Could not parse {ctx.PeekArgument().AsCode()} as a message ID or link."); + throw new PKSyntaxError("You must pass a message ID or link."); } - var isDelete = ctx.Match("delete") || ctx.MatchFlag("delete"); - - var message = await ctx.Repository.GetFullMessage(messageId.Value); + var message = await ctx.Repository.GetFullMessage(messageRef.MessageId.Value); if (message == null) { - await GetCommandMessage(ctx, messageId.Value, isDelete); + await GetCommandMessage(ctx, messageRef.MessageId.Value, isDelete, showEmbed); return; } @@ -360,8 +346,6 @@ public class ProxiedMessage else if (!await ctx.CheckPermissionsInGuildChannel(channel, PermissionSet.ViewChannel)) showContent = false; - var format = ctx.MatchFormat(); - if (format != ReplyFormat.Standard) { var discordMessage = await _rest.GetMessageOrNull(message.Message.Channel, message.Message.Mid); @@ -423,10 +407,10 @@ public class ProxiedMessage return; } - if (ctx.Match("author") || ctx.MatchFlag("author")) + if (author) { var user = await _rest.GetUser(message.Message.Sender); - if (ctx.MatchFlag("show-embed", "se")) + if (showEmbed) { var eb = new EmbedBuilder() .Author(new Embed.EmbedAuthor( @@ -446,7 +430,7 @@ public class ProxiedMessage return; } - if (ctx.MatchFlag("show-embed", "se")) + if (showEmbed) { await ctx.Reply(embed: await _embeds.CreateMessageInfoEmbed(message, showContent, ctx.Config)); return; @@ -455,7 +439,7 @@ public class ProxiedMessage await ctx.Reply(components: await _embeds.CreateMessageInfoMessageComponents(message, showContent, ctx.Config)); } - private async Task GetCommandMessage(Context ctx, ulong messageId, bool isDelete) + private async Task GetCommandMessage(Context ctx, ulong messageId, bool isDelete, bool showEmbed) { var msg = await _repo.GetCommandMessage(messageId); if (msg == null) @@ -484,7 +468,7 @@ public class ProxiedMessage else if (!await ctx.CheckPermissionsInGuildChannel(channel, PermissionSet.ViewChannel)) showContent = false; - if (ctx.MatchFlag("show-embed", "se")) + if (showEmbed) { await ctx.Reply(embed: await _embeds.CreateCommandMessageInfoEmbed(msg, showContent)); return; diff --git a/PluralKit.Bot/Commands/Random.cs b/PluralKit.Bot/Commands/Random.cs index 4b0aa8a4..180d9b0c 100644 --- a/PluralKit.Bot/Commands/Random.cs +++ b/PluralKit.Bot/Commands/Random.cs @@ -15,7 +15,7 @@ public class Random // todo: get postgresql to return one random member/group instead of querying all members/groups - public async Task Member(Context ctx, PKSystem target) + public async Task Member(Context ctx, PKSystem target, bool all, bool showEmbed = false) { if (target == null) throw Errors.NoSystemError(ctx.DefaultPrefix); @@ -24,7 +24,7 @@ public class Random var members = await ctx.Repository.GetSystemMembers(target.Id).ToListAsync(); - if (!ctx.MatchFlag("all", "a")) + if (!all) members = members.Where(m => m.MemberVisibility == PrivacyLevel.Public).ToList(); else ctx.CheckOwnSystem(target); @@ -37,7 +37,7 @@ public class Random var randInt = randGen.Next(members.Count); - if (ctx.MatchFlag("show-embed", "se")) + if (showEmbed) { await ctx.Reply( text: EmbedService.LEGACY_EMBED_WARNING, @@ -49,7 +49,7 @@ public class Random components: await _embeds.CreateMemberMessageComponents(target, members[randInt], ctx.Guild, ctx.Config, ctx.LookupContextFor(target.Id), ctx.Zone)); } - public async Task Group(Context ctx, PKSystem target) + public async Task Group(Context ctx, PKSystem target, bool all, bool showEmbed = false) { if (target == null) throw Errors.NoSystemError(ctx.DefaultPrefix); @@ -57,7 +57,7 @@ public class Random ctx.CheckSystemPrivacy(target.Id, target.GroupListPrivacy); var groups = await ctx.Repository.GetSystemGroups(target.Id).ToListAsync(); - if (!ctx.MatchFlag("all", "a")) + if (!all) groups = groups.Where(g => g.Visibility == PrivacyLevel.Public).ToList(); else ctx.CheckOwnSystem(target); @@ -70,23 +70,23 @@ public class Random var randInt = randGen.Next(groups.Count()); - if (ctx.MatchFlag("show-embed", "se")) + if (showEmbed) { await ctx.Reply( text: EmbedService.LEGACY_EMBED_WARNING, - embed: await _embeds.CreateGroupEmbed(ctx, target, groups.ToArray()[randInt])); + embed: await _embeds.CreateGroupEmbed(ctx, target, groups.ToArray()[randInt], all)); return; } await ctx.Reply( - components: await _embeds.CreateGroupMessageComponents(ctx, target, groups.ToArray()[randInt])); + components: await _embeds.CreateGroupMessageComponents(ctx, target, groups.ToArray()[randInt], all)); } - public async Task GroupMember(Context ctx, PKGroup group) + public async Task GroupMember(Context ctx, PKGroup group, bool all, bool show_embed, IHasListOptions flags) { ctx.CheckSystemPrivacy(group.System, group.ListPrivacy); - var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(group.System), ctx.LookupContextFor(group.System)); + var opts = flags.GetListOptions(ctx, group.System); opts.GroupFilter = group.Id; var members = await ctx.Database.Execute(conn => conn.QueryMemberList(group.System, opts.ToQueryOptions())); @@ -96,7 +96,7 @@ public class Random "This group has no members!" + (ctx.System?.Id == group.System ? " Please add at least one member to this group before using this command." : "")); - if (!ctx.MatchFlag("all", "a")) + if (!all) members = members.Where(g => g.MemberVisibility == PrivacyLevel.Public); else ctx.CheckOwnGroup(group); @@ -112,7 +112,7 @@ public class Random var randInt = randGen.Next(ms.Count); - if (ctx.MatchFlag("show-embed", "se")) + if (show_embed) { await ctx.Reply( text: EmbedService.LEGACY_EMBED_WARNING, diff --git a/PluralKit.Bot/Commands/ServerConfig.cs b/PluralKit.Bot/Commands/ServerConfig.cs index ef648438..9ecc5170 100644 --- a/PluralKit.Bot/Commands/ServerConfig.cs +++ b/PluralKit.Bot/Commands/ServerConfig.cs @@ -110,34 +110,27 @@ public class ServerConfig ); } - public async Task SetLogChannel(Context ctx) + public async Task ShowLogChannel(Context ctx) { await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); var settings = await ctx.Repository.GetGuild(ctx.Guild.Id); - if (ctx.MatchClear() && await ctx.ConfirmClear("the server log channel")) + if (settings.LogChannel == null) { - await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogChannel = null }); - await ctx.Reply($"{Emojis.Success} Proxy logging channel cleared."); + await ctx.Reply("This server does not have a log channel set."); return; } - if (!ctx.HasNext()) - { - if (settings.LogChannel == null) - { - await ctx.Reply("This server does not have a log channel set."); - return; - } + await ctx.Reply($"This server's log channel is currently set to <#{settings.LogChannel}>."); + } - await ctx.Reply($"This server's log channel is currently set to <#{settings.LogChannel}>."); - return; - } + public async Task SetLogChannel(Context ctx, Channel channel) + { + await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); + + if (channel.GuildId != ctx.Guild.Id) + throw Errors.ChannelNotFound(channel.Id.ToString()); - Channel channel = null; - var channelString = ctx.PeekArgument(); - channel = await ctx.MatchChannel(); - if (channel == null || channel.GuildId != ctx.Guild.Id) throw Errors.ChannelNotFound(channelString); if (channel.Type != Channel.ChannelType.GuildText && channel.Type != Channel.ChannelType.GuildPublicThread && channel.Type != Channel.ChannelType.GuildPrivateThread) throw new PKError("PluralKit cannot log messages to this type of channel."); @@ -151,46 +144,18 @@ public class ServerConfig await ctx.Reply($"{Emojis.Success} Proxy logging channel set to <#{channel.Id}>."); } - // legacy behaviour: enable/disable logging for commands - // new behaviour is add/remove from log blacklist (see #LogBlacklistNew) - public async Task SetLogEnabled(Context ctx, bool enable) + public async Task ClearLogChannel(Context ctx, bool confirmYes) { await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); - var affectedChannels = new List(); - if (ctx.Match("all")) - affectedChannels = (await _cache.GetGuildChannels(ctx.Guild.Id)) - .Where(x => x.Type == Channel.ChannelType.GuildText).ToList(); - else if (!ctx.HasNext()) throw new PKSyntaxError("You must pass one or more #channels."); - else - while (ctx.HasNext()) - { - var channelString = ctx.PeekArgument(); - var channel = await ctx.MatchChannel(); - if (channel == null || channel.GuildId != ctx.Guild.Id) throw Errors.ChannelNotFound(channelString); - affectedChannels.Add(channel); - } + if (!await ctx.ConfirmClear("the server log channel", confirmYes)) + return; - ulong? logChannel = null; - var config = await ctx.Repository.GetGuild(ctx.Guild.Id); - logChannel = config.LogChannel; - - var blacklist = config.LogBlacklist.ToHashSet(); - if (enable) - blacklist.ExceptWith(affectedChannels.Select(c => c.Id)); - else - blacklist.UnionWith(affectedChannels.Select(c => c.Id)); - - await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogBlacklist = blacklist.ToArray() }); - - await ctx.Reply( - $"{Emojis.Success} Message logging for the given channels {(enable ? "enabled" : "disabled")}." + - (logChannel == null - ? $"\n{Emojis.Warn} Please note that no logging channel is set, so there is nowhere to log messages to. You can set a logging channel using `{ctx.DefaultPrefix}serverconfig log channel #your-log-channel`." - : "")); + await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogChannel = null }); + await ctx.Reply($"{Emojis.Success} Proxy logging channel cleared."); } - public async Task ShowProxyBlacklisted(Context ctx) + public async Task ShowProxyBlacklist(Context ctx) { await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); @@ -240,14 +205,73 @@ public class ServerConfig }); } - public async Task ShowLogDisabledChannels(Context ctx) + public async Task AddProxyBlacklist(Context ctx, Channel? channel, bool all) + { + await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); + + var affectedChannels = new List(); + if (all) + { + affectedChannels = (await _cache.GetGuildChannels(ctx.Guild.Id)) + .Where(x => DiscordUtils.IsValidGuildChannel(x)).ToList(); + } + else if (channel != null) + { + if (channel.GuildId != ctx.Guild.Id) + throw Errors.ChannelNotFound(channel.Id.ToString()); + affectedChannels.Add(channel); + } + else + { + throw new PKSyntaxError("You must specify a channel or use the --all flag."); + } + + var guild = await ctx.Repository.GetGuild(ctx.Guild.Id); + var blacklist = guild.Blacklist.ToHashSet(); + blacklist.UnionWith(affectedChannels.Select(c => c.Id)); + + await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { Blacklist = blacklist.ToArray() }); + + await ctx.Reply($"{Emojis.Success} {(all ? "All channels" : "Channel")} added to the proxy blacklist."); + } + + public async Task RemoveProxyBlacklist(Context ctx, Channel? channel, bool all) + { + await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); + + var affectedChannels = new List(); + if (all) + { + affectedChannels = (await _cache.GetGuildChannels(ctx.Guild.Id)) + .Where(x => DiscordUtils.IsValidGuildChannel(x)).ToList(); + } + else if (channel != null) + { + if (channel.GuildId != ctx.Guild.Id) + throw Errors.ChannelNotFound(channel.Id.ToString()); + affectedChannels.Add(channel); + } + else + { + throw new PKSyntaxError("You must specify a channel or use the --all flag."); + } + + var guild = await ctx.Repository.GetGuild(ctx.Guild.Id); + var blacklist = guild.Blacklist.ToHashSet(); + blacklist.ExceptWith(affectedChannels.Select(c => c.Id)); + + await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { Blacklist = blacklist.ToArray() }); + + await ctx.Reply($"{Emojis.Success} {(all ? "All channels" : "Channel")} removed from the proxy blacklist."); + } + + public async Task ShowLogBlacklist(Context ctx) { await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); var config = await ctx.Repository.GetGuild(ctx.Guild.Id); // Resolve all channels from the cache and order by position - // todo: GetAllChannels? var channels = (await Task.WhenAll(config.LogBlacklist .Select(id => _cache.TryGetChannel(ctx.Guild.Id, id)))) .Where(c => c != null) @@ -291,78 +315,75 @@ public class ServerConfig }); } - - - public async Task SetProxyBlacklisted(Context ctx, bool shouldAdd) + public async Task AddLogBlacklist(Context ctx, Channel? channel, bool all) { await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); var affectedChannels = new List(); - if (ctx.Match("all")) + if (all) + { affectedChannels = (await _cache.GetGuildChannels(ctx.Guild.Id)) - // All the channel types you can proxy in .Where(x => DiscordUtils.IsValidGuildChannel(x)).ToList(); - else if (!ctx.HasNext()) throw new PKSyntaxError("You must pass one or more #channels."); + } + else if (channel != null) + { + if (channel.GuildId != ctx.Guild.Id) + throw Errors.ChannelNotFound(channel.Id.ToString()); + affectedChannels.Add(channel); + } else - while (ctx.HasNext()) - { - var channelString = ctx.PeekArgument(); - var channel = await ctx.MatchChannel(); - if (channel == null || channel.GuildId != ctx.Guild.Id) throw Errors.ChannelNotFound(channelString); - affectedChannels.Add(channel); - } + { + throw new PKSyntaxError("You must specify a channel or use the --all flag."); + } var guild = await ctx.Repository.GetGuild(ctx.Guild.Id); - - var blacklist = guild.Blacklist.ToHashSet(); - if (shouldAdd) - blacklist.UnionWith(affectedChannels.Select(c => c.Id)); - else - blacklist.ExceptWith(affectedChannels.Select(c => c.Id)); - - await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { Blacklist = blacklist.ToArray() }); - - await ctx.Reply( - $"{Emojis.Success} Channels {(shouldAdd ? "added to" : "removed from")} the proxy blacklist."); - } - - public async Task SetLogBlacklisted(Context ctx, bool shouldAdd) - { - await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); - - var affectedChannels = new List(); - if (ctx.Match("all")) - affectedChannels = (await _cache.GetGuildChannels(ctx.Guild.Id)) - // All the channel types you can proxy in - .Where(x => DiscordUtils.IsValidGuildChannel(x)).ToList(); - else if (!ctx.HasNext()) throw new PKSyntaxError("You must pass one or more #channels."); - else - while (ctx.HasNext()) - { - var channelString = ctx.PeekArgument(); - var channel = await ctx.MatchChannel(); - if (channel == null || channel.GuildId != ctx.Guild.Id) throw Errors.ChannelNotFound(channelString); - affectedChannels.Add(channel); - } - - var guild = await ctx.Repository.GetGuild(ctx.Guild.Id); - var blacklist = guild.LogBlacklist.ToHashSet(); - if (shouldAdd) - blacklist.UnionWith(affectedChannels.Select(c => c.Id)); - else - blacklist.ExceptWith(affectedChannels.Select(c => c.Id)); + blacklist.UnionWith(affectedChannels.Select(c => c.Id)); await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogBlacklist = blacklist.ToArray() }); await ctx.Reply( - $"{Emojis.Success} Channels {(shouldAdd ? "added to" : "removed from")} the logging blacklist." + + $"{Emojis.Success} {(all ? "All channels" : "Channel")} added to the logging blacklist." + (guild.LogChannel == null ? $"\n{Emojis.Warn} Please note that no logging channel is set, so there is nowhere to log messages to. You can set a logging channel using `{ctx.DefaultPrefix}serverconfig log channel #your-log-channel`." : "")); } - public async Task SetLogCleanup(Context ctx) + public async Task RemoveLogBlacklist(Context ctx, Channel? channel, bool all) + { + await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); + + var affectedChannels = new List(); + if (all) + { + affectedChannels = (await _cache.GetGuildChannels(ctx.Guild.Id)) + .Where(x => DiscordUtils.IsValidGuildChannel(x)).ToList(); + } + else if (channel != null) + { + if (channel.GuildId != ctx.Guild.Id) + throw Errors.ChannelNotFound(channel.Id.ToString()); + affectedChannels.Add(channel); + } + else + { + throw new PKSyntaxError("You must specify a channel or use the --all flag."); + } + + var guild = await ctx.Repository.GetGuild(ctx.Guild.Id); + var blacklist = guild.LogBlacklist.ToHashSet(); + blacklist.ExceptWith(affectedChannels.Select(c => c.Id)); + + await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogBlacklist = blacklist.ToArray() }); + + await ctx.Reply( + $"{Emojis.Success} {(all ? "All channels" : "Channel")} removed from the logging blacklist." + + (guild.LogChannel == null + ? $"\n{Emojis.Warn} Please note that no logging channel is set, so there is nowhere to log messages to. You can set a logging channel using `{ctx.DefaultPrefix}serverconfig log channel #your-log-channel`." + : "")); + } + + public async Task ShowLogCleanup(Context ctx) { var botList = string.Join(", ", LoggerCleanService.Bots.Select(b => b.Name).OrderBy(x => x.ToLowerInvariant())); var eb = new EmbedBuilder() @@ -377,74 +398,77 @@ public class ServerConfig } await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); - bool? newValue = ctx.MatchToggleOrNull(); - if (newValue == null) - { - if (ctx.GuildConfig!.LogCleanupEnabled) - eb.Description( - $"Log cleanup is currently **on** for this server. To disable it, type `{ctx.DefaultPrefix}serverconfig logclean off`."); - else - eb.Description( - $"Log cleanup is currently **off** for this server. To enable it, type `{ctx.DefaultPrefix}serverconfig logclean on`."); - await ctx.Reply(embed: eb.Build()); - return; - } + if (ctx.GuildConfig!.LogCleanupEnabled) + eb.Description( + $"Log cleanup is currently **on** for this server. To disable it, type `{ctx.DefaultPrefix}serverconfig logclean off`."); + else + eb.Description( + $"Log cleanup is currently **off** for this server. To enable it, type `{ctx.DefaultPrefix}serverconfig logclean on`."); - await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogCleanupEnabled = newValue.Value }); + await ctx.Reply(embed: eb.Build()); + } - if (newValue.Value) + public async Task SetLogCleanup(Context ctx, bool value) + { + await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); + + var botList = string.Join(", ", LoggerCleanService.Bots.Select(b => b.Name).OrderBy(x => x.ToLowerInvariant())); + + await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogCleanupEnabled = value }); + + if (value) await ctx.Reply( $"{Emojis.Success} Log cleanup has been **enabled** for this server. Messages deleted by PluralKit will now be cleaned up from logging channels managed by the following bots:\n- **{botList}**\n\n{Emojis.Note} Make sure PluralKit has the **Manage Messages** permission in the channels in question.\n{Emojis.Note} Also, make sure to blacklist the logging channel itself from the bots in question to prevent conflicts."); else await ctx.Reply($"{Emojis.Success} Log cleanup has been **disabled** for this server."); } - public async Task InvalidCommandResponse(Context ctx) + public async Task ShowInvalidCommandResponse(Context ctx) { await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); - if (!ctx.HasNext()) - { - var msg = $"Error responses for unknown/invalid commands are currently **{EnabledDisabled(ctx.GuildConfig!.InvalidCommandResponseEnabled)}**."; - await ctx.Reply(msg); - return; - } - - var newVal = ctx.MatchToggle(false); - await ctx.Repository.UpdateGuild(ctx.Guild.Id, new() { InvalidCommandResponseEnabled = newVal }); - await ctx.Reply($"Error responses for unknown/invalid commands are now {EnabledDisabled(newVal)}."); + var msg = $"Error responses for unknown/invalid commands are currently **{EnabledDisabled(ctx.GuildConfig!.InvalidCommandResponseEnabled)}**."; + await ctx.Reply(msg); } - public async Task RequireSystemTag(Context ctx) + public async Task SetInvalidCommandResponse(Context ctx, bool value) { await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); - if (!ctx.HasNext()) - { - var msg = $"System tags are currently **{(ctx.GuildConfig!.RequireSystemTag ? "required" : "not required")}** for PluralKit users in this server."; - await ctx.Reply(msg); - return; - } - - var newVal = ctx.MatchToggle(false); - await ctx.Repository.UpdateGuild(ctx.Guild.Id, new() { RequireSystemTag = newVal }); - await ctx.Reply($"System tags are now **{(newVal ? "required" : "not required")}** for PluralKit users in this server."); + await ctx.Repository.UpdateGuild(ctx.Guild.Id, new() { InvalidCommandResponseEnabled = value }); + await ctx.Reply($"Error responses for unknown/invalid commands are now {EnabledDisabled(value)}."); } - public async Task SuppressNotifications(Context ctx) + public async Task ShowRequireSystemTag(Context ctx) { await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); - if (!ctx.HasNext()) - { - var msg = $"Suppressing notifications for proxied messages is currently **{EnabledDisabled(ctx.GuildConfig!.SuppressNotifications)}**."; - await ctx.Reply(msg); - return; - } + var msg = $"System tags are currently **{(ctx.GuildConfig!.RequireSystemTag ? "required" : "not required")}** for PluralKit users in this server."; + await ctx.Reply(msg); + } - var newVal = ctx.MatchToggle(false); - await ctx.Repository.UpdateGuild(ctx.Guild.Id, new() { SuppressNotifications = newVal }); - await ctx.Reply($"Suppressing notifications for proxied messages is now {EnabledDisabled(newVal)}."); + public async Task SetRequireSystemTag(Context ctx, bool value) + { + await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); + + await ctx.Repository.UpdateGuild(ctx.Guild.Id, new() { RequireSystemTag = value }); + await ctx.Reply($"System tags are now **{(value ? "required" : "not required")}** for PluralKit users in this server."); + } + + public async Task ShowSuppressNotifications(Context ctx) + { + await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); + + var msg = $"Suppressing notifications for proxied messages is currently **{EnabledDisabled(ctx.GuildConfig!.SuppressNotifications)}**."; + await ctx.Reply(msg); + } + + public async Task SetSuppressNotifications(Context ctx, bool value) + { + await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server"); + + await ctx.Repository.UpdateGuild(ctx.Guild.Id, new() { SuppressNotifications = value }); + await ctx.Reply($"Suppressing notifications for proxied messages is now {EnabledDisabled(value)}."); } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/Switch.cs b/PluralKit.Bot/Commands/Switch.cs index 83465bd4..f566ef6a 100644 --- a/PluralKit.Bot/Commands/Switch.cs +++ b/PluralKit.Bot/Commands/Switch.cs @@ -8,11 +8,10 @@ namespace PluralKit.Bot; public class Switch { - public async Task SwitchDo(Context ctx) + public async Task SwitchDo(Context ctx, ICollection members) { ctx.CheckSystem(); - var members = await ctx.ParseMemberList(ctx.System.Id); await DoSwitchCommand(ctx, members); } @@ -21,11 +20,12 @@ public class Switch ctx.CheckSystem(); // Switch with no members = switch-out - await DoSwitchCommand(ctx, new PKMember[] { }); + await DoSwitchCommand(ctx, []); } - private async Task DoSwitchCommand(Context ctx, ICollection members) + private async Task DoSwitchCommand(Context ctx, ICollection? members) { + if (members == null) members = new List(); // Make sure there are no dupes in the list // We do this by checking if removing duplicate member IDs results in a list of different length if (members.Select(m => m.Id).Distinct().Count() != members.Count) throw Errors.DuplicateSwitchMembers; @@ -57,16 +57,14 @@ public class Switch $"{Emojis.Success} Switch registered. Current fronters are now {string.Join(", ", members.Select(m => m.NameFor(ctx)))}."); } - public async Task SwitchMove(Context ctx) + public async Task SwitchMove(Context ctx, string str, bool confirmYes = false) { ctx.CheckSystem(); - var timeToMove = ctx.RemainderOrNull() ?? - throw new PKSyntaxError("Must pass a date or time to move the switch to."); var tz = TzdbDateTimeZoneSource.Default.ForId(ctx.Config?.UiTz ?? "UTC"); - var result = DateUtils.ParseDateTime(timeToMove, true, tz); - if (result == null) throw Errors.InvalidDateTime(timeToMove); + var result = DateUtils.ParseDateTime(str, true, tz); + if (result == null) throw Errors.InvalidDateTime(str); var time = result.Value; @@ -97,18 +95,18 @@ public class Switch // yeet var msg = $"{Emojis.Warn} This will move the latest switch ({lastSwitchMemberStr}) from ({lastSwitchDeltaStr} ago) to ({newSwitchDeltaStr} ago). Is this OK?"; - if (!await ctx.PromptYesNo(msg, "Move Switch")) throw Errors.SwitchMoveCancelled; + if (!await ctx.PromptYesNo(msg, "Move Switch", flagValue: confirmYes)) throw Errors.SwitchMoveCancelled; // aaaand *now* we do the move await ctx.Repository.MoveSwitch(lastTwoSwitches[0].Id, time.ToInstant()); await ctx.Reply($"{Emojis.Success} Switch moved to ({newSwitchDeltaStr} ago)."); } - public async Task SwitchEdit(Context ctx, bool newSwitch = false) + public async Task SwitchEdit(Context ctx, List? newMembers, bool newSwitch = false, bool first = false, bool remove = false, bool append = false, bool prepend = false, bool confirmYes = false) { ctx.CheckSystem(); - var newMembers = await ctx.ParseMemberList(ctx.System.Id); + if (newMembers == null) newMembers = new List(); await using var conn = await ctx.Database.Obtain(); var currentSwitch = await ctx.Repository.GetLatestSwitch(ctx.System.Id); @@ -116,24 +114,24 @@ public class Switch throw Errors.NoRegisteredSwitches; var currentSwitchMembers = await ctx.Repository.GetSwitchMembers(conn, currentSwitch.Id).ToListAsync().AsTask(); - if (ctx.MatchFlag("first", "f")) + if (first) newMembers = FirstInSwitch(newMembers[0], currentSwitchMembers); - else if (ctx.MatchFlag("remove", "r")) + else if (remove) newMembers = RemoveFromSwitch(newMembers, currentSwitchMembers); - else if (ctx.MatchFlag("append", "a")) + else if (append) newMembers = AppendToSwitch(newMembers, currentSwitchMembers); - else if (ctx.MatchFlag("prepend", "p")) + else if (prepend) newMembers = PrependToSwitch(newMembers, currentSwitchMembers); if (newSwitch) { // if there's no edit flag, assume we're appending - if (!ctx.MatchFlag("first", "f", "remove", "r", "append", "a", "prepend", "p")) + if (!prepend && !append && !remove && !first) newMembers = AppendToSwitch(newMembers, currentSwitchMembers); await DoSwitchCommand(ctx, newMembers); } else - await DoEditCommand(ctx, newMembers); + await DoEditCommand(ctx, newMembers, confirmYes); } public List PrependToSwitch(List members, List currentSwitchMembers) @@ -169,14 +167,16 @@ public class Switch return members; } - public async Task SwitchEditOut(Context ctx) + public async Task SwitchEditOut(Context ctx, bool confirmYes) { ctx.CheckSystem(); - await DoEditCommand(ctx, new PKMember[] { }); + await DoEditCommand(ctx, [], confirmYes); } - public async Task DoEditCommand(Context ctx, ICollection members) + public async Task DoEditCommand(Context ctx, ICollection? members, bool confirmYes) { + if (members == null) members = new List(); + // Make sure there are no dupes in the list // We do this by checking if removing duplicate member IDs results in a list of different length if (members.Select(m => m.Id).Distinct().Count() != members.Count) throw Errors.DuplicateSwitchMembers; @@ -203,7 +203,7 @@ public class Switch msg = $"{Emojis.Warn} This will turn the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago) into a switch-out. Is this okay?"; else msg = $"{Emojis.Warn} This will change the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago) to {newSwitchMemberStr}. Is this okay?"; - if (!await ctx.PromptYesNo(msg, "Edit")) throw Errors.SwitchEditCancelled; + if (!await ctx.PromptYesNo(msg, "Edit", flagValue: confirmYes)) throw Errors.SwitchEditCancelled; // Actually edit the switch await ctx.Repository.EditSwitch(conn, lastSwitch.Id, members.Select(m => m.Id).ToList()); @@ -217,16 +217,16 @@ public class Switch await ctx.Reply($"{Emojis.Success} Switch edited. Current fronters are now {newSwitchMemberStr}."); } - public async Task SwitchDelete(Context ctx) + public async Task SwitchDelete(Context ctx, bool all = false, bool confirmYes = false) { ctx.CheckSystem(); - if (ctx.Match("all", "clear") || ctx.MatchFlag("all", "clear", "c")) + if (all) { // Subcommand: "delete all" var purgeMsg = $"{Emojis.Warn} This will delete *all registered switches* in your system. Are you sure you want to proceed?"; - if (!await ctx.PromptYesNo(purgeMsg, "Clear Switches")) + if (!await ctx.PromptYesNo(purgeMsg, "Clear Switches", flagValue: confirmYes)) throw Errors.GenericCancelled(); await ctx.Repository.DeleteAllSwitches(ctx.System.Id); await ctx.Reply($"{Emojis.Success} Cleared system switches!"); @@ -258,7 +258,7 @@ public class Switch msg = $"{Emojis.Warn} This will delete the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago). The next latest switch is {secondSwitchMemberStr} ({secondSwitchDeltaStr} ago). Is this okay?"; } - if (!await ctx.PromptYesNo(msg, "Delete Switch")) throw Errors.SwitchDeleteCancelled; + if (!await ctx.PromptYesNo(msg, "Delete Switch", flagValue: confirmYes)) throw Errors.SwitchDeleteCancelled; await ctx.Repository.DeleteSwitch(lastTwoSwitches[0].Id); await ctx.Reply($"{Emojis.Success} Switch deleted."); diff --git a/PluralKit.Bot/Commands/System.cs b/PluralKit.Bot/Commands/System.cs index 2160dfd1..f803abc8 100644 --- a/PluralKit.Bot/Commands/System.cs +++ b/PluralKit.Bot/Commands/System.cs @@ -14,23 +14,22 @@ public class System _embeds = embeds; } - public async Task Query(Context ctx, PKSystem system) + public async Task Query(Context ctx, PKSystem system, bool all, bool @public, bool @private, bool showEmbed = false) { if (system == null) throw Errors.NoSystemError(ctx.DefaultPrefix); - if (ctx.MatchFlag("show-embed", "se")) + if (showEmbed) { - await ctx.Reply(text: EmbedService.LEGACY_EMBED_WARNING, embed: await _embeds.CreateSystemEmbed(ctx, system, ctx.LookupContextFor(system.Id))); + await ctx.Reply(text: EmbedService.LEGACY_EMBED_WARNING, embed: await _embeds.CreateSystemEmbed(ctx, system, ctx.LookupContextFor(system.Id), all)); return; } - await ctx.Reply(components: await _embeds.CreateSystemMessageComponents(ctx, system, ctx.LookupContextFor(system.Id))); + await ctx.Reply(components: await _embeds.CreateSystemMessageComponents(ctx, system, ctx.LookupContextFor(system.Id), all)); } - public async Task New(Context ctx) + public async Task New(Context ctx, string? systemName) { ctx.CheckNoSystem(); - var systemName = ctx.RemainderOrNull(); if (systemName != null && systemName.Length > Limits.MaxSystemNameLength) throw Errors.StringTooLongError("System name", systemName.Length, Limits.MaxSystemNameLength); diff --git a/PluralKit.Bot/Commands/SystemEdit.cs b/PluralKit.Bot/Commands/SystemEdit.cs index 3af4a639..31d7d761 100644 --- a/PluralKit.Bot/Commands/SystemEdit.cs +++ b/PluralKit.Bot/Commands/SystemEdit.cs @@ -29,7 +29,7 @@ public class SystemEdit _avatarHosting = avatarHosting; } - public async Task Name(Context ctx, PKSystem target) + public async Task ShowName(Context ctx, PKSystem target, ReplyFormat format) { ctx.CheckSystemPrivacy(target.Id, target.NamePrivacy); var isOwnSystem = target.Id == ctx.System?.Id; @@ -38,15 +38,11 @@ public class SystemEdit if (isOwnSystem) noNameSetMessage += $" Type `{ctx.DefaultPrefix}system name ` to set one."; - var format = ctx.MatchFormat(); - - // if there's nothing next or what's next is "raw"/"plaintext" we're doing a query, so check for null - if (!ctx.HasNext(false) || format != ReplyFormat.Standard) - if (target.Name == null) - { - await ctx.Reply(noNameSetMessage); - return; - } + if (target.Name == null) + { + await ctx.Reply(noNameSetMessage); + return; + } if (format == ReplyFormat.Raw) { @@ -61,37 +57,40 @@ public class SystemEdit return; } - if (!ctx.HasNext(false)) - { - await ctx.Reply( - $"{(isOwnSystem ? "Your" : "This")} system's name is currently **{target.Name}**." - + (isOwnSystem ? $" Type `{ctx.DefaultPrefix}system name -clear` to clear it." - + $" Using {target.Name.Length}/{Limits.MaxSystemNameLength} characters." : "")); - return; - } + await ctx.Reply( + $"{(isOwnSystem ? "Your" : "This")} system's name is currently **{target.Name}**." + + (isOwnSystem ? $" Type `{ctx.DefaultPrefix}system name -clear` to clear it." + + $" Using {target.Name.Length}/{Limits.MaxSystemNameLength} characters." : "")); + return; + } + public async Task ClearName(Context ctx, PKSystem target, bool flagConfirmYes) + { + ctx.CheckSystemPrivacy(target.Id, target.NamePrivacy); ctx.CheckSystem().CheckOwnSystem(target); - if (ctx.MatchClear() && await ctx.ConfirmClear("your system's name")) + if (await ctx.ConfirmClear("your system's name", flagConfirmYes)) { await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Name = null }); await ctx.Reply($"{Emojis.Success} System name cleared."); } - else - { - var newSystemName = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); - - if (newSystemName.Length > Limits.MaxSystemNameLength) - throw Errors.StringTooLongError("System name", newSystemName.Length, Limits.MaxSystemNameLength); - - await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Name = newSystemName }); - - await ctx.Reply($"{Emojis.Success} System name changed (using {newSystemName.Length}/{Limits.MaxSystemNameLength} characters)."); - } } - public async Task ServerName(Context ctx, PKSystem target) + public async Task Rename(Context ctx, PKSystem target, string newSystemName) + { + ctx.CheckSystemPrivacy(target.Id, target.NamePrivacy); + ctx.CheckSystem().CheckOwnSystem(target); + + if (newSystemName.Length > Limits.MaxSystemNameLength) + throw Errors.StringTooLongError("System name", newSystemName.Length, Limits.MaxSystemNameLength); + + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Name = newSystemName }); + + await ctx.Reply($"{Emojis.Success} System name changed (using {newSystemName.Length}/{Limits.MaxSystemNameLength} characters)."); + } + + public async Task ShowServerName(Context ctx, PKSystem target, ReplyFormat format) { ctx.CheckGuildContext(); @@ -103,15 +102,11 @@ public class SystemEdit var settings = await ctx.Repository.GetSystemGuild(ctx.Guild.Id, target.Id); - var format = ctx.MatchFormat(); - - // if there's nothing next or what's next is "raw"/"plaintext" we're doing a query, so check for null - if (!ctx.HasNext(false) || format != ReplyFormat.Standard) - if (settings.DisplayName == null) - { - await ctx.Reply(noNameSetMessage); - return; - } + if (settings.DisplayName == null) + { + await ctx.Reply(noNameSetMessage); + return; + } if (format == ReplyFormat.Raw) { @@ -126,37 +121,53 @@ public class SystemEdit return; } - if (!ctx.HasNext(false)) - { - await ctx.Reply( - $"{(isOwnSystem ? "Your" : "This")} system's name for this server is currently **{settings.DisplayName}**." - + (isOwnSystem ? $" Type `{ctx.DefaultPrefix}system servername -clear` to clear it." - + $" Using {settings.DisplayName.Length}/{Limits.MaxSystemNameLength} characters." : "")); - return; - } + await ctx.Reply( + $"{(isOwnSystem ? "Your" : "This")} system's name for this server is currently **{settings.DisplayName}**." + + (isOwnSystem ? $" Type `{ctx.DefaultPrefix}system servername -clear` to clear it." + + $" Using {settings.DisplayName.Length}/{Limits.MaxSystemNameLength} characters." : "")); + return; + } + public async Task ClearServerName(Context ctx, PKSystem target, bool flagConfirmYes) + { + ctx.CheckGuildContext(); ctx.CheckSystem().CheckOwnSystem(target); - if (ctx.MatchClear() && await ctx.ConfirmClear("your system's name for this server")) + if (await ctx.ConfirmClear("your system's name for this server", flagConfirmYes)) { await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id, new SystemGuildPatch { DisplayName = null }); await ctx.Reply($"{Emojis.Success} System name for this server cleared."); } - else + } + + public async Task RenameServerName(Context ctx, PKSystem target, string newSystemGuildName) + { + ctx.CheckGuildContext(); + ctx.CheckSystem().CheckOwnSystem(target); + + if (newSystemGuildName.Length > Limits.MaxSystemNameLength) + throw Errors.StringTooLongError("System name for this server", newSystemGuildName.Length, Limits.MaxSystemNameLength); + + await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id, new SystemGuildPatch { DisplayName = newSystemGuildName }); + + await ctx.Reply($"{Emojis.Success} System name for this server changed (using {newSystemGuildName.Length}/{Limits.MaxSystemNameLength} characters)."); + } + + public async Task ClearDescription(Context ctx, PKSystem target, bool flagConfirmYes) + { + ctx.CheckSystemPrivacy(target.Id, target.DescriptionPrivacy); + ctx.CheckSystem().CheckOwnSystem(target); + + if (await ctx.ConfirmClear("your system's description", flagConfirmYes)) { - var newSystemGuildName = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Description = null }); - if (newSystemGuildName.Length > Limits.MaxSystemNameLength) - throw Errors.StringTooLongError("System name for this server", newSystemGuildName.Length, Limits.MaxSystemNameLength); - - await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id, new SystemGuildPatch { DisplayName = newSystemGuildName }); - - await ctx.Reply($"{Emojis.Success} System name for this server changed (using {newSystemGuildName.Length}/{Limits.MaxSystemNameLength} characters)."); + await ctx.Reply($"{Emojis.Success} System description cleared."); } } - public async Task Description(Context ctx, PKSystem target) + public async Task ShowDescription(Context ctx, PKSystem target, ReplyFormat format) { ctx.CheckSystemPrivacy(target.Id, target.DescriptionPrivacy); @@ -166,15 +177,11 @@ public class SystemEdit if (isOwnSystem) noDescriptionSetMessage += $" To set one, type `{ctx.DefaultPrefix}s description `."; - var format = ctx.MatchFormat(); - - // if there's nothing next or what's next is "raw"/"plaintext" we're doing a query, so check for null - if (!ctx.HasNext(false) || format != ReplyFormat.Standard) - if (target.Description == null) - { - await ctx.Reply(noDescriptionSetMessage); - return; - } + if (target.Description == null) + { + await ctx.Reply(noDescriptionSetMessage); + return; + } if (format == ReplyFormat.Raw) { @@ -189,94 +196,148 @@ public class SystemEdit return; } - if (!ctx.HasNext(false)) - { - await ctx.Reply(embed: new EmbedBuilder() - .Title("System description") - .Description(target.Description) - .Footer(new Embed.EmbedFooter( - $"To print the description with formatting, type `{ctx.DefaultPrefix}s description -raw`." - + (isOwnSystem ? $" To clear it, type `{ctx.DefaultPrefix}s description -clear`. To change it, type `{ctx.DefaultPrefix}s description `." - + $" Using {target.Description.Length}/{Limits.MaxDescriptionLength} characters." : ""))) - .Build()); - return; - } - - ctx.CheckSystem().CheckOwnSystem(target); - - if (ctx.MatchClear() && await ctx.ConfirmClear("your system's description")) - { - await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Description = null }); - - await ctx.Reply($"{Emojis.Success} System description cleared."); - } - else - { - var newDescription = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); - if (newDescription.Length > Limits.MaxDescriptionLength) - throw Errors.StringTooLongError("Description", newDescription.Length, Limits.MaxDescriptionLength); - - await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Description = newDescription }); - - await ctx.Reply($"{Emojis.Success} System description changed (using {newDescription.Length}/{Limits.MaxDescriptionLength} characters)."); - } + await ctx.Reply(embed: new EmbedBuilder() + .Title("System description") + .Description(target.Description) + .Footer(new Embed.EmbedFooter( + $"To print the description with formatting, type `{ctx.DefaultPrefix}s description -raw`." + + (isOwnSystem ? $" To clear it, type `{ctx.DefaultPrefix}s description -clear`. To change it, type `{ctx.DefaultPrefix}s description `." + + $" Using {target.Description.Length}/{Limits.MaxDescriptionLength} characters." : ""))) + .Build()); } - public async Task Color(Context ctx, PKSystem target) + public async Task ChangeDescription(Context ctx, PKSystem target, string newDescription) { - var isOwnSystem = ctx.System?.Id == target.Id; - var matchedFormat = ctx.MatchFormat(); - var matchedClear = ctx.MatchClear(); - - if (!isOwnSystem || !(ctx.HasNext() || matchedClear)) - { - if (target.Color == null) - await ctx.Reply( - "This system does not have a color set." + (isOwnSystem ? $" To set one, type `{ctx.DefaultPrefix}system color `." : "")); - else if (matchedFormat == ReplyFormat.Raw) - await ctx.Reply("```\n#" + target.Color + "\n```"); - else if (matchedFormat == ReplyFormat.Plaintext) - await ctx.Reply(target.Color); - else - await ctx.Reply(embed: new EmbedBuilder() - .Title("System color") - .Color(target.Color.ToDiscordColor()) - .Thumbnail(new Embed.EmbedThumbnail($"attachment://color.gif")) - .Description( - $"This system's color is **#{target.Color}**." + (isOwnSystem ? $" To clear it, type `{ctx.DefaultPrefix}s color -clear`." : "")) - .Build(), - files: [MiscUtils.GenerateColorPreview(target.Color)]); - return; - } - + ctx.CheckSystemPrivacy(target.Id, target.DescriptionPrivacy); ctx.CheckSystem().CheckOwnSystem(target); - if (matchedClear) + newDescription = newDescription.NormalizeLineEndSpacing(); + if (newDescription.Length > Limits.MaxDescriptionLength) + throw Errors.StringTooLongError("Description", newDescription.Length, Limits.MaxDescriptionLength); + + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Description = newDescription }); + + await ctx.Reply($"{Emojis.Success} System description changed (using {newDescription.Length}/{Limits.MaxDescriptionLength} characters)."); + } + + public async Task ChangeColor(Context ctx, PKSystem target, string newColor) + { + ctx.CheckSystem().CheckOwnSystem(target); + + if (newColor.StartsWith("#")) newColor = newColor.Substring(1); + if (!Regex.IsMatch(newColor, "^[0-9a-fA-F]{6}$")) throw Errors.InvalidColorError(newColor); + + await ctx.Repository.UpdateSystem(target.Id, + new SystemPatch { Color = Partial.Present(newColor.ToLowerInvariant()) }); + + await ctx.Reply(embed: new EmbedBuilder() + .Title($"{Emojis.Success} System color changed.") + .Color(newColor.ToDiscordColor()) + .Thumbnail(new Embed.EmbedThumbnail($"attachment://color.gif")) + .Build(), + files: [MiscUtils.GenerateColorPreview(newColor)] + ); + } + + public async Task ClearColor(Context ctx, PKSystem target, bool flagConfirmYes) + { + ctx.CheckSystem().CheckOwnSystem(target); + + if (await ctx.ConfirmClear("your system's color", flagConfirmYes)) { await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Color = Partial.Null() }); - await ctx.Reply($"{Emojis.Success} System color cleared."); } - else - { - var color = ctx.RemainderOrNull(); - - if (color.StartsWith("#")) color = color.Substring(1); - if (!Regex.IsMatch(color, "^[0-9a-fA-F]{6}$")) throw Errors.InvalidColorError(color); - - await ctx.Repository.UpdateSystem(target.Id, - new SystemPatch { Color = Partial.Present(color.ToLowerInvariant()) }); - - await ctx.Reply(embed: new EmbedBuilder() - .Title($"{Emojis.Success} System color changed.") - .Color(color.ToDiscordColor()) - .Thumbnail(new Embed.EmbedThumbnail($"attachment://color.gif")) - .Build(), - files: [MiscUtils.GenerateColorPreview(color)]); - } } - public async Task Tag(Context ctx, PKSystem target) + public async Task ShowColor(Context ctx, PKSystem target, ReplyFormat format) + { + var isOwnSystem = ctx.System?.Id == target.Id; + + if (target.Color == null) + { + await ctx.Reply( + "This system does not have a color set." + (isOwnSystem ? $" To set one, type `{ctx.DefaultPrefix}system color `." : "")); + return; + } + + if (format == ReplyFormat.Raw) + { + await ctx.Reply("```\n#" + target.Color + "\n```"); + return; + } + + if (format == ReplyFormat.Plaintext) + { + await ctx.Reply(target.Color); + return; + } + + await ctx.Reply(embed: new EmbedBuilder() + .Title("System color") + .Color(target.Color.ToDiscordColor()) + .Thumbnail(new Embed.EmbedThumbnail($"attachment://color.gif")) + .Description( + $"This system's color is **#{target.Color}**." + (isOwnSystem ? $" To clear it, type `{ctx.DefaultPrefix}s color -clear`." : "")) + .Build(), + files: [MiscUtils.GenerateColorPreview(target.Color)]); + } + + public async Task ClearTag(Context ctx, PKSystem target, bool flagConfirmYes) + { + ctx.CheckSystem().CheckOwnSystem(target); + + if (await ctx.ConfirmClear("your system's tag", flagConfirmYes)) + { + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Tag = null }); + + var replyStr = $"{Emojis.Success} System tag cleared."; + + if (ctx.Guild != null) + { + var servertag = (await ctx.Repository.GetSystemGuild(ctx.Guild.Id, target.Id)).Tag; + if (servertag is not null) + replyStr += $"\n{Emojis.Note} You have a server tag set in this server ({servertag}) so it will still be shown on proxies."; + else if (ctx.GuildConfig.RequireSystemTag) + replyStr += $"\n{Emojis.Warn} This server requires a tag in order to proxy. If you do not add a new tag you will not be able to proxy in this server."; + } + + await ctx.Reply(replyStr); + } + } + + public async Task ChangeTag(Context ctx, PKSystem target, string newTag) + { + ctx.CheckSystem().CheckOwnSystem(target); + + newTag = newTag.NormalizeLineEndSpacing(); + if (newTag.Length > Limits.MaxSystemTagLength) + throw Errors.StringTooLongError("System tag", newTag.Length, Limits.MaxSystemTagLength); + + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Tag = newTag }); + + var replyStr = $"{Emojis.Success} System tag changed (using {newTag.Length}/{Limits.MaxSystemTagLength} characters)."; + if (ctx.Config.NameFormat is null || ctx.Config.NameFormat.Contains("{tag}")) + replyStr += $"Member names will now have the tag {newTag.AsCode()} when proxied.\n{Emojis.Note}To check or change where your tag appears in your name use the command `{ctx.DefaultPrefix}cfg name format`."; + else + replyStr += $"\n{Emojis.Warn} You do not have a designated place for a tag in your name format so it **will not be put in proxy names**. To change this type `{ctx.DefaultPrefix}cfg name format`."; + + if (ctx.Guild != null) + { + var guildSettings = await ctx.Repository.GetSystemGuild(ctx.Guild.Id, target.Id); + + if (guildSettings.Tag is not null) + replyStr += $"\n{Emojis.Note} Note that you have a server tag set ({guildSettings.Tag}) and it will be shown in proxies instead."; + if (!guildSettings.TagEnabled) + replyStr += $"\n{Emojis.Note} Note that your tag is disabled in this server and will not be shown in proxies. To change this type `{ctx.DefaultPrefix}system servertag enable`."; + if (guildSettings.NameFormat is not null && !guildSettings.NameFormat.Contains("{tag}")) + replyStr += $"\n{Emojis.Note} You do not have a designated place for a tag in your server name format so it **will not be put in proxy names**. To change this type `{ctx.DefaultPrefix}cfg server name format`."; + } + + await ctx.Reply(replyStr); + } + + public async Task ShowTag(Context ctx, PKSystem target, ReplyFormat format) { var isOwnSystem = ctx.System?.Id == target.Id; @@ -284,15 +345,11 @@ public class SystemEdit ? $"You currently have no system tag set. To set one, type `{ctx.DefaultPrefix}s tag `." : "This system currently has no system tag set."; - var format = ctx.MatchFormat(); - - // if there's nothing next or what's next is "raw"/"plaintext" we're doing a query, so check for null - if (!ctx.HasNext(false) || format != ReplyFormat.Standard) - if (target.Tag == null) - { - await ctx.Reply(noTagSetMessage); - return; - } + if (target.Tag == null) + { + await ctx.Reply(noTagSetMessage); + return; + } if (format == ReplyFormat.Raw) { @@ -307,214 +364,168 @@ public class SystemEdit return; } - if (!ctx.HasNext(false)) - { - await ctx.Reply($"{(isOwnSystem ? "Your" : "This system's")} current system tag is {target.Tag.AsCode()}." - + (isOwnSystem ? $"To change it, type `{ctx.DefaultPrefix}s tag `. To clear it, type `{ctx.DefaultPrefix}s tag -clear`." : "")); - return; - } - - ctx.CheckSystem().CheckOwnSystem(target); - - if (ctx.MatchClear() && await ctx.ConfirmClear("your system's tag")) - { - await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Tag = null }); - - var replyStr = $"{Emojis.Success} System tag cleared."; - - if (ctx.Guild != null) - { - var servertag = (await ctx.Repository.GetSystemGuild(ctx.Guild.Id, target.Id)).Tag; - if (servertag is not null) - replyStr += $"\n{Emojis.Note} You have a server tag set in this server ({servertag}) so it will still be shown on proxies."; - - else if (ctx.GuildConfig.RequireSystemTag) - replyStr += $"\n{Emojis.Warn} This server requires a tag in order to proxy. If you do not add a new tag you will not be able to proxy in this server."; - } - - await ctx.Reply(replyStr); - } - else - { - var newTag = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); - if (newTag != null) - if (newTag.Length > Limits.MaxSystemTagLength) - throw Errors.StringTooLongError("System tag", newTag.Length, Limits.MaxSystemTagLength); - - await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Tag = newTag }); - - var replyStr = $"{Emojis.Success} System tag changed (using {newTag.Length}/{Limits.MaxSystemTagLength} characters)."; - if (ctx.Config.NameFormat is null || ctx.Config.NameFormat.Contains("{tag}")) - replyStr += $"Member names will now have the tag {newTag.AsCode()} when proxied.\n{Emojis.Note}To check or change where your tag appears in your name use the command `{ctx.DefaultPrefix}cfg name format`."; - else - replyStr += $"\n{Emojis.Warn} You do not have a designated place for a tag in your name format so it **will not be put in proxy names**. To change this type `{ctx.DefaultPrefix}cfg name format`."; - if (ctx.Guild != null) - { - var guildSettings = await ctx.Repository.GetSystemGuild(ctx.Guild.Id, target.Id); - - if (guildSettings.Tag is not null) - replyStr += $"\n{Emojis.Note} Note that you have a server tag set ({guildSettings.Tag}) and it will be shown in proxies instead."; - if (!guildSettings.TagEnabled) - replyStr += $"\n{Emojis.Note} Note that your tag is disabled in this server and will not be shown in proxies. To change this type `{ctx.DefaultPrefix}system servertag enable`."; - if (guildSettings.NameFormat is not null && !guildSettings.NameFormat.Contains("{tag}")) - replyStr += $"\n{Emojis.Note} You do not have a designated place for a tag in your server name format so it **will not be put in proxy names**. To change this type `{ctx.DefaultPrefix}cfg server name format`."; - } - - await ctx.Reply(replyStr); - } + await ctx.Reply($"{(isOwnSystem ? "Your" : "This system's")} current system tag is {target.Tag.AsCode()}." + + (isOwnSystem ? $"To change it, type `{ctx.DefaultPrefix}s tag `. To clear it, type `{ctx.DefaultPrefix}s tag -clear`." : "")); } - public async Task ServerTag(Context ctx, PKSystem target) + public async Task ShowServerTag(Context ctx, PKSystem target, ReplyFormat format) { ctx.CheckSystem().CheckOwnSystem(target).CheckGuildContext(); - var setDisabledWarning = - $"{Emojis.Warn} Your system tag is currently **disabled** in this server. No tag will be applied when proxying.\nTo re-enable the system tag in the current server, type `{ctx.DefaultPrefix}s servertag -enable`."; - var settings = await ctx.Repository.GetSystemGuild(ctx.Guild.Id, target.Id); - async Task Show(ReplyFormat format = ReplyFormat.Standard) + if (settings.Tag != null) { - if (settings.Tag != null) + if (format == ReplyFormat.Raw) { - if (format == ReplyFormat.Raw) - { - await ctx.Reply($"```{settings.Tag}```"); - return; - } - if (format == ReplyFormat.Plaintext) - { - var eb = new EmbedBuilder() - .Description($"Showing servertag for system `{target.DisplayHid(ctx.Config)}`"); - await ctx.Reply(settings.Tag, embed: eb.Build()); - return; - } - - var msg = $"Your current system tag in '{ctx.Guild.Name}' is {settings.Tag.AsCode()}"; - if (!settings.TagEnabled) - msg += $", but it is currently **disabled**. To re-enable it, type `{ctx.DefaultPrefix}s servertag -enable`."; - else - msg += - $". To change it, type `{ctx.DefaultPrefix}s servertag `. To clear it, type `{ctx.DefaultPrefix}s servertag -clear`."; - - await ctx.Reply(msg); + await ctx.Reply($"```{settings.Tag}```"); + return; + } + if (format == ReplyFormat.Plaintext) + { + var eb = new EmbedBuilder() + .Description($"Showing servertag for system `{target.DisplayHid(ctx.Config)}`"); + await ctx.Reply(settings.Tag, embed: eb.Build()); return; } + var msg = $"Your current system tag in '{ctx.Guild.Name}' is {settings.Tag.AsCode()}"; if (!settings.TagEnabled) - await ctx.Reply( - $"Your global system tag is {target.Tag}, but it is **disabled** in this server. To re-enable it, type `{ctx.DefaultPrefix}s servertag -enable`"); + msg += $", but it is currently **disabled**. To re-enable it, type `{ctx.DefaultPrefix}s servertag -enable`."; else - await ctx.Reply( - $"You currently have no system tag specific to the server '{ctx.Guild.Name}'. To set one, type `{ctx.DefaultPrefix}s servertag `. To disable the system tag in the current server, type `{ctx.DefaultPrefix}s servertag -disable`."); + msg += + $". To change it, type `{ctx.DefaultPrefix}s servertag `. To clear it, type `{ctx.DefaultPrefix}s servertag -clear`."; + + await ctx.Reply(msg); + return; } - async Task Set() - { - var newTag = ctx.RemainderOrNull(false); - if (newTag != null && newTag.Length > Limits.MaxSystemTagLength) - throw Errors.StringTooLongError("System server tag", newTag.Length, Limits.MaxSystemTagLength); - - await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id, new SystemGuildPatch { Tag = newTag }); - - var replyStr = $"{Emojis.Success} System server tag changed (using {newTag.Length}/{Limits.MaxSystemTagLength} characters). Member names will now have the tag {newTag.AsCode()} when proxied in the current server '{ctx.Guild.Name}'.\n\nTo check or change where your tag appears in your name use the command `{ctx.DefaultPrefix}cfg name format`."; - - if (!settings.TagEnabled) - replyStr += "\n" + setDisabledWarning; - - await ctx.Reply(replyStr); - } - - async Task Clear() - { - await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id, new SystemGuildPatch { Tag = null }); - - var replyStr = $"{Emojis.Success} System server tag cleared. Member names will now use the global system tag, if there is one set.\n\nTo check or change where your tag appears in your name use the command `{ctx.DefaultPrefix}cfg name format`."; - - if (!settings.TagEnabled) - replyStr += "\n" + setDisabledWarning; - - await ctx.Reply(replyStr); - } - - async Task EnableDisable(bool newValue) - { - await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id, - new SystemGuildPatch { TagEnabled = newValue }); - - await ctx.Reply(PrintEnableDisableResult(newValue, newValue != settings.TagEnabled)); - } - - string PrintEnableDisableResult(bool newValue, bool changedValue) - { - var opStr = newValue ? "enabled" : "disabled"; - var str = ""; - - if (!changedValue) - str = $"{Emojis.Note} The system tag is already {opStr} in this server."; - else - str = $"{Emojis.Success} System tag {opStr} in this server."; - - if (newValue) - { - if (settings.TagEnabled) - { - if (settings.Tag == null) - str += - " However, you do not have a system tag specific to this server. Messages will be proxied using your global system tag, if there is one set."; - else - str += - $" Your current system tag in '{ctx.Guild.Name}' is {settings.Tag.AsCode()}."; - } - else - { - if (settings.Tag != null) - str += - $" Member names will now use the server-specific tag {settings.Tag.AsCode()} when proxied in the current server '{ctx.Guild.Name}'." - + $"\n\nTo check or change where your tag appears in your name use the command `{ctx.DefaultPrefix}cfg name format`."; - else - str += - " Member names will now use the global system tag when proxied in the current server, if there is one set." - + $"\n\nTo check or change where your tag appears in your name use the command `{ctx.DefaultPrefix}cfg name format`."; - } - } - - return str; - } - - if (ctx.MatchClear() && await ctx.ConfirmClear("your system's server tag")) - await Clear(); - else if (ctx.Match("disable") || ctx.MatchFlag("disable")) - await EnableDisable(false); - else if (ctx.Match("enable") || ctx.MatchFlag("enable")) - await EnableDisable(true); - else if (ctx.MatchFormat() != ReplyFormat.Standard) - await Show(ctx.MatchFormat()); - else if (!ctx.HasNext(false)) - await Show(); + if (!settings.TagEnabled) + await ctx.Reply( + $"Your global system tag is {target.Tag}, but it is **disabled** in this server. To re-enable it, type `{ctx.DefaultPrefix}s servertag -enable`"); else - await Set(); + await ctx.Reply( + $"You currently have no system tag specific to the server '{ctx.Guild.Name}'. To set one, type `{ctx.DefaultPrefix}s servertag `. To disable the system tag in the current server, type `{ctx.DefaultPrefix}s servertag -disable`."); } - public async Task Pronouns(Context ctx, PKSystem target) + public async Task ClearServerTag(Context ctx, PKSystem target, bool flagConfirmYes) + { + ctx.CheckSystem().CheckOwnSystem(target).CheckGuildContext(); + + if (!await ctx.ConfirmClear("your system's server tag", flagConfirmYes)) + return; + + var settings = await ctx.Repository.GetSystemGuild(ctx.Guild.Id, target.Id); + await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id, new SystemGuildPatch { Tag = null }); + + var replyStr = $"{Emojis.Success} System server tag cleared. Member names will now use the global system tag, if there is one set.\n\nTo check or change where your tag appears in your name use the command `{ctx.DefaultPrefix}cfg name format`."; + + if (!settings.TagEnabled) + replyStr += $"\n{Emojis.Warn} Your system tag is currently **disabled** in this server. No tag will be applied when proxying.\nTo re-enable the system tag in the current server, type `{ctx.DefaultPrefix}s servertag -enable`."; + + await ctx.Reply(replyStr); + } + + public async Task ChangeServerTag(Context ctx, PKSystem target, string newTag) + { + ctx.CheckSystem().CheckOwnSystem(target).CheckGuildContext(); + + var settings = await ctx.Repository.GetSystemGuild(ctx.Guild.Id, target.Id); + + if (newTag != null && newTag.Length > Limits.MaxSystemTagLength) + throw Errors.StringTooLongError("System server tag", newTag.Length, Limits.MaxSystemTagLength); + + await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id, new SystemGuildPatch { Tag = newTag }); + + var replyStr = $"{Emojis.Success} System server tag changed (using {newTag.Length}/{Limits.MaxSystemTagLength} characters). Member names will now have the tag {newTag.AsCode()} when proxied in the current server '{ctx.Guild.Name}'.\n\nTo check or change where your tag appears in your name use the command `{ctx.DefaultPrefix}cfg name format`."; + + if (!settings.TagEnabled) + replyStr += $"\n{Emojis.Warn} Your system tag is currently **disabled** in this server. No tag will be applied when proxying.\nTo re-enable the system tag in the current server, type `{ctx.DefaultPrefix}s servertag -enable`."; + + await ctx.Reply(replyStr); + } + + public async Task ToggleServerTag(Context ctx, PKSystem target, bool newValue) + { + ctx.CheckSystem().CheckOwnSystem(target).CheckGuildContext(); + + var settings = await ctx.Repository.GetSystemGuild(ctx.Guild.Id, target.Id); + await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id, new SystemGuildPatch { TagEnabled = newValue }); + + var opStr = newValue ? "enabled" : "disabled"; + string str; + + if (newValue == settings.TagEnabled) + str = $"{Emojis.Note} The system tag is already {opStr} in this server."; + else + str = $"{Emojis.Success} System tag {opStr} in this server."; + + if (newValue) + { + if (settings.TagEnabled) + { + if (settings.Tag == null) + str += " However, you do not have a system tag specific to this server. Messages will be proxied using your global system tag, if there is one set."; + else + str += $" Your current system tag in '{ctx.Guild.Name}' is {settings.Tag.AsCode()}."; + } + else + { + if (settings.Tag != null) + str += + $" Member names will now use the server-specific tag {settings.Tag.AsCode()} when proxied in the current server '{ctx.Guild.Name}'." + + $"\n\nTo check or change where your tag appears in your name use the command `{ctx.DefaultPrefix}cfg name format`."; + else + str += + " Member names will now use the global system tag when proxied in the current server, if there is one set." + + $"\n\nTo check or change where your tag appears in your name use the command `{ctx.DefaultPrefix}cfg name format`."; + } + } + + await ctx.Reply(str); + } + + public async Task ClearPronouns(Context ctx, PKSystem target, bool flagConfirmYes) + { + ctx.CheckSystemPrivacy(target.Id, target.PronounPrivacy); + ctx.CheckSystem().CheckOwnSystem(target); + + if (await ctx.ConfirmClear("your system's pronouns", flagConfirmYes)) + { + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Pronouns = null }); + await ctx.Reply($"{Emojis.Success} System pronouns cleared."); + } + } + + public async Task ChangePronouns(Context ctx, PKSystem target, string newPronouns) + { + ctx.CheckSystemPrivacy(target.Id, target.PronounPrivacy); + ctx.CheckSystem().CheckOwnSystem(target); + + newPronouns = newPronouns.NormalizeLineEndSpacing(); + if (newPronouns.Length > Limits.MaxPronounsLength) + throw Errors.StringTooLongError("Pronouns", newPronouns.Length, Limits.MaxPronounsLength); + + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Pronouns = newPronouns }); + + await ctx.Reply($"{Emojis.Success} System pronouns changed (using {newPronouns.Length}/{Limits.MaxPronounsLength} characters)."); + } + + public async Task ShowPronouns(Context ctx, PKSystem target, ReplyFormat format) { ctx.CheckSystemPrivacy(target.Id, target.PronounPrivacy); var isOwnSystem = ctx.System.Id == target.Id; - var noPronounsSetMessage = "This system does not have pronouns set."; - if (isOwnSystem) - noPronounsSetMessage += $" To set some, type `{ctx.DefaultPrefix}system pronouns `"; + if (target.Pronouns == null) + { + var noPronounsSetMessage = "This system does not have pronouns set."; + if (isOwnSystem) + noPronounsSetMessage += $" To set some, type `{ctx.DefaultPrefix}system pronouns `"; - var format = ctx.MatchFormat(); - - // if there's nothing next or what's next is "raw"/"plaintext" we're doing a query, so check for null - if (!ctx.HasNext(false) || format != ReplyFormat.Standard) - if (target.Pronouns == null) - { - await ctx.Reply(noPronounsSetMessage); - return; - } + await ctx.Reply(noPronounsSetMessage); + return; + } if (format == ReplyFormat.Raw) { @@ -529,286 +540,246 @@ public class SystemEdit return; } - if (!ctx.HasNext(false)) - { - await ctx.Reply($"{(isOwnSystem ? "Your" : "This system's")} current pronouns are **{target.Pronouns}**.\nTo print the pronouns with formatting, type `{ctx.DefaultPrefix}system pronouns -raw`." + await ctx.Reply($"{(isOwnSystem ? "Your" : "This system's")} current pronouns are **{target.Pronouns}**.\nTo print the pronouns with formatting, type `{ctx.DefaultPrefix}system pronouns -raw`." + (isOwnSystem ? $" To clear them, type `{ctx.DefaultPrefix}system pronouns -clear`." + $" Using {target.Pronouns.Length}/{Limits.MaxPronounsLength} characters." : "")); - return; - } - - ctx.CheckSystem().CheckOwnSystem(target); - - if (ctx.MatchClear() && await ctx.ConfirmClear("your system's pronouns")) - { - await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Pronouns = null }); - - await ctx.Reply($"{Emojis.Success} System pronouns cleared."); - } - else - { - var newPronouns = ctx.RemainderOrNull(false).NormalizeLineEndSpacing(); - if (newPronouns != null) - if (newPronouns.Length > Limits.MaxPronounsLength) - throw Errors.StringTooLongError("Pronouns", newPronouns.Length, Limits.MaxPronounsLength); - - await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Pronouns = newPronouns }); - - await ctx.Reply( - $"{Emojis.Success} System pronouns changed (using {newPronouns.Length}/{Limits.MaxPronounsLength} characters)."); - } } - public async Task Avatar(Context ctx, PKSystem target) + public async Task ClearAvatar(Context ctx, PKSystem target, bool flagConfirmYes) { - async Task ClearIcon() - { - ctx.CheckOwnSystem(target); + ctx.CheckSystemPrivacy(target.Id, target.AvatarPrivacy); + ctx.CheckSystem().CheckOwnSystem(target); + if (await ctx.ConfirmClear("your system's icon", flagConfirmYes)) + { await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { AvatarUrl = null }); await ctx.Reply($"{Emojis.Success} System icon cleared."); } - - async Task SetIcon(ParsedImage img) - { - ctx.CheckOwnSystem(target); - - img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System); - await _avatarHosting.VerifyAvatarOrThrow(img.Url); - - await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { AvatarUrl = img.CleanUrl ?? img.Url }); - - var msg = img.Source switch - { - AvatarSource.User => - $"{Emojis.Success} System icon changed to {img.SourceUser?.Username}'s avatar!\n{Emojis.Warn} If {img.SourceUser?.Username} changes their avatar, the system icon will need to be re-set.", - AvatarSource.Url => $"{Emojis.Success} System icon changed to the image at the given URL.", - AvatarSource.HostedCdn => $"{Emojis.Success} System icon changed to attached image.", - AvatarSource.Attachment => - $"{Emojis.Success} System icon changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the system icon will stop working.", - _ => throw new ArgumentOutOfRangeException() - }; - - // The attachment's already right there, no need to preview it. - var hasEmbed = img.Source != AvatarSource.Attachment && img.Source != AvatarSource.HostedCdn; - await (hasEmbed - ? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build()) - : ctx.Reply(msg)); - } - - async Task ShowIcon() - { - if ((target.AvatarUrl?.Trim() ?? "").Length > 0) - { - if (!target.AvatarPrivacy.CanAccess(ctx.DirectLookupContextFor(target.Id))) - throw new PKSyntaxError("This system does not have an icon set or it is private."); - switch (ctx.MatchFormat()) - { - case ReplyFormat.Raw: - await ctx.Reply($"`{target.AvatarUrl.TryGetCleanCdnUrl()}`"); - break; - case ReplyFormat.Plaintext: - var ebP = new EmbedBuilder() - .Description($"Showing icon for system {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)"); - await ctx.Reply(text: $"<{target.AvatarUrl.TryGetCleanCdnUrl()}>", embed: ebP.Build()); - break; - default: - var ebS = new EmbedBuilder() - .Title("System icon") - .Image(new Embed.EmbedImage(target.AvatarUrl.TryGetCleanCdnUrl())); - if (target.Id == ctx.System?.Id) - ebS.Description($"To clear, use `{ctx.DefaultPrefix}system icon clear`."); - await ctx.Reply(embed: ebS.Build()); - break; - } - } - else - { - var isOwner = target.Id == ctx.System?.Id; - throw new PKSyntaxError( - $"This system does not have an icon set{(isOwner ? "" : " or it is private")}." - + (isOwner ? " Set one by attaching an image to this command, or by passing an image URL or @mention." : "")); - } - } - - if (target != null && target?.Id != ctx.System?.Id) - { - await ShowIcon(); - return; - } - - if (ctx.MatchClear() && await ctx.ConfirmClear("your system's icon")) - await ClearIcon(); - else if (await ctx.MatchImage() is { } img) - await SetIcon(img); - else - await ShowIcon(); } - public async Task ServerAvatar(Context ctx, PKSystem target) + public async Task ShowAvatar(Context ctx, PKSystem target, ReplyFormat format) { - - async Task ClearIcon() - { - ctx.CheckOwnSystem(target); - - await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id, new SystemGuildPatch { AvatarUrl = null }); - await ctx.Reply($"{Emojis.Success} System server avatar cleared."); - } - - async Task SetIcon(ParsedImage img) - { - ctx.CheckOwnSystem(target); - - img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System); - await _avatarHosting.VerifyAvatarOrThrow(img.Url); - - await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id, new SystemGuildPatch { AvatarUrl = img.CleanUrl ?? img.Url }); - - var msg = img.Source switch - { - AvatarSource.User => - $"{Emojis.Success} System icon for this server changed to {img.SourceUser?.Username}'s avatar! It will now be used for anything that uses system avatar in this server.\n{Emojis.Warn} If {img.SourceUser?.Username} changes their avatar, the system icon for this server will need to be re-set.", - AvatarSource.Url => - $"{Emojis.Success} System icon for this server changed to the image at the given URL. It will now be used for anything that uses system avatar in this server.", - AvatarSource.HostedCdn => $"{Emojis.Success} System icon for this server changed to attached image.", - AvatarSource.Attachment => - $"{Emojis.Success} System icon for this server changed to attached image. It will now be used for anything that uses system avatar in this server.\n{Emojis.Warn} If you delete the message containing the attachment, the system icon for this server will stop working.", - _ => throw new ArgumentOutOfRangeException() - }; - - // The attachment's already right there, no need to preview it. - var hasEmbed = img.Source != AvatarSource.Attachment && img.Source != AvatarSource.HostedCdn; - await (hasEmbed - ? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build()) - : ctx.Reply(msg)); - } - - async Task ShowIcon() - { - var settings = await ctx.Repository.GetSystemGuild(ctx.Guild.Id, target.Id); - - if ((settings.AvatarUrl?.Trim() ?? "").Length > 0) - { - if (!target.AvatarPrivacy.CanAccess(ctx.DirectLookupContextFor(target.Id))) - throw new PKSyntaxError("This system does not have a icon specific to this server or it is private."); - switch (ctx.MatchFormat()) - { - case ReplyFormat.Raw: - await ctx.Reply($"`{settings.AvatarUrl.TryGetCleanCdnUrl()}`"); - break; - case ReplyFormat.Plaintext: - var ebP = new EmbedBuilder() - .Description($"Showing icon for system {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)"); - await ctx.Reply(text: $"<{settings.AvatarUrl.TryGetCleanCdnUrl()}>", embed: ebP.Build()); - break; - default: - var ebS = new EmbedBuilder() - .Title("System server icon") - .Image(new Embed.EmbedImage(settings.AvatarUrl.TryGetCleanCdnUrl())); - if (target.Id == ctx.System?.Id) - ebS.Description($"To clear, use `{ctx.DefaultPrefix}system servericon clear`."); - await ctx.Reply(embed: ebS.Build()); - break; - } - } - else - { - var isOwner = target.Id == ctx.System?.Id; - throw new PKSyntaxError( - $"This system does not have a icon specific to this server{(isOwner ? "" : " or it is private")}." - + (isOwner ? " Set one by attaching an image to this command, or by passing an image URL or @mention." : "")); - } - } - - ctx.CheckGuildContext(); - - if (target != null && target?.Id != ctx.System?.Id) - { - await ShowIcon(); - return; - } - - if (ctx.MatchClear() && await ctx.ConfirmClear("your system's icon for this server")) - await ClearIcon(); - else if (await ctx.MatchImage() is { } img) - await SetIcon(img); - else - await ShowIcon(); - } - - public async Task BannerImage(Context ctx, PKSystem target) - { - ctx.CheckSystemPrivacy(target.Id, target.BannerPrivacy); - + ctx.CheckSystemPrivacy(target.Id, target.AvatarPrivacy); var isOwnSystem = target.Id == ctx.System?.Id; - if ((!ctx.HasNext() && ctx.Message.Attachments.Length == 0) || ctx.PeekMatchFormat() != ReplyFormat.Standard) + if ((target.AvatarUrl?.Trim() ?? "").Length > 0) { - if ((target.BannerImage?.Trim() ?? "").Length > 0) - switch (ctx.MatchFormat()) - { - case ReplyFormat.Raw: - await ctx.Reply($"`{target.BannerImage.TryGetCleanCdnUrl()}`"); - break; - case ReplyFormat.Plaintext: - var ebP = new EmbedBuilder() - .Description($"Showing banner for system {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)"); - await ctx.Reply(text: $"<{target.BannerImage.TryGetCleanCdnUrl()}>", embed: ebP.Build()); - break; - default: - var ebS = new EmbedBuilder() - .Title("System banner image") - .Image(new Embed.EmbedImage(target.BannerImage.TryGetCleanCdnUrl())); - if (target.Id == ctx.System?.Id) - ebS.Description($"To clear, use `{ctx.DefaultPrefix}system banner clear`."); - await ctx.Reply(embed: ebS.Build()); - break; - } - else - throw new PKSyntaxError("This system does not have a banner image set." - + (isOwnSystem ? "Set one by attaching an image to this command, or by passing an image URL or @mention." : "")); + if (!target.AvatarPrivacy.CanAccess(ctx.DirectLookupContextFor(target.Id))) + throw new PKSyntaxError("This system does not have an icon set or it is private."); - return; + switch (format) + { + case ReplyFormat.Raw: + await ctx.Reply($"`{target.AvatarUrl.TryGetCleanCdnUrl()}`"); + break; + case ReplyFormat.Plaintext: + var ebP = new EmbedBuilder() + .Description($"Showing icon for system {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)"); + await ctx.Reply(text: $"<{target.AvatarUrl.TryGetCleanCdnUrl()}>", embed: ebP.Build()); + break; + default: + var ebS = new EmbedBuilder() + .Title("System icon") + .Image(new Embed.EmbedImage(target.AvatarUrl.TryGetCleanCdnUrl())); + if (target.Id == ctx.System?.Id) + ebS.Description($"To clear, use `{ctx.DefaultPrefix}system icon clear`."); + await ctx.Reply(embed: ebS.Build()); + break; + } } + else + { + throw new PKSyntaxError( + $"This system does not have an icon set{(isOwnSystem ? "" : " or it is private")}." + + (isOwnSystem ? " Set one by attaching an image to this command, or by passing an image URL or @mention." : "")); + } + } + public async Task ChangeAvatar(Context ctx, PKSystem target, ParsedImage img) + { + ctx.CheckSystemPrivacy(target.Id, target.AvatarPrivacy); ctx.CheckSystem().CheckOwnSystem(target); - if (ctx.MatchClear() && await ctx.ConfirmClear("your system's banner image")) + img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System); + await _avatarHosting.VerifyAvatarOrThrow(img.Url); + + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { AvatarUrl = img.CleanUrl ?? img.Url }); + + var msg = img.Source switch + { + AvatarSource.User => + $"{Emojis.Success} System icon changed to {img.SourceUser?.Username}'s avatar!\n{Emojis.Warn} If {img.SourceUser?.Username} changes their avatar, the system icon will need to be re-set.", + AvatarSource.Url => $"{Emojis.Success} System icon changed to the image at the given URL.", + AvatarSource.HostedCdn => $"{Emojis.Success} System icon changed to attached image.", + AvatarSource.Attachment => + $"{Emojis.Success} System icon changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the system icon will stop working.", + _ => throw new ArgumentOutOfRangeException() + }; + + // The attachment's already right there, no need to preview it. + var hasEmbed = img.Source != AvatarSource.Attachment && img.Source != AvatarSource.HostedCdn; + await (hasEmbed + ? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build()) + : ctx.Reply(msg)); + } + + public async Task ClearServerAvatar(Context ctx, PKSystem target, bool flagConfirmYes) + { + ctx.CheckGuildContext(); + ctx.CheckSystem().CheckOwnSystem(target); + + if (await ctx.ConfirmClear("your system's icon for this server", flagConfirmYes)) + { + await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id, new SystemGuildPatch { AvatarUrl = null }); + await ctx.Reply($"{Emojis.Success} System server icon cleared."); + } + } + + public async Task ShowServerAvatar(Context ctx, PKSystem target, ReplyFormat format) + { + ctx.CheckGuildContext(); + var isOwnSystem = target.Id == ctx.System?.Id; + + var settings = await ctx.Repository.GetSystemGuild(ctx.Guild.Id, target.Id); + + if ((settings.AvatarUrl?.Trim() ?? "").Length > 0) + { + if (!target.AvatarPrivacy.CanAccess(ctx.DirectLookupContextFor(target.Id))) + throw new PKSyntaxError("This system does not have a icon specific to this server or it is private."); + + switch (format) + { + case ReplyFormat.Raw: + await ctx.Reply($"`{settings.AvatarUrl.TryGetCleanCdnUrl()}`"); + break; + case ReplyFormat.Plaintext: + var ebP = new EmbedBuilder() + .Description($"Showing icon for system {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)"); + await ctx.Reply(text: $"<{settings.AvatarUrl.TryGetCleanCdnUrl()}>", embed: ebP.Build()); + break; + default: + var ebS = new EmbedBuilder() + .Title("System server icon") + .Image(new Embed.EmbedImage(settings.AvatarUrl.TryGetCleanCdnUrl())); + if (target.Id == ctx.System?.Id) + ebS.Description($"To clear, use `{ctx.DefaultPrefix}system servericon clear`."); + await ctx.Reply(embed: ebS.Build()); + break; + } + } + else + { + throw new PKSyntaxError( + $"This system does not have a icon specific to this server{(isOwnSystem ? "" : " or it is private")}." + + (isOwnSystem ? " Set one by attaching an image to this command, or by passing an image URL or @mention." : "")); + } + } + + public async Task ChangeServerAvatar(Context ctx, PKSystem target, ParsedImage img) + { + ctx.CheckGuildContext(); + ctx.CheckSystem().CheckOwnSystem(target); + + img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System); + await _avatarHosting.VerifyAvatarOrThrow(img.Url); + + await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id, new SystemGuildPatch { AvatarUrl = img.CleanUrl ?? img.Url }); + + var msg = img.Source switch + { + AvatarSource.User => + $"{Emojis.Success} System icon for this server changed to {img.SourceUser?.Username}'s avatar! It will now be used for anything that uses system avatar in this server.\n{Emojis.Warn} If {img.SourceUser?.Username} changes their avatar, the system icon for this server will need to be re-set.", + AvatarSource.Url => + $"{Emojis.Success} System icon for this server changed to the image at the given URL. It will now be used for anything that uses system avatar in this server.", + AvatarSource.HostedCdn => $"{Emojis.Success} System icon for this server changed to attached image.", + AvatarSource.Attachment => + $"{Emojis.Success} System icon for this server changed to attached image. It will now be used for anything that uses system avatar in this server.\n{Emojis.Warn} If you delete the message containing the attachment, the system icon for this server will stop working.", + _ => throw new ArgumentOutOfRangeException() + }; + + // The attachment's already right there, no need to preview it. + var hasEmbed = img.Source != AvatarSource.Attachment && img.Source != AvatarSource.HostedCdn; + await (hasEmbed + ? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build()) + : ctx.Reply(msg)); + } + + public async Task ClearBannerImage(Context ctx, PKSystem target, bool flagConfirmYes) + { + ctx.CheckSystemPrivacy(target.Id, target.BannerPrivacy); + ctx.CheckSystem().CheckOwnSystem(target); + + if (await ctx.ConfirmClear("your system's banner image", flagConfirmYes)) { await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { BannerImage = null }); await ctx.Reply($"{Emojis.Success} System banner image cleared."); } + } - else if (await ctx.MatchImage() is { } img) + public async Task ShowBannerImage(Context ctx, PKSystem target, ReplyFormat format) + { + ctx.CheckSystemPrivacy(target.Id, target.BannerPrivacy); + var isOwnSystem = target.Id == ctx.System?.Id; + + if ((target.BannerImage?.Trim() ?? "").Length > 0) { - img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Banner, ctx.Author.Id, ctx.System); - await _avatarHosting.VerifyAvatarOrThrow(img.Url, true); - - await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { BannerImage = img.CleanUrl ?? img.Url }); - - var msg = img.Source switch + switch (format) { - AvatarSource.Url => $"{Emojis.Success} System banner image changed to the image at the given URL.", - AvatarSource.HostedCdn => $"{Emojis.Success} System banner image changed to attached image.", - AvatarSource.Attachment => - $"{Emojis.Success} System banner image changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the banner image will stop working.", - AvatarSource.User => throw new PKError("Cannot set a banner image to an user's avatar."), - _ => throw new ArgumentOutOfRangeException() - }; - - // The attachment's already right there, no need to preview it. - var hasEmbed = img.Source != AvatarSource.Attachment && img.Source != AvatarSource.HostedCdn; - await (hasEmbed - ? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build()) - : ctx.Reply(msg)); + case ReplyFormat.Raw: + await ctx.Reply($"`{target.BannerImage.TryGetCleanCdnUrl()}`"); + break; + case ReplyFormat.Plaintext: + var ebP = new EmbedBuilder() + .Description($"Showing banner for system {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)"); + await ctx.Reply(text: $"<{target.BannerImage.TryGetCleanCdnUrl()}>", embed: ebP.Build()); + break; + default: + var ebS = new EmbedBuilder() + .Title("System banner image") + .Image(new Embed.EmbedImage(target.BannerImage.TryGetCleanCdnUrl())); + if (target.Id == ctx.System?.Id) + ebS.Description($"To clear, use `{ctx.DefaultPrefix}system banner clear`."); + await ctx.Reply(embed: ebS.Build()); + break; + } + } + else + { + throw new PKSyntaxError("This system does not have a banner image set." + + (isOwnSystem ? " Set one by attaching an image to this command, or by passing an image URL or @mention." : "")); } } - public async Task Delete(Context ctx, PKSystem target) + public async Task ChangeBannerImage(Context ctx, PKSystem target, ParsedImage img) + { + ctx.CheckSystemPrivacy(target.Id, target.BannerPrivacy); + ctx.CheckSystem().CheckOwnSystem(target); + + img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Banner, ctx.Author.Id, ctx.System); + await _avatarHosting.VerifyAvatarOrThrow(img.Url, true); + + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { BannerImage = img.CleanUrl ?? img.Url }); + + var msg = img.Source switch + { + AvatarSource.Url => $"{Emojis.Success} System banner image changed to the image at the given URL.", + AvatarSource.HostedCdn => $"{Emojis.Success} System banner image changed to attached image.", + AvatarSource.Attachment => + $"{Emojis.Success} System banner image changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the banner image will stop working.", + AvatarSource.User => throw new PKError("Cannot set a banner image to an user's avatar."), + _ => throw new ArgumentOutOfRangeException() + }; + + // The attachment's already right there, no need to preview it. + var hasEmbed = img.Source != AvatarSource.Attachment && img.Source != AvatarSource.HostedCdn; + await (hasEmbed + ? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build()) + : ctx.Reply(msg)); + } + + public async Task Delete(Context ctx, PKSystem target, bool noExport) { ctx.CheckSystem().CheckOwnSystem(target); - var noExport = ctx.MatchFlag("ne", "no-export"); var warnMsg = $"{Emojis.Warn} Are you sure you want to delete your system? If so, reply to this message with your system's ID (`{target.DisplayHid(ctx.Config)}`).\n"; if (!noExport) @@ -862,11 +833,32 @@ public class SystemEdit await ctx.Repository.DeleteSystem(target.Id); } - public async Task SystemProxy(Context ctx) + public async Task ToggleSystemProxy(Context ctx, Guild guildArg, bool newValue) { ctx.CheckSystem(); - var guild = await ctx.MatchGuild() ?? ctx.Guild ?? + var guild = guildArg ?? + throw new PKError("You must run this command in a server or pass a server ID."); + + string serverText; + if (guild.Id == ctx.Guild?.Id) + serverText = $"this server ({guild.Name.EscapeMarkdown()})"; + else + serverText = $"the server {guild.Name.EscapeMarkdown()}"; + + await ctx.Repository.UpdateSystemGuild(ctx.System.Id, guild.Id, new SystemGuildPatch { ProxyEnabled = newValue }); + + if (newValue) + await ctx.Reply($"Message proxying in {serverText} is now **enabled** for your system."); + else + await ctx.Reply($"Message proxying in {serverText} is now **disabled** for your system."); + } + + public async Task ShowSystemProxy(Context ctx, Guild guildArg) + { + ctx.CheckSystem(); + + var guild = guildArg ?? throw new PKError("You must run this command in a server or pass a server ID."); var gs = await ctx.Repository.GetSystemGuild(guild.Id, ctx.System.Id); @@ -877,100 +869,80 @@ public class SystemEdit else serverText = $"the server {guild.Name.EscapeMarkdown()}"; - if (!ctx.HasNext()) - { - if (gs.ProxyEnabled) - await ctx.Reply( - $"Proxying in {serverText} is currently **enabled** for your system. To disable it, type `{ctx.DefaultPrefix}system proxy off`."); - else - await ctx.Reply( - $"Proxying in {serverText} is currently **disabled** for your system. To enable it, type `{ctx.DefaultPrefix}system proxy on`."); - return; - } - - var newValue = ctx.MatchToggle(); - - await ctx.Repository.UpdateSystemGuild(ctx.System.Id, guild.Id, new SystemGuildPatch { ProxyEnabled = newValue }); - - if (newValue) - await ctx.Reply($"Message proxying in {serverText} is now **enabled** for your system."); + if (gs.ProxyEnabled) + await ctx.Reply( + $"Proxying in {serverText} is currently **enabled** for your system. To disable it, type `{ctx.DefaultPrefix}system proxy off`."); else - await ctx.Reply($"Message proxying in {serverText} is now **disabled** for your system."); + await ctx.Reply( + $"Proxying in {serverText} is currently **disabled** for your system. To enable it, type `{ctx.DefaultPrefix}system proxy on`."); } - public async Task SystemPrivacy(Context ctx, PKSystem target) + public async Task ShowSystemPrivacy(Context ctx, PKSystem target) { ctx.CheckSystem().CheckOwnSystem(target); - Task PrintEmbed() + var eb = new EmbedBuilder() + .Title("Current privacy settings for your system") + .Field(new Embed.Field("Name", target.NamePrivacy.Explanation())) + .Field(new Embed.Field("Avatar", target.AvatarPrivacy.Explanation())) + .Field(new Embed.Field("Description", target.DescriptionPrivacy.Explanation())) + .Field(new Embed.Field("Banner", target.BannerPrivacy.Explanation())) + .Field(new Embed.Field("Pronouns", target.PronounPrivacy.Explanation())) + .Field(new Embed.Field("Member list", target.MemberListPrivacy.Explanation())) + .Field(new Embed.Field("Group list", target.GroupListPrivacy.Explanation())) + .Field(new Embed.Field("Current fronter(s)", target.FrontPrivacy.Explanation())) + .Field(new Embed.Field("Front/switch history", target.FrontHistoryPrivacy.Explanation())) + .Description( + $"To edit privacy settings, use the command:\n`{ctx.DefaultPrefix}system privacy `\n\n- `subject` is one of `name`, `avatar`, `description`, `banner`, `pronouns`, `list`, `front`, `fronthistory`, `groups`, or `all` \n- `level` is either `public` or `private`."); + await ctx.Reply(embed: eb.Build()); + } + + public async Task ChangeSystemPrivacy(Context ctx, PKSystem target, SystemPrivacySubject subject, PrivacyLevel level) + { + ctx.CheckSystem().CheckOwnSystem(target); + + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch().WithPrivacy(subject, level)); + + var levelExplanation = level switch { - var eb = new EmbedBuilder() - .Title("Current privacy settings for your system") - .Field(new Embed.Field("Name", target.NamePrivacy.Explanation())) - .Field(new Embed.Field("Avatar", target.AvatarPrivacy.Explanation())) - .Field(new Embed.Field("Description", target.DescriptionPrivacy.Explanation())) - .Field(new Embed.Field("Banner", target.BannerPrivacy.Explanation())) - .Field(new Embed.Field("Pronouns", target.PronounPrivacy.Explanation())) - .Field(new Embed.Field("Member list", target.MemberListPrivacy.Explanation())) - .Field(new Embed.Field("Group list", target.GroupListPrivacy.Explanation())) - .Field(new Embed.Field("Current fronter(s)", target.FrontPrivacy.Explanation())) - .Field(new Embed.Field("Front/switch history", target.FrontHistoryPrivacy.Explanation())) - .Description( - $"To edit privacy settings, use the command:\n`{ctx.DefaultPrefix}system privacy `\n\n- `subject` is one of `name`, `avatar`, `description`, `banner`, `pronouns`, `list`, `front`, `fronthistory`, `groups`, or `all` \n- `level` is either `public` or `private`."); - return ctx.Reply(embed: eb.Build()); - } + PrivacyLevel.Public => "be able to query", + PrivacyLevel.Private => "*not* be able to query", + _ => "" + }; - async Task SetLevel(SystemPrivacySubject subject, PrivacyLevel level) + var subjectStr = subject switch { - await ctx.Repository.UpdateSystem(target.Id, new SystemPatch().WithPrivacy(subject, level)); + SystemPrivacySubject.Name => "name", + SystemPrivacySubject.Avatar => "avatar", + SystemPrivacySubject.Description => "description", + SystemPrivacySubject.Banner => "banner", + SystemPrivacySubject.Pronouns => "pronouns", + SystemPrivacySubject.Front => "front", + SystemPrivacySubject.FrontHistory => "front history", + SystemPrivacySubject.MemberList => "member list", + SystemPrivacySubject.GroupList => "group list", + _ => "" + }; - var levelExplanation = level switch - { - PrivacyLevel.Public => "be able to query", - PrivacyLevel.Private => "*not* be able to query", - _ => "" - }; + var msg = $"System {subjectStr} privacy has been set to **{level.LevelName()}**. Other accounts will now {levelExplanation} your system {subjectStr}."; + await ctx.Reply($"{Emojis.Success} {msg}"); + } - var subjectStr = subject switch - { - SystemPrivacySubject.Name => "name", - SystemPrivacySubject.Avatar => "avatar", - SystemPrivacySubject.Description => "description", - SystemPrivacySubject.Banner => "banner", - SystemPrivacySubject.Pronouns => "pronouns", - SystemPrivacySubject.Front => "front", - SystemPrivacySubject.FrontHistory => "front history", - SystemPrivacySubject.MemberList => "member list", - SystemPrivacySubject.GroupList => "group list", - _ => "" - }; + public async Task ChangeSystemPrivacyAll(Context ctx, PKSystem target, PrivacyLevel level) + { + ctx.CheckSystem().CheckOwnSystem(target); - var msg = - $"System {subjectStr} privacy has been set to **{level.LevelName()}**. Other accounts will now {levelExplanation} your system {subjectStr}."; - await ctx.Reply($"{Emojis.Success} {msg}"); - } + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch().WithAllPrivacy(level)); - async Task SetAll(PrivacyLevel level) + var msg = level switch { - await ctx.Repository.UpdateSystem(target.Id, new SystemPatch().WithAllPrivacy(level)); + PrivacyLevel.Private => + $"All system privacy settings have been set to **{level.LevelName()}**. Other accounts will now not be able to view your member list, group list, front history, or system description.", + PrivacyLevel.Public => + $"All system privacy settings have been set to **{level.LevelName()}**. Other accounts will now be able to view everything.", + _ => "" + }; - var msg = level switch - { - PrivacyLevel.Private => - $"All system privacy settings have been set to **{level.LevelName()}**. Other accounts will now not be able to view your member list, group list, front history, or system description.", - PrivacyLevel.Public => - $"All system privacy settings have been set to **{level.LevelName()}**. Other accounts will now be able to view everything.", - _ => "" - }; - - await ctx.Reply($"{Emojis.Success} {msg}"); - } - - if (!ctx.HasNext()) - await PrintEmbed(); - else if (ctx.Match("all")) - await SetAll(ctx.PopPrivacyLevel()); - else - await SetLevel(ctx.PopSystemPrivacySubject(), ctx.PopPrivacyLevel()); + await ctx.Reply($"{Emojis.Success} {msg}"); } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/SystemFront.cs b/PluralKit.Bot/Commands/SystemFront.cs index f5b06f87..7b8021c8 100644 --- a/PluralKit.Bot/Commands/SystemFront.cs +++ b/PluralKit.Bot/Commands/SystemFront.cs @@ -15,7 +15,7 @@ public class SystemFront _embeds = embeds; } - public async Task SystemFronter(Context ctx, PKSystem system) + public async Task Fronter(Context ctx, PKSystem system) { if (system == null) throw Errors.NoSystemError(ctx.DefaultPrefix); ctx.CheckSystemPrivacy(system.Id, system.FrontPrivacy); @@ -26,11 +26,11 @@ public class SystemFront await ctx.Reply(embed: await _embeds.CreateFronterEmbed(sw, ctx.Zone, ctx.LookupContextFor(system.Id))); } - public async Task SystemFrontHistory(Context ctx, PKSystem system) + public async Task FrontHistory(Context ctx, PKSystem system, bool showMemberId, bool clear = false) { - if (ctx.MatchFlag("clear", "c") || ctx.PeekArgument() == "clear") + if (clear) { - await new Switch().SwitchDelete(ctx); + await new Switch().SwitchDelete(ctx, true); return; } @@ -55,8 +55,6 @@ public class SystemFront embedTitle = $"Front history of {guildSettings.DisplayName} (`{system.Hid}`)"; } - var showMemberId = ctx.MatchFlag("with-id", "wid"); - await ctx.Paginate( sws, totalSwitches, @@ -106,7 +104,7 @@ public class SystemFront ); } - public async Task FrontPercent(Context ctx, PKSystem? system = null, PKGroup? group = null) + public async Task FrontPercent(Context ctx, PKSystem? system, string? durationStr, bool ignoreNoFronters = false, bool showFlat = false, PKGroup? group = null) { if (system == null && group == null) throw Errors.NoSystemError(ctx.DefaultPrefix); if (system == null) system = await GetGroupSystem(ctx, group); @@ -116,10 +114,8 @@ public class SystemFront var totalSwitches = await ctx.Repository.GetSwitchCount(system.Id); if (totalSwitches == 0) throw Errors.NoRegisteredSwitches; - var ignoreNoFronters = ctx.MatchFlag("fo", "fronters-only"); - var showFlat = ctx.MatchFlag("flat"); - - var durationStr = ctx.RemainderOrNull() ?? "30d"; + if (durationStr == null) + durationStr = "30d"; // Picked the UNIX epoch as a random date // even though we don't store switch timestamps in UNIX time diff --git a/PluralKit.Bot/Commands/SystemLink.cs b/PluralKit.Bot/Commands/SystemLink.cs index df0743fb..16838367 100644 --- a/PluralKit.Bot/Commands/SystemLink.cs +++ b/PluralKit.Bot/Commands/SystemLink.cs @@ -1,4 +1,5 @@ using Myriad.Extensions; +using Myriad.Types; using PluralKit.Core; @@ -6,12 +7,10 @@ namespace PluralKit.Bot; public class SystemLink { - public async Task LinkSystem(Context ctx) + public async Task LinkSystem(Context ctx, User account, bool confirmYes = false) { ctx.CheckSystem(); - var account = await ctx.MatchUser() ?? - throw new PKSyntaxError("You must pass an account to link with (either ID or @mention)."); var accountIds = await ctx.Repository.GetSystemAccounts(ctx.System.Id); if (accountIds.Contains(account.Id)) throw Errors.AccountAlreadyLinked; @@ -21,17 +20,17 @@ public class SystemLink throw Errors.AccountInOtherSystem(existingAccount, ctx.Config, ctx.DefaultPrefix); var msg = $"{account.Mention()}, please confirm the link."; - if (!await ctx.PromptYesNo(msg, "Confirm", account, false)) throw Errors.MemberLinkCancelled; + if (!await ctx.PromptYesNo(msg, "Confirm", account, true, confirmYes)) throw Errors.MemberLinkCancelled; await ctx.Repository.AddAccount(ctx.System.Id, account.Id); await ctx.Reply($"{Emojis.Success} Account linked to system."); } - public async Task UnlinkAccount(Context ctx) + public async Task UnlinkAccount(Context ctx, string idRaw, bool confirmYes) { ctx.CheckSystem(); ulong id; - if (!ctx.MatchUserRaw(out id)) + if (!idRaw.TryParseMention(out id)) throw new PKSyntaxError("You must pass an account to unlink from (either ID or @mention)."); var accountIds = (await ctx.Repository.GetSystemAccounts(ctx.System.Id)).ToList(); @@ -39,7 +38,7 @@ public class SystemLink if (accountIds.Count == 1) throw Errors.UnlinkingLastAccount(ctx.DefaultPrefix); var msg = $"Are you sure you want to unlink <@{id}> from your system?"; - if (!await ctx.PromptYesNo(msg, "Unlink")) throw Errors.MemberUnlinkCancelled; + if (!await ctx.PromptYesNo(msg, "Unlink", flagValue: confirmYes)) throw Errors.MemberUnlinkCancelled; await ctx.Repository.RemoveAccount(ctx.System.Id, id); await ctx.Reply($"{Emojis.Success} Account unlinked."); diff --git a/PluralKit.Bot/Commands/SystemList.cs b/PluralKit.Bot/Commands/SystemList.cs index 6fc3ff75..f100ac05 100644 --- a/PluralKit.Bot/Commands/SystemList.cs +++ b/PluralKit.Bot/Commands/SystemList.cs @@ -8,16 +8,20 @@ namespace PluralKit.Bot; public class SystemList { - public async Task MemberList(Context ctx, PKSystem target) + public async Task MemberList(Context ctx, PKSystem target, string? query, IHasListOptions flags) { + ctx.CheckSystem(target); + if (target == null) throw Errors.NoSystemError(ctx.DefaultPrefix); ctx.CheckSystemPrivacy(target.Id, target.MemberListPrivacy); + var opts = flags.GetListOptions(ctx, target.Id); + opts.Search = query; + // explanation of privacy lookup here: // - ParseListOptions checks list access privacy and sets the privacy filter (which members show up in list) // - RenderMemberList checks the indivual privacy for each member (NameFor, etc) // the own system is always allowed to look up their list - var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(target.Id), ctx.LookupContextFor(target.Id)); await ctx.RenderMemberList( ctx.LookupContextFor(target.Id), target.Id, diff --git a/PluralKit.Bot/Handlers/MessageCreated.cs b/PluralKit.Bot/Handlers/MessageCreated.cs index a14d024b..82d01d6f 100644 --- a/PluralKit.Bot/Handlers/MessageCreated.cs +++ b/PluralKit.Bot/Handlers/MessageCreated.cs @@ -140,7 +140,40 @@ public class MessageCreated: IEventHandler var config = system != null ? await _repo.GetSystemConfig(system.Id) : null; var guildConfig = guild != null ? await _repo.GetGuild(guild.Id) : null; - await _tree.ExecuteCommand(new Context(_services, shardId, guild, channel, evt, cmdStart, system, config, guildConfig, _config.Prefixes ?? BotConfig.DefaultPrefixes)); + // parse parameters + Parameters parameters; + try + { + parameters = new Parameters(evt.Content?.Substring(0, cmdStart), evt.Content?.Substring(cmdStart)); + } + catch (PKError e) + { + // don't send an "invalid command" response if the guild has those turned off + // TODO: only dont send command not found, not every parse error (eg. missing params, syntax error...) + if (!(guildConfig != null && guildConfig!.InvalidCommandResponseEnabled != true)) + { + await _rest.CreateMessage(channel.Id, new MessageRequest + { + Content = $"{Emojis.Error} {e.Message}", + }); + } + throw; + } + + var ctx = new Context(_services, shardId, guild, channel, evt, cmdStart, system, config, guildConfig, _config.Prefixes ?? BotConfig.DefaultPrefixes, parameters); + + Commands command; + try + { + command = await Commands.FromContext(ctx); + } + catch (PKError e) + { + await ctx.Reply($"{Emojis.Error} {e.Message}"); + throw; + } + + await _tree.ExecuteCommand(ctx, command); } catch (PKError) { diff --git a/PluralKit.Bot/PluralKit.Bot.csproj b/PluralKit.Bot/PluralKit.Bot.csproj index 152d736a..2205674d 100644 --- a/PluralKit.Bot/PluralKit.Bot.csproj +++ b/PluralKit.Bot/PluralKit.Bot.csproj @@ -4,6 +4,7 @@ Exe net8.0 annotations + true enable diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index f33d56f8..1ad8292e 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -43,7 +43,7 @@ public class EmbedService return Task.WhenAll(ids.Select(Inner)); } - public async Task CreateSystemMessageComponents(Context cctx, PKSystem system, LookupContext ctx) + public async Task CreateSystemMessageComponents(Context cctx, PKSystem system, LookupContext ctx, bool countctxByOwner) { // Fetch/render info for all accounts simultaneously var accounts = await _repo.GetSystemAccounts(system.Id); @@ -55,7 +55,7 @@ public class EmbedService }; var countctx = LookupContext.ByNonOwner; - if (cctx.MatchFlag("a", "all")) + if (countctxByOwner) { if (system.Id == cctx.System?.Id) countctx = LookupContext.ByOwner; @@ -206,14 +206,14 @@ public class EmbedService ]; } - public async Task CreateSystemEmbed(Context cctx, PKSystem system, LookupContext ctx) + public async Task CreateSystemEmbed(Context cctx, PKSystem system, LookupContext ctx, bool countctxByOwner) { // Fetch/render info for all accounts simultaneously var accounts = await _repo.GetSystemAccounts(system.Id); var users = (await GetUsers(accounts)).Select(x => x.User?.NameAndMention() ?? $"(deleted account {x.Id})"); var countctx = LookupContext.ByNonOwner; - if (cctx.MatchFlag("a", "all")) + if (countctxByOwner) { if (system.Id == cctx.System?.Id) countctx = LookupContext.ByOwner; @@ -560,7 +560,7 @@ public class EmbedService return eb.Build(); } - public async Task CreateGroupMessageComponents(Context ctx, PKSystem system, PKGroup target) + public async Task CreateGroupMessageComponents(Context ctx, PKSystem system, PKGroup target, bool all) { var pctx = ctx.LookupContextFor(system.Id); var name = target.NameFor(ctx); @@ -568,7 +568,7 @@ public class EmbedService var systemName = (ctx.Guild != null && systemGuildSettings?.DisplayName != null) ? systemGuildSettings?.DisplayName! : system.NameFor(ctx); var countctx = LookupContext.ByNonOwner; - if (ctx.MatchFlag("a", "all")) + if (all) { if (system.Id == ctx.System?.Id) countctx = LookupContext.ByOwner; @@ -673,12 +673,12 @@ public class EmbedService ]; } - public async Task CreateGroupEmbed(Context ctx, PKSystem system, PKGroup target) + public async Task CreateGroupEmbed(Context ctx, PKSystem system, PKGroup target, bool all) { var pctx = ctx.LookupContextFor(system.Id); var countctx = LookupContext.ByNonOwner; - if (ctx.MatchFlag("a", "all")) + if (all) { if (system.Id == ctx.System?.Id) countctx = LookupContext.ByOwner; diff --git a/PluralKit.Bot/Utils/ContextUtils.cs b/PluralKit.Bot/Utils/ContextUtils.cs index 7cd3de42..f40785d2 100644 --- a/PluralKit.Bot/Utils/ContextUtils.cs +++ b/PluralKit.Bot/Utils/ContextUtils.cs @@ -16,17 +16,17 @@ namespace PluralKit.Bot; public static class ContextUtils { - public static async Task ConfirmClear(this Context ctx, string toClear) + public static async Task ConfirmClear(this Context ctx, string toClear, bool confirmYes) { - if (!await ctx.PromptYesNo($"{Emojis.Warn} Are you sure you want to clear {toClear}?", "Clear")) + if (!await ctx.PromptYesNo($"{Emojis.Warn} Are you sure you want to clear {toClear}?", "Clear", flagValue: confirmYes)) throw Errors.GenericCancelled(); return true; } public static async Task PromptYesNo(this Context ctx, string msgString, string acceptButton, - User user = null, bool matchFlag = true) + User user = null, bool matchFlag = true, bool flagValue = false) { - if (matchFlag && ctx.MatchFlag("y", "yes")) return true; + if (matchFlag && flagValue) return true; var prompt = new YesNoPrompt(ctx) { diff --git a/ci/Dockerfile.dotnet b/ci/Dockerfile.dotnet index c10952b3..6054e74c 100644 --- a/ci/Dockerfile.dotnet +++ b/ci/Dockerfile.dotnet @@ -2,6 +2,15 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /app +RUN apt-get update && apt-get install -y curl build-essential && \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + +ENV PATH="/root/.cargo/bin:${PATH}" +ENV RUSTFLAGS='-C link-arg=-s' + +# Install uniffi-bindgen-cs +RUN cargo install uniffi-bindgen-cs --git https://github.com/90-008/uniffi-bindgen-cs + # Restore/fetch dependencies excluding app code to make use of caching COPY PluralKit.sln /app/ COPY Myriad/Myriad.csproj /app/Myriad/ @@ -13,8 +22,21 @@ COPY .git/ /app/.git COPY Serilog/ /app/Serilog/ RUN dotnet restore PluralKit.sln -# Copy the rest of the code and build +# Copy the rest of the code COPY . /app + +# copy parser code +COPY Cargo.toml /app/ +COPY Cargo.lock /app/ + +COPY crates/ /app/crates + +# Generate command parser bindings +RUN mkdir -p /app/bin && cargo -Z unstable-options build --package commands --lib --release --artifact-dir /app/bin/ +RUN uniffi-bindgen-cs "/app/bin/libcommands.so" --library --out-dir="/app/PluralKit.Bot" +RUN cargo run --package commands --bin write_cs_glue -- "/app/PluralKit.Bot/commandtypes.cs" + +# build bot RUN dotnet build -c Release -o bin # Build runtime stage (doesn't include SDK) diff --git a/crates/avatars/src/main.rs b/crates/avatars/src/main.rs index df80ac82..8ff66016 100644 --- a/crates/avatars/src/main.rs +++ b/crates/avatars/src/main.rs @@ -153,7 +153,7 @@ async fn verify( ) .await?; - process::process_async(result.data, req.kind).await?; + let _ = process::process_async(result.data, req.kind).await?; Ok(()) } diff --git a/crates/command_definitions/Cargo.toml b/crates/command_definitions/Cargo.toml new file mode 100644 index 00000000..e17e23a9 --- /dev/null +++ b/crates/command_definitions/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "command_definitions" +version = "0.1.0" +edition = "2021" + +[dependencies] +command_parser = { path = "../command_parser"} \ No newline at end of file diff --git a/crates/command_definitions/src/admin.rs b/crates/command_definitions/src/admin.rs new file mode 100644 index 00000000..00da3289 --- /dev/null +++ b/crates/command_definitions/src/admin.rs @@ -0,0 +1,75 @@ +use super::*; + +pub fn admin() -> &'static str { + "admin" +} + +pub fn cmds() -> impl IntoIterator { + let admin = admin(); + + let abuselog = tokens!(admin, ("abuselog", ["al"])); + let make_abuselog_cmds = |log_param: Parameter| { + [ + command!(abuselog, ("show", ["s"]), log_param => format!("admin_abuselog_show_{}", log_param.name())) + .help("Shows an abuse log entry"), + command!(abuselog, ("flagdeny", ["fd"]), log_param, Optional(("value", Toggle)) => format!("admin_abuselog_flag_deny_{}", log_param.name())) + .help("Sets the deny flag on an abuse log entry"), + command!(abuselog, ("description", ["desc"]), log_param, Optional(Remainder(("desc", OpaqueString))) => format!("admin_abuselog_description_{}", log_param.name())) + .flag(CLEAR) + .flag(YES) + .help("Sets the description of an abuse log entry"), + command!(abuselog, ("adduser", ["au"]), log_param => format!("admin_abuselog_add_user_{}", log_param.name())) + .help("Adds a user to an abuse log entry"), + command!(abuselog, ("removeuser", ["ru"]), log_param => format!("admin_abuselog_remove_user_{}", log_param.name())) + .help("Removes a user from an abuse log entry"), + command!(abuselog, ("delete", ["d"]), log_param => format!("admin_abuselog_delete_{}", log_param.name())) + .help("Deletes an abuse log entry"), + ] + }; + let abuselog_cmds = [ + command!(abuselog, ("create", ["c", "new"]), ("account", UserRef), Optional(Remainder(("description", OpaqueString))) => "admin_abuselog_create") + .flag(("deny-boy-usage", ["deny"])) + .help("Creates an abuse log entry") + ] + .into_iter() + .chain(make_abuselog_cmds(Skip(("account", UserRef)).into())) // falls through to log_id + .chain(make_abuselog_cmds(("log_id", OpaqueString).into())); + + [ + command!(admin, ("updatesystemid", ["usid"]), SystemRef, ("new_hid", OpaqueString) => "admin_update_system_id") + .flag(YES) + .help("Updates a system's ID"), + command!(admin, ("updatememberid", ["umid"]), MemberRef, ("new_hid", OpaqueString) => "admin_update_member_id") + .flag(YES) + .help("Updates a member's ID"), + command!(admin, ("updategroupid", ["ugid"]), GroupRef, ("new_hid", OpaqueString) => "admin_update_group_id") + .flag(YES) + .help("Updates a group's ID"), + command!(admin, ("rerollsystemid", ["rsid"]), SystemRef => "admin_reroll_system_id") + .flag(YES) + .help("Rerolls a system's ID"), + command!(admin, ("rerollmemberid", ["rmid"]), MemberRef => "admin_reroll_member_id") + .flag(YES) + .help("Rerolls a member's ID"), + command!(admin, ("rerollgroupid", ["rgid"]), GroupRef => "admin_reroll_group_id") + .flag(YES) + .help("Rerolls a group's ID"), + command!(admin, ("updatememberlimit", ["uml"]), SystemRef, Optional(("limit", OpaqueInt)) => "admin_system_member_limit") + .flag(YES) + .help("Updates a system's member limit"), + command!(admin, ("updategrouplimit", ["ugl"]), SystemRef, Optional(("limit", OpaqueInt)) => "admin_system_group_limit") + .flag(YES) + .help("Updates a system's group limit"), + command!(admin, ("systemrecover", ["sr"]), ("token", OpaqueString), ("account", UserRef) => "admin_system_recover") + .flag(YES) + .flag(("reroll-token", ["rt"])) + .help("Recovers a system"), + command!(admin, ("systemdelete", ["sd"]), SystemRef => "admin_system_delete") + .help("Deletes a system"), + command!(admin, ("sendmessage", ["sendmsg"]), ("account", UserRef), Remainder(("content", OpaqueString)) => "admin_send_message") + .help("Sends a message to a user"), + ] + .into_iter() + .chain(abuselog_cmds) + .map(|cmd| cmd.show_in_suggestions(false)) +} diff --git a/crates/command_definitions/src/api.rs b/crates/command_definitions/src/api.rs new file mode 100644 index 00000000..82fca08b --- /dev/null +++ b/crates/command_definitions/src/api.rs @@ -0,0 +1,9 @@ +use super::*; + +pub fn cmds() -> impl IntoIterator { + [ + command!("token" => "token_display").help("Gets your system's API token"), + command!("token", ("refresh", ["renew", "regen", "reroll"]) => "token_refresh") + .help("Generates a new API token and invalidates the old one"), + ] +} diff --git a/crates/command_definitions/src/autoproxy.rs b/crates/command_definitions/src/autoproxy.rs new file mode 100644 index 00000000..fd65be01 --- /dev/null +++ b/crates/command_definitions/src/autoproxy.rs @@ -0,0 +1,20 @@ +use super::*; + +pub fn autoproxy() -> (&'static str, [&'static str; 2]) { + ("autoproxy", ["ap", "auto"]) +} + +pub fn cmds() -> impl IntoIterator { + let ap = autoproxy(); + + [ + command!(ap => "autoproxy_show").help("Shows your current autoproxy settings"), + command!(ap, ("off", ["stop", "cancel", "no", "disable", "remove"]) => "autoproxy_off") + .help("Disables autoproxying for your system in the current server"), + command!(ap, ("latch", ["last", "proxy", "stick", "sticky", "l"]) => "autoproxy_latch") + .help("Sets your system's autoproxy in this server to proxy the last manually proxied member"), + command!(ap, ("front", ["fronter", "switch", "f"]) => "autoproxy_front") + .help("Sets your system's autoproxy in this server to proxy the first member currently registered as front"), + command!(ap, MemberRef => "autoproxy_member").help("Sets your system's autoproxy in this server to proxy a specific member"), + ] +} diff --git a/crates/command_definitions/src/config.rs b/crates/command_definitions/src/config.rs new file mode 100644 index 00000000..ebd73491 --- /dev/null +++ b/crates/command_definitions/src/config.rs @@ -0,0 +1,239 @@ +use command_parser::parameter; + +use super::*; + +pub fn cmds() -> impl IntoIterator { + let cfg = ("config", ["cfg", "configure"]); + + let base = [command!(cfg => "cfg_show").help("Shows the current configuration")]; + + let ap = tokens!(cfg, ("autoproxy", ["ap"])); + let ap_account = tokens!(ap, ("account", ["ac"])); + let ap_timeout = tokens!(ap, ("timeout", ["tm"])); + let autoproxy = [ + command!(ap_account => "cfg_ap_account_show") + .help("Shows autoproxy status for the account"), + command!(ap_account, Toggle => "cfg_ap_account_update") + .help("Toggles autoproxy globally for the current account"), + command!(ap_timeout => "cfg_ap_timeout_show").help("Shows the autoproxy timeout"), + command!(ap_timeout, RESET => "cfg_ap_timeout_reset") + .help("Resets the autoproxy timeout"), + command!(ap_timeout, parameter::Toggle::Off => "cfg_ap_timeout_off") + .help("Disables the autoproxy timeout"), + command!(ap_timeout, ("timeout", OpaqueString) => "cfg_ap_timeout_update") + .help("Sets the latch timeout duration for your system"), + ]; + + let timezone_tokens = tokens!(cfg, ("timezone", ["zone", "tz"])); + let timezone = [ + command!(timezone_tokens => "cfg_timezone_show").help("Shows the system timezone"), + command!(timezone_tokens, RESET => "cfg_timezone_reset") + .help("Resets the system timezone"), + command!(timezone_tokens, ("timezone", OpaqueString) => "cfg_timezone_update") + .flag(YES) + .help("Changes your system's time zone"), + ]; + + let ping_tokens = tokens!(cfg, "ping"); + let ping = [ + command!(ping_tokens => "cfg_ping_show").help("Shows ping preferences"), + command!(ping_tokens, Toggle => "cfg_ping_update") + .help("Changes your system's ping preferences"), + ]; + + let priv_ = ("private", ["priv"]); + let member_privacy = tokens!(cfg, priv_, ("member", ["mem"])); + let member_privacy_short = tokens!(cfg, "mp"); + let group_privacy = tokens!(cfg, priv_, ("group", ["grp"])); + let group_privacy_short = tokens!(cfg, "gp"); + let privacy = [ + command!(member_privacy => "cfg_member_privacy_show") + .help("Shows the default privacy for new members"), + command!(member_privacy, Toggle => "cfg_member_privacy_update") + .help("Sets whether member privacy is automatically set to private when creating a new member"), + command!(member_privacy_short => "cfg_member_privacy_show") + .help("Shows the default privacy for new members"), + command!(member_privacy_short, Toggle => "cfg_member_privacy_update") + .help("Sets whether member privacy is automatically set to private when creating a new member"), + command!(group_privacy => "cfg_group_privacy_show") + .help("Shows the default privacy for new groups"), + command!(group_privacy, Toggle => "cfg_group_privacy_update") + .help("Sets whether group privacy is automatically set to private when creating a new group"), + command!(group_privacy_short => "cfg_group_privacy_show") + .help("Shows the default privacy for new groups"), + command!(group_privacy_short, Toggle => "cfg_group_privacy_update") + .help("Sets whether group privacy is automatically set to private when creating a new group"), + ]; + + let show = "show"; + let show_private = tokens!(cfg, show, priv_); + let show_private_short = tokens!(cfg, "sp"); + let private_info = [ + command!(show_private => "cfg_show_private_info_show") + .help("Shows whether private info is shown"), + command!(show_private, Toggle => "cfg_show_private_info_update") + .help("Sets whether private information is shown to linked accounts by default"), + command!(show_private_short => "cfg_show_private_info_show") + .help("Shows whether private info is shown"), + command!(show_private_short, Toggle => "cfg_show_private_info_update") + .help("Sets whether private information is shown to linked accounts by default"), + ]; + + let proxy = ("proxy", ["px"]); + let proxy_case = tokens!(cfg, proxy, ("case", ["caps", "capitalize", "capitalise"])); + let proxy_error = tokens!(cfg, proxy, ("error", ["errors"])); + let proxy_error_short = tokens!(cfg, "pe"); + let proxy_switch = tokens!(cfg, proxy, "switch"); + let proxy_switch_short = tokens!(cfg, ("proxyswitch", ["ps"])); + let proxy_settings = [ + command!(proxy_case => "cfg_case_sensitive_proxy_tags_show") + .help("Shows whether proxy tags are case-sensitive"), + command!(proxy_case, Toggle => "cfg_case_sensitive_proxy_tags_update") + .help("Toggles case sensitivity for proxy tags"), + command!(proxy_error => "cfg_proxy_error_message_show") + .help("Shows whether proxy error messages are enabled"), + command!(proxy_error, Toggle => "cfg_proxy_error_message_update") + .help("Toggles proxy error messages"), + command!(proxy_error_short => "cfg_proxy_error_message_show") + .help("Shows whether proxy error messages are enabled"), + command!(proxy_error_short, Toggle => "cfg_proxy_error_message_update") + .help("Toggles proxy error messages"), + command!(proxy_switch => "cfg_proxy_switch_show").help("Shows the proxy switch behavior"), + command!(proxy_switch, ProxySwitchAction => "cfg_proxy_switch_update") + .help("Sets the switching behavior when proxy tags are used"), + command!(proxy_switch_short => "cfg_proxy_switch_show") + .help("Shows the proxy switch behavior"), + command!(proxy_switch_short, ProxySwitchAction => "cfg_proxy_switch_update") + .help("Sets the switching behavior when proxy tags are used"), + ]; + + let id = ("id", ["ids"]); + let split_id = tokens!(cfg, "split", id); + let split_id_short = tokens!(cfg, ("sid", ["sids"])); + let cap_id = tokens!(cfg, ("cap", ["caps", "capitalize", "capitalise"]), id); + let cap_id_short = tokens!(cfg, ("capid", ["capids"])); + let pad = ("pad", ["padding"]); + let pad_id = tokens!(cfg, pad, id); + let id_pad = tokens!(cfg, id, pad); + let id_pad_short = tokens!(cfg, ("idpad", ["padid", "padids"])); + let id_settings = [ + command!(split_id => "cfg_hid_split_show").help("Shows whether IDs are split in lists"), + command!(split_id, Toggle => "cfg_hid_split_update").help("Toggles splitting IDs in lists"), + command!(split_id_short => "cfg_hid_split_show") + .help("Shows whether IDs are split in lists"), + command!(split_id_short, Toggle => "cfg_hid_split_update") + .help("Toggles splitting IDs in lists"), + command!(cap_id => "cfg_hid_caps_show").help("Shows whether IDs are capitalized in lists"), + command!(cap_id, Toggle => "cfg_hid_caps_update") + .help("Toggles capitalization of IDs in lists"), + command!(cap_id_short => "cfg_hid_caps_show") + .help("Shows whether IDs are capitalized in lists"), + command!(cap_id_short, Toggle => "cfg_hid_caps_update") + .help("Toggles capitalization of IDs in lists"), + command!(pad_id => "cfg_hid_padding_show").help("Shows the ID padding for lists"), + command!(pad_id, ("padding", OpaqueString) => "cfg_hid_padding_update") + .help("Sets the ID padding for lists"), + command!(id_pad => "cfg_hid_padding_show").help("Shows the ID padding for lists"), + command!(id_pad, ("padding", OpaqueString) => "cfg_hid_padding_update") + .help("Sets the ID padding for lists"), + command!(id_pad_short => "cfg_hid_padding_show").help("Shows the ID padding for lists"), + command!(id_pad_short, ("padding", OpaqueString) => "cfg_hid_padding_update") + .help("Sets the ID padding for lists"), + ]; + + let show_color = tokens!(cfg, show, ("color", ["colour", "colors", "colours"])); + let show_color_short = tokens!( + cfg, + ( + "showcolor", + [ + "showcolour", + "showcolors", + "showcolours", + "colorcode", + "colorhex" + ] + ) + ); + let color_settings = [ + command!(show_color => "cfg_card_show_color_hex_show") + .help("Shows whether color hex codes are shown on cards"), + command!(show_color, Toggle => "cfg_card_show_color_hex_update") + .help("Toggles showing color hex codes on cards"), + command!(show_color_short => "cfg_card_show_color_hex_show") + .help("Shows whether color hex codes are shown on cards"), + command!(show_color_short, Toggle => "cfg_card_show_color_hex_update") + .help("Toggles showing color hex codes on cards"), + ]; + + let format = "format"; + let name_format = tokens!(cfg, "name", format); + let name_format_short = tokens!(cfg, ("nameformat", ["nf"])); + let name_formatting = [ + command!(name_format => "cfg_name_format_show").help("Shows the name format"), + command!(name_format, RESET => "cfg_name_format_reset") + .help("Resets the name format"), + command!(name_format, ("format", OpaqueString) => "cfg_name_format_update") + .help("Changes your system's username formatting"), + command!(name_format_short => "cfg_name_format_show").help("Shows the name format"), + command!(name_format_short, RESET => "cfg_name_format_reset") + .help("Resets the name format"), + command!(name_format_short, ("format", OpaqueString) => "cfg_name_format_update") + .help("Changes your system's username formatting"), + ]; + + let server = "server"; + let server_name_format = tokens!(cfg, server, "name", format); + let server_format = tokens!( + cfg, + ("server", ["servername"]), + ("format", ["nameformat", "nf"]) + ); + let server_format_short = tokens!( + cfg, + ("snf", ["servernf", "servernameformat", "snameformat"]) + ); + let server_name_formatting = [ + command!(server_name_format => "cfg_server_name_format_show") + .help("Shows the server name format"), + command!(server_name_format, RESET => "cfg_server_name_format_reset") + .help("Resets the server name format"), + command!(server_name_format, ("format", OpaqueString) => "cfg_server_name_format_update") + .help("Changes your system's username formatting in the current server"), + command!(server_format => "cfg_server_name_format_show") + .help("Shows the server name format"), + command!(server_format, RESET => "cfg_server_name_format_reset") + .help("Resets the server name format"), + command!(server_format, ("format", OpaqueString) => "cfg_server_name_format_update") + .help("Changes your system's username formatting in the current server"), + command!(server_format_short => "cfg_server_name_format_show") + .help("Shows the server name format"), + command!(server_format_short, RESET => "cfg_server_name_format_reset") + .help("Resets the server name format"), + command!(server_format_short, ("format", OpaqueString) => "cfg_server_name_format_update") + .help("Changes your system's username formatting in the current server"), + ]; + + let limit_ = ("limit", ["lim"]); + let member_limit = tokens!(cfg, ("member", ["mem"]), limit_); + let group_limit = tokens!(cfg, ("group", ["grp"]), limit_); + let limit = tokens!(cfg, limit_); + let limits = [ + command!(member_limit => "cfg_limits_update").help("Refreshes member/group limits"), + command!(group_limit => "cfg_limits_update").help("Refreshes member/group limits"), + command!(limit => "cfg_limits_update").help("Refreshes member/group limits"), + ]; + + base.into_iter() + .chain(autoproxy) + .chain(timezone) + .chain(ping) + .chain(privacy) + .chain(private_info) + .chain(proxy_settings) + .chain(id_settings) + .chain(color_settings) + .chain(name_formatting) + .chain(server_name_formatting) + .chain(limits) +} diff --git a/crates/command_definitions/src/debug.rs b/crates/command_definitions/src/debug.rs new file mode 100644 index 00000000..52f50307 --- /dev/null +++ b/crates/command_definitions/src/debug.rs @@ -0,0 +1,20 @@ +use command_parser::parameter::MESSAGE_REF; + +use super::*; + +pub fn debug() -> (&'static str, [&'static str; 1]) { + ("debug", ["dbg"]) +} + +pub fn cmds() -> impl IntoIterator { + let debug = debug(); + let perms = ("permissions", ["perms", "permcheck"]); + [ + command!(debug, perms, ("channel", ["ch"]), ChannelRef => "permcheck_channel") + .help("Checks if PluralKit has the required permissions in a channel"), + command!(debug, perms, ("guild", ["g"]), GuildRef => "permcheck_guild") + .help("Checks whether a server's permission setup is correct"), + command!(debug, ("proxy", ["proxying", "proxycheck"]), MESSAGE_REF => "message_proxy_check") + .help("Checks why a message has not been proxied"), + ] +} diff --git a/crates/command_definitions/src/fun.rs b/crates/command_definitions/src/fun.rs new file mode 100644 index 00000000..99423269 --- /dev/null +++ b/crates/command_definitions/src/fun.rs @@ -0,0 +1,17 @@ +use super::*; + +pub fn cmds() -> impl IntoIterator { + [ + command!("thunder" => "fun_thunder").help("Vanquishes your opponent with a lightning bolt"), + command!("meow" => "fun_meow").help("mrrp :3"), + command!("mn" => "fun_pokemon").help("Gotta catch 'em all!"), + command!("fire" => "fun_fire").help("Engulfs your opponent in a pillar of fire"), + command!("freeze" => "fun_freeze").help("Freezes your opponent solid"), + command!("starstorm" => "fun_starstorm") + .help("Summons a storm of meteors to strike your opponent"), + command!("flash" => "fun_flash").help("Explodes your opponent with a ball of green light"), + command!("rool" => "fun_rool").help("\"What the fuck is a Pokémon?\""), + command!("sus" => "amogus").help("ඞ"), + command!("error" => "fun_error").help("Shows a fake error message"), + ] +} diff --git a/crates/command_definitions/src/group.rs b/crates/command_definitions/src/group.rs new file mode 100644 index 00000000..32ffe96a --- /dev/null +++ b/crates/command_definitions/src/group.rs @@ -0,0 +1,182 @@ +use std::iter::once; + +use command_parser::token::TokensIterator; + +use crate::utils::get_list_flags; + +use super::*; + +pub fn group() -> (&'static str, [&'static str; 1]) { + ("group", ["g"]) +} + +pub fn targeted() -> TokensIterator { + tokens!(group(), GroupRef) +} + +pub fn cmds() -> impl Iterator { + let group = group(); + let group_target = targeted(); + + let group_new = tokens!(group, ("new", ["n"])); + let group_new_cmd = once( + command!(group_new, Remainder(("name", OpaqueString)) => "group_new") + .flag(YES) + .help("Creates a new group"), + ); + + let group_info_cmd = once( + command!(group_target => "group_info") + .flag(ALL) + .help("Looks up information about a group"), + ); + + let group_name = tokens!( + group_target, + ("name", ["rename", "changename", "setname", "rn"]) + ); + let group_name_cmd = [ + command!(group_name => "group_show_name").help("Shows the group's name"), + command!(group_name, CLEAR => "group_clear_name") + .flag(YES) + .help("Clears the group's name"), + command!(group_name, Remainder(("name", OpaqueString)) => "group_rename") + .flag(YES) + .help("Renames a group"), + ]; + + let group_display_name = tokens!(group_target, ("displayname", ["dn", "nick", "nickname"])); + let group_display_name_cmd = [ + command!(group_display_name => "group_show_display_name") + .help("Shows the group's display name"), + command!(group_display_name, CLEAR => "group_clear_display_name") + .flag(YES) + .help("Clears the group's display name"), + command!(group_display_name, Remainder(("name", OpaqueString)) => "group_change_display_name") + .help("Changes the group's display name"), + ]; + + let group_description = tokens!( + group_target, + ( + "description", + ["desc", "describe", "d", "bio", "info", "text", "intro"] + ) + ); + let group_description_cmd = [ + command!(group_description => "group_show_description") + .help("Shows the group's description"), + command!(group_description, CLEAR => "group_clear_description") + .flag(YES) + .help("Clears the group's description"), + command!(group_description, Remainder(("description", OpaqueString)) => "group_change_description") + .help("Changes the group's description"), + ]; + + let group_icon = tokens!( + group_target, + ("icon", ["avatar", "picture", "image", "pic", "pfp"]) + ); + let group_icon_cmd = [ + command!(group_icon => "group_show_icon").help("Shows the group's icon"), + command!(group_icon, CLEAR => "group_clear_icon") + .flag(YES) + .help("Clears the group's icon"), + command!(group_icon, ("icon", Avatar) => "group_change_icon") + .help("Changes a group's icon"), + ]; + + let group_banner = tokens!(group_target, ("banner", ["splash", "cover"])); + let group_banner_cmd = [ + command!(group_banner => "group_show_banner").help("Sets the group's banner image"), + command!(group_banner, CLEAR => "group_clear_banner") + .flag(YES) + .help("Clears the group's banner"), + command!(group_banner, ("banner", Avatar) => "group_change_banner") + .help("Sets the group's banner image"), + ]; + + let group_color = tokens!(group_target, ("color", ["colour"])); + let group_color_cmd = [ + command!(group_color => "group_show_color").help("Shows the group's color"), + command!(group_color, CLEAR => "group_clear_color") + .flag(YES) + .help("Clears the group's color"), + command!(group_color, ("color", OpaqueString) => "group_change_color") + .help("Changes a group's color"), + ]; + + let group_privacy = tokens!(group_target, ("privacy", ["priv"])); + let group_privacy_cmd = [ + command!(group_privacy => "group_show_privacy") + .help("Shows the group's privacy settings"), + command!(group_privacy, ALL, ("level", PrivacyLevel) => "group_change_privacy_all") + .help("Changes all privacy settings for the group"), + command!(group_privacy, ("privacy", GroupPrivacyTarget), ("level", PrivacyLevel) => "group_change_privacy") + .help("Changes a specific privacy setting for the group"), + ]; + + let group_public_cmd = [ + command!(group_target, ("public", ["pub"]) => "group_set_public") + .help("Sets the group to public"), + ]; + + let group_private_cmd = [ + command!(group_target, ("private", ["priv"]) => "group_set_private") + .help("Sets the group to private"), + ]; + + let group_delete_cmd = [ + command!(group_target, ("delete", ["destroy", "erase", "yeet"]) => "group_delete") + .help("Deletes a group"), + ]; + + let group_id_cmd = [command!(group_target, "id" => "group_id").help("Prints a group's ID")]; + + let group_front = tokens!(group_target, ("front", ["fronter", "fronters", "f"])); + let group_front_cmd = [ + command!(group_front, ("percent", ["p", "%"]) => "group_fronter_percent") + .help("Shows a group's front breakdown") + .flag(("duration", OpaqueString)) + .flag(("fronters-only", ["fo"])) + .flag("flat"), + ]; + + let apply_list_opts = |cmd: Command| cmd.flags(get_list_flags()); + let search_param = Optional(Remainder(("query", OpaqueString))); + + let group_list_members_cmd = + once(command!(group_target, ("members", ["list", "ls"]), search_param => "group_members")) + .map(apply_list_opts); + + let group_modify_members_cmd = [ + command!(group_target, "add", Optional(MemberRefs) => "group_add_member") + .help("Adds one or more members to a group") + .flag(ALL), + command!(group_target, ("remove", ["rem", "rm"]), Optional(MemberRefs) => "group_remove_member") + .help("Removes one or more members from a group") + .flag(ALL).flag(YES), + ]; + + let system_groups_cmd = + once(command!(group, ("list", ["ls", "l"]), search_param => "groups_self")) + .map(apply_list_opts); + + system_groups_cmd + .chain(group_new_cmd) + .chain(group_info_cmd) + .chain(group_name_cmd) + .chain(group_display_name_cmd) + .chain(group_description_cmd) + .chain(group_icon_cmd) + .chain(group_banner_cmd) + .chain(group_color_cmd) + .chain(group_privacy_cmd) + .chain(group_public_cmd) + .chain(group_private_cmd) + .chain(group_front_cmd) + .chain(group_modify_members_cmd) + .chain(group_delete_cmd) + .chain(group_id_cmd) + .chain(group_list_members_cmd) +} diff --git a/crates/command_definitions/src/help.rs b/crates/command_definitions/src/help.rs new file mode 100644 index 00000000..0f32658e --- /dev/null +++ b/crates/command_definitions/src/help.rs @@ -0,0 +1,15 @@ +use super::*; + +pub fn cmds() -> impl IntoIterator { + let help = ("help", ["h"]); + [ + command!(("commands", ["cmd", "c"]), ("subject", OpaqueString) => "commands_list") + .help("Lists all commands or commands in a specific category"), + command!(("dashboard", ["dash"]) => "dashboard") + .help("Gets a link to the PluralKit web dashboard"), + command!("explain" => "explain").help("Explains the basics of systems and proxying"), + command!(help => "help").help("Shows the help command"), + command!(help, "commands" => "help_commands").help("help commands"), + command!(help, "proxy" => "help_proxy").help("help proxy"), + ] +} diff --git a/crates/command_definitions/src/import_export.rs b/crates/command_definitions/src/import_export.rs new file mode 100644 index 00000000..9a73e7a2 --- /dev/null +++ b/crates/command_definitions/src/import_export.rs @@ -0,0 +1,10 @@ +use super::*; + +pub fn cmds() -> impl IntoIterator { + [ + command!("import", Optional(Remainder(("url", OpaqueString))) => "import") + .help("Imports system information from a data file") + .flag(YES), + command!("export" => "export").help("Exports system information to a file"), + ] +} diff --git a/crates/command_definitions/src/lib.rs b/crates/command_definitions/src/lib.rs new file mode 100644 index 00000000..54550b57 --- /dev/null +++ b/crates/command_definitions/src/lib.rs @@ -0,0 +1,59 @@ +pub mod admin; +pub mod api; +pub mod autoproxy; +pub mod config; +pub mod debug; +pub mod fun; +pub mod group; +pub mod help; +pub mod import_export; +pub mod member; +pub mod message; +pub mod misc; +pub mod random; +pub mod server_config; +pub mod switch; +pub mod system; + +pub mod utils; + +use command_parser::{ + command, + command::Command, + parameter::{Optional, Parameter, ParameterKind::*, Remainder, Skip}, + tokens, +}; + +pub fn all() -> impl Iterator { + std::iter::empty() + .chain(help::cmds()) + .chain(system::cmds()) + .chain(group::cmds()) + .chain(member::cmds()) + .chain(config::cmds()) + .chain(server_config::cmds()) + .chain(fun::cmds()) + .chain(switch::cmds()) + .chain(random::cmds()) + .chain(api::cmds()) + .chain(autoproxy::cmds()) + .chain(debug::cmds()) + .chain(message::cmds()) + .chain(import_export::cmds()) + .chain(admin::cmds()) + .chain(misc::cmds()) + .map(|cmd| { + cmd.hidden_flag(("plaintext", ["pt"])) + .hidden_flag(("raw", ["r"])) + .hidden_flag(("show-embed", ["se"])) + .hidden_flag(("by-id", ["id"])) + .hidden_flag(("private", ["priv"])) + .hidden_flag(("public", ["pub"])) + }) +} + +pub const RESET: (&str, [&str; 2]) = ("reset", ["clear", "default"]); + +pub const CLEAR: (&str, [&str; 1]) = ("clear", ["c"]); +pub const YES: (&str, [&str; 1]) = ("yes", ["y"]); +pub const ALL: (&str, [&str; 1]) = ("all", ["a"]); diff --git a/crates/command_definitions/src/member.rs b/crates/command_definitions/src/member.rs new file mode 100644 index 00000000..69022e96 --- /dev/null +++ b/crates/command_definitions/src/member.rs @@ -0,0 +1,331 @@ +use std::iter::once; + +use command_parser::token::TokensIterator; + +use crate::utils::get_list_flags; + +use super::*; + +pub fn member() -> (&'static str, [&'static str; 1]) { + ("member", ["m"]) +} + +pub fn targetted() -> TokensIterator { + tokens!(member(), MemberRef) +} + +pub fn cmds() -> impl Iterator { + let member = member(); + let member_target = targetted(); + + let name = ("name", ["n"]); + let description = ("description", ["desc"]); + let pronouns = ("pronouns", ["pronoun", "prns", "pn"]); + let privacy = ("privacy", ["priv"]); + let new = ("new", ["n"]); + let banner = ("banner", ["bn"]); + let color = ("color", ["colour"]); + let birthday = ("birthday", ["bday", "bd"]); + let display_name = ("displayname", ["dname", "dn"]); + let server_name = ("servername", ["sname", "sn"]); + let keep_proxy = ("keepproxy", ["kp"]); + let server_keep_proxy = ("serverkeepproxy", ["skp"]); + let autoproxy = ("autoproxy", ["ap"]); + let proxy = ("proxy", ["tags", "proxytags", "brackets"]); + let tts = ("tts", ["texttospeech"]); + let delete = ("delete", ["del", "remove"]); + + let member_new_cmd = once( + command!(member, new, ("name", OpaqueString) => "member_new") + .flag(YES) + .help("Creates a new member"), + ); + + let member_info_cmd = once( + command!(member_target => "member_show") + .flag("pt") + .help("Looks up information about a member"), + ); + + let member_name_cmd = { + let member_name = tokens!(member_target, name); + [ + command!(member_name => "member_name_show").help("Shows a member's name"), + command!(member_name, Remainder(("name", OpaqueString)) => "member_name_update") + .flag(YES) + .help("Renames a member"), + ] + }; + + let member_description_cmd = { + let member_desc = tokens!(member_target, description); + [ + command!(member_desc => "member_desc_show").help("Shows a member's description"), + command!(member_desc, CLEAR => "member_desc_clear") + .flag(YES) + .help("Clears a member's description"), + command!(member_desc, Remainder(("description", OpaqueString)) => "member_desc_update") + .help("Changes a member's description"), + ] + }; + + let member_privacy_cmd = { + let member_privacy = tokens!(member_target, privacy); + [ + command!(member_privacy => "member_privacy_show") + .help("Displays a member's current privacy settings"), + command!( + member_privacy, MemberPrivacyTarget, ("new_privacy_level", PrivacyLevel) + => "member_privacy_update" + ) + .help("Changes a member's privacy settings"), + ] + }; + + let member_pronouns_cmd = { + let member_pronouns = tokens!(member_target, pronouns); + [ + command!(member_pronouns => "member_pronouns_show") + .help("Shows a member's pronouns"), + command!(member_pronouns, Remainder(("pronouns", OpaqueString)) => "member_pronouns_update") + .help("Changes a member's pronouns"), + command!(member_pronouns, CLEAR => "member_pronouns_clear") + .flag(YES) + .help("Clears a member's pronouns"), + ] + }; + + let member_banner_cmd = { + let member_banner = tokens!(member_target, banner); + [ + command!(member_banner => "member_banner_show").help("Shows a member's banner image"), + command!(member_banner, ("banner", Avatar) => "member_banner_update") + .help("Sets the member's banner image"), + command!(member_banner, CLEAR => "member_banner_clear") + .flag(YES) + .help("Clears a member's banner image"), + ] + }; + + let member_color_cmd = { + let member_color = tokens!(member_target, color); + [ + command!(member_color => "member_color_show").help("Shows a member's color"), + command!(member_color, ("color", OpaqueString) => "member_color_update") + .help("Changes a member's color"), + command!(member_color, CLEAR => "member_color_clear") + .flag(YES) + .help("Clears a member's color"), + ] + }; + + let member_birthday_cmd = { + let member_birthday = tokens!(member_target, birthday); + [ + command!(member_birthday => "member_birthday_show").help("Shows a member's birthday"), + command!(member_birthday, ("birthday", OpaqueString) => "member_birthday_update") + .help("Changes a member's birthday"), + command!(member_birthday, CLEAR => "member_birthday_clear") + .flag(YES) + .help("Clears a member's birthday"), + ] + }; + + let member_display_name_cmd = { + let member_display_name = tokens!(member_target, display_name); + [ + command!(member_display_name => "member_displayname_show") + .help("Shows a member's display name"), + command!(member_display_name, Remainder(("name", OpaqueString)) => "member_displayname_update") + .help("Changes a member's display name"), + command!(member_display_name, CLEAR => "member_displayname_clear") + .flag(YES) + .help("Clears a member's display name"), + ] + }; + + let member_server_name_cmd = { + let member_server_name = tokens!(member_target, server_name); + [ + command!(member_server_name => "member_servername_show") + .help("Shows a member's server name"), + command!(member_server_name, Remainder(("name", OpaqueString)) => "member_servername_update") + .help("Changes a member's display name in the current server"), + command!(member_server_name, CLEAR => "member_servername_clear") + .flag(YES) + .help("Clears a member's server name"), + ] + }; + + let member_proxy_cmd = { + let member_proxy = tokens!(member_target, proxy); + [ + command!(member_proxy => "member_proxy_show") + .help("Shows a member's proxy tags"), + command!(member_proxy, ("add", ["a"]), ("tag", OpaqueString) => "member_proxy_add") + .flag(YES) + .help("Adds proxy tag to a member"), + command!(member_proxy, ("remove", ["r", "rm"]), ("tag", OpaqueString) => "member_proxy_remove") + .help("Removes proxy tag from a member"), + command!(member_proxy, CLEAR => "member_proxy_clear") + .flag(YES) + .help("Clears all proxy tags from a member"), + command!(member_proxy, Remainder(("tags", OpaqueString)) => "member_proxy_set") + .flag(YES) + .help("Sets a member's proxy tags"), + ] + }; + + let member_proxy_settings_cmd = { + let member_keep_proxy = tokens!(member_target, keep_proxy); + let member_server_keep_proxy = tokens!(member_target, server_keep_proxy); + [ + command!(member_keep_proxy => "member_keepproxy_show") + .help("Shows a member's keep-proxy setting"), + command!(member_keep_proxy, ("value", Toggle) => "member_keepproxy_update") + .help("Sets whether to include a member's proxy tags when proxying"), + command!(member_server_keep_proxy => "member_server_keepproxy_show") + .help("Shows a member's server-specific keep-proxy setting"), + command!(member_server_keep_proxy, CLEAR => "member_server_keepproxy_clear") + .flag(YES) + .help("Clears a member's server-specific keep-proxy setting"), + command!(member_server_keep_proxy, ("value", Toggle) => "member_server_keepproxy_update") + .help("Sets whether to include a member's proxy tags when proxying in the current server"), + ] + }; + + let member_message_settings_cmd = { + let member_tts = tokens!(member_target, tts); + let member_autoproxy = tokens!(member_target, autoproxy); + [ + command!(member_tts => "member_tts_show") + .help("Shows whether a member's messages are sent as TTS"), + command!(member_tts, ("value", Toggle) => "member_tts_update") + .help("Sets whether to send a member's messages as text-to-speech messages"), + command!(member_autoproxy => "member_autoproxy_show") + .help("Shows whether a member can be autoproxied"), + command!(member_autoproxy, ("value", Toggle) => "member_autoproxy_update") + .help("Sets whether a member will be autoproxied when autoproxy is set to latch or front mode"), + ] + }; + + let member_avatar_cmd = { + let member_avatar = tokens!( + member_target, + ( + "avatar", + ["profile", "picture", "icon", "image", "pfp", "pic"] + ) + ); + [ + command!(member_avatar => "member_avatar_show").help("Shows a member's avatar"), + command!(member_avatar, ("avatar", Avatar) => "member_avatar_update") + .help("Changes a member's avatar"), + command!(member_avatar, CLEAR => "member_avatar_clear") + .flag(YES) + .help("Clears a member's avatar"), + ] + }; + + let member_webhook_avatar_cmd = { + let member_webhook_avatar = tokens!( + member_target, + ( + "proxyavatar", + [ + "proxypfp", + "webhookavatar", + "webhookpfp", + "pa", + "pavatar", + "ppfp" + ] + ) + ); + [ + command!(member_webhook_avatar => "member_webhook_avatar_show") + .help("Shows a member's proxy avatar"), + command!(member_webhook_avatar, ("avatar", Avatar) => "member_webhook_avatar_update") + .help("Changes a member's proxy avatar"), + command!(member_webhook_avatar, CLEAR => "member_webhook_avatar_clear") + .flag(YES) + .help("Clears a member's proxy avatar"), + ] + }; + + let member_server_avatar_cmd = { + let member_server_avatar = tokens!( + member_target, + ( + "serveravatar", + [ + "sa", + "servericon", + "serverimage", + "serverpfp", + "serverpic", + "savatar", + "spic", + "guildavatar", + "guildpic", + "guildicon", + "sicon", + "spfp" + ] + ) + ); + [ + command!(member_server_avatar => "member_server_avatar_show") + .help("Shows a member's server-specific avatar"), + command!(member_server_avatar, ("avatar", Avatar) => "member_server_avatar_update") + .help("Changes a member's avatar in the current server"), + command!(member_server_avatar, CLEAR => "member_server_avatar_clear") + .flag(YES) + .help("Clears a member's server-specific avatar"), + ] + }; + + let member_group = tokens!(member_target, ("groups", ["group", "g"])); + let member_list_group_cmds = once( + command!(member_group, Optional(Remainder(("query", OpaqueString))) => "member_groups"), + ) + .map(|cmd| cmd.flags(get_list_flags())); + let member_add_remove_group_cmds = [ + command!(member_group, "add", Optional(("groups", GroupRefs)) => "member_group_add") + .help("Adds a member to one or more groups"), + command!(member_group, ("remove", ["rem"]), Optional(("groups", GroupRefs)) => "member_group_remove") + .help("Removes a member from one or more groups"), + ]; + + let member_display_id_cmd = + [command!(member_target, "id" => "member_id").help("Prints a member's ID")]; + + let member_delete_cmd = + [command!(member_target, delete => "member_delete").help("Deletes a member")]; + + let member_easter_eggs = + [command!(member_target, "soulscream" => "member_soulscream").show_in_suggestions(false)]; + + member_new_cmd + .chain(member_info_cmd) + .chain(member_name_cmd) + .chain(member_description_cmd) + .chain(member_privacy_cmd) + .chain(member_pronouns_cmd) + .chain(member_banner_cmd) + .chain(member_color_cmd) + .chain(member_birthday_cmd) + .chain(member_display_name_cmd) + .chain(member_server_name_cmd) + .chain(member_proxy_cmd) + .chain(member_avatar_cmd) + .chain(member_webhook_avatar_cmd) + .chain(member_server_avatar_cmd) + .chain(member_proxy_settings_cmd) + .chain(member_message_settings_cmd) + .chain(member_display_id_cmd) + .chain(member_delete_cmd) + .chain(member_easter_eggs) + .chain(member_add_remove_group_cmds) + .chain(member_list_group_cmds) +} diff --git a/crates/command_definitions/src/message.rs b/crates/command_definitions/src/message.rs new file mode 100644 index 00000000..00a5767f --- /dev/null +++ b/crates/command_definitions/src/message.rs @@ -0,0 +1,44 @@ +use command_parser::{ + parameter::{MESSAGE_LINK, MESSAGE_REF}, + token::TokensIterator, +}; + +use super::*; + +pub fn cmds() -> impl IntoIterator { + let message = tokens!(("message", ["msg", "messageinfo"]), Optional(MESSAGE_REF)); + + let author = ("author", ["sender", "a"]); + let delete = ("delete", ["del", "d"]); + let reproxy = ("reproxy", ["rp", "crimes", "crime"]); + + let edit = ("edit", ["e"]); + let new_content_param = Optional(Remainder(("new_content", OpaqueString))); + let edit_short_subcmd = tokens!(Optional(MESSAGE_LINK), new_content_param); + + let apply_edit = |cmd: Command| { + cmd.flag(("append", ["a"])) + .flag(("prepend", ["p"])) + .flag(("regex", ["r"])) + .flag(("no-space", ["nospace", "ns"])) + .flag(("clear-embeds", ["clear-embed", "ce"])) + .flag(("clear-attachments", ["clear-attachment", "ca"])) + .help("Edits a previously proxied message") + }; + let make_edit_cmd = |tokens: TokensIterator| apply_edit(command!(tokens => "message_edit")); + + [ + make_edit_cmd(tokens!(edit, edit_short_subcmd)), + // this one always does regex + make_edit_cmd(tokens!("x", edit_short_subcmd)).flag_value("regex", None), + command!(reproxy, Optional(("msg", MESSAGE_REF)), ("member", MemberRef) => "message_reproxy") + .help("Reproxies a previously proxied message with a different member"), + command!(message, author => "message_author").help("Shows the author of a proxied message"), + command!(message, delete => "message_delete").help("Deletes a proxied message"), + make_edit_cmd(tokens!(message, edit, new_content_param)), + command!(message => "message_info") + .flag(delete) + .flag(author) + .help("Shows information about a proxied message"), + ] +} diff --git a/crates/command_definitions/src/misc.rs b/crates/command_definitions/src/misc.rs new file mode 100644 index 00000000..7e7f1641 --- /dev/null +++ b/crates/command_definitions/src/misc.rs @@ -0,0 +1,9 @@ +use super::*; + +pub fn cmds() -> impl IntoIterator { + [ + command!("invite" => "invite").help("Gets a link to invite PluralKit to other servers"), + command!(("stats", ["status"]) => "stats") + .help("Shows statistics and information about PluralKit"), + ] +} diff --git a/crates/command_definitions/src/random.rs b/crates/command_definitions/src/random.rs new file mode 100644 index 00000000..c8e6c414 --- /dev/null +++ b/crates/command_definitions/src/random.rs @@ -0,0 +1,31 @@ +use crate::utils::get_list_flags; + +use super::*; + +pub fn cmds() -> impl Iterator { + let random = ("random", ["rand", "r"]); + let group = group::group(); + let member = member::member(); + + [ + command!(random => "random_self") + .help("Shows the info card of a randomly selected member in your system") + .flag(group), + command!(random, member => "random_self"), + command!(random, group => "random_group_self") + .help("Shows the info card of a randomly selected group in your system"), + command!(random, group::targeted() => "random_group_member_self") + .help("Shows the info card of a randomly selected member in a group in your system") + .flags(get_list_flags()), + command!(system::targeted(), random => "system_random") + .help("Shows the info card of a randomly selected member in a system") + .flag(group), + command!(system::targeted(), random, group => "system_random_group") + .help("Shows the info card of a randomly selected group in a system"), + command!(group::targeted(), random => "group_random_member") + .help("Shows the info card of a randomly selected member in a group") + .flags(get_list_flags()), + ] + .into_iter() + .map(|cmd| cmd.flag(ALL)) +} diff --git a/crates/command_definitions/src/server_config.rs b/crates/command_definitions/src/server_config.rs new file mode 100644 index 00000000..ceca4db9 --- /dev/null +++ b/crates/command_definitions/src/server_config.rs @@ -0,0 +1,140 @@ +use std::iter::once; + +use super::*; + +pub fn cmds() -> impl Iterator { + let server_config = ("serverconfig", ["guildconfig", "scfg", "gcfg"]); + + let log = tokens!(server_config, ("log", ["log", "logging"])); + let log_channel = tokens!(log, ("channel", ["ch", "chan"])); + let log_cleanup = tokens!(log, ("cleanup", ["clean"])); + let log_cleanup_short = tokens!(server_config, ("logclean", ["logclean", "logcleanup"])); + let log_blacklist = tokens!(log, ("blacklist", ["bl", "ignore"])); + + let proxy = tokens!(server_config, ("proxy", ["proxy", "proxying"])); + let proxy_blacklist = tokens!(proxy, ("blacklist", ["bl", "ignore", "disable"])); + + let invalid = tokens!( + server_config, + ("invalid", ["invalid", "unknown"]), + ("command", ["command", "cmd"]), + ("error", ["error", "response"]) + ); + let invalid_short = tokens!( + server_config, + ( + "invalidcommanderror", + ["invalidcommanderror", "unknowncommanderror", "ice"] + ) + ); + + let require_tag = tokens!( + server_config, + ("require", ["require", "enforce"]), + ("tag", ["tag", "systemtag"]) + ); + let require_tag_short = tokens!(server_config, ("requiretag", ["requiretag", "enforcetag"])); + + let suppress = tokens!( + server_config, + ("suppress", ["suppress"]), + ("notifications", ["notifications", "notifs"]) + ); + let suppress_short = tokens!(server_config, ("proxysilent", ["proxysilent", "silent"])); + + // Common tokens for add/remove operations + let add = ("add", ["enable", "on", "deny"]); + let remove = ("remove", ["disable", "off", "allow"]); + + let log_channel_cmds = [ + command!(log_channel => "server_config_log_channel_show") + .help("Shows the current log channel"), + command!(log_channel, ("channel", ChannelRef) => "server_config_log_channel_set") + .help("Designates a channel to post proxied messages to"), + command!(log_channel, CLEAR => "server_config_log_channel_clear") + .flag(YES) + .help("Clears the log channel"), + ]; + + let log_cleanup_cmds = [ + command!(log_cleanup => "server_config_log_cleanup_show") + .help("Shows whether log cleanup is enabled"), + command!(log_cleanup, Toggle => "server_config_log_cleanup_set") + .help("Toggles whether to clean up other bots' log channels"), + command!(log_cleanup_short => "server_config_log_cleanup_show") + .help("Shows whether log cleanup is enabled"), + command!(log_cleanup_short, Toggle => "server_config_log_cleanup_set") + .help("Toggles whether to clean up other bots' log channels"), + ]; + + let log_blacklist_cmds = [ + command!(log_blacklist => "server_config_log_blacklist_show") + .help("Shows channels where logging is disabled"), + command!(log_blacklist, add, Optional(("channel", ChannelRef)) => "server_config_log_blacklist_add") + .flag(ALL) + .help("Disables message logging in certain channels"), + command!(log_blacklist, remove, Optional(("channel", ChannelRef)) => "server_config_log_blacklist_remove") + .flag(ALL) + .help("Enables message logging in certain channels"), + ]; + + let proxy_blacklist_cmds = [ + command!(proxy_blacklist => "server_config_proxy_blacklist_show") + .help("Shows channels where proxying is disabled"), + command!(proxy_blacklist, add, Optional(("channel", ChannelRef)) => "server_config_proxy_blacklist_add") + .flag(ALL) + .help("Disables message proxying in certain channels"), + command!(proxy_blacklist, remove, Optional(("channel", ChannelRef)) => "server_config_proxy_blacklist_remove") + .flag(ALL) + .help("Enables message proxying in certain channels"), + ]; + + let invalid_cmds = [ + command!(invalid => "server_config_invalid_command_response_show") + .help("Shows whether error responses for invalid commands are enabled"), + command!(invalid, Toggle => "server_config_invalid_command_response_set") + .help("Sets whether to show an error message when an unknown command is sent"), + command!(invalid_short => "server_config_invalid_command_response_show") + .help("Shows whether error responses for invalid commands are enabled"), + command!(invalid_short, Toggle => "server_config_invalid_command_response_set") + .help("Sets whether to show an error message when an unknown command is sent"), + ]; + + let require_tag_cmds = [ + command!(require_tag => "server_config_require_system_tag_show") + .help("Shows whether system tags are required"), + command!(require_tag, Toggle => "server_config_require_system_tag_set").help( + "Sets whether server users are required to have a system tag on proxied messages", + ), + command!(require_tag_short => "server_config_require_system_tag_show") + .help("Shows whether system tags are required"), + command!(require_tag_short, Toggle => "server_config_require_system_tag_set").help( + "Sets whether server users are required to have a system tag on proxied messages", + ), + ]; + + let suppress_cmds = [ + command!(suppress => "server_config_suppress_notifications_show") + .help("Shows whether notifications are suppressed for proxied messages"), + command!(suppress, Toggle => "server_config_suppress_notifications_set") + .help("Sets whether all proxied messages will have notifications suppressed (sent as `@silent` messages)"), + command!(suppress_short => "server_config_suppress_notifications_show") + .help("Shows whether notifications are suppressed for proxied messages"), + command!(suppress_short, Toggle => "server_config_suppress_notifications_set") + .help("Sets whether all proxied messages will have notifications suppressed (sent as `@silent` messages)"), + ]; + + let main_cmd = once( + command!(server_config => "server_config_show") + .help("Shows the current server configuration"), + ); + + main_cmd + .chain(log_channel_cmds) + .chain(log_cleanup_cmds) + .chain(log_blacklist_cmds) + .chain(proxy_blacklist_cmds) + .chain(invalid_cmds) + .chain(require_tag_cmds) + .chain(suppress_cmds) +} diff --git a/crates/command_definitions/src/switch.rs b/crates/command_definitions/src/switch.rs new file mode 100644 index 00000000..c33410ea --- /dev/null +++ b/crates/command_definitions/src/switch.rs @@ -0,0 +1,42 @@ +use super::*; + +pub fn cmds() -> impl IntoIterator { + let switch = ("switch", ["sw"]); + + let edit = ("edit", ["e", "replace"]); + let r#move = ("move", ["m", "shift", "offset"]); + let delete = ("delete", ["remove", "erase", "cancel", "yeet"]); + let copy = ("copy", ["add", "duplicate", "dupe"]); + let out = "out"; + + let edit_flags = [ + ("first", ["f"]), + ("remove", ["r"]), + ("append", ["a"]), + ("prepend", ["p"]), + ]; + + [ + command!(switch, ("commands", ["help"]) => "switch_commands") + .help("Shows help for switch commands"), + command!(switch, out => "switch_out").help("Registers a switch with no members"), + command!(switch, delete => "switch_delete") + .flag(YES) + .help("Deletes the latest switch") + .flag(("all", ["clear", "c"])), + command!(switch, r#move, Remainder(OpaqueString) => "switch_move") + .flag(YES) + .help("Moves the latest switch in time"), // TODO: datetime parsing + command!(switch, edit, out => "switch_edit_out") + .help("Turns the latest switch into a switch-out") + .flag(YES), + command!(switch, edit, Optional(MemberRefs) => "switch_edit") + .flag(YES) + .help("Edits the members in the latest switch") + .flags(edit_flags), + command!(switch, copy, Optional(MemberRefs) => "switch_copy") + .help("Makes a new switch with the listed members added") + .flags(edit_flags), + command!(switch, MemberRefs => "switch_do").help("Registers a switch"), + ] +} diff --git a/crates/command_definitions/src/system.rs b/crates/command_definitions/src/system.rs new file mode 100644 index 00000000..5a76cfb0 --- /dev/null +++ b/crates/command_definitions/src/system.rs @@ -0,0 +1,315 @@ +use std::iter::once; + +use command_parser::token::TokensIterator; + +use crate::utils::get_list_flags; + +use super::*; + +pub fn cmds() -> impl Iterator { + edit() +} + +pub fn system() -> (&'static str, [&'static str; 1]) { + ("system", ["s"]) +} + +pub fn targeted() -> TokensIterator { + tokens!(system(), SystemRef) +} + +pub fn edit() -> impl Iterator { + let system = system(); + + let system_new_cmd = + once( + command!(system, ("new", ["n"]), Optional(Remainder(("name", OpaqueString))) => "system_new") + .help("Creates a new system") + ); + + let system_webhook = tokens!(system, ("webhook", ["hook"])); + let system_webhook_cmd = [ + command!(system_webhook => "system_webhook_show").help("Shows your system's webhook URL"), + command!(system_webhook, CLEAR => "system_webhook_clear") + .flag(YES) + .help("Clears your system's webhook URL"), + command!(system_webhook, ("url", OpaqueString) => "system_webhook_set") + .help("Sets your system's webhook URL"), + ]; + + let add_info_flags = |cmd: Command| { + cmd.flag(("public", ["pub"])) + .flag(("private", ["priv"])) + .flag(ALL) + }; + let system_info_cmd = [ + command!(system, Optional(SystemRef) => "system_info") + .help("Shows information about your system"), + command!(system, Optional(SystemRef), ("info", ["show", "view"]) => "system_info") + .help("Shows information about your system"), + ] + .into_iter() + .map(add_info_flags); + + let name = "name"; + let system_name_cmd = once( + command!(system, Optional(SystemRef), name => "system_show_name") + .help("Shows the systems name"), + ); + let system_name_self = tokens!(system, ("rename", [name])); + let system_name_self_cmd = [ + command!(system_name_self, CLEAR => "system_clear_name") + .flag(YES) + .help("Clears your system's name"), + command!(system_name_self, Remainder(("name", OpaqueString)) => "system_rename") + .help("Renames your system"), + ]; + + let server_name = ("servername", ["sn", "guildname"]); + let system_server_name_cmd = once( + command!(system, Optional(SystemRef), server_name => "system_show_server_name") + .help("Shows the system's server name"), + ); + let system_server_name_self = tokens!(system, server_name); + let system_server_name_self_cmd = [ + command!(system_server_name_self, CLEAR => "system_clear_server_name") + .flag(YES) + .help("Clears your system's server name"), + command!(system_server_name_self, Remainder(("name", OpaqueString)) => "system_rename_server_name") + .help("Renames your system's server name"), + ]; + + let description = ("description", ["desc", "d"]); + let system_description_cmd = once( + command!(system, Optional(SystemRef), description => "system_show_description") + .help("Shows the system's description"), + ); + let system_description_self = tokens!(system, description); + let system_description_self_cmd = [ + command!(system_description_self, CLEAR => "system_clear_description") + .flag(YES) + .help("Clears your system's description"), + command!(system_description_self, Remainder(("description", OpaqueString)) => "system_change_description") + .help("Changes your system's description"), + ]; + + let color = ("color", ["colour"]); + let system_color_cmd = once( + command!(system, Optional(SystemRef), color => "system_show_color") + .help("Shows the system's color"), + ); + let system_color_self = tokens!(system, color); + let system_color_self_cmd = [ + command!(system_color_self, CLEAR => "system_clear_color") + .flag(YES) + .help("Clears your system's color"), + command!(system_color_self, ("color", OpaqueString) => "system_change_color") + .help("Changes your system's color"), + ]; + + let tag = ("tag", ["suffix"]); + let system_tag_cmd = once( + command!(system, Optional(SystemRef), tag => "system_show_tag") + .help("Shows the system's tag"), + ); + let system_tag_self = tokens!(system, tag); + let system_tag_self_cmd = [ + command!(system_tag_self, CLEAR => "system_clear_tag") + .flag(YES) + .help("Clears your system's tag"), + command!(system_tag_self, Remainder(("tag", OpaqueString)) => "system_change_tag") + .help("Changes your system's tag"), + ]; + + let servertag = ("servertag", ["st", "guildtag", "stag", "deer"]); + let system_server_tag_cmd = once( + command!(system, Optional(SystemRef), servertag => "system_show_server_tag") + .help("Shows the system's server tag"), + ); + let system_server_tag_self = tokens!(system, servertag); + let system_server_tag_self_cmd = [ + command!(system_server_tag_self, CLEAR => "system_clear_server_tag") + .flag(YES) + .help("Clears your system's server tag"), + command!(system_server_tag_self, Remainder(("tag", OpaqueString)) => "system_change_server_tag") + .help("Changes your system's server tag"), + ]; + + let pronouns = ("pronouns", ["prns"]); + let system_pronouns_cmd = once( + command!(system, Optional(SystemRef), pronouns => "system_show_pronouns") + .help("Shows the system's pronouns"), + ); + let system_pronouns_self = tokens!(system, pronouns); + let system_pronouns_self_cmd = [ + command!(system_pronouns_self, CLEAR => "system_clear_pronouns") + .flag(YES) + .help("Clears your system's pronouns"), + command!(system_pronouns_self, Remainder(("pronouns", OpaqueString)) => "system_change_pronouns") + .help("Changes your system's pronouns"), + ]; + + let avatar = ("avatar", ["pfp"]); + let system_avatar_cmd = once( + command!(system, Optional(SystemRef), avatar => "system_show_avatar") + .help("Shows the system's avatar"), + ); + let system_avatar_self = tokens!(system, avatar); + let system_avatar_self_cmd = [ + command!(system_avatar_self, CLEAR => "system_clear_avatar") + .flag(YES) + .help("Clears your system's avatar"), + command!(system_avatar_self, ("avatar", Avatar) => "system_change_avatar") + .help("Changes your system's avatar"), + ]; + + let serveravatar = ("serveravatar", ["spfp"]); + let system_server_avatar_cmd = once( + command!(system, Optional(SystemRef), serveravatar => "system_show_server_avatar") + .help("Shows the system's server avatar"), + ); + let system_server_avatar_self = tokens!(system, serveravatar); + let system_server_avatar_self_cmd = [ + command!(system_server_avatar_self, CLEAR => "system_clear_server_avatar") + .flag(YES) + .help("Clears your system's server avatar"), + command!(system_server_avatar_self, ("avatar", Avatar) => "system_change_server_avatar") + .help("Changes your system's server avatar"), + ]; + + let banner = ("banner", ["cover"]); + let system_banner_cmd = once( + command!(system, Optional(SystemRef), banner => "system_show_banner") + .help("Shows the system's banner"), + ); + let system_banner_self = tokens!(system, banner); + let system_banner_self_cmd = [ + command!(system_banner_self, CLEAR => "system_clear_banner") + .flag(YES) + .help("Clears your system's banner"), + command!(system_banner_self, ("banner", Avatar) => "system_change_banner") + .help("Changes your system's banner"), + ]; + + let system_proxy = tokens!(system, "proxy"); + let system_proxy_cmd = [ + command!(system_proxy => "system_show_proxy_current") + .help("Shows your system's proxy setting for the guild you are in"), + command!(system_proxy, Skip(Toggle) => "system_toggle_proxy_current") + .help("Toggle your system's proxy for the guild you are in"), + command!(system_proxy, GuildRef => "system_show_proxy") + .help("Shows your system's proxy setting for a guild"), + command!(system_proxy, GuildRef, Toggle => "system_toggle_proxy") + .help("Toggle your system's proxy for a guild"), + ]; + + let system_privacy = tokens!(system, ("privacy", ["priv"])); + let system_privacy_cmd = [ + command!(system_privacy => "system_show_privacy") + .help("Shows your system's privacy settings"), + command!(system_privacy, ALL, ("level", PrivacyLevel) => "system_change_privacy_all") + .help("Changes all privacy settings for your system"), + command!(system_privacy, ("privacy", SystemPrivacyTarget), ("level", PrivacyLevel) => "system_change_privacy") + .help("Changes a specific privacy setting for your system"), + ]; + + let front = ("front", ["fronter", "fronters", "f"]); + let make_front_history = |subcmd: TokensIterator| { + command!(system, Optional(SystemRef), subcmd => "system_fronter_history") + .help("Shows a system's front history") + .flag(CLEAR) + }; + let make_front_percent = |subcmd: TokensIterator| { + command!(system, Optional(SystemRef), subcmd => "system_fronter_percent") + .help("Shows a system's front breakdown") + .flag(("duration", OpaqueString)) + .flag(("fronters-only", ["fo"])) + .flag("flat") + }; + let system_front_cmd = [ + command!(system, Optional(SystemRef), front => "system_fronter") + .help("Shows a system's fronter(s)"), + make_front_history(tokens!(front, ("history", ["h"]))), + make_front_history(tokens!(("fronthistory", ["fh", "history", "switches"]))), + make_front_percent(tokens!(front, ("percent", ["p", "%"]))), + make_front_percent(tokens!(( + "frontpercent", + ["fp", "front%", "frontbreakdown"] + ))), + ]; + + let search_param = Optional(Remainder(("query", OpaqueString))); + let apply_list_opts = |cmd: Command| cmd.flags(get_list_flags()); + + let members_subcmd = tokens!( + ( + "members", + ["l", "ls", "list", "find", "search", "query", "fd"] + ), + search_param + ); + let system_members_cmd = [ + command!(system, Optional(SystemRef), members_subcmd => "system_members") + .help("Lists a system's members"), + command!(members_subcmd => "system_members").help("Lists your system's members"), + ] + .map(apply_list_opts); + + let system_groups_cmd = once( + command!(system, Optional(SystemRef), ("groups", ["g"]), search_param => "system_groups") + .help("Lists groups in a system"), + ) + .map(apply_list_opts); + + let system_display_id_cmd = once( + command!(system, Optional(SystemRef), "id" => "system_display_id") + .help("Prints a system's ID"), + ); + + let system_delete = once( + command!(system, ("delete", ["erase", "remove", "yeet"]) => "system_delete") + .flag(("no-export", ["ne"])) + .help("Deletes the system"), + ); + + let system_link = [ + command!("link", ("account", UserRef) => "system_link") + .flag(YES) + .help("Links another Discord account to your system"), + command!("unlink", ("account", OpaqueString) => "system_unlink") + .help("Unlinks a Discord account from your system") + .flag(YES), + ]; + + system_new_cmd + .chain(system_webhook_cmd) + .chain(system_name_self_cmd) + .chain(system_server_name_self_cmd) + .chain(system_description_self_cmd) + .chain(system_color_self_cmd) + .chain(system_tag_self_cmd) + .chain(system_server_tag_self_cmd) + .chain(system_pronouns_self_cmd) + .chain(system_avatar_self_cmd) + .chain(system_server_avatar_self_cmd) + .chain(system_banner_self_cmd) + .chain(system_delete) + .chain(system_privacy_cmd) + .chain(system_proxy_cmd) + .chain(system_name_cmd) + .chain(system_server_name_cmd) + .chain(system_description_cmd) + .chain(system_color_cmd) + .chain(system_tag_cmd) + .chain(system_server_tag_cmd) + .chain(system_pronouns_cmd) + .chain(system_avatar_cmd) + .chain(system_server_avatar_cmd) + .chain(system_banner_cmd) + .chain(system_info_cmd) + .chain(system_front_cmd) + .chain(system_link) + .chain(system_members_cmd) + .chain(system_groups_cmd) + .chain(system_display_id_cmd) +} diff --git a/crates/command_definitions/src/utils.rs b/crates/command_definitions/src/utils.rs new file mode 100644 index 00000000..2c847ac7 --- /dev/null +++ b/crates/command_definitions/src/utils.rs @@ -0,0 +1,54 @@ +use command_parser::flag::Flag; + +use crate::ALL; + +pub fn get_list_flags() -> [Flag; 22] { + [ + // Short or long list + Flag::from(("full", ["f", "big", "details", "long"])), + // Search description + Flag::from(( + "search-description", + [ + "filter-description", + "in-description", + "sd", + "description", + "desc", + ], + )), + // Sort properties + Flag::from(("by-name", ["bn"])), + Flag::from(("by-display-name", ["by-displayname", "bdn"])), + Flag::from(("by-id", ["bid"])), + Flag::from(("by-message-count", ["bmc"])), + Flag::from(("by-created", ["bc", "bcd"])), + Flag::from(( + "by-last-fronted", + ["by-last-front", "by-last-switch", "blf", "bls"], + )), + Flag::from(("by-last-message", ["blm", "blp"])), + Flag::from(("by-birthday", ["by-birthdate", "bbd"])), + Flag::from(("random", ["rand"])), + // Sort reverse + Flag::from(("reverse", ["r", "rev"])), + // Privacy filter + Flag::from(ALL), + Flag::from(("private-only", ["po"])), + // Additional fields to include + Flag::from(( + "with-last-switch", + ["with-last-fronted", "with-last-front", "wls", "wlf"], + )), + Flag::from(("with-last-message", ["with-last-proxy", "wlm", "wlp"])), + Flag::from(("with-message-count", ["wmc"])), + Flag::from(("with-created", ["wc"])), + Flag::from(( + "with-avatar", + ["with-image", "with-icon", "wa", "wi", "ia", "ii", "img"], + )), + Flag::from(("with-pronouns", ["wp", "wprns"])), + Flag::from(("with-display-name", ["with-displayname", "wdn"])), + Flag::from(("with-birthday", ["wbd", "wb"])), + ] +} diff --git a/crates/command_parser/Cargo.toml b/crates/command_parser/Cargo.toml new file mode 100644 index 00000000..248a5ab2 --- /dev/null +++ b/crates/command_parser/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "command_parser" +version = "0.1.0" +edition = "2024" + +[dependencies] +lazy_static = { workspace = true } +smol_str = "0.3.2" +ordermap = "0.5" +regex = "1" +strsim = "0.11" +log = "0.4" \ No newline at end of file diff --git a/crates/command_parser/src/command.rs b/crates/command_parser/src/command.rs new file mode 100644 index 00000000..88434638 --- /dev/null +++ b/crates/command_parser/src/command.rs @@ -0,0 +1,162 @@ +use std::{ + collections::{HashMap, HashSet}, + fmt::{Debug, Display}, + sync::Arc, +}; + +use smol_str::SmolStr; + +use crate::{flag::Flag, parameter::ParameterValue, token::Token}; + +#[derive(Debug, Clone)] +pub struct Command { + // TODO: fix hygiene + pub tokens: Vec, + pub flags: HashSet, + pub help: SmolStr, + pub cb: SmolStr, + pub show_in_suggestions: bool, + pub parse_flags_before: usize, + pub hidden_flags: HashSet, + pub flag_values: HashMap>, + pub original: Option>, +} + +impl Command { + pub fn new(tokens: impl IntoIterator, cb: impl Into) -> Self { + let tokens = tokens.into_iter().collect::>(); + assert!(tokens.len() > 0); + // figure out which token to parse / put flags after + // (by default, put flags after the last token) + let mut parse_flags_before = tokens.len(); + for (idx, token) in tokens.iter().enumerate().rev() { + match token { + // we want flags to go before any parameters + Token::Parameter(_) => parse_flags_before = idx, + Token::Value { .. } => break, + } + } + Self { + flags: HashSet::new(), + help: SmolStr::new_static(""), + cb: cb.into(), + show_in_suggestions: true, + parse_flags_before, + tokens, + hidden_flags: HashSet::new(), + flag_values: HashMap::new(), + original: None, + } + } + + pub fn help(mut self, v: impl Into) -> Self { + self.help = v.into(); + self + } + + pub fn show_in_suggestions(mut self, v: bool) -> Self { + self.show_in_suggestions = v; + self + } + + pub fn flags(mut self, flags: impl IntoIterator>) -> Self { + self.flags.extend(flags.into_iter().map(Into::into)); + self + } + + pub fn flag(mut self, flag: impl Into) -> Self { + self.flags.insert(flag.into()); + self + } + + pub fn hidden_flag(mut self, flag: impl Into) -> Self { + let flag = flag.into(); + self.hidden_flags.insert(flag.get_name().into()); + self.flags.insert(flag); + self + } + + pub fn flag_value( + mut self, + flag_name: impl Into, + value: impl Into>, + ) -> Self { + self.flag_values.insert(flag_name.into(), value.into()); + self + } +} + +impl PartialEq for Command { + fn eq(&self, other: &Self) -> bool { + self.cb == other.cb + } +} + +impl Eq for Command {} + +impl std::hash::Hash for Command { + fn hash(&self, state: &mut H) { + self.cb.hash(state); + } +} + +impl Display for Command { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let visible_flags = self + .flags + .iter() + .filter(|flag| !self.hidden_flags.contains(flag.get_name())) + .collect::>(); + let write_flags = |f: &mut std::fmt::Formatter<'_>, space: bool| { + if visible_flags.is_empty() { + return Ok(()); + } + write!(f, "{}(", space.then_some(" ").unwrap_or(""))?; + let mut written = 0; + let max_flags = visible_flags.len().min(5); + for flag in &visible_flags { + if written > max_flags { + break; + } + write!(f, "{flag}")?; + if max_flags > written { + write!(f, " ")?; + } + written += 1; + } + if visible_flags.len() > written { + let rest_count = visible_flags.len() - written; + write!( + f, + " ...and {rest_count} flag{}...", + (rest_count > 1).then_some("s").unwrap_or(""), + )?; + } + write!(f, "){}", space.then_some("").unwrap_or(" ")) + }; + + for (idx, token) in self.tokens.iter().enumerate() { + if idx == self.parse_flags_before { + write_flags(f, false)?; + } + write!( + f, + "{token}{}", + (idx < self.tokens.len() - 1).then_some(" ").unwrap_or("") + )?; + } + if self.tokens.len() == self.parse_flags_before { + write_flags(f, true)?; + } + Ok(()) + } +} + +// a macro is required because generic cant be different types at the same time (which means you couldnt have ["member", MemberRef, "subcmd"] etc) +// (and something like &dyn Trait would require everything to be referenced which doesnt look nice anyway) +#[macro_export] +macro_rules! command { + ($($v:expr),+ => $cb:expr$(,)*) => { + $crate::command::Command::new($crate::tokens!($($v),+), $cb) + }; +} diff --git a/crates/command_parser/src/flag.rs b/crates/command_parser/src/flag.rs new file mode 100644 index 00000000..2df4221a --- /dev/null +++ b/crates/command_parser/src/flag.rs @@ -0,0 +1,137 @@ +use std::{fmt::Display, hash::Hash}; + +use smol_str::SmolStr; + +use crate::parameter::{Parameter, ParameterKind, ParameterValue}; + +#[derive(Debug)] +pub enum FlagValueMatchError { + ValueMissing, + InvalidValue { raw: SmolStr, msg: SmolStr }, +} + +#[derive(Debug, Clone)] +pub struct Flag { + name: SmolStr, + aliases: Vec, + value: Option, +} + +impl Display for Flag { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "-{}", self.name)?; + if let Some(value) = self.value.as_ref() { + write!(f, "=")?; + value.fmt(f)?; + } + Ok(()) + } +} + +impl PartialEq for Flag { + fn eq(&self, other: &Self) -> bool { + self.name.eq(&other.name) + } +} + +impl Eq for Flag {} + +impl Hash for Flag { + fn hash(&self, state: &mut H) { + self.name.hash(state); + } +} + +#[derive(Debug)] +pub enum FlagMatchError { + ValueMatchFailed(FlagValueMatchError), +} + +type TryMatchFlagResult = Option, FlagMatchError>>; + +impl Flag { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + aliases: Vec::new(), + value: None, + } + } + + pub fn value(mut self, param: impl Into) -> Self { + self.value = Some(param.into()); + self + } + + pub fn alias(mut self, alias: impl Into) -> Self { + self.aliases.push(alias.into()); + self + } + + pub fn get_name(&self) -> &str { + &self.name + } + + pub fn get_value(&self) -> Option<&Parameter> { + self.value.as_ref() + } + + pub fn get_aliases(&self) -> impl Iterator { + self.aliases.iter().map(|s| s.as_str()) + } + + pub fn try_match(&self, input_name: &str, input_value: Option<&str>) -> TryMatchFlagResult { + // if not matching the name or any aliases then skip anymore matching + if self.name != input_name && self.get_aliases().all(|s| s.ne(input_name)) { + return None; + } + // get token to try matching with, if flag doesn't have one then that means it is matched (it is without any value) + let Some(value) = self.value.as_ref() else { + return Some(Ok(None)); + }; + // check if we have a non-empty flag value, we return error if not (because flag requested a value) + let Some(input_value) = input_value else { + return Some(Err(FlagMatchError::ValueMatchFailed( + FlagValueMatchError::ValueMissing, + ))); + }; + // try matching the value + match value.match_value(input_value) { + Ok(param) => Some(Ok(Some(param))), + Err(err) => Some(Err(FlagMatchError::ValueMatchFailed( + FlagValueMatchError::InvalidValue { + raw: input_value.into(), + msg: err, + }, + ))), + } + } +} + +impl From<&str> for Flag { + fn from(name: &str) -> Self { + Flag::new(name) + } +} + +impl From<(&str, ParameterKind)> for Flag { + fn from((name, value): (&str, ParameterKind)) -> Self { + Flag::new(name).value(value) + } +} + +impl From<(&str, [&str; L])> for Flag { + fn from((name, aliases): (&str, [&str; L])) -> Self { + let mut flag = Flag::new(name); + for alias in aliases { + flag = flag.alias(alias); + } + flag + } +} + +impl From<((&str, [&str; L]), ParameterKind)> for Flag { + fn from(((name, aliases), value): ((&str, [&str; L]), ParameterKind)) -> Self { + Flag::from((name, aliases)).value(value) + } +} diff --git a/crates/command_parser/src/lib.rs b/crates/command_parser/src/lib.rs new file mode 100644 index 00000000..bf457c3d --- /dev/null +++ b/crates/command_parser/src/lib.rs @@ -0,0 +1,566 @@ +#![feature(anonymous_lifetime_in_impl_trait)] +#![feature(round_char_boundary)] +#![feature(iter_intersperse)] + +use std::sync::Arc; + +pub mod command; +pub mod flag; +pub mod parameter; +mod string; +pub mod token; +pub mod tree; + +use core::panic; +use std::fmt::Write; +use std::ops::Not; +use std::{collections::HashMap, usize}; + +use command::Command; +use flag::{Flag, FlagMatchError, FlagValueMatchError}; +use log::debug; +use parameter::ParameterValue; +use smol_str::SmolStr; +use string::MatchedFlag; +use token::{Token, TokenMatchResult}; + +// todo: this should come from the bot probably +const MAX_SUGGESTIONS: usize = 5; + +pub type Tree = tree::TreeBranch; + +#[derive(Debug)] +pub struct ParsedCommand { + pub command_def: Arc, + pub parameters: HashMap, + pub flags: HashMap>, +} + +#[derive(Clone, Debug)] +struct MatchedTokenState { + tree: Arc, + token: Token, + match_result: TokenMatchResult, + start_pos: usize, + filtered_tokens: Vec, +} + +pub fn parse_command( + command_tree: impl Into>, + prefix: String, + input: String, +) -> Result { + let input: SmolStr = input.into(); + let mut local_tree = command_tree.into(); + + // end position of all currently matched tokens + let mut current_pos: usize = 0; + let mut current_token_idx: usize = 0; + let mut raw_flags: Vec<(usize, MatchedFlag)> = Vec::new(); + + let mut matched_tokens: Vec = Vec::new(); + let mut filtered_tokens: Vec = Vec::new(); // these are tokens that we've already tried (and failed) + + let mut last_optional_param_error: Option<(SmolStr, SmolStr)> = None; + + // track the best attempt at parsing (deepest matched tokens) + // so we can use it for error messages/suggestions even if we backtrack later + let mut best_attempt: Option<(Arc, Vec, usize)> = None; + + loop { + let mut possible_tokens = local_tree + .possible_tokens() + .filter(|t| !filtered_tokens.contains(t)) + .collect::>(); + // sort so parameters come last + // we always want to test values first + // parameters that parse the remainder come last (otherwise they would always match) + possible_tokens.sort_by(|a, b| match (a, b) { + (Token::Parameter(param), _) if param.is_remainder() => std::cmp::Ordering::Greater, + (_, Token::Parameter(param)) if param.is_remainder() => std::cmp::Ordering::Less, + (Token::Parameter(_), Token::Parameter(_)) => std::cmp::Ordering::Equal, + (Token::Parameter(_), _) => std::cmp::Ordering::Greater, + (_, Token::Parameter(_)) => std::cmp::Ordering::Less, + _ => std::cmp::Ordering::Equal, + }); + debug!("possible: {:?}", possible_tokens); + let next = next_token(possible_tokens.iter().cloned(), &input, current_pos); + debug!("next: {:?}", next); + match &next { + Some((found_token, result, new_pos)) => { + match &result { + // todo: better error messages for these? + TokenMatchResult::MissingParameter { name } => { + return Err(format!( + "Expected parameter `{name}` in command `{prefix}{input} {found_token}`." + )); + } + TokenMatchResult::ParameterMatchError { input: raw, msg } => { + // we can try other branches if the parameter is optional or skip-on-error parameter + if matches!(found_token, Token::Parameter(param) if param.is_optional() || param.is_skip()) + && possible_tokens.len() > 1 + { + // save error for later, will be used if no other tokens match + last_optional_param_error = Some((raw.clone(), msg.clone())); + // try the other branches first + filtered_tokens.push(found_token.clone()); + continue; + } + + return Err(format!( + "Parameter `{raw}` in command `{prefix}{input}` could not be parsed: {msg}." + )); + } + // don't use a catch-all here, we want to make sure compiler errors when new errors are added + TokenMatchResult::MatchedParameter { .. } | TokenMatchResult::MatchedValue => { + // clear the error since we successfully matched forward, we dont need it anymore + last_optional_param_error = None; + } + } + + if let TokenMatchResult::MatchedParameter { .. } = result { + // we don't add params here, but wait until we matched a full command + // then we can use matched_tokens to extract the params + // this is so we don't have to keep track of "params" when trying branches + } + + // move to the next branch + if let Some(next_tree) = local_tree.get_branch(&found_token) { + matched_tokens.push(MatchedTokenState { + tree: local_tree.clone(), + token: found_token.clone(), + match_result: result.clone(), + start_pos: current_pos, + filtered_tokens: filtered_tokens.clone(), + }); + + // update best attempt if we're deeper + if best_attempt.as_ref().map(|x| x.1.len()).unwrap_or(0) < matched_tokens.len() + { + best_attempt = Some((next_tree.clone(), matched_tokens.clone(), *new_pos)); + } + + filtered_tokens.clear(); // new branch, new tokens + local_tree = next_tree.clone(); + } else { + panic!("found token {found_token:?} could not match tree, at {input}"); + } + + // advance our position on the input + current_pos = *new_pos; + current_token_idx += 1; + } + None => { + // redo the previous branches if we didnt match on a parameter + // this is a bit of a hack, but its necessary for making parameters on the same depth work + if let Some(state) = matched_tokens + .pop() + .and_then(|m| matches!(m.token, Token::Parameter(_)).then_some(m)) + { + debug!("redoing previous branch: {:?}", state.token); + local_tree = state.tree; + current_pos = state.start_pos; // reset position to previous branch's start + filtered_tokens = state.filtered_tokens; // reset filtered tokens to the previous branch's + filtered_tokens.push(state.token); + continue; + } + + if let Some((raw, msg)) = last_optional_param_error { + return Err(format!( + "Parameter `{raw}` in command `{prefix}{input}` could not be parsed: {msg}." + )); + } + + // restore best attempt if it's deeper than current state + // this helps when we backtracked out of the correct path because of a later error + if let Some((best_tree, best_matched, best_pos)) = best_attempt { + if best_matched.len() > matched_tokens.len() { + local_tree = best_tree; + matched_tokens = best_matched; + current_pos = best_pos; + } + } + + let mut error = format!("Unknown command `{prefix}{input}`."); + + // normalize input by replacing parameters with placeholders + let mut normalized_input = String::new(); + for state in &matched_tokens { + write!(&mut normalized_input, "{} ", state.token).unwrap(); + } + normalized_input.push_str(&input[current_pos..].trim_start()); + + let input_tokens = input.split_whitespace().collect::>(); + let mut possible_commands = rank_possible_commands( + &normalized_input, + local_tree.possible_commands(usize::MAX), + &input_tokens, + ); + + // checks if we matched a parameter last + // if we did, we might have matched a parameter "by accident" (ie. `pk;s renam` matched `s `) + // so we also want to suggest commands from the *previous* branch + if let Some(state) = matched_tokens.last() + && matches!(state.token, Token::Parameter(_)) + { + let mut parent_input = String::new(); + // recreate input string up to the parameter + for parent_state in matched_tokens.iter().take(matched_tokens.len() - 1) { + write!(&mut parent_input, "{} ", parent_state.token).unwrap(); + } + // assume the user intended to type a command here, so we use the raw input + // (eg. `s renam` -> `s renam`) + parent_input.push_str(&input[state.start_pos..].trim_start()); + + let input_tokens = parent_input.split_whitespace().collect::>(); + let parent_commands = rank_possible_commands( + &parent_input, + state.tree.possible_commands(usize::MAX), + &input_tokens, + ); + possible_commands.extend(parent_commands); + + // re-deduplicate + possible_commands.dedup_by(|a, b| { + let cmd_a = a.0.original.as_deref().unwrap_or(&a.0); + let cmd_b = b.0.original.as_deref().unwrap_or(&b.0); + cmd_a == cmd_b + }); + // re-sort after extending + possible_commands + .sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal)); + } + + if possible_commands.is_empty().not() { + error.push_str(" Perhaps you meant one of the following commands:\n"); + fmt_commands_list(&mut error, &prefix, possible_commands); + } else { + // add a space between the unknown command and "for a list of all possible commands" + // message if we didn't add any possible suggestions + error.push_str(" "); + } + + error.push_str( + "For a list of all possible commands, see .", + ); + + // todo: check if last token is a common incorrect unquote (multi-member names etc) + // todo: check if this is a system name in pk;s command + return Err(error); + } + } + // match flags until there are none left + while let Some(matched_flag) = string::next_flag(&input, current_pos) { + current_pos = matched_flag.next_pos; + debug!("flag matched {matched_flag:?}"); + raw_flags.push((current_token_idx, matched_flag)); + } + // if we have a command, stop parsing and return it (only if there is no remaining input) + if current_pos >= input.len() + && let Some(command) = local_tree.command() + { + // match the flags against this commands flags + let mut flags: HashMap> = HashMap::new(); + let mut misplaced_flags: Vec = Vec::new(); + let mut invalid_flags: Vec = Vec::new(); + + let mut params: HashMap = HashMap::new(); + for state in &matched_tokens { + if let TokenMatchResult::MatchedParameter { name, value } = &state.match_result { + params.insert(name.to_string(), value.clone()); + } + } + + for (token_idx, raw_flag) in raw_flags { + let Some(matched_flag) = match_flag(command.flags.iter(), raw_flag.clone()) else { + invalid_flags.push(raw_flag); + continue; + }; + + if token_idx != command.parse_flags_before { + misplaced_flags.push(raw_flag); + continue; + } + + match matched_flag { + // a flag was matched + Ok((name, value)) => { + flags.insert(name.into(), value); + } + Err((flag, err)) => { + let error = match err { + FlagMatchError::ValueMatchFailed(FlagValueMatchError::ValueMissing) => { + format!( + "Flag `-{name}` in command `{prefix}{input}` is missing a value, try passing `{flag}`.", + name = flag.get_name() + ) + } + FlagMatchError::ValueMatchFailed( + FlagValueMatchError::InvalidValue { msg, raw }, + ) => { + format!( + "Flag `-{name}` in command `{prefix}{input}` has a value (`{raw}`) that could not be parsed: {msg}.", + name = flag.get_name() + ) + } + }; + return Err(error); + } + } + } + + let full_cmd = command.original.as_ref().unwrap_or(&command); + if misplaced_flags.is_empty().not() { + let mut error = format!( + "Flag{} ", + (misplaced_flags.len() > 1).then_some("s").unwrap_or("") + ); + for (idx, matched_flag) in misplaced_flags.iter().enumerate() { + write!(&mut error, "`-{}`", matched_flag.name).expect("oom"); + if idx < misplaced_flags.len() - 1 { + error.push_str(", "); + } + } + write!( + &mut error, + " in command `{prefix}{input}` {} misplaced. Try reordering to match the command usage `{prefix}{command}`.", + (misplaced_flags.len() > 1).then_some("are").unwrap_or("is"), + command = full_cmd + ).expect("oom"); + return Err(error); + } + if invalid_flags.is_empty().not() { + let mut error = format!( + "Flag{} ", + (invalid_flags.len() > 1).then_some("s").unwrap_or("") + ); + for (idx, matched_flag) in invalid_flags.iter().enumerate() { + write!(&mut error, "`-{}`", matched_flag.name).expect("oom"); + if idx < invalid_flags.len() - 1 { + error.push_str(", "); + } + } + write!( + &mut error, + " {} seem to be applicable in this command (`{prefix}{command}`).", + (invalid_flags.len() > 1) + .then_some("don't") + .unwrap_or("doesn't"), + command = full_cmd + ) + .expect("oom"); + return Err(error); + } + + for (name, value) in &full_cmd.flag_values { + flags.insert(name.to_string(), value.clone()); + } + + debug!("{} {flags:?} {params:?}", full_cmd.cb); + return Ok(ParsedCommand { + command_def: full_cmd.clone(), + flags, + parameters: params, + }); + } + } +} + +fn match_flag<'a>( + possible_flags: impl Iterator, + matched_flag: MatchedFlag<'a>, +) -> Option), (&'a Flag, FlagMatchError)>> { + // check for all (possible) flags, see if token matches + for flag in possible_flags { + debug!("matching flag {flag:?}"); + match flag.try_match(matched_flag.name, matched_flag.value) { + Some(Ok(param)) => return Some(Ok((flag.get_name().into(), param))), + Some(Err(err)) => return Some(Err((flag, err))), + None => {} + } + } + + None +} + +/// Find the next token from an either raw or partially parsed command string +/// +/// Returns: +/// - nothing (none matched) +/// - matched token, to move deeper into the tree +/// - matched value (if this command matched an user-provided value such as a member name) +/// - end position of matched token +/// - error when matching +fn next_token<'a>( + possible_tokens: impl Iterator, + input: &str, + current_pos: usize, +) -> Option<(Token, TokenMatchResult, usize)> { + // get next parameter, matching quotes + let matched = string::next_param(&input, current_pos); + debug!("matched: {matched:?}\n---"); + + // iterate over tokens and run try_match + for token in possible_tokens { + let is_match_remaining_token = + |token: &Token| matches!(token, Token::Parameter(param) if param.is_remainder()); + // check if this is a token that matches the rest of the input + let match_remaining = is_match_remaining_token(token); + // either use matched param or rest of the input if matching remaining + let input_to_match = matched.as_ref().map(|v| { + match_remaining + .then_some(&input[current_pos..]) + .unwrap_or(v.value) + }); + let next_pos = match matched { + // return last possible pos if we matched remaining, + Some(_) if match_remaining => input.len(), + // otherwise use matched param next pos, + Some(ref param) => param.next_pos, + // and if didnt match anything we stay where we are + None => current_pos, + }; + match token.try_match(input_to_match) { + Some(result) => { + //debug!("matched token: {}", token); + return Some((token.clone(), result, next_pos)); + } + None => {} // continue matching until we exhaust all tokens + } + } + + None +} + +// todo: should probably move this somewhere else +fn rank_possible_commands( + input: &str, + possible_commands: impl IntoIterator, + input_tokens: &[&str], +) -> Vec<(Command, String, f64, bool)> { + let mut commands_with_scores: Vec<(&Command, String, f64, bool)> = possible_commands + .into_iter() + .map(|cmd| cmd.original.as_deref().unwrap_or(cmd)) + .filter(|cmd| cmd.show_in_suggestions) + .flat_map(|cmd| { + let versions = generate_command_versions(cmd, input_tokens); + versions + .into_iter() + .map(move |(display, scoring, is_alias)| { + let similarity = strsim::jaro_winkler(&input, &scoring); + // if similarity > 0.7 { + // debug!("DEBUG: ranking: '{}' vs '{}' = {}", input, scoring, similarity); + // } + (cmd, display, similarity, is_alias) + }) + }) + .collect(); + + commands_with_scores.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal)); + + // remove duplicate commands + let mut seen_commands = std::collections::HashSet::new(); + let mut best_commands = Vec::new(); + for (cmd, version, score, is_alias) in commands_with_scores { + if seen_commands.insert(cmd) { + best_commands.push((cmd, version, score, is_alias)); + } + } + + const MIN_SCORE_THRESHOLD: f64 = 0.8; + if best_commands.is_empty() || best_commands[0].2 < MIN_SCORE_THRESHOLD { + return Vec::new(); + } + + // if score falls off too much, don't show + let falloff_threshold: f64 = 0.2; + let best_score = best_commands[0].2; + + let mut commands_to_show = Vec::new(); + for (command, version, score, is_alias) in best_commands.into_iter().take(MAX_SUGGESTIONS) { + let delta = best_score - score; + if delta > falloff_threshold { + break; + } + commands_to_show.push((command.clone(), version, score, is_alias)); + } + + commands_to_show +} + +fn fmt_commands_list( + f: &mut String, + prefix: &str, + commands_to_show: Vec<(Command, String, f64, bool)>, +) { + for (command, version, _, is_alias) in commands_to_show { + writeln!( + f, + "- **{prefix}{version}**{alias} - *{help}*", + help = command.help, + alias = is_alias + .then(|| format!( + " (alias of **{prefix}{base_version}**)", + base_version = build_command_string(&command, None, &[]) + )) + .unwrap_or_else(String::new), + ) + .expect("oom"); + } +} + +fn generate_command_versions(cmd: &Command, input_tokens: &[&str]) -> Vec<(String, String, bool)> { + let mut versions = Vec::new(); + + // Start with base version using primary names + let base_display = build_command_string(cmd, None, &[]); + let base_scoring = build_command_string(cmd, None, input_tokens); + versions.push((base_display, base_scoring, false)); + + // Generate versions for each alias combination + for (idx, token) in cmd.tokens.iter().enumerate() { + if let Token::Value { aliases, .. } = token { + for alias in aliases { + let display = build_command_string(cmd, Some((idx, alias.as_str())), &[]); + let scoring = build_command_string(cmd, Some((idx, alias.as_str())), input_tokens); + versions.push((display, scoring, true)); + } + } + } + + versions +} + +fn build_command_string( + cmd: &Command, + alias_replacement: Option<(usize, &str)>, + input_tokens: &[&str], +) -> String { + let mut result = String::new(); + for (idx, token) in cmd.tokens.iter().enumerate() { + if idx > 0 { + result.push(' '); + } + + // Check if we should use an alias for this token + let replacement = alias_replacement + .filter(|(i, _)| *i == idx) + .map(|(_, alias)| alias); + + match token { + Token::Value { name, .. } => { + result.push_str(replacement.unwrap_or(name)); + } + Token::Parameter(param) => { + // if we have an input token at this position, use it + // otherwise use the placeholder + if let Some(input_token) = input_tokens.get(idx) { + result.push_str(input_token); + } else { + write!(&mut result, "{param}").unwrap() + } + } + } + } + result +} diff --git a/crates/command_parser/src/parameter.rs b/crates/command_parser/src/parameter.rs new file mode 100644 index 00000000..6b8e7e5f --- /dev/null +++ b/crates/command_parser/src/parameter.rs @@ -0,0 +1,520 @@ +use std::{ + fmt::{Debug, Display}, + str::FromStr, +}; + +use regex::Regex; +use smol_str::{SmolStr, format_smolstr}; + +use crate::token::{Token, TokenMatchResult}; + +pub const MESSAGE_REF: ParameterKind = ParameterKind::MessageRef { + id: true, + link: true, +}; +pub const MESSAGE_LINK: ParameterKind = ParameterKind::MessageRef { + id: false, + link: true, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ParameterKind { + OpaqueString, + OpaqueInt, + MemberRef, + MemberRefs, + GroupRef, + GroupRefs, + SystemRef, + UserRef, + MessageRef { id: bool, link: bool }, + ChannelRef, + GuildRef, + MemberPrivacyTarget, + GroupPrivacyTarget, + SystemPrivacyTarget, + PrivacyLevel, + Toggle, + Avatar, + ProxySwitchAction, +} + +impl ParameterKind { + pub(crate) fn default_name(&self) -> &str { + match self { + ParameterKind::OpaqueString => "string", + ParameterKind::OpaqueInt => "number", + ParameterKind::MemberRef => "target", + ParameterKind::MemberRefs => "targets", + ParameterKind::GroupRef => "target", + ParameterKind::GroupRefs => "targets", + ParameterKind::SystemRef => "target", + ParameterKind::UserRef => "target", + ParameterKind::MessageRef { .. } => "target", + ParameterKind::ChannelRef => "target", + ParameterKind::GuildRef => "target", + ParameterKind::MemberPrivacyTarget => "member_privacy_target", + ParameterKind::GroupPrivacyTarget => "group_privacy_target", + ParameterKind::SystemPrivacyTarget => "system_privacy_target", + ParameterKind::PrivacyLevel => "privacy_level", + ParameterKind::Toggle => "toggle", + ParameterKind::Avatar => "avatar", + ParameterKind::ProxySwitchAction => "proxy_switch_action", + } + } +} + +#[derive(Debug, Clone)] +pub enum ParameterValue { + OpaqueString(String), + OpaqueInt(i32), + MemberRef(String), + MemberRefs(Vec), + GroupRef(String), + GroupRefs(Vec), + SystemRef(String), + UserRef(u64), + MessageRef(Option, Option, u64), + ChannelRef(u64), + GuildRef(u64), + MemberPrivacyTarget(String), + GroupPrivacyTarget(String), + SystemPrivacyTarget(String), + PrivacyLevel(String), + Toggle(bool), + Avatar(String), + ProxySwitchAction(ProxySwitchAction), + Null, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Parameter { + name: SmolStr, + kind: ParameterKind, + remainder: bool, + optional: bool, + skip: bool, +} + +impl Parameter { + pub fn name(&self) -> &str { + &self.name + } + + pub fn kind(&self) -> ParameterKind { + self.kind + } + + pub fn remainder(mut self) -> Self { + self.remainder = true; + self + } + + pub fn optional(mut self) -> Self { + self.optional = true; + self + } + + pub fn skip(mut self) -> Self { + self.skip = true; + self + } + + pub fn is_remainder(&self) -> bool { + self.remainder + } + + pub fn is_optional(&self) -> bool { + self.optional + } + + pub fn is_skip(&self) -> bool { + self.skip + } + + pub fn match_value(&self, input: &str) -> Result { + match self.kind { + // TODO: actually parse image url + ParameterKind::OpaqueString => Ok(ParameterValue::OpaqueString(input.into())), + ParameterKind::OpaqueInt => input + .parse::() + .map(|num| ParameterValue::OpaqueInt(num)) + .map_err(|err| format_smolstr!("invalid integer: {err}")), + ParameterKind::GroupRef => Ok(ParameterValue::GroupRef(input.into())), + ParameterKind::GroupRefs => Ok(ParameterValue::GroupRefs( + input.split(' ').map(|s| s.trim().to_string()).collect(), + )), + ParameterKind::MemberRef => Ok(ParameterValue::MemberRef(input.into())), + ParameterKind::MemberRefs => Ok(ParameterValue::MemberRefs( + input.split(' ').map(|s| s.trim().to_string()).collect(), + )), + ParameterKind::SystemRef => Ok(ParameterValue::SystemRef(input.into())), + ParameterKind::UserRef => parse_user_ref(input), + ParameterKind::MemberPrivacyTarget => MemberPrivacyTargetKind::from_str(input) + .map(|target| ParameterValue::MemberPrivacyTarget(target.as_ref().into())), + ParameterKind::GroupPrivacyTarget => GroupPrivacyTargetKind::from_str(input) + .map(|target| ParameterValue::GroupPrivacyTarget(target.as_ref().into())), + ParameterKind::SystemPrivacyTarget => SystemPrivacyTargetKind::from_str(input) + .map(|target| ParameterValue::SystemPrivacyTarget(target.as_ref().into())), + ParameterKind::PrivacyLevel => PrivacyLevelKind::from_str(input) + .map(|level| ParameterValue::PrivacyLevel(level.as_ref().into())), + ParameterKind::Toggle => { + Toggle::from_str(input).map(|t| ParameterValue::Toggle(t.into())) + } + ParameterKind::Avatar => Ok(ParameterValue::Avatar(input.into())), + ParameterKind::MessageRef { id, link } => { + if id { + if let Ok(message_id) = input.parse::() { + return Ok(ParameterValue::MessageRef(None, None, message_id)); + } + } + + if link { + static SERVER_RE: std::sync::LazyLock = std::sync::LazyLock::new( + || { + regex::Regex::new( + r"https://(?:\w+\.)?discord(?:app)?\.com/channels/(?P\d+)/(?P\d+)/(?P\d+)", + ) + .unwrap() + }, + ); + + static DM_RE: std::sync::LazyLock = std::sync::LazyLock::new( + || { + regex::Regex::new( + r"https://(?:\w+\.)?discord(?:app)?\.com/channels/@me/(?P\d+)/(?P\d+)", + ) + .unwrap() + }, + ); + + if let Some(captures) = SERVER_RE.captures(input) { + let guild_id = captures.parse_id("guild")?; + let channel_id = captures.parse_id("channel")?; + let message_id = captures.parse_id("message")?; + + Ok(ParameterValue::MessageRef( + Some(guild_id), + Some(channel_id), + message_id, + )) + } else if let Some(captures) = DM_RE.captures(input) { + let channel_id = captures.parse_id("channel")?; + let message_id = captures.parse_id("message")?; + + Ok(ParameterValue::MessageRef( + None, + Some(channel_id), + message_id, + )) + } else { + Err(SmolStr::new("invalid message reference")) + } + } else { + unreachable!("link and id both cant be false") + } + } + ParameterKind::ChannelRef => { + let mut text = input; + + if text.len() > 3 && text.starts_with("<#") && text.ends_with('>') { + text = &text[2..text.len() - 1]; + } + + text.parse::() + .map(ParameterValue::ChannelRef) + .map_err(|_| SmolStr::new("invalid channel ID")) + } + ParameterKind::GuildRef => input + .parse::() + .map(ParameterValue::GuildRef) + .map_err(|_| SmolStr::new("invalid guild ID")), + ParameterKind::ProxySwitchAction => ProxySwitchAction::from_str(input) + .map(ParameterValue::ProxySwitchAction) + .map_err(|_| SmolStr::new("invalid proxy switch action, must be new/add/off")), + } + } +} + +impl Display for Parameter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.kind { + ParameterKind::OpaqueString => { + write!(f, "[{}]", self.name) + } + ParameterKind::OpaqueInt => { + write!(f, "[{}]", self.name) + } + ParameterKind::MemberRef => write!(f, ""), + ParameterKind::MemberRefs => write!(f, " "), + ParameterKind::GroupRef => write!(f, ""), + ParameterKind::GroupRefs => write!(f, " "), + ParameterKind::SystemRef => write!(f, ""), + ParameterKind::UserRef => write!(f, ""), + ParameterKind::MessageRef { link, id } => write!( + f, + "", + link.then_some("link") + .into_iter() + .chain(id.then_some("id")) + .collect::>() + .join("/") + ), + ParameterKind::ChannelRef => write!(f, ""), + ParameterKind::GuildRef => write!(f, ""), + ParameterKind::MemberPrivacyTarget => write!(f, ""), + ParameterKind::GroupPrivacyTarget => write!(f, ""), + ParameterKind::SystemPrivacyTarget => write!(f, ""), + ParameterKind::PrivacyLevel => write!(f, "[privacy level]"), + ParameterKind::Toggle => write!(f, ""), + ParameterKind::Avatar => write!(f, ""), + ParameterKind::ProxySwitchAction => write!(f, ""), + }?; + if self.is_remainder() { + write!(f, "...")?; + } + Ok(()) + } +} + +fn is_remainder(kind: ParameterKind) -> bool { + matches!(kind, ParameterKind::MemberRefs | ParameterKind::GroupRefs) +} + +impl From for Parameter { + fn from(value: ParameterKind) -> Self { + Parameter { + name: value.default_name().into(), + kind: value, + remainder: is_remainder(value), + optional: false, + skip: false, + } + } +} + +impl From<(&str, ParameterKind)> for Parameter { + fn from((name, kind): (&str, ParameterKind)) -> Self { + Parameter { + name: name.into(), + kind, + remainder: is_remainder(kind), + optional: false, + skip: false, + } + } +} + +/// if no input is left to parse, this parameter matches to Null +#[derive(Clone)] +pub struct Optional>(pub P); + +impl> From> for Parameter { + fn from(value: Optional

) -> Self { + let p = value.0.into(); + p.optional() + } +} + +/// tells the parser to use the remainder of the input as the input to this parameter +#[derive(Clone)] +pub struct Remainder>(pub P); + +impl> From> for Parameter { + fn from(value: Remainder

) -> Self { + let p = value.0.into(); + p.remainder() + } +} + +// todo: this should ideally be removed in favor of making Token::Parameter take multiple parameters +/// skips the branch this parameter is in if it does not match +#[derive(Clone)] +pub struct Skip>(pub P); + +impl> From> for Parameter { + fn from(value: Skip

) -> Self { + let p = value.0.into(); + p.skip() + } +} + +fn parse_user_ref(input: &str) -> Result { + if let Ok(user_id) = input.parse::() { + return Ok(ParameterValue::UserRef(user_id)); + } + + static RE: std::sync::LazyLock = + std::sync::LazyLock::new(|| Regex::new(r"<@!?(\d{17,19})>").unwrap()); + if let Some(captures) = RE.captures(&input) { + return captures[1] + .parse::() + .map(|id| ParameterValue::UserRef(id)) + .map_err(|_| SmolStr::new("invalid user ID")); + } + + Err(SmolStr::new("invalid user ID")) +} + +macro_rules! define_enum { + ($name:ident ($pretty_name:expr): $($variant:ident),* $(,)?) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum $name { + $($variant),* + } + + impl $name { + pub const PRETTY_NAME: &'static str = $pretty_name; + + pub fn variants() -> impl Iterator { + [$(Self::$variant),*].into_iter() + } + + pub fn variants_str() -> impl Iterator { + [$(Self::$variant.as_ref()),*].into_iter() + } + + pub fn get_error() -> SmolStr { + let pretty_name = Self::PRETTY_NAME; + let vars = Self::variants_str().intersperse("/").collect::(); + format_smolstr!("invalid {pretty_name}, must be one of {vars}") + } + } + }; +} + +macro_rules! str_enum { + ($name:ident: $($variant:ident = $variant_str:literal),* $(,)?) => { + impl AsRef for $name { + fn as_ref(&self) -> &str { + match self { + $(Self::$variant => $variant_str),* + } + } + } + }; +} + +macro_rules! auto_enum { + ($name:ident ($pretty_name:expr): $($variant:ident = $variant_str:literal $(| $variant_matches:literal)*),* $(,)?) => { + define_enum!($name($pretty_name): $($variant),*); + + str_enum!($name: $($variant = $variant_str),*); + + impl FromStr for $name { + type Err = SmolStr; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + $($variant_str $(| $variant_matches)* => Ok(Self::$variant),)* + _ => Err(Self::get_error()), + } + } + } + }; +} + +auto_enum! { + MemberPrivacyTargetKind("member privacy target"): + Visibility = "visibility", + Name = "name", + Description = "description", + Banner = "banner", + Avatar = "avatar", + Birthday = "birthday", + Pronouns = "pronouns", + Proxy = "proxy", + Metadata = "metadata", +} + +auto_enum! { + GroupPrivacyTargetKind("group privacy target"): + Name = "name", + Icon = "icon" | "avatar", + Description = "description", + Banner = "banner", + List = "list", + Metadata = "metadata", + Visibility = "visibility", +} + +auto_enum! { + SystemPrivacyTargetKind("system privacy target"): + Name = "name", + Avatar = "avatar" | "pfp" | "pic" | "icon", + Description = "description" | "desc" | "bio" | "info", + Banner = "banner" | "splash" | "cover", + Pronouns = "pronouns" | "prns" | "pn", + MemberList = "members" | "memberlist" | "list", + GroupList = "groups" | "gs", + Front = "front" | "fronter" | "fronters", + FrontHistory = "fronthistory" | "fh" | "switches", +} + +auto_enum! { + PrivacyLevelKind("privacy level"): + Public = "public", + Private = "private", +} + +define_enum!(Toggle("toggle"): On, Off); +str_enum!(Toggle: On = "on", Off = "off"); + +impl FromStr for Toggle { + type Err = SmolStr; + + fn from_str(s: &str) -> Result { + let matches_self = |toggle: &Self| { + matches!( + Token::from(*toggle).try_match(Some(s)), + Some(TokenMatchResult::MatchedValue) + ) + }; + Self::variants() + .find(matches_self) + .ok_or_else(Self::get_error) + } +} + +impl From for Token { + fn from(toggle: Toggle) -> Self { + match toggle { + Toggle::On => Self::from(("on", ["yes", "true", "enable", "enabled"])), + Toggle::Off => Self::from(("off", ["no", "false", "disable", "disabled"])), + } + } +} + +impl Into for Toggle { + fn into(self) -> bool { + match self { + Toggle::On => true, + Toggle::Off => false, + } + } +} + +define_enum!(ProxySwitchAction("proxy switch action"): New, Add, Off); +str_enum!(ProxySwitchAction: New = "new", Add = "add", Off = "off"); + +impl FromStr for ProxySwitchAction { + type Err = SmolStr; + + fn from_str(s: &str) -> Result { + Self::variants() + .find(|action| action.as_ref() == s) + .ok_or_else(Self::get_error) + } +} + +trait ParseMessageLink { + fn parse_id(&self, name: &str) -> Result; +} + +impl ParseMessageLink for regex::Captures<'_> { + fn parse_id(&self, name: &str) -> Result { + self.name(name) + .and_then(|m| m.as_str().parse::().ok()) + .ok_or_else(|| SmolStr::new(format!("invalid {} ID in message link", name))) + } +} diff --git a/crates/command_parser/src/string.rs b/crates/command_parser/src/string.rs new file mode 100644 index 00000000..2d07331e --- /dev/null +++ b/crates/command_parser/src/string.rs @@ -0,0 +1,186 @@ +use std::collections::HashMap; + +use log::debug; +use smol_str::{SmolStr, ToSmolStr}; + +lazy_static::lazy_static! { + // Dictionary of (left, right) quote pairs + // Each char in the string is an individual quote, multi-char strings imply "one of the following chars" + // Certain languages can have quote patterns that have a different character for open and close + pub static ref QUOTE_PAIRS: HashMap = { + let mut pairs: HashMap = HashMap::new(); + + let mut insert_pair = |a: &'static str, b: &'static str| { + let a = SmolStr::new_static(a); + let b = SmolStr::new_static(b); + pairs.insert(a.clone(), b.clone()); + // make it easier to look up right quotes + for char in a.chars() { + pairs.insert(char.to_smolstr(), b.clone()); + } + }; + + // Basic + insert_pair( "'", "'" ); // ASCII single quotes + insert_pair( "\"", "\"" ); // ASCII double quotes + + // "Smart quotes" + // Specifically ignore the left/right status of the quotes and match any combination of them + // Left string also includes "low" quotes to allow for the low-high style used in some locales + insert_pair( "\u{201C}\u{201D}\u{201F}\u{201E}", "\u{201C}\u{201D}\u{201F}" ); // double quotes + insert_pair( "\u{2018}\u{2019}\u{201B}\u{201A}", "\u{2018}\u{2019}\u{201B}" ); // single quotes + + // Chevrons (normal and "fullwidth" variants) + insert_pair( "\u{00AB}\u{300A}", "\u{00BB}\u{300B}" ); // double chevrons, pointing away (<>) + insert_pair( "\u{00BB}\u{300B}", "\u{00AB}\u{300A}" ); // double chevrons, pointing together (>>text<<) + insert_pair( "\u{2039}\u{3008}", "\u{203A}\u{3009}" ); // single chevrons, pointing away () + insert_pair( "\u{203A}\u{3009}", "\u{2039}\u{3008}" ); // single chevrons, pointing together (>text<) + + // Other + insert_pair( "\u{300C}\u{300E}", "\u{300D}\u{300F}" ); // corner brackets (Japanese/Chinese) + + pairs + }; +} + +// very very simple quote matching +// expects match_str to be trimmed (no whitespace, from the start at least) +// returns the position of an end quote if any is found +// quotes need to be at start/end of words, and are ignored if a closing quote is not present +// WTB POSIX quoting: https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html +fn find_quotes(match_str: &str) -> Option { + if let Some(right) = QUOTE_PAIRS.get(&match_str[0..match_str.ceil_char_boundary(1)]) { + // try matching end quote + for possible_quote in right.chars() { + for (pos, _) in match_str.match_indices(possible_quote) { + if match_str.len() == pos + 1 + || match_str.chars().nth(pos + 1).unwrap().is_whitespace() + { + return Some(pos); + } + } + } + } + None +} + +#[derive(Debug)] +pub(super) struct MatchedParam<'a> { + pub(super) value: &'a str, + pub(super) next_pos: usize, + #[allow(dead_code)] // this'll prolly be useful sometime later + pub(super) in_quotes: bool, +} + +pub(super) fn next_param<'a>(input: &'a str, current_pos: usize) -> Option> { + if input.len() == current_pos { + return None; + } + + let leading_whitespace_count = + input[..current_pos].len() - input[..current_pos].trim_start().len(); + let substr_to_match = &input[current_pos + leading_whitespace_count..]; + debug!("stuff: {input} {current_pos} {leading_whitespace_count}"); + debug!("to match: {substr_to_match}"); + + if let Some(end_quote_pos) = find_quotes(substr_to_match) { + // return quoted string, without quotes + return Some(MatchedParam { + value: &substr_to_match[1..end_quote_pos], + next_pos: current_pos + end_quote_pos + 1, + in_quotes: true, + }); + } + + // find next whitespace character + for (pos, char) in substr_to_match.char_indices() { + if char.is_whitespace() { + return Some(MatchedParam { + value: &substr_to_match[..pos], + next_pos: current_pos + pos + 1, + in_quotes: false, + }); + } + } + + // if we're here, we went to EOF and didn't match any whitespace + // so we return the whole string + Some(MatchedParam { + value: substr_to_match, + next_pos: current_pos + substr_to_match.len(), + in_quotes: false, + }) +} + +#[derive(Debug, Clone)] +pub(super) struct MatchedFlag<'a> { + pub(super) name: &'a str, + pub(super) value: Option<&'a str>, + pub(super) next_pos: usize, +} + +pub(super) fn next_flag<'a>(input: &'a str, mut current_pos: usize) -> Option> { + if input.len() == current_pos { + return None; + } + + let leading_whitespace_count = + input[..current_pos].len() - input[..current_pos].trim_start().len(); + let substr_to_match = &input[current_pos + leading_whitespace_count..]; + + // if the param is quoted, it should not be processed as a flag + if find_quotes(substr_to_match).is_some() { + return None; + } + + debug!("flag input {substr_to_match}"); + // strip the - + let original_len = substr_to_match.len(); + let substr_without_dashes = substr_to_match.trim_start_matches('-'); + let dash_count = original_len - substr_without_dashes.len(); + + if dash_count == 0 || dash_count > 2 { + // if it doesn't have one, then it is not a flag + // or if it has more dashes than 2, assume its not a flag + return None; + } + + let substr_to_match = substr_without_dashes; + current_pos += dash_count; + + // try finding = or whitespace + for (pos, char) in substr_to_match.char_indices() { + debug!("flag find char {char} at {pos}"); + if char == '=' { + let name = &substr_to_match[..pos]; + debug!("flag find {name}"); + // try to get the value + let Some(param) = next_param(input, current_pos + pos + 1) else { + return Some(MatchedFlag { + name, + value: Some(""), + next_pos: current_pos + pos + 1, + }); + }; + return Some(MatchedFlag { + name, + value: Some(param.value), + next_pos: param.next_pos, + }); + } else if char.is_whitespace() { + // no value if whitespace + return Some(MatchedFlag { + name: &substr_to_match[..pos], + value: None, + next_pos: current_pos + pos + 1, + }); + } + } + + // if eof then no value + Some(MatchedFlag { + name: substr_to_match, + value: None, + next_pos: current_pos + substr_to_match.len(), + }) +} diff --git a/crates/command_parser/src/token.rs b/crates/command_parser/src/token.rs new file mode 100644 index 00000000..67f7ddcd --- /dev/null +++ b/crates/command_parser/src/token.rs @@ -0,0 +1,174 @@ +use std::fmt::{Debug, Display}; + +use smol_str::SmolStr; + +use crate::parameter::{Parameter, ParameterValue}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Token { + /// A bot-defined command / subcommand (usually) (eg. "member" in `pk;member MyName`) + Value { + name: SmolStr, + aliases: Vec, + }, + + /// A parameter that must be provided a value + Parameter(Parameter), +} + +#[derive(Clone, Debug)] +pub enum TokenMatchResult { + MatchedValue, + MatchedParameter { + name: SmolStr, + value: ParameterValue, + }, + ParameterMatchError { + input: SmolStr, + msg: SmolStr, + }, + MissingParameter { + name: SmolStr, + }, +} + +// q: why not have a NoMatch variant in TokenMatchResult? +// a: because we want to differentiate between no match and match failure (it matched with an error) +// "no match" has a different charecteristic because we want to continue matching other tokens... +// ...while "match failure" means we should stop matching and return the error +// Option fits this better (and it makes some code look a bit nicer) +pub type TryMatchResult = Option; + +impl Token { + pub fn try_match(&self, input: Option<&str>) -> TryMatchResult { + let input = match input { + Some(input) => input, + None => { + // short circuit on: + return match self { + // missing paramaters + Self::Parameter(param) => Some( + param + .is_optional() + .then(|| TokenMatchResult::MatchedParameter { + name: param.name().into(), + value: ParameterValue::Null, + }) + .unwrap_or_else(|| TokenMatchResult::MissingParameter { + name: param.name().into(), + }), + ), + // everything else doesnt match if no input anyway + Self::Value { .. } => None, + // don't add a _ match here! + }; + } + }; + let input = input.trim(); + + // try actually matching stuff + match self { + Self::Value { name, aliases } => (aliases.iter().chain(std::iter::once(name))) + .any(|v| v.eq(input)) + .then(|| TokenMatchResult::MatchedValue), + Self::Parameter(param) => Some(match param.match_value(input) { + Ok(matched) => TokenMatchResult::MatchedParameter { + name: param.name().into(), + value: matched, + }, + Err(err) => TokenMatchResult::ParameterMatchError { + input: input.into(), + msg: err, + }, + }), + // don't add a _ match here! + } + } +} + +impl Display for Token { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Value { name, .. } => write!(f, "{name}"), + Self::Parameter(param) => write!(f, "{param}"), + } + } +} + +// (name, aliases) -> Token::Value +impl From<(&str, [&str; L])> for Token { + fn from((name, aliases): (&str, [&str; L])) -> Self { + Self::Value { + name: name.into(), + aliases: aliases.into_iter().map(SmolStr::new).collect::>(), + } + } +} + +// name -> Token::Value +impl From<&str> for Token { + fn from(value: &str) -> Self { + Self::from((value, [])) + } +} + +// parameter -> Token::Parameter +impl> From

for Token { + fn from(value: P) -> Self { + Self::Parameter(value.into()) + } +} + +/// Iterator that produces [`Token`]s. +/// +/// This is more of a convenience type that the [`tokens!`] macro uses in order +/// to more easily combine tokens together. +#[derive(Debug, Clone)] +pub struct TokensIterator { + inner: Vec, +} + +impl TokensIterator { + pub(crate) fn new(tokens: Vec) -> Self { + Self { inner: tokens } + } +} + +impl Iterator for TokensIterator { + type Item = Token; + + fn next(&mut self) -> Option { + (self.inner.len() > 0).then(|| self.inner.remove(0)) + } +} + +impl From> for TokensIterator { + fn from(value: Vec) -> Self { + Self::new(value) + } +} + +impl> From for TokensIterator { + fn from(value: T) -> Self { + Self::new(vec![value.into()]) + } +} + +impl From<[Token; L]> for TokensIterator { + fn from(value: [Token; L]) -> Self { + Self::new(value.into_iter().collect()) + } +} + +impl From<[Self; L]> for TokensIterator { + fn from(value: [Self; L]) -> Self { + Self::new(value.into_iter().map(|t| t.inner).flatten().collect()) + } +} + +#[macro_export] +macro_rules! tokens { + ($($v:expr),+$(,)*) => { + $crate::token::TokensIterator::from([$($crate::token::TokensIterator::from($v.clone())),+]) + }; +} diff --git a/crates/command_parser/src/tree.rs b/crates/command_parser/src/tree.rs new file mode 100644 index 00000000..022ef981 --- /dev/null +++ b/crates/command_parser/src/tree.rs @@ -0,0 +1,97 @@ +use std::sync::Arc; + +use ordermap::OrderMap; + +use crate::{command::Command, token::Token}; + +#[derive(Debug, Clone)] +pub struct TreeBranch { + current_command: Option>, + branches: OrderMap>, +} + +impl Default for TreeBranch { + fn default() -> Self { + Self { + current_command: None, + branches: OrderMap::new(), + } + } +} + +impl TreeBranch { + pub fn register_command(&mut self, command: Command) { + let mut current_branch = self; + // iterate over tokens in command + for (index, token) in command.tokens.clone().into_iter().enumerate() { + // if the token is an optional parameter, register rest of the tokens to a separate branch + // this allows optional parameters to work if they are not the last token + if matches!(token, Token::Parameter(ref param) if param.is_optional()) + && index < command.tokens.len() - 1 + { + let mut new_command = command.clone(); + new_command.tokens = command.tokens[index + 1..].to_vec(); + new_command.original = command + .original + .clone() + .or_else(|| Some(Arc::new(command.clone()))); + + // if the optional parameter we're skipping is *before* the flag insertion point, + // we need to shift the index left by 1 to account for the removed token + if new_command.parse_flags_before > index { + new_command.parse_flags_before -= 1; + } + + current_branch.register_command(new_command); + } + // recursively get or create a sub-branch for each token + current_branch = Arc::make_mut( + current_branch + .branches + .entry(token) + .or_insert_with(|| Arc::new(TreeBranch::default())), + ); + } + // when we're out of tokens add the command to the last branch + current_branch.current_command = Some(Arc::new(command)); + } + + pub fn command(&self) -> Option> { + self.current_command.clone() + } + + pub fn possible_tokens(&self) -> impl Iterator + Clone { + self.branches.keys() + } + + pub fn possible_commands(&self, max_depth: usize) -> impl Iterator { + // dusk: i am too lazy to write an iterator for this without using recursion so we box everything + fn box_iter<'a>( + iter: impl Iterator + 'a, + ) -> Box + 'a> { + Box::new(iter) + } + + if max_depth == 0 { + return box_iter(std::iter::empty()); + } + let mut commands = box_iter(std::iter::empty()); + for branch in self.branches.values() { + if let Some(command) = branch.current_command.as_ref() { + commands = box_iter(commands.chain(std::iter::once(command.as_ref()))); + // we dont need to look further if we found a command + continue; + } + commands = box_iter(commands.chain(branch.possible_commands(max_depth - 1))); + } + commands + } + + pub fn get_branch(&self, token: &Token) -> Option<&Arc> { + self.branches.get(token) + } + + pub fn branches(&self) -> impl Iterator)> { + self.branches.iter() + } +} diff --git a/crates/command_parser/tests/parser.rs b/crates/command_parser/tests/parser.rs new file mode 100644 index 00000000..20813153 --- /dev/null +++ b/crates/command_parser/tests/parser.rs @@ -0,0 +1,51 @@ +use command_parser::{Tree, command::Command, parameter::*, parse_command, tokens}; + +/// this checks if we properly keep track of filtered tokens (eg. branches we failed on) +/// when we backtrack. a previous parser bug would cause infinite loops since it did not +/// (the parser would "flip-flop" between branches) this is here for reference. +#[test] +fn test_infinite_loop_repro() { + let p1 = Optional(("param1", ParameterKind::OpaqueString)); + let p2 = Optional(("param2", ParameterKind::OpaqueString)); + + let cmd1 = Command::new(tokens!("s", p1, "A"), "cmd1"); + let cmd2 = Command::new(tokens!("s", p2, "B"), "cmd2"); + + let mut tree = Tree::default(); + tree.register_command(cmd1); + tree.register_command(cmd2); + + let input = "s foo C"; + // this should fail and not loop + let result = parse_command(tree, "pk;".to_string(), input.to_string()); + assert!(result.is_err()); +} + +/// check if we have params from other branches when we trying to match them and they succeeded +/// but then we backtracked, making them invalid. this should no longer happen since we just +/// extract params from matched tokens when we match the command, but keeping here just for reference. +#[test] +fn test_dirty_params() { + let p1 = Optional(("param1", ParameterKind::OpaqueString)); + let p2 = Optional(("param2", ParameterKind::OpaqueString)); + + let cmd1 = Command::new(tokens!("s", p1, "A"), "cmd1"); + let cmd2 = Command::new(tokens!("s", p2, "B"), "cmd2"); + + let mut tree = Tree::default(); + tree.register_command(cmd1); + tree.register_command(cmd2); + + let input = "s foo B"; + let result = parse_command(tree, "pk;".to_string(), input.to_string()).unwrap(); + + println!("params: {:?}", result.parameters); + assert!( + !result.parameters.contains_key("param1"), + "params should not contain 'param1' from failed branch" + ); + assert!( + result.parameters.contains_key("param2"), + "params should contain 'param2'" + ); +} diff --git a/crates/command_parser/tests/ranking.rs b/crates/command_parser/tests/ranking.rs new file mode 100644 index 00000000..6ddaeed8 --- /dev/null +++ b/crates/command_parser/tests/ranking.rs @@ -0,0 +1,55 @@ +use command_parser::{Tree, command::Command, parameter::*, parse_command, tokens}; + +#[test] +fn test_typoed_command_with_parameter() { + let message_token = ("message", ["msg", "messageinfo"]); + let author_token = ("author", ["sender", "a"]); + + // message author + let cmd = Command::new( + tokens!(message_token, Optional(MESSAGE_REF), author_token), + "message_author", + ) + .help("Shows the author of a proxied message"); + + let mut tree = Tree::default(); + tree.register_command(cmd); + + let input = "message 1 auth"; + let result = parse_command(tree, "pk;".to_string(), input.to_string()); + + match result { + Ok(_) => panic!("Should have failed to parse"), + Err(msg) => { + println!("Error: {}", msg); + assert!(msg.contains("Perhaps you meant one of the following commands")); + assert!(msg.contains("message author")); + } + } +} + +#[test] +fn test_typoed_command_with_flags() { + let message_token = ("message", ["msg", "messageinfo"]); + let author_token = ("author", ["sender", "a"]); + + let cmd = Command::new(tokens!(message_token, author_token), "message_author") + .flag(("flag", ["f"])) + .flag(("flag2", ["f2"])) + .help("Shows the author of a proxied message"); + + let mut tree = Tree::default(); + tree.register_command(cmd); + + let input = "message auth -f -flag2"; + let result = parse_command(tree, "pk;".to_string(), input.to_string()); + + match result { + Ok(_) => panic!("Should have failed to parse"), + Err(msg) => { + println!("Error: {}", msg); + assert!(msg.contains("Perhaps you meant one of the following commands")); + assert!(msg.contains("message author")); + } + } +} diff --git a/crates/commands/Cargo.toml b/crates/commands/Cargo.toml new file mode 100644 index 00000000..1d9dbebd --- /dev/null +++ b/crates/commands/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "commands" +version = "0.1.0" +edition = "2021" +default-run = "commands" + +[[bin]] +name = "write_cs_glue" +path = "src/write_cs_glue.rs" + +[lib] +crate-type = ["cdylib", "lib"] + +[dependencies] +lazy_static = { workspace = true } +command_parser = { path = "../command_parser"} +command_definitions = { path = "../command_definitions"} +uniffi = { version = "0.29" } +log = "0.4" +simple_logger = "4.3.3" + +[build-dependencies] +uniffi = { version = "0.29", features = [ "build" ] } diff --git a/crates/commands/build.rs b/crates/commands/build.rs new file mode 100644 index 00000000..3f31f453 --- /dev/null +++ b/crates/commands/build.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::generate_scaffolding("src/commands.udl").unwrap(); +} diff --git a/crates/commands/src/commands.udl b/crates/commands/src/commands.udl new file mode 100644 index 00000000..3bffa3cb --- /dev/null +++ b/crates/commands/src/commands.udl @@ -0,0 +1,36 @@ +namespace commands { + CommandResult parse_command(string prefix, string input); + string get_related_commands(string prefix, string input); +}; +[Enum] +interface CommandResult { + Ok(ParsedCommand command); + Err(string error); +}; +[Enum] +interface Parameter { + MemberRef(string member); + MemberRefs(sequence members); + GroupRef(string group); + GroupRefs(sequence groups); + SystemRef(string system); + UserRef(u64 user_id); + MessageRef(u64? guild_id, u64? channel_id, u64 message_id); + ChannelRef(u64 channel_id); + GuildRef(u64 guild_id); + MemberPrivacyTarget(string target); + GroupPrivacyTarget(string target); + SystemPrivacyTarget(string target); + PrivacyLevel(string level); + OpaqueString(string raw); + OpaqueInt(i32 raw); + Toggle(boolean toggle); + Avatar(string avatar); + ProxySwitchAction(string action); + Null(); +}; +dictionary ParsedCommand { + string command_ref; + record params; + record flags; +}; diff --git a/crates/commands/src/lib.rs b/crates/commands/src/lib.rs new file mode 100644 index 00000000..a54fb3f0 --- /dev/null +++ b/crates/commands/src/lib.rs @@ -0,0 +1,183 @@ +use std::{ + collections::HashMap, + fmt::Write, + sync::{Arc, Once}, +}; + +use command_parser::{parameter::ParameterValue, token::TokenMatchResult, Tree}; + +uniffi::include_scaffolding!("commands"); + +lazy_static::lazy_static! { + pub static ref COMMAND_TREE: Arc = { + let mut tree = Tree::default(); + + command_definitions::all().into_iter().for_each(|x| tree.register_command(x)); + + Arc::new(tree) + }; +} + +static LOG_INIT: Once = Once::new(); + +#[derive(Debug)] +pub enum CommandResult { + Ok { command: ParsedCommand }, + Err { error: String }, +} + +#[derive(Debug, Clone)] +pub enum Parameter { + MemberRef { + member: String, + }, + MemberRefs { + members: Vec, + }, + GroupRef { + group: String, + }, + GroupRefs { + groups: Vec, + }, + SystemRef { + system: String, + }, + UserRef { + user_id: u64, + }, + MessageRef { + guild_id: Option, + channel_id: Option, + message_id: u64, + }, + ChannelRef { + channel_id: u64, + }, + GuildRef { + guild_id: u64, + }, + MemberPrivacyTarget { + target: String, + }, + GroupPrivacyTarget { + target: String, + }, + SystemPrivacyTarget { + target: String, + }, + PrivacyLevel { + level: String, + }, + OpaqueString { + raw: String, + }, + OpaqueInt { + raw: i32, + }, + Toggle { + toggle: bool, + }, + Avatar { + avatar: String, + }, + ProxySwitchAction { + action: String, + }, + Null, +} + +impl From for Parameter { + fn from(value: ParameterValue) -> Self { + match value { + ParameterValue::MemberRef(member) => Self::MemberRef { member }, + ParameterValue::MemberRefs(members) => Self::MemberRefs { members }, + ParameterValue::GroupRef(group) => Self::GroupRef { group }, + ParameterValue::GroupRefs(groups) => Self::GroupRefs { groups }, + ParameterValue::SystemRef(system) => Self::SystemRef { system }, + ParameterValue::UserRef(user_id) => Self::UserRef { user_id }, + ParameterValue::MemberPrivacyTarget(target) => Self::MemberPrivacyTarget { target }, + ParameterValue::GroupPrivacyTarget(target) => Self::GroupPrivacyTarget { target }, + ParameterValue::SystemPrivacyTarget(target) => Self::SystemPrivacyTarget { target }, + ParameterValue::PrivacyLevel(level) => Self::PrivacyLevel { level }, + ParameterValue::OpaqueString(raw) => Self::OpaqueString { raw }, + ParameterValue::OpaqueInt(raw) => Self::OpaqueInt { raw }, + ParameterValue::Toggle(toggle) => Self::Toggle { toggle }, + ParameterValue::Avatar(avatar) => Self::Avatar { avatar }, + ParameterValue::MessageRef(guild_id, channel_id, message_id) => Self::MessageRef { + guild_id, + channel_id, + message_id, + }, + ParameterValue::ChannelRef(channel_id) => Self::ChannelRef { channel_id }, + ParameterValue::GuildRef(guild_id) => Self::GuildRef { guild_id }, + ParameterValue::Null => Self::Null, + ParameterValue::ProxySwitchAction(action) => Self::ProxySwitchAction { + action: action.as_ref().to_string(), + }, + } + } +} + +#[derive(Debug)] +pub struct ParsedCommand { + pub command_ref: String, + pub params: HashMap, + pub flags: HashMap>, +} + +pub fn parse_command(prefix: String, input: String) -> CommandResult { + LOG_INIT.call_once(|| { + if let Err(err) = simple_logger::SimpleLogger::new() + .with_level(log::LevelFilter::Info) + .env() + .init() + { + eprintln!("cant initialize logger: {err}"); + } + }); + + command_parser::parse_command(COMMAND_TREE.clone(), prefix, input).map_or_else( + |error| CommandResult::Err { error }, + |parsed| CommandResult::Ok { + command: { + let command_ref = parsed.command_def.cb.clone().into(); + let mut flags = HashMap::with_capacity(parsed.flags.capacity()); + for (name, value) in parsed.flags { + flags.insert(name, value.map(Parameter::from)); + } + let mut params = HashMap::with_capacity(parsed.parameters.capacity()); + for (name, value) in parsed.parameters { + params.insert(name, Parameter::from(value)); + } + ParsedCommand { + command_ref, + flags, + params, + } + }, + }, + ) +} + +pub fn get_related_commands(prefix: String, input: String) -> String { + let mut s = String::new(); + for command in command_definitions::all() { + if !command.show_in_suggestions { + continue; + } + if command.tokens.first().map_or(false, |token| { + token + .try_match(Some(&input)) + .map_or(false, |r| matches!(r, TokenMatchResult::MatchedValue)) + }) { + writeln!( + &mut s, + "- **{prefix}{command}** - *{help}*", + help = command.help + ) + .unwrap(); + } + } + s +} diff --git a/crates/commands/src/main.rs b/crates/commands/src/main.rs new file mode 100644 index 00000000..325fc469 --- /dev/null +++ b/crates/commands/src/main.rs @@ -0,0 +1,51 @@ +#![feature(iter_intersperse)] + +use command_parser::Tree; +use commands::COMMAND_TREE; + +fn main() { + parse(); +} + +fn related() { + let cmd = std::env::args().nth(1).unwrap(); + let related = commands::get_related_commands("pk;".to_string(), cmd); + println!("Related commands:\n{related}"); +} + +fn parse() { + let cmd = std::env::args() + .skip(1) + .intersperse(" ".to_string()) + .collect::(); + if !cmd.is_empty() { + use commands::CommandResult; + let parsed = commands::parse_command("pk;".to_string(), cmd); + match parsed { + CommandResult::Ok { command } => println!("{command:#?}"), + CommandResult::Err { error } => println!("{error}"), + } + } else { + for command in command_definitions::all() { + println!("{} => {} - {}", command.cb, command, command.help); + } + } +} + +fn print_tree(tree: &Tree, depth: usize) { + println!(); + for (token, branch) in tree.branches() { + for _ in 0..depth { + print!(" "); + } + for _ in 0..depth { + print!("-"); + } + print!("> {token:?}"); + if let Some(command) = branch.command() { + println!(": {}", command.cb) + } else { + print_tree(branch, depth + 1) + } + } +} diff --git a/crates/commands/src/write_cs_glue.rs b/crates/commands/src/write_cs_glue.rs new file mode 100644 index 00000000..6d693f20 --- /dev/null +++ b/crates/commands/src/write_cs_glue.rs @@ -0,0 +1,318 @@ +use std::{collections::HashSet, env, fmt::Write, fs, path::PathBuf, str::FromStr}; + +use command_parser::{ + parameter::{Parameter, ParameterKind}, + token::Token, +}; + +fn main() -> Result<(), Box> { + let write_location = env::args() + .nth(1) + .expect("file location should be provided"); + let write_location = PathBuf::from_str(&write_location).unwrap(); + + let commands = command_definitions::all().collect::>(); + + let mut glue = String::new(); + + writeln!(&mut glue, "#nullable enable\n")?; + writeln!(&mut glue, "using PluralKit.Core;\n")?; + writeln!(&mut glue, "using Myriad.Types;")?; + writeln!(&mut glue, "namespace PluralKit.Bot;\n")?; + + let mut commands_seen = HashSet::new(); + let mut record_fields = String::new(); + for command in &commands { + if commands_seen.contains(&command.cb) { + continue; + } + writeln!( + &mut record_fields, + r#"public record {command_name}({command_name}Params parameters, {command_name}Flags flags): Commands;"#, + command_name = command_callback_to_name(&command.cb), + )?; + commands_seen.insert(command.cb.clone()); + } + + commands_seen.clear(); + let mut match_branches = String::new(); + for command in &commands { + if commands_seen.contains(&command.cb) { + continue; + } + let mut command_params_init = String::new(); + let command_params = find_parameters(&command.tokens); + for param in &command_params { + writeln!( + &mut command_params_init, + r#"@{fieldName} = await ctx.ParamResolve{extract_fn_name}("{name}"){throw_null},"#, + fieldName = param.name().replace("-", "_"), + name = param.name(), + extract_fn_name = get_param_param_ty(param.kind()), + throw_null = param + .is_optional() + .then(String::new) + .unwrap_or(format!(" ?? throw new PKError(\"parameter {} not found but was required, this is a bug in the command parser, for command: {}!\")", param.name(), command.cb)), + )?; + } + let mut command_flags_init = String::new(); + for flag in &command.flags { + if let Some(param) = flag.get_value() { + writeln!( + &mut command_flags_init, + r#"@{fieldName} = await ctx.FlagResolve{extract_fn_name}("{name}"),"#, + fieldName = flag.get_name().replace("-", "_"), + name = flag.get_name(), + extract_fn_name = get_param_param_ty(param.kind()), + )?; + } else { + writeln!( + &mut command_flags_init, + r#"@{fieldName} = ctx.Parameters.HasFlag("{name}"),"#, + fieldName = flag.get_name().replace("-", "_"), + name = flag.get_name(), + )?; + } + } + write!( + &mut match_branches, + r#" + "{command_callback}" => new {command_name}( + new {command_name}Params {{ {command_params_init} }}, + new {command_name}Flags {{ {command_flags_init} }} + ), + "#, + command_name = command_callback_to_name(&command.cb), + command_callback = command.cb, + )?; + commands_seen.insert(command.cb.clone()); + } + write!( + &mut glue, + r#" + public abstract record Commands() + {{ + {record_fields} + + public static async Task FromContext(Context ctx) + {{ + return ctx.Parameters.Callback() switch + {{ + {match_branches} + _ => null, + }}; + }} + }} + "#, + )?; + + commands_seen.clear(); + for command in &commands { + if commands_seen.contains(&command.cb) { + continue; + } + let mut command_params_fields = String::new(); + let command_params = find_parameters(&command.tokens); + for param in &command_params { + writeln!( + &mut command_params_fields, + r#"public required {ty}{nullable} @{name};"#, + name = param.name().replace("-", "_"), + ty = get_param_ty(param.kind()), + nullable = param.is_optional().then_some("?").unwrap_or(""), + )?; + } + let mut command_flags_fields = String::new(); + for flag in &command.flags { + if let Some(param) = flag.get_value() { + writeln!( + &mut command_flags_fields, + r#"public {ty}? @{name};"#, + name = flag.get_name().replace("-", "_"), + ty = get_param_ty(param.kind()), + )?; + } else { + writeln!( + &mut command_flags_fields, + r#"public required bool @{name};"#, + name = flag.get_name().replace("-", "_"), + )?; + } + } + let mut command_reply_format = String::new(); + if command + .flags + .iter() + .any(|flag| flag.get_name() == "plaintext") + { + writeln!( + &mut command_reply_format, + r#"if (plaintext) return ReplyFormat.Plaintext;"#, + )?; + } + if command.flags.iter().any(|flag| flag.get_name() == "raw") { + writeln!( + &mut command_reply_format, + r#"if (raw) return ReplyFormat.Raw;"#, + )?; + } + command_reply_format.push_str("return ReplyFormat.Standard;\n"); + let mut command_list_options = String::new(); + let mut command_list_options_class = String::new(); + let list_flags = command_definitions::utils::get_list_flags(); + if list_flags.iter().all(|flag| command.flags.contains(&flag)) { + write!(&mut command_list_options_class, ": IHasListOptions")?; + writeln!( + &mut command_list_options, + r#" + public ListOptions GetListOptions(Context ctx, SystemId target) + {{ + var directLookupCtx = ctx.DirectLookupContextFor(target); + var lookupCtx = ctx.LookupContextFor(target); + + var p = new ListOptions(); + p.Type = full ? ListType.Long : ListType.Short; + // Search description filter + p.SearchDescription = search_description; + + // Sort property + if (by_name) p.SortProperty = SortProperty.Name; + if (by_display_name) p.SortProperty = SortProperty.DisplayName; + if (by_id) p.SortProperty = SortProperty.Hid; + if (by_message_count) p.SortProperty = SortProperty.MessageCount; + if (by_created) p.SortProperty = SortProperty.CreationDate; + if (by_last_fronted) p.SortProperty = SortProperty.LastSwitch; + if (by_last_message) p.SortProperty = SortProperty.LastMessage; + if (by_birthday) p.SortProperty = SortProperty.Birthdate; + if (random) p.SortProperty = SortProperty.Random; + + // Sort reverse + p.Reverse = reverse; + + // Privacy filter + if (all) p.PrivacyFilter = null; + if (private_only) p.PrivacyFilter = PrivacyLevel.Private; + // PERM CHECK: If we're trying to access non-public members of another system, error + if (p.PrivacyFilter != PrivacyLevel.Public && directLookupCtx != LookupContext.ByOwner) + // TODO: should this just return null instead of throwing or something? >.> + throw Errors.NotOwnInfo; + + // this is for searching + p.Context = lookupCtx; + + // Additional fields to include + p.IncludeLastSwitch = with_last_switch; + p.IncludeLastMessage = with_last_message; + p.IncludeMessageCount = with_message_count; + p.IncludeCreated = with_created; + p.IncludeAvatar = with_avatar; + p.IncludePronouns = with_pronouns; + p.IncludeDisplayName = with_display_name; + p.IncludeBirthday = with_birthday; + + // Always show the sort property (unless short list and already showing something else) + if (p.Type != ListType.Short || p.includedCount == 0) + {{ + if (p.SortProperty == SortProperty.DisplayName) p.IncludeDisplayName = true; + if (p.SortProperty == SortProperty.MessageCount) p.IncludeMessageCount = true; + if (p.SortProperty == SortProperty.CreationDate) p.IncludeCreated = true; + if (p.SortProperty == SortProperty.LastSwitch) p.IncludeLastSwitch = true; + if (p.SortProperty == SortProperty.LastMessage) p.IncludeLastMessage = true; + if (p.SortProperty == SortProperty.Birthdate) p.IncludeBirthday = true; + }} + + p.AssertIsValid(); + return p; + }} + "#, + )?; + } + write!( + &mut glue, + r#" + public class {command_name}Params + {{ + {command_params_fields} + }} + public class {command_name}Flags {command_list_options_class} + {{ + {command_flags_fields} + + public ReplyFormat GetReplyFormat() + {{ + {command_reply_format} + }} + + {command_list_options} + }} + "#, + command_name = command_callback_to_name(&command.cb), + )?; + commands_seen.insert(command.cb.clone()); + } + fs::write(write_location, glue)?; + Ok(()) +} + +fn command_callback_to_name(cb: &str) -> String { + cb.split("_") + .map(|w| w.chars().nth(0).unwrap().to_uppercase().collect::() + &w[1..]) + .collect() +} + +fn get_param_ty(kind: ParameterKind) -> &'static str { + match kind { + ParameterKind::OpaqueString => "string", + ParameterKind::OpaqueInt => "int", + ParameterKind::MemberRef => "PKMember", + ParameterKind::MemberRefs => "List", + ParameterKind::GroupRef => "PKGroup", + ParameterKind::GroupRefs => "List", + ParameterKind::SystemRef => "PKSystem", + ParameterKind::UserRef => "User", + ParameterKind::MemberPrivacyTarget => "MemberPrivacySubject", + ParameterKind::GroupPrivacyTarget => "GroupPrivacySubject", + ParameterKind::SystemPrivacyTarget => "SystemPrivacySubject", + ParameterKind::PrivacyLevel => "PrivacyLevel", + ParameterKind::Toggle => "bool", + ParameterKind::Avatar => "ParsedImage", + ParameterKind::MessageRef { .. } => "Message.Reference", + ParameterKind::ChannelRef => "Channel", + ParameterKind::GuildRef => "Guild", + ParameterKind::ProxySwitchAction => "SystemConfig.ProxySwitchAction", + } +} + +fn get_param_param_ty(kind: ParameterKind) -> &'static str { + match kind { + ParameterKind::OpaqueString => "Opaque", + ParameterKind::OpaqueInt => "Number", + ParameterKind::MemberRef => "Member", + ParameterKind::MemberRefs => "Members", + ParameterKind::GroupRef => "Group", + ParameterKind::GroupRefs => "Groups", + ParameterKind::SystemRef => "System", + ParameterKind::UserRef => "User", + ParameterKind::MemberPrivacyTarget => "MemberPrivacyTarget", + ParameterKind::GroupPrivacyTarget => "GroupPrivacyTarget", + ParameterKind::SystemPrivacyTarget => "SystemPrivacyTarget", + ParameterKind::PrivacyLevel => "PrivacyLevel", + ParameterKind::Toggle => "Toggle", + ParameterKind::Avatar => "Avatar", + ParameterKind::MessageRef { .. } => "Message", + ParameterKind::ChannelRef => "Channel", + ParameterKind::GuildRef => "Guild", + ParameterKind::ProxySwitchAction => "ProxySwitchAction", + } +} + +fn find_parameters(tokens: &[Token]) -> Vec<&Parameter> { + let mut result = Vec::new(); + for token in tokens { + match token { + Token::Parameter(param) => result.push(param), + _ => {} + } + } + result +} diff --git a/crates/commands/uniffi.toml b/crates/commands/uniffi.toml new file mode 100644 index 00000000..86d3d6f1 --- /dev/null +++ b/crates/commands/uniffi.toml @@ -0,0 +1,2 @@ +[bindings.csharp] +cdylib_name = "commands" diff --git a/crates/gateway/src/main.rs b/crates/gateway/src/main.rs index 3ac7be21..03003c97 100644 --- a/crates/gateway/src/main.rs +++ b/crates/gateway/src/main.rs @@ -1,5 +1,4 @@ #![feature(if_let_guard)] -#![feature(duration_constructors)] use chrono::Timelike; use discord::gateway::cluster_config; diff --git a/crates/libpk/Cargo.toml b/crates/libpk/Cargo.toml index 1f0c3c42..23d912c0 100644 --- a/crates/libpk/Cargo.toml +++ b/crates/libpk/Cargo.toml @@ -12,7 +12,7 @@ pk_macros = { path = "../macros" } sentry = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -sqlx = { workspace = true } +sqlx = { workspace = true, features = ["chrono"] } tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true} diff --git a/flake.lock b/flake.lock index 4024bfd9..590c05d6 100644 --- a/flake.lock +++ b/flake.lock @@ -3,16 +3,16 @@ "crane": { "flake": false, "locked": { - "lastModified": 1727316705, - "narHash": "sha256-/mumx8AQ5xFuCJqxCIOFCHTVlxHkMT21idpbgbm/TIE=", + "lastModified": 1758758545, + "narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=", "owner": "ipetkov", "repo": "crane", - "rev": "5b03654ce046b5167e7b0bccbd8244cb56c16f0e", + "rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364", "type": "github" }, "original": { "owner": "ipetkov", - "ref": "v0.19.0", + "ref": "v0.21.1", "repo": "crane", "type": "github" } @@ -26,11 +26,11 @@ "pyproject-nix": "pyproject-nix" }, "locked": { - "lastModified": 1754978539, - "narHash": "sha256-nrDovydywSKRbWim9Ynmgj8SBm8LK3DI2WuhIqzOHYI=", + "lastModified": 1765953015, + "narHash": "sha256-5FBZbbWR1Csp3Y2icfRkxMJw/a/5FGg8hCXej2//bbI=", "owner": "nix-community", "repo": "dream2nix", - "rev": "fbec3263cb4895ac86ee9506cdc4e6919a1a2214", + "rev": "69eb01fa0995e1e90add49d8ca5bcba213b0416f", "type": "github" }, "original": { @@ -62,7 +62,7 @@ "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", "revCount": 69, "type": "tarball", - "url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz" + "url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz?rev=ff81ac966bb2cae68946d5ed5fc4994f96d0ffec&revCount=69" }, "original": { "type": "tarball", @@ -74,13 +74,13 @@ "locked": { "lastModified": 1681286841, "narHash": "sha256-3XlJrwlR0nBiREnuogoa5i1b4+w/XPe0z8bbrJASw0g=", - "owner": "yusdacra", + "owner": "90-008", "repo": "mk-naked-shell", "rev": "7612f828dd6f22b7fb332cc69440e839d7ffe6bd", "type": "github" }, "original": { - "owner": "yusdacra", + "owner": "90-008", "repo": "mk-naked-shell", "type": "github" } @@ -104,26 +104,26 @@ ] }, "locked": { - "lastModified": 1756016279, - "narHash": "sha256-5BhsvOXsoMu4ZNe9HxQSynbbXm2FAZ0TIW5mWKlG5+Q=", - "owner": "yusdacra", + "lastModified": 1768285363, + "narHash": "sha256-n4dqIGCz2+/pyP0jtuTZxFTjuyBkgiKMwtOJrmbipDA=", + "owner": "90-008", "repo": "nix-cargo-integration", - "rev": "4714a69e6de235cf750e6cc73f6a989cc7867579", + "rev": "90432aa96bd7bb603ff710ffa2c02959dc338bd3", "type": "github" }, "original": { - "owner": "yusdacra", + "owner": "90-008", "repo": "nix-cargo-integration", "type": "github" } }, "nixpkgs": { "locked": { - "lastModified": 1755829505, - "narHash": "sha256-4/Jd+LkQ2ssw8luQVkqVs9spDBVE6h/u/hC/tzngsPo=", + "lastModified": 1768302833, + "narHash": "sha256-h5bRFy9bco+8QcK7rGoOiqMxMbmn21moTACofNLRMP4=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "f937f8ecd1c70efd7e9f90ba13dfb400cf559de4", + "rev": "61db79b0c6b838d9894923920b612048e1201926", "type": "github" }, "original": { @@ -134,11 +134,11 @@ }, "nixpkgs-lib": { "locked": { - "lastModified": 1753579242, - "narHash": "sha256-zvaMGVn14/Zz8hnp4VWT9xVnhc8vuL3TStRqwk22biA=", + "lastModified": 1765674936, + "narHash": "sha256-k00uTP4JNfmejrCLJOwdObYC9jHRrr/5M/a/8L2EIdo=", "owner": "nix-community", "repo": "nixpkgs.lib", - "rev": "0f36c44e01a6129be94e3ade315a5883f0228a6e", + "rev": "2075416fcb47225d9b68ac469a5c4801a9c4dd85", "type": "github" }, "original": { @@ -152,11 +152,11 @@ "nixpkgs-lib": "nixpkgs-lib" }, "locked": { - "lastModified": 1754487366, - "narHash": "sha256-pHYj8gUBapuUzKV/kN/tR3Zvqc7o6gdFB9XKXIp1SQ8=", + "lastModified": 1768135262, + "narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "af66ad14b28a127c5c0f3bbb298218fc63528a18", + "rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac", "type": "github" }, "original": { @@ -167,11 +167,11 @@ }, "process-compose": { "locked": { - "lastModified": 1749418557, - "narHash": "sha256-wJHHckWz4Gvj8HXtM5WVJzSKXAEPvskQANVoRiu2w1w=", + "lastModified": 1767863885, + "narHash": "sha256-XXekPAxzbv1DmHFo3Elmj/vDnvWc1V0jdDUvM0/Wf7k=", "owner": "Platonic-Systems", "repo": "process-compose-flake", - "rev": "91dcc48a6298e47e2441ec76df711f4e38eab94e", + "rev": "99bea96cf269cfd235833ebdf645b567069fd398", "type": "github" }, "original": { @@ -211,11 +211,11 @@ ] }, "locked": { - "lastModified": 1752481895, - "narHash": "sha256-luVj97hIMpCbwhx3hWiRwjP2YvljWy8FM+4W9njDhLA=", + "lastModified": 1763017646, + "narHash": "sha256-Z+R2lveIp6Skn1VPH3taQIuMhABg1IizJd8oVdmdHsQ=", "owner": "pyproject-nix", "repo": "pyproject.nix", - "rev": "16ee295c25107a94e59a7fc7f2e5322851781162", + "rev": "47bd6f296502842643078d66128f7b5e5370790c", "type": "github" }, "original": { @@ -234,7 +234,8 @@ "process-compose": "process-compose", "services": "services", "systems": "systems", - "treefmt": "treefmt" + "treefmt": "treefmt", + "uniffi-bindgen-cs": "uniffi-bindgen-cs" } }, "rust-overlay": { @@ -245,11 +246,11 @@ ] }, "locked": { - "lastModified": 1756003222, - "narHash": "sha256-lmEMhIIbjt8Wp1EYbNqCojuU9ygyDFv8Tu0X1k8qIMc=", + "lastModified": 1768272338, + "narHash": "sha256-Tg/kL8eKMpZtceDvBDQYU8zowgpr7ucFRnpP/AtfuRM=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "88ceedecde53e809b4bf8b5fd10d181889d9bac7", + "rev": "03dda130a8701b08b0347fcaf850a190c53a3c1e", "type": "github" }, "original": { @@ -260,11 +261,11 @@ }, "services": { "locked": { - "lastModified": 1755996515, - "narHash": "sha256-1RQQIDhshp1g4PP5teqibcFLfk/ckTDOJRckecAHiU0=", + "lastModified": 1765168239, + "narHash": "sha256-NZ7H4lbbytPNwe4ZyvovycuS1BMBFwJrptgX7NiF+F0=", "owner": "juspay", "repo": "services-flake", - "rev": "e316d6b994fd153f0c35d54bd07d60e53f0ad9a9", + "rev": "8b6244f2b310f229568d5cadf7dfcb5ebe6f8bda", "type": "github" }, "original": { @@ -317,11 +318,11 @@ ] }, "locked": { - "lastModified": 1755934250, - "narHash": "sha256-CsDojnMgYsfshQw3t4zjRUkmMmUdZGthl16bXVWgRYU=", + "lastModified": 1768158989, + "narHash": "sha256-67vyT1+xClLldnumAzCTBvU0jLZ1YBcf4vANRWP3+Ak=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "74e1a52d5bd9430312f8d1b8b0354c92c17453e5", + "rev": "e96d59dff5c0d7fddb9d113ba108f03c3ef99eca", "type": "github" }, "original": { @@ -329,6 +330,25 @@ "repo": "treefmt-nix", "type": "github" } + }, + "uniffi-bindgen-cs": { + "flake": false, + "locked": { + "lastModified": 1759932560, + "narHash": "sha256-CnfB7/n1W5hbeC+cniJZthkpWO9kLyog/q5ldL6yS9g=", + "ref": "refs/heads/main", + "rev": "66c316454a04c025a88f8cc8af495bfc2b422d2f", + "revCount": 181, + "submodules": true, + "type": "git", + "url": "https://github.com/90-008/uniffi-bindgen-cs" + }, + "original": { + "ref": "refs/heads/main", + "submodules": true, + "type": "git", + "url": "https://github.com/90-008/uniffi-bindgen-cs" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index c0a766da..fa27a418 100644 --- a/flake.nix +++ b/flake.nix @@ -11,11 +11,13 @@ # rust d2n.url = "github:nix-community/dream2nix"; d2n.inputs.nixpkgs.follows = "nixpkgs"; - nci.url = "github:yusdacra/nix-cargo-integration"; + nci.url = "github:90-008/nix-cargo-integration"; nci.inputs.parts.follows = "parts"; nci.inputs.nixpkgs.follows = "nixpkgs"; nci.inputs.dream2nix.follows = "d2n"; nci.inputs.treefmt.follows = "treefmt"; + uniffi-bindgen-cs.url = "git+https://github.com/90-008/uniffi-bindgen-cs?ref=refs/heads/main&submodules=1"; + uniffi-bindgen-cs.flake = false; # misc treefmt.url = "github:numtide/treefmt-nix"; treefmt.inputs.nixpkgs.follows = "nixpkgs"; @@ -37,210 +39,188 @@ self', pkgs, lib, - system, ... }: let - # this is used as devshell for bot, and in the process-compose processes as environment - mkBotEnv = - cmd: - pkgs.buildFHSEnv { - name = "env"; - targetPkgs = - pkgs: with pkgs; [ - coreutils - git - dotnet-sdk_8 - gcc - omnisharp-roslyn - bashInteractive - ]; - runScript = cmd; - }; + uniffi-bindgen-cs = config.nci.lib.buildCrate { + src = inp.uniffi-bindgen-cs; + cratePath = "bindgen"; + }; rustOutputs = config.nci.outputs; - composeCfg = config.process-compose."dev"; + + sourceDotenv = '' + # shellcheck disable=SC1091 + [[ -f ".env" ]] && echo "sourcing .env file..." && set -a && source .env && set +a + ''; in { - # _module.args.pkgs = import inp.nixpkgs { - # inherit system; - # config.permittedInsecurePackages = [ "dotnet-sdk-6.0.428" ]; - # }; - treefmt = { projectRootFile = "flake.nix"; programs.nixfmt.enable = true; }; nci.toolchainConfig = ./rust-toolchain.toml; - nci.projects."pluralkit-services" = { + nci.projects."pk-services" = { path = ./.; export = false; }; - # nci.crates."gateway" = { - # depsDrvConfig.mkDerivation = { - # nativeBuildInputs = [ pkgs.protobuf ]; - # }; - # drvConfig.mkDerivation = { - # nativeBuildInputs = [ pkgs.protobuf ]; - # }; - # }; + nci.crates."commands" = rec { + depsDrvConfig.env = { + # we don't really need this since the lib is just used to generate the bindings + doNotRemoveReferencesToVendorDir = true; + }; + depsDrvConfig.mkDerivation = { + # also not really needed + dontPatchShebangs = true; + }; + drvConfig = depsDrvConfig; + }; + + apps = { + generate-command-parser-bindings.program = pkgs.writeShellApplication { + name = "generate-command-parser-bindings"; + runtimeInputs = [ + (config.nci.toolchains.mkBuild pkgs) + self'.devShells.services.stdenv.cc + pkgs.dotnet-sdk_8 + pkgs.csharpier + pkgs.coreutils + uniffi-bindgen-cs + ]; + text = '' + set -x + commandslib="''${1:-}" + if [ "$commandslib" == "" ]; then + cargo -Z unstable-options build --package commands --lib --release --artifact-dir obj/ + commandslib="obj/libcommands.so" + else + cp -f "$commandslib" obj/ + fi + uniffi-bindgen-cs "$commandslib" --library --out-dir="''${2:-./PluralKit.Bot}" + cargo run --package commands --bin write_cs_glue -- "''${2:-./PluralKit.Bot}"/commandtypes.cs + ''; + }; + }; # TODO: expose other rust packages after it's verified they build and work properly - packages = lib.genAttrs ["gateway"] (name: rustOutputs.${name}.packages.release); + packages = lib.genAttrs [ "gateway" "commands" ] (name: rustOutputs.${name}.packages.release); # TODO: package the bot itself (dotnet) - devShells = { - services = rustOutputs."pluralkit-services".devShell; - bot = (mkBotEnv "bash").env; + devShells = rec { + services = rustOutputs."pk-services".devShell; + bot = pkgs.mkShell { + name = "pkbot-devshell"; + nativeBuildInputs = with pkgs; [ + coreutils + git + dotnet-sdk_8 + gcc + omnisharp-roslyn + bashInteractive + postgresql + ]; + }; + all = (pkgs.mkShell.override { stdenv = services.stdenv; }) { + name = "pk-devshell"; + nativeBuildInputs = bot.nativeBuildInputs ++ services.nativeBuildInputs; + shellHook = '' + ${sourceDotenv} + ''; + }; docs = pkgs.mkShellNoCC { buildInputs = with pkgs; [ nodejs yarn ]; NODE_OPTIONS = "--openssl-legacy-provider"; }; }; - process-compose."dev" = let - dataDir = ".nix-process-compose"; - sourceDotenv = '' - # shellcheck disable=SC2046 - [[ -f ".env" ]] && echo "sourcing .env file..." && export $(xargs < .env) - ''; - in { - imports = [ inp.services.processComposeModules.default ]; + process-compose."dev" = + let + dataDir = ".nix-process-compose"; + in + { + imports = [ inp.services.processComposeModules.default ]; - settings.log_location = "${dataDir}/log"; + settings.log_location = "${dataDir}/log"; - settings.environment = { - DOTNET_CLI_TELEMETRY_OPTOUT = "1"; - NODE_OPTIONS = "--openssl-legacy-provider"; - }; + settings.environment = { + DOTNET_CLI_TELEMETRY_OPTOUT = "1"; + NODE_OPTIONS = "--openssl-legacy-provider"; + }; - services.redis."redis" = { - enable = true; - dataDir = "${dataDir}/redis"; - }; - services.postgres."postgres" = { - enable = true; - dataDir = "${dataDir}/postgres"; - initialScript.before = '' - CREATE DATABASE pluralkit; - CREATE USER postgres WITH password 'postgres'; - GRANT ALL PRIVILEGES ON DATABASE pluralkit TO postgres; - ALTER DATABASE pluralkit OWNER TO postgres; - ''; - }; + services.redis."redis" = { + enable = true; + dataDir = "${dataDir}/redis"; + }; + services.postgres."postgres" = { + enable = true; + dataDir = "${dataDir}/postgres"; + initialScript.before = '' + CREATE DATABASE pluralkit; + CREATE USER postgres WITH password 'postgres'; + GRANT ALL PRIVILEGES ON DATABASE pluralkit TO postgres; + ALTER DATABASE pluralkit OWNER TO postgres; + ''; + }; - settings.processes = - let - procCfg = composeCfg.settings.processes; - mkServiceInitProcess = - { - name, - inputs ? [ ], - ... - }: - let - shell = rustOutputs.${name}.devShell; - in - { + settings.processes = + let + mkServiceProcess = + name: attrs: + { + command = pkgs.writeShellApplication { + name = "pluralkit-${name}"; + runtimeInputs = [ pkgs.coreutils ]; + text = '' + ${sourceDotenv} + set -x + nix develop .#services -c cargo run --package ${name} + ''; + }; + } // attrs; + in + { + ### migrations ### + pluralkit-migrate = mkServiceProcess "migrate" { + depends_on.postgres.condition = "process_healthy"; + }; + ### bot ### + pluralkit-bot = { command = pkgs.writeShellApplication { - name = "pluralkit-${name}-init"; - runtimeInputs = - (with pkgs; [ - coreutils - shell.stdenv.cc - ]) - ++ shell.nativeBuildInputs - ++ inputs; + name = "pluralkit-bot"; + runtimeInputs = [ pkgs.coreutils ]; text = '' ${sourceDotenv} set -x - exec cargo build --bin ${name} + ${self'.apps.generate-command-parser-bindings.program} + nix develop .#bot -c bash -c "dotnet build ./PluralKit.Bot/PluralKit.Bot.csproj -c Release -o obj/ && dotnet obj/PluralKit.Bot.dll" ''; }; + depends_on.postgres.condition = "process_healthy"; + depends_on.redis.condition = "process_healthy"; + depends_on.pluralkit-gateway.condition = "process_log_ready"; + depends_on.pluralkit-migrate.condition = "process_completed_successfully"; + # TODO: add liveness check + ready_log_line = "Connected! All is good (probably)."; + availability.restart = "on_failure"; + availability.max_restarts = 3; }; - in - { - ### bot ### - pluralkit-bot-init = { - command = pkgs.writeShellApplication { - name = "pluralkit-bot-init"; - runtimeInputs = [ - pkgs.coreutils - pkgs.git - ]; - text = '' - ${sourceDotenv} - set -x - exec ${mkBotEnv "dotnet build -c Release -o obj/"}/bin/env - ''; + ### gateway ### + pluralkit-gateway = mkServiceProcess "gateway" { + depends_on.postgres.condition = "process_healthy"; + depends_on.redis.condition = "process_healthy"; + # configure health checks + # TODO: don't assume port? + liveness_probe.exec.command = ''${pkgs.curl}/bin/curl -s -o /dev/null -w "%{http_code}" http://localhost:5000/stats | ${pkgs.busybox}/bin/grep "302"''; + liveness_probe.period_seconds = 7; + # TODO: add actual listening or running line in gateway + ready_log_line = "Running "; + availability.restart = "on_failure"; + availability.max_restarts = 3; }; + # TODO: add the rest of the services }; - pluralkit-bot = { - command = pkgs.writeShellApplication { - name = "pluralkit-bot"; - runtimeInputs = [ pkgs.coreutils ]; - text = '' - ${sourceDotenv} - set -x - exec ${mkBotEnv "dotnet obj/PluralKit.Bot.dll"}/bin/env - ''; - }; - depends_on.pluralkit-bot-init.condition = "process_completed_successfully"; - depends_on.postgres.condition = "process_healthy"; - depends_on.redis.condition = "process_healthy"; - depends_on.pluralkit-gateway.condition = "process_healthy"; - # TODO: add liveness check - ready_log_line = "Received Ready"; - }; - ### migrations ### - pluralkit-migrate-init = mkServiceInitProcess { - name = "migrate"; - }; - pluralkit-migrate = { - command = pkgs.writeShellApplication { - name = "pluralkit-migrate"; - text = '' - ${sourceDotenv} - set -x - exec target/debug/migrate - ''; - }; - depends_on.postgres.condition = "process_healthy"; - depends_on.pluralkit-migrate-init.condition = "process_completed_successfully"; - }; - ### gateway ### - pluralkit-gateway-init = mkServiceInitProcess { - name = "gateway"; - }; - pluralkit-gateway = { - command = pkgs.writeShellApplication { - name = "pluralkit-gateway"; - runtimeInputs = with pkgs; [ - coreutils - curl - gnugrep - ]; - text = '' - ${sourceDotenv} - set -x - exec target/debug/gateway - ''; - }; - depends_on.postgres.condition = "process_healthy"; - depends_on.redis.condition = "process_healthy"; - depends_on.pluralkit-gateway-init.condition = "process_completed_successfully"; - # configure health checks - # TODO: don't assume port? - liveness_probe.exec.command = ''curl -s -o /dev/null -w "%{http_code}" http://localhost:5000/stats | grep "302"''; - liveness_probe.period_seconds = 5; - readiness_probe.exec.command = procCfg.pluralkit-gateway.liveness_probe.exec.command; - readiness_probe.period_seconds = 5; - readiness_probe.initial_delay_seconds = 3; - }; - # TODO: add the rest of the services - }; - }; + }; }; }; }