From 3d07413690c551d9f034c93af85ae8da5a495e14 Mon Sep 17 00:00:00 2001 From: seth Date: Sat, 20 Apr 2024 02:31:40 +0000 Subject: spring cleaning (#165) * treewide: lightly refactor everything * once_cell -> std::sync * remove build.rs we can get our target at runtime * commands::copypasta: refactor selection * drop owo_colors * reactboard: always remove author from count * commands: better handle behavior outside of guilds * ci: garnix -> gha * nix: drop flake-parts & pre-commit-hooks * nix: fix rust flags in derivation * add gha badge to readme * ci: fail when format changes are made * ci: only run on push to main * nix: fix nil script * nix: add libiconv to darwin deps * ci: disable fail-fast * nix: fix actionlint & static checks * ci: add release gates * nix: fix nil check again * ci: give release gates unique names * ci: only build static packages in docker workflow * nix: move dev outputs to subflake * fix some typos * nix: cleanup checks & dev shell * add editorconfig --- .codespellrc | 2 - .editorconfig | 13 ++ .env.template | 6 +- .envrc | 11 +- .gitattributes | 1 - .github/dependabot.yml | 4 +- .github/workflows/autobot.yaml | 4 +- .github/workflows/ci.yaml | 114 +++++++++++++++++ .github/workflows/clippy.yaml | 49 -------- .github/workflows/docker.yaml | 61 +++++---- .github/workflows/nix.yaml | 64 ++++++++++ .github/workflows/update-flake.yaml | 60 +++++++-- .prettierignore | 1 - Cargo.lock | 12 +- Cargo.toml | 19 ++- README.md | 2 +- build.rs | 6 - flake.lock | 185 +-------------------------- flake.nix | 85 +++++-------- garnix.yaml | 6 - nix/ci.nix | 23 ---- nix/deployment/default.nix | 35 ------ nix/deployment/module.nix | 146 ---------------------- nix/deployment/static.nix | 50 -------- nix/derivation.nix | 43 +++---- nix/dev.nix | 71 ----------- nix/dev/checks.nix | 27 ++++ nix/dev/docker.nix | 25 ++++ nix/dev/flake.lock | 145 ++++++++++++++++++++++ nix/dev/flake.nix | 62 +++++++++ nix/dev/procfile.nix | 13 ++ nix/dev/shell.nix | 45 +++++++ nix/dev/static.nix | 39 ++++++ nix/dev/treefmt.nix | 21 ++++ nix/module.nix | 148 ++++++++++++++++++++++ nix/packages.nix | 12 -- src/api/guzzle.rs | 28 ++--- src/api/mod.rs | 34 +++-- src/api/shiggy.rs | 24 +--- src/colors.rs | 17 --- src/commands/general/ask.rs | 7 +- src/commands/general/bing.rs | 4 +- src/commands/general/config.rs | 124 +++++++++--------- src/commands/general/convert.rs | 12 +- src/commands/general/mod.rs | 28 +---- src/commands/general/random.rs | 27 ++-- src/commands/general/version.rs | 18 ++- src/commands/mod.rs | 53 ++++++-- src/commands/moderation/clear.rs | 39 ------ src/commands/moderation/clear_messages.rs | 30 +++++ src/commands/moderation/mod.rs | 11 +- src/commands/optional/copypasta.rs | 94 +++++++------- src/commands/optional/mod.rs | 19 +-- src/commands/optional/teawiespam.rs | 26 ++-- src/commands/optional/uwurandom.rs | 24 ++-- src/consts.rs | 49 ++++++++ src/copypastas/ticktock.txt | 14 +-- src/handlers/error.rs | 8 +- src/handlers/event/guild.rs | 41 +++--- src/handlers/event/message.rs | 27 ++-- src/handlers/event/mod.rs | 32 ++--- src/handlers/event/pinboard.rs | 13 +- src/handlers/event/reactboard.rs | 60 +++++---- src/handlers/mod.rs | 7 +- src/main.rs | 113 ++++++++--------- src/storage/mod.rs | 200 +++++++++++------------------- src/storage/reactboard.rs | 2 - src/storage/settings.rs | 6 +- src/utils.rs | 4 +- 69 files changed, 1464 insertions(+), 1341 deletions(-) delete mode 100644 .codespellrc create mode 100644 .editorconfig delete mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yaml delete mode 100644 .github/workflows/clippy.yaml create mode 100644 .github/workflows/nix.yaml delete mode 100644 .prettierignore delete mode 100644 build.rs delete mode 100644 garnix.yaml delete mode 100644 nix/ci.nix delete mode 100644 nix/deployment/default.nix delete mode 100644 nix/deployment/module.nix delete mode 100644 nix/deployment/static.nix delete mode 100644 nix/dev.nix create mode 100644 nix/dev/checks.nix create mode 100644 nix/dev/docker.nix create mode 100644 nix/dev/flake.lock create mode 100644 nix/dev/flake.nix create mode 100644 nix/dev/procfile.nix create mode 100644 nix/dev/shell.nix create mode 100644 nix/dev/static.nix create mode 100644 nix/dev/treefmt.nix create mode 100644 nix/module.nix delete mode 100644 nix/packages.nix delete mode 100644 src/colors.rs delete mode 100644 src/commands/moderation/clear.rs create mode 100644 src/commands/moderation/clear_messages.rs diff --git a/.codespellrc b/.codespellrc deleted file mode 100644 index 794d507..0000000 --- a/.codespellrc +++ /dev/null @@ -1,2 +0,0 @@ -[codespell] -ignore-words = crate diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e24cebd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# http://editorconfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = tab +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{lock,nix,yml,yaml}] +indent_style = space +indent_size = 2 diff --git a/.env.template b/.env.template index 60492e1..6223b8c 100644 --- a/.env.template +++ b/.env.template @@ -1 +1,5 @@ -TOKEN=AAAAAAAAAAAAAAAAAAAAAAAAAA.AMOGUS.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +TOKEN= +REDIS_URL= + +RUST_BACKTRACE=1 +RUST_LOG="teawiebot=debug,warn" diff --git a/.envrc b/.envrc index dcc53e4..cd7fdf5 100644 --- a/.envrc +++ b/.envrc @@ -1,11 +1,6 @@ -# only use flake when `nix` is present -if command -v nix &> /dev/null; then - if ! has nix_direnv_version || ! nix_direnv_version 2.2.1; then - source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/2.2.1/direnvrc" "sha256-zelF0vLbEl5uaqrfIzbgNzJWGmLzCmYAkInj/LNxvKs=" - fi - - watch_file ./parts/dev.nix - use flake +# only use flake when `nix-direnv` is installed +if has nix_direnv_version; then + use flake ./nix/dev fi dotenv_if_exists diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index fcadb2c..0000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -* text eol=lf diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ea75cc8..f9f0b67 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,11 +5,11 @@ updates: schedule: interval: "weekly" commit-message: - prefix: "deps(actions)" + prefix: "ci" - package-ecosystem: "cargo" directory: "/" schedule: interval: "weekly" commit-message: - prefix: "deps(crates)" + prefix: "crates" diff --git a/.github/workflows/autobot.yaml b/.github/workflows/autobot.yaml index a8b959f..69d6ad0 100644 --- a/.github/workflows/autobot.yaml +++ b/.github/workflows/autobot.yaml @@ -5,14 +5,14 @@ on: pull_request jobs: automerge: name: Check and merge PR + if: github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest permissions: contents: write pull-requests: write - if: github.actor == 'dependabot[bot]' - steps: - uses: dependabot/fetch-metadata@v2 id: metadata diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..67371c0 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,114 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + build: + name: Build + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + components: clippy + + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + + - name: Run build + run: cargo build --locked --release + + clippy: + name: Run Clippy scan + + runs-on: ubuntu-latest + + permissions: + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v10 + + - name: Setup Nix cache + uses: DeterminateSystems/magic-nix-cache-action@v4 + + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + + - name: Install SARIF tools + run: | + nix profile install \ + --inputs-from ./nix/dev \ + github:getchoo/nix-exprs#{clippy-sarif,sarif-fmt} + + - name: Fetch Cargo deps + run: | + nix develop ./nix/dev#ci --command \ + cargo fetch --locked + + - name: Run Clippy + continue-on-error: true + run: | + nix develop ./nix/dev#ci --command \ + cargo clippy \ + --all-features \ + --all-targets \ + --message-format=json \ + | clippy-sarif | tee /tmp/clippy.sarif | sarif-fmt + + - name: Upload results + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: /tmp/clippy.sarif + wait-for-processing: true + + format: + name: Check formatting + + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v10 + + - name: Setup Nix cache + uses: DeterminateSystems/magic-nix-cache-action@v4 + + - name: Run treefmt + run: | + pushd nix/dev + nix fmt + popd + git diff --color=always --exit-code + + release-gate: + name: CI Release Gate + needs: [build, format] + + runs-on: ubuntu-latest + + steps: + - name: Exit with result + run: echo "We're good to go!" diff --git a/.github/workflows/clippy.yaml b/.github/workflows/clippy.yaml deleted file mode 100644 index 1c3a316..0000000 --- a/.github/workflows/clippy.yaml +++ /dev/null @@ -1,49 +0,0 @@ -name: Clippy - -on: - push: - branches: ["main"] - pull_request: - -jobs: - clippy: - name: Run Clippy scan - runs-on: ubuntu-latest - - permissions: - security-events: write - - steps: - - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - toolchain: stable - components: clippy - - - name: Setup Rust cache - uses: Swatinem/rust-cache@v2 - - - name: Install SARIF tools - run: cargo install clippy-sarif sarif-fmt - - - name: Fetch Cargo deps - run: cargo fetch --locked - - - name: Run Clippy - continue-on-error: true - run: | - set -euxo pipefail - - cargo clippy \ - --all-features \ - --all-targets \ - --message-format=json \ - | clippy-sarif | tee /tmp/clippy.sarif | sarif-fmt - - - name: Upload results - uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: /tmp/clippy.sarif - wait-for-processing: true diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index d738366..5f45bec 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -1,27 +1,25 @@ -name: Push to image registry +name: Docker on: - check_suite: - types: [completed] + push: + branches: [main] + pull_request: workflow_dispatch: jobs: build: name: Build image - runs-on: ubuntu-latest strategy: + fail-fast: false matrix: arch: [x86_64, aarch64] - # https://github.com/sellout/bash-strict-mode/commit/9bf1d65c2f786a9887facfcb81e06d8b8b5f4667 - if: github.event.check_suite.app.name == 'Garnix CI' - && github.event.check_suite.conclusion == 'success' - && github.event.check_suite.latest_check_runs_count >= 8 - && github.event.check_suite.head_branch == 'main' + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 - name: Install Nix uses: DeterminateSystems/nix-installer-action@v10 @@ -31,10 +29,17 @@ jobs: - name: Build Docker image id: build + env: + ARCH: ${{ matrix.arch }} run: | - nix build -L --accept-flake-config .#container-${{ matrix.arch }} + nix build \ + --fallback \ + --print-build-logs \ + ./nix/dev#container-"$ARCH" + + # exit if no `result` from nix build [ ! -L result ] && exit 1 - echo "path=$(realpath result)" >> "$GITHUB_OUTPUT" + echo "path=$(readlink -f ./result)" >> "$GITHUB_OUTPUT" - name: Upload image uses: actions/upload-artifact@v4 @@ -44,10 +49,21 @@ jobs: if-no-files-found: error retention-days: 1 + release-gate: + name: Docker Release Gate + needs: build + + runs-on: ubuntu-latest + + steps: + - name: Exit with result + run: echo "We're good to go!" + push: name: Push image + if: github.event_name == 'push' + needs: release-gate - needs: build runs-on: ubuntu-latest permissions: @@ -55,14 +71,15 @@ jobs: env: REGISTRY: ghcr.io - USERNAME: getchoo + USERNAME: ${{ github.actor }} steps: - name: Set image name run: | echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 - name: Download images uses: actions/download-artifact@v4 @@ -80,17 +97,15 @@ jobs: env: TAG: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest run: | - set -eux - architectures=("x86_64" "aarch64") for arch in "${architectures[@]}"; do docker load < images/container-"$arch"/*.tar.gz - docker tag teawiebot:latest-"$arch" ${{ env.TAG }}-"$arch" - docker push ${{ env.TAG }}-"$arch" + docker tag teawiebot:latest-"$arch" "$TAG"-"$arch" + docker push "$TAG"-"$arch" done - docker manifest create ${{ env.TAG }} \ - --amend ${{ env.TAG }}-x86_64 \ - --amend ${{ env.TAG }}-aarch64 + docker manifest create "$TAG" \ + --amend "$TAG"-x86_64 \ + --amend "$TAG"-aarch64 - docker manifest push ${{ env.TAG }} + docker manifest push "$TAG" diff --git a/.github/workflows/nix.yaml b/.github/workflows/nix.yaml new file mode 100644 index 0000000..4427afa --- /dev/null +++ b/.github/workflows/nix.yaml @@ -0,0 +1,64 @@ +name: Nix + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + build: + name: Build + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v10 + + - name: Setup Nix cache + uses: DeterminateSystems/magic-nix-cache-action@v4 + + - name: Run build + run: nix build --fallback --print-build-logs + + check: + name: Check flake + + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v10 + + - name: Setup Nix cache + uses: DeterminateSystems/magic-nix-cache-action@v4 + + - name: Run nix flake check + run: | + nix flake check \ + --all-systems \ + --fallback \ + --print-build-logs \ + --show-trace + + release-gate: + name: Nix Release Gate + needs: [build, check] + + runs-on: ubuntu-latest + + steps: + - name: Exit with result + run: echo "We're good to go!" diff --git a/.github/workflows/update-flake.yaml b/.github/workflows/update-flake.yaml index 3726908..a3a6293 100644 --- a/.github/workflows/update-flake.yaml +++ b/.github/workflows/update-flake.yaml @@ -1,4 +1,4 @@ -name: Update flake.lock +name: Update lockfiles on: schedule: @@ -8,29 +8,65 @@ on: jobs: update: + name: Run update runs-on: ubuntu-latest permissions: contents: write pull-requests: write + env: + PR_BRANCH: "update-lockfiles" + steps: - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 - name: Install Nix uses: DeterminateSystems/nix-installer-action@v10 - - name: Update lockfile & make PR - uses: DeterminateSystems/update-flake-lock@v21 - id: update - with: - commit-msg: "flake: update inputs" - pr-title: "flake: update inputs" - token: ${{ github.token }} + - name: Set Git user info + run: | + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + + - name: Create new branch + id: branch + run: | + git switch -c "$PR_BRANCH" + + - name: Update flake inputs + run: | + pushd nix/dev + nix flake update \ + --commit-lock-file \ + --commit-lockfile-summary "nix: update dev flake.lock" + popd + + nix flake update \ + --commit-lock-file \ + --commit-lockfile-summary "nix: update flake.lock" + + - name: Make PR if needed + env: + GH_TOKEN: ${{ github.token }} + run: | + if ! git diff --color=always --exit-code origin/main; then + git fetch origin "$PR_BRANCH" || true + git push --force-with-lease -u origin "$PR_BRANCH" + + open_prs="$(gh pr list --base main --head "$PR_BRANCH" | wc -l)" + if [ "$open_prs" -eq 0 ]; then + gh pr create \ + --base main \ + --head "$PR_BRANCH" \ + --title "chore: update lockfiles" \ + --fill + fi + fi - name: Enable auto-merge shell: bash - run: gh pr merge --auto --rebase "$PR_ID" + run: gh pr merge --auto --squash env: - GH_TOKEN: ${{ github.token }} - PR_ID: ${{ steps.update.outputs.pull-request-number }} + GH_TOKEN: ${{ secrets.MERGE_TOKEN }} diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 301d47e..0000000 --- a/.prettierignore +++ /dev/null @@ -1 +0,0 @@ -flake.lock diff --git a/Cargo.lock b/Cargo.lock index aa92642..130fb2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -304,7 +304,7 @@ dependencies = [ "eyre", "indenter", "once_cell", - "owo-colors 3.5.0", + "owo-colors", "tracing-error", ] @@ -315,7 +315,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd6be1b2a7e382e2b98b43b2adcca6bb0e465af0bdd38123873ae61eb17a72c2" dependencies = [ "once_cell", - "owo-colors 3.5.0", + "owo-colors", "tracing-core", "tracing-error", ] @@ -1168,12 +1168,6 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" -[[package]] -name = "owo-colors" -version = "4.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caff54706df99d2a78a5a4e3455ff45448d81ef1bb63c22cd14052ca0e993a3f" - [[package]] name = "parking_lot" version = "0.12.1" @@ -2067,8 +2061,6 @@ dependencies = [ "eyre", "include_dir", "log", - "once_cell", - "owo-colors 4.0.0", "poise", "rand 0.8.5", "redis 0.25.2", diff --git a/Cargo.toml b/Cargo.toml index ddc542b..45f9391 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,6 @@ edition = "2021" repository = "https://github.com/getchoo/teawieBot" license = "MIT" readme = "README.md" -build = "build.rs" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -14,12 +13,13 @@ bottomify = "1.2.0" color-eyre = "0.6.3" dotenvy = "0.15.7" env_logger = "0.11.3" -eyre = "0.6.12" +eyre = { version = "0.6.12", default-features = false, features = [ + "auto-install", + "track-caller", +] } include_dir = "0.7.3" log = "0.4.21" poise = "0.6.1" -once_cell = "1.19.0" -owo-colors = "4.0.0" rand = "0.8.5" redis = { version = "0.25.2", features = ["tokio-comp", "tokio-rustls-comp"] } redis-macros = "0.2.1" @@ -36,3 +36,14 @@ tokio = { version = "1.37.0", features = [ ] } url = { version = "2.5.0", features = ["serde"] } uwurandom-rs = "1.1.0" + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +complexity = "warn" +correctness = "deny" +pedantic = "warn" +perf = "warn" +style = "warn" +suspicious = "deny" diff --git a/README.md b/README.md index a4051a5..026405e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # teawie bot 🦀🦀🦀 -[![built with garnix](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Fgarnix.io%2Fapi%2Fbadges%2Fgetchoo%2FteawieBot)](https://garnix.io) +[![CI](https://github.com/getchoo/teawieBot/actions/workflows/ci.yaml/badge.svg?event=push)](https://github.com/getchoo/teawieBot/actions/workflows/ci.yaml) okay so like basically, it's just a discord bot named "teawie" (so cool!! and now in rust!!!)🚀🚀 diff --git a/build.rs b/build.rs deleted file mode 100644 index af1183f..0000000 --- a/build.rs +++ /dev/null @@ -1,6 +0,0 @@ -fn main() { - println!( - "cargo:rustc-env=TARGET={}", - std::env::var("TARGET").unwrap() - ); -} diff --git a/flake.lock b/flake.lock index 53a2823..4472224 100644 --- a/flake.lock +++ b/flake.lock @@ -1,85 +1,5 @@ { "nodes": { - "fenix": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src" - }, - "locked": { - "lastModified": 1711606966, - "narHash": "sha256-nTaO7ZDL4D02dVC5ktqnXNiNuODBUHyE4qEcFjAUCQY=", - "owner": "nix-community", - "repo": "fenix", - "rev": "aa45c3e901ea42d6633af083c0c555efaf948b17", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, - "flake-parts": { - "inputs": { - "nixpkgs-lib": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1709336216, - "narHash": "sha256-Dt/wOWeW6Sqm11Yh+2+t0dfEWxoMxGBvv3JpIocFl9E=", - "owner": "hercules-ci", - "repo": "flake-parts", - "rev": "f7b3c975cf067e56e7cda6cb098ebe3fb4d74ca2", - "type": "github" - }, - "original": { - "owner": "hercules-ci", - "repo": "flake-parts", - "type": "github" - } - }, - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1710146030, - "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "gitignore": { - "inputs": { - "nixpkgs": [ - "pre-commit-hooks-nix", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1709087332, - "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", - "owner": "hercules-ci", - "repo": "gitignore.nix", - "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", - "type": "github" - }, - "original": { - "owner": "hercules-ci", - "repo": "gitignore.nix", - "type": "github" - } - }, "nixpkgs": { "locked": { "lastModified": 1711715736, @@ -96,112 +16,9 @@ "type": "github" } }, - "pre-commit-hooks-nix": { - "inputs": { - "flake-compat": [], - "flake-utils": "flake-utils", - "gitignore": "gitignore", - "nixpkgs": [ - "nixpkgs" - ], - "nixpkgs-stable": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1711760291, - "narHash": "sha256-GWIBVrHnQQYtZA4aaS3FQebthxvL0Uz4ifJSvJxblfw=", - "owner": "cachix", - "repo": "pre-commit-hooks.nix", - "rev": "0d676ca9ca9df7f2d4d5fb0de511fed3a4b67fdf", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "pre-commit-hooks.nix", - "type": "github" - } - }, - "procfile-nix": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1711158989, - "narHash": "sha256-exgncIe/lQIswv2L1M0y+RrHAg5dofLFCOxGu4/yJww=", - "owner": "getchoo", - "repo": "procfile-nix", - "rev": "6388308f9e9c8a8fbfdff54b30adf486fa292cf9", - "type": "github" - }, - "original": { - "owner": "getchoo", - "repo": "procfile-nix", - "type": "github" - } - }, "root": { "inputs": { - "fenix": "fenix", - "flake-parts": "flake-parts", - "nixpkgs": "nixpkgs", - "pre-commit-hooks-nix": "pre-commit-hooks-nix", - "procfile-nix": "procfile-nix", - "treefmt-nix": "treefmt-nix" - } - }, - "rust-analyzer-src": { - "flake": false, - "locked": { - "lastModified": 1711562745, - "narHash": "sha256-s/YOyBM0vumhkqCFi8CnV5imFlC5JJrGia8CmEXyQkM=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "ad51a17c627b4ca57f83f0dc1f3bb5f3f17e6d0b", - "type": "github" - }, - "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", - "type": "github" - } - }, - "systems": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "treefmt-nix": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1711531821, - "narHash": "sha256-5n4hq7PsH8g9czJ5HvXpVrJ4AiJdzrutHK01oKIaCXE=", - "owner": "numtide", - "repo": "treefmt-nix", - "rev": "c2172ef83d6904cdff3118e0c08e89171db6028a", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "treefmt-nix", - "type": "github" + "nixpkgs": "nixpkgs" } } }, diff --git a/flake.nix b/flake.nix index 28c35a6..4e4ece1 100644 --- a/flake.nix +++ b/flake.nix @@ -1,62 +1,35 @@ { description = "teawie moment"; - nixConfig = { - extra-substituters = ["https://cache.garnix.io"]; - extra-trusted-public-keys = ["cache.garnix.io:CTFPyKSLcx5RMJKfLo5EEPUObbA78b0YQ2DTCJXqr9g="]; - }; - - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; - - flake-parts = { - url = "github:hercules-ci/flake-parts"; - inputs.nixpkgs-lib.follows = "nixpkgs"; - }; - - fenix = { - url = "github:nix-community/fenix"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - - pre-commit-hooks-nix = { - url = "github:cachix/pre-commit-hooks.nix"; - inputs = { - nixpkgs.follows = "nixpkgs"; - nixpkgs-stable.follows = "nixpkgs"; - flake-compat.follows = ""; - }; - }; - - treefmt-nix = { - url = "github:numtide/treefmt-nix"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - - procfile-nix = { - url = "github:getchoo/procfile-nix"; - inputs.nixpkgs.follows = "nixpkgs"; + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + + outputs = { + self, + nixpkgs, + ... + }: let + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + + forAllSystems = fn: nixpkgs.lib.genAttrs systems (system: fn nixpkgs.legacyPackages.${system}); + in { + nixosModules.default = import ./nix/module.nix self; + + packages = forAllSystems ({ + pkgs, + system, + ... + }: { + teawiebot = pkgs.callPackage ./nix/derivation.nix {inherit self;}; + default = self.packages.${system}.teawiebot; + }); + + overlays.default = _: prev: { + teawiebot = prev.callPackage ./nix/derivation.nix {inherit self;}; }; }; - - outputs = inputs: - inputs.flake-parts.lib.mkFlake {inherit inputs;} { - imports = [ - inputs.pre-commit-hooks-nix.flakeModule - inputs.procfile-nix.flakeModule - inputs.treefmt-nix.flakeModule - - ./nix/ci.nix - ./nix/deployment - ./nix/dev.nix - ./nix/packages.nix - ]; - - systems = [ - "x86_64-linux" - "x86_64-darwin" - "aarch64-linux" - "aarch64-darwin" - ]; - }; } diff --git a/garnix.yaml b/garnix.yaml deleted file mode 100644 index 64bee81..0000000 --- a/garnix.yaml +++ /dev/null @@ -1,6 +0,0 @@ -builds: - exclude: [] - include: - - "checks.x86_64-linux.*" - - "packages.x86_64-linux.*" - - "devShells.x86_64-linux.default" diff --git a/nix/ci.nix b/nix/ci.nix deleted file mode 100644 index 682f46f..0000000 --- a/nix/ci.nix +++ /dev/null @@ -1,23 +0,0 @@ -{ - perSystem = { - pkgs, - lib, - self', - ... - }: { - /* - require packages, checks, and devShells for ci to be considered a success - - also thanks DetSys for showing me i don't need to use runCommand, symlinkJoin, or linkFarm! - https://determinate.systems/posts/hydra-deployment-source-of-truth - */ - - packages.ciGate = pkgs.writeText "ci-gate" ( - lib.concatMapStringsSep "\n" (s: toString (lib.attrValues s)) [ - self'.checks - self'.devShells - (builtins.removeAttrs self'.packages ["default" "ciGate"]) - ] - ); - }; -} diff --git a/nix/deployment/default.nix b/nix/deployment/default.nix deleted file mode 100644 index 7fd379f..0000000 --- a/nix/deployment/default.nix +++ /dev/null @@ -1,35 +0,0 @@ -{ - flake-parts-lib, - withSystem, - ... -}: { - imports = [./static.nix]; - - flake.nixosModules.default = flake-parts-lib.importApply ./module.nix { - inherit withSystem; - }; - - perSystem = { - lib, - pkgs, - self', - ... - }: let - containerFor = arch: - pkgs.dockerTools.buildLayeredImage { - name = "teawiebot"; - tag = "latest-${arch}"; - contents = [pkgs.dockerTools.caCertificates]; - config.Cmd = [ - (lib.getExe self'.packages."teawiebot-static-${arch}") - ]; - - architecture = withSystem "${arch}-linux" ({pkgs, ...}: pkgs.pkgsStatic.go.GOARCH); - }; - in { - packages = { - container-x86_64 = containerFor "x86_64"; - container-aarch64 = containerFor "aarch64"; - }; - }; -} diff --git a/nix/deployment/module.nix b/nix/deployment/module.nix deleted file mode 100644 index 09999f1..0000000 --- a/nix/deployment/module.nix +++ /dev/null @@ -1,146 +0,0 @@ -{withSystem, ...}: { - config, - lib, - pkgs, - ... -}: let - cfg = config.services.teawiebot; - defaultUser = "teawiebot"; - - inherit - (lib) - getExe - literalExpression - mdDoc - mkEnableOption - mkIf - mkOption - mkPackageOption - optionals - types - ; -in { - options.services.teawiebot = { - enable = mkEnableOption "teawiebot"; - package = mkPackageOption ( - withSystem pkgs.stdenv.hostPlatform.system ({self', ...}: self'.packages) - ) "teawiebot" {}; - - user = mkOption { - description = mdDoc '' - User under which the service should run. If this is the default value, - the user will be created, with the specified group as the primary - group. - ''; - type = types.str; - default = defaultUser; - example = literalExpression '' - "bob" - ''; - }; - - group = mkOption { - description = mdDoc '' - Group under which the service should run. If this is the default value, - the group will be created. - ''; - type = types.str; - default = defaultUser; - example = literalExpression '' - "discordbots" - ''; - }; - - redisUrl = mkOption { - description = mdDoc '' - Connection to a redis server. If this needs to include credentials - that shouldn't be world-readable in the Nix store, set environmentFile - and override the `REDIS_URL` entry. - Pass the string `local` to setup a local Redis database. - ''; - type = types.str; - default = "local"; - example = literalExpression '' - "redis://localhost/" - ''; - }; - - environmentFile = mkOption { - description = mdDoc '' - Environment file as defined in {manpage}`systemd.exec(5)` - ''; - type = types.nullOr types.path; - default = null; - example = literalExpression '' - "/run/agenix.d/1/teawieBot" - ''; - }; - }; - - config = mkIf cfg.enable { - services.redis.servers.teawiebot = mkIf (cfg.redisUrl == "local") { - enable = true; - inherit (cfg) user; - port = 0; # disable tcp listener - }; - - systemd.services."teawiebot" = { - enable = true; - wantedBy = ["multi-user.target"]; - after = - ["network.target"] - ++ optionals (cfg.redisUrl == "local") ["redis-teawiebot.service"]; - - script = '' - ${getExe cfg.package} - ''; - - environment = { - REDIS_URL = - if cfg.redisUrl == "local" - then "unix:${config.services.redis.servers.teawiebot.unixSocket}" - else cfg.redisUrl; - }; - - serviceConfig = { - Type = "simple"; - Restart = "always"; - - EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile; - - User = cfg.user; - Group = cfg.group; - - # hardening - NoNewPrivileges = true; - PrivateDevices = true; - PrivateTmp = true; - PrivateUsers = true; - ProtectClock = true; - ProtectControlGroups = true; - ProtectHome = true; - ProtectHostname = true; - ProtectKernelLogs = true; - ProtectKernelModules = true; - ProtectKernelTunables = true; - ProtectSystem = "strict"; - RestrictNamespaces = "uts ipc pid user cgroup"; - RestrictSUIDSGID = true; - Umask = "0007"; - }; - }; - - users = { - users = mkIf (cfg.user == defaultUser) { - ${defaultUser} = { - isSystemUser = true; - inherit (cfg) group; - }; - }; - - groups = mkIf (cfg.group == defaultUser) { - ${defaultUser} = {}; - }; - }; - }; -} diff --git a/nix/deployment/static.nix b/nix/deployment/static.nix deleted file mode 100644 index dcdf0f3..0000000 --- a/nix/deployment/static.nix +++ /dev/null @@ -1,50 +0,0 @@ -{ - perSystem = { - lib, - pkgs, - inputs', - self', - ... - }: let - targets = with pkgs.pkgsCross; { - x86_64 = musl64.pkgsStatic; - aarch64 = aarch64-multiplatform.pkgsStatic; - }; - - toolchain = let - fenix = inputs'.fenix.packages; - in - with fenix; - combine ( - [minimal.cargo minimal.rustc] - ++ map ( - pkgs: - fenix.targets.${pkgs.stdenv.hostPlatform.config}.latest.rust-std - ) (lib.attrValues targets) - ); - - rustPlatforms = - lib.mapAttrs ( - lib.const (pkgs: - pkgs.makeRustPlatform ( - lib.genAttrs ["cargo" "rustc"] (lib.const toolchain) - )) - ) - targets; - - buildTeawieWith = rustPlatform: - self'.packages.teawiebot.override { - inherit rustPlatform; - lto = true; - optimizeSize = true; - }; - in { - packages = lib.optionalAttrs pkgs.stdenv.isLinux ( - lib.mapAttrs' ( - target: rustPlatform: - lib.nameValuePair "teawiebot-static-${target}" (buildTeawieWith rustPlatform) - ) - rustPlatforms - ); - }; -} diff --git a/nix/derivation.nix b/nix/derivation.nix index bb60706..fa4867a 100644 --- a/nix/derivation.nix +++ b/nix/derivation.nix @@ -4,7 +4,7 @@ rustPlatform, darwin, self, - lto ? false, + lto ? true, optimizeSize ? false, }: rustPlatform.buildRustPackage { @@ -21,7 +21,6 @@ rustPlatform.buildRustPackage { ../src ../Cargo.toml ../Cargo.lock - ../build.rs ]; }; @@ -33,30 +32,28 @@ rustPlatform.buildRustPackage { CoreFoundation Security SystemConfiguration + darwin.libiconv ]); - env = { - GIT_SHA = self.shortRev or self.dirtyShortRev or "unknown-dirty"; - CARGO_BUILD_RUSTFLAGS = lib.concatStringsSep " " ( - lib.optionals lto [ - "-C" - "lto=thin" - "-C" - "embed-bitcode=yes" - "-Zdylib-lto" - ] - ++ lib.optionals optimizeSize [ - "-C" - "codegen-units=1" - "-C" - "panic=abort" - "-C" - "strip=symbols" - "-C" - "opt-level=z" - ] + env = let + toRustFlags = lib.mapAttrs' ( + name: + lib.nameValuePair + "CARGO_BUILD_RELEASE_${lib.toUpper (builtins.replaceStrings ["-"] ["_"] name)}" ); - }; + in + { + GIT_SHA = self.shortRev or self.dirtyShortRev or "unknown-dirty"; + } + // lib.optionalAttrs lto (toRustFlags { + lto = "thin"; + }) + // lib.optionalAttrs optimizeSize (toRustFlags { + codegen-units = 1; + opt-level = "s"; + panic = "abort"; + strip = "symbols"; + }); meta = with lib; { mainProgram = "teawiebot"; diff --git a/nix/dev.nix b/nix/dev.nix deleted file mode 100644 index d9f15d4..0000000 --- a/nix/dev.nix +++ /dev/null @@ -1,71 +0,0 @@ -{ - perSystem = { - lib, - pkgs, - config, - self', - ... - }: let - enableAll = lib.flip lib.genAttrs (lib.const {enable = true;}); - in { - treefmt = { - projectRootFile = "flake.nix"; - - programs = enableAll [ - "alejandra" - "deadnix" - "prettier" - "rustfmt" - ]; - - settings.global = { - excludes = [ - "./target" - "./flake.lock" - "./Cargo.lock" - ]; - }; - }; - - pre-commit.settings = { - settings.treefmt.package = config.treefmt.build.wrapper; - - hooks = enableAll [ - "actionlint" - "nil" - "statix" - "treefmt" - ]; - }; - - procfiles.daemons.processes = { - redis = lib.getExe' pkgs.redis "redis-server"; - }; - - devShells = { - default = pkgs.mkShell { - packages = with pkgs; [ - # general - actionlint - nodePackages_latest.prettier - config.procfiles.daemons.package - - # rust - cargo - rustc - clippy - rustfmt - rust-analyzer - - # nix - self'.formatter - deadnix - nil - statix - ]; - - RUST_SRC_PATH = "${pkgs.rust.packages.stable.rustPlatform.rustLibSrc}"; - }; - }; - }; -} diff --git a/nix/dev/checks.nix b/nix/dev/checks.nix new file mode 100644 index 0000000..1d65625 --- /dev/null +++ b/nix/dev/checks.nix @@ -0,0 +1,27 @@ +{ + perSystem = { + lib, + pkgs, + ... + }: { + checks = { + actionlint = pkgs.runCommand "check-actionlint" {} '' + ${lib.getExe pkgs.actionlint} ${../../.github/workflows}/* + touch $out + ''; + + editorconfig = pkgs.runCommand "check-editorconfig" {} '' + cd ${../../.} + ${lib.getExe pkgs.editorconfig-checker} \ + -exclude '.git' . + + touch $out + ''; + + statix = pkgs.runCommand "check-statix" {} '' + ${lib.getExe pkgs.statix} check ${../../.} + touch $out + ''; + }; + }; +} diff --git a/nix/dev/docker.nix b/nix/dev/docker.nix new file mode 100644 index 0000000..b209015 --- /dev/null +++ b/nix/dev/docker.nix @@ -0,0 +1,25 @@ +{withSystem, ...}: { + perSystem = { + lib, + pkgs, + self', + ... + }: let + containerFor = arch: + pkgs.dockerTools.buildLayeredImage { + name = "teawiebot"; + tag = "latest-${arch}"; + contents = [pkgs.dockerTools.caCertificates]; + config.Cmd = [ + (lib.getExe self'.packages."teawiebot-static-${arch}") + ]; + + architecture = withSystem "${arch}-linux" ({pkgs, ...}: pkgs.pkgsStatic.go.GOARCH); + }; + in { + packages = { + container-x86_64 = containerFor "x86_64"; + container-aarch64 = containerFor "aarch64"; + }; + }; +} diff --git a/nix/dev/flake.lock b/nix/dev/flake.lock new file mode 100644 index 0000000..881402a --- /dev/null +++ b/nix/dev/flake.lock @@ -0,0 +1,145 @@ +{ + "nodes": { + "fenix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1712384501, + "narHash": "sha256-AZmYmEnc1ZkSlxUJVUtGh9VFAqWPr+xtNIiBqD2eKfc=", + "owner": "nix-community", + "repo": "fenix", + "rev": "99c6241db5ca5363c05c8f4acbdf3a4e8fc42844", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1712014858, + "narHash": "sha256-sB4SWl2lX95bExY2gMFG5HIzvva5AVMJd4Igm+GpZNw=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "9126214d0a59633752a136528f5f3b9aa8565b7d", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "get-flake": { + "locked": { + "lastModified": 1694475786, + "narHash": "sha256-s5wDmPooMUNIAAsxxCMMh9g68AueGg63DYk2hVZJbc8=", + "owner": "ursi", + "repo": "get-flake", + "rev": "ac54750e3b95dab6ec0726d77f440efe6045bec1", + "type": "github" + }, + "original": { + "owner": "ursi", + "repo": "get-flake", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1712512205, + "narHash": "sha256-CrKKps0h7FoagRcE2LT3h/72Z64D0Oh83UF1XZVhCLQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "3c1b6f75344e207a67536d834886ee9b4577ebe7", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "procfile-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1712476251, + "narHash": "sha256-o6L0IDUy5ri1ijXP6D132gYcSYT7JweNQr5P9DOozRM=", + "owner": "getchoo", + "repo": "procfile-nix", + "rev": "c3320b59670450b4d2b5cb01cdbd3e176a6d3232", + "type": "github" + }, + "original": { + "owner": "getchoo", + "repo": "procfile-nix", + "type": "github" + } + }, + "root": { + "inputs": { + "fenix": "fenix", + "flake-parts": "flake-parts", + "get-flake": "get-flake", + "nixpkgs": "nixpkgs", + "procfile-nix": "procfile-nix", + "treefmt-nix": "treefmt-nix" + } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1712156296, + "narHash": "sha256-St7ZQrkrr5lmQX9wC1ZJAFxL8W7alswnyZk9d1se3Us=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "8e581ac348e223488622f4d3003cb2bd412bf27e", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1711963903, + "narHash": "sha256-N3QDhoaX+paWXHbEXZapqd1r95mdshxToGowtjtYkGI=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "49dc4a92b02b8e68798abd99184f228243b6e3ac", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/nix/dev/flake.nix b/nix/dev/flake.nix new file mode 100644 index 0000000..1442839 --- /dev/null +++ b/nix/dev/flake.nix @@ -0,0 +1,62 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + + flake-parts = { + url = "github:hercules-ci/flake-parts"; + inputs.nixpkgs-lib.follows = "nixpkgs"; + }; + + fenix = { + url = "github:nix-community/fenix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + get-flake.url = "github:ursi/get-flake"; + + treefmt-nix = { + url = "github:numtide/treefmt-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + procfile-nix = { + url = "github:getchoo/procfile-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = inputs: + inputs.flake-parts.lib.mkFlake {inherit inputs;} { + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + + imports = [ + # dev utils + ./checks.nix + ./procfile.nix + ./shell.nix + ./treefmt.nix + + # special, private builds + ./docker.nix + ./static.nix + + inputs.treefmt-nix.flakeModule + inputs.procfile-nix.flakeModule + ]; + + perSystem = { + lib, + system, + ... + }: { + _module.args = { + teawiebot' = lib.mapAttrs (lib.const (v: v.${system} or v)) (inputs.get-flake ../../.); + }; + }; + }; +} diff --git a/nix/dev/procfile.nix b/nix/dev/procfile.nix new file mode 100644 index 0000000..4b1c665 --- /dev/null +++ b/nix/dev/procfile.nix @@ -0,0 +1,13 @@ +{ + perSystem = { + lib, + pkgs, + ... + }: { + procfiles.daemons = { + processes = { + redis = lib.getExe' pkgs.redis "redis-server"; + }; + }; + }; +} diff --git a/nix/dev/shell.nix b/nix/dev/shell.nix new file mode 100644 index 0000000..c5589ef --- /dev/null +++ b/nix/dev/shell.nix @@ -0,0 +1,45 @@ +{ + perSystem = { + config, + pkgs, + self', + teawiebot', + ... + }: { + devShells = { + default = pkgs.mkShell { + packages = [ + # rust tools + pkgs.clippy + pkgs.rustfmt + pkgs.rust-analyzer + + # nix tools + pkgs.deadnix + pkgs.nil + pkgs.statix + + # misc formatter/linters + pkgs.actionlint + self'.formatter + + config.procfiles.daemons.package + ]; + + inputsFrom = [teawiebot'.packages.teawiebot]; + RUST_SRC_PATH = "${pkgs.rustPlatform.rustLibSrc}"; + }; + + ci = pkgs.mkShell { + packages = [ + pkgs.clippy + pkgs.rustfmt + + self'.formatter + ]; + + inputsFrom = [teawiebot'.packages.teawiebot]; + }; + }; + }; +} diff --git a/nix/dev/static.nix b/nix/dev/static.nix new file mode 100644 index 0000000..e8780f4 --- /dev/null +++ b/nix/dev/static.nix @@ -0,0 +1,39 @@ +{ + perSystem = { + lib, + pkgs, + inputs', + teawiebot', + ... + }: let + crossTargets = with pkgs.pkgsCross; { + x86_64 = musl64.pkgsStatic; + aarch64 = aarch64-multiplatform.pkgsStatic; + }; + + rustStdFor = pkgs: inputs'.fenix.packages.targets.${pkgs.stdenv.hostPlatform.rust.rustcTarget}.stable.rust-std; + toolchain = with inputs'.fenix.packages; + combine (lib.flatten [ + stable.cargo + stable.rustc + (map rustStdFor (lib.attrValues crossTargets)) + ]); + + rustPlatformFor = pkgs: + pkgs.makeRustPlatform ( + lib.genAttrs ["cargo" "rustc"] (lib.const toolchain) + ); + crossPlatforms = lib.mapAttrs (lib.const rustPlatformFor) crossTargets; + + buildTeawieWith = rustPlatform: + teawiebot'.packages.teawiebot.override { + inherit rustPlatform; + optimizeSize = true; + }; + in { + packages = { + teawiebot-static-x86_64 = buildTeawieWith crossPlatforms.x86_64; + teawiebot-static-aarch64 = buildTeawieWith crossPlatforms.aarch64; + }; + }; +} diff --git a/nix/dev/treefmt.nix b/nix/dev/treefmt.nix new file mode 100644 index 0000000..5e1fd1f --- /dev/null +++ b/nix/dev/treefmt.nix @@ -0,0 +1,21 @@ +{ + perSystem = { + treefmt = { + projectRootFile = "flake.nix"; + + programs = { + alejandra.enable = true; + deadnix.enable = true; + rustfmt.enable = true; + }; + + settings.global = { + excludes = [ + "./target" + "./flake.lock" + "./Cargo.lock" + ]; + }; + }; + }; +} diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 0000000..c129e68 --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,148 @@ +self: { + config, + lib, + pkgs, + ... +}: let + cfg = config.services.teawiebot; + defaultUser = "teawiebot"; + + inherit + (lib) + getExe + literalExpression + mdDoc + mkEnableOption + mkIf + mkOption + mkPackageOption + optionals + types + ; + + inherit (pkgs.stdenv.hostPlatform) system; +in { + options.services.teawiebot = { + enable = mkEnableOption "teawiebot"; + package = mkPackageOption ( + self.packages.${system} or (builtins.throw "${system} is not supported!") + ) "teawiebot" {}; + + user = mkOption { + description = mdDoc '' + User under which the service should run. If this is the default value, + the user will be created, with the specified group as the primary + group. + ''; + type = types.str; + default = defaultUser; + example = literalExpression '' + "bob" + ''; + }; + + group = mkOption { + description = mdDoc '' + Group under which the service should run. If this is the default value, + the group will be created. + ''; + type = types.str; + default = defaultUser; + example = literalExpression '' + "discordbots" + ''; + }; + + redisUrl = mkOption { + description = mdDoc '' + Connection to a redis server. If this needs to include credentials + that shouldn't be world-readable in the Nix store, set environmentFile + and override the `REDIS_URL` entry. + Pass the string `local` to setup a local Redis database. + ''; + type = types.str; + default = "local"; + example = literalExpression '' + "redis://localhost/" + ''; + }; + + environmentFile = mkOption { + description = mdDoc '' + Environment file as defined in {manpage}`systemd.exec(5)` + ''; + type = types.nullOr types.path; + default = null; + example = literalExpression '' + "/run/agenix.d/1/teawieBot" + ''; + }; + }; + + config = mkIf cfg.enable { + services.redis.servers.teawiebot = mkIf (cfg.redisUrl == "local") { + enable = true; + inherit (cfg) user; + port = 0; # disable tcp listener + }; + + systemd.services."teawiebot" = { + enable = true; + wantedBy = ["multi-user.target"]; + after = + ["network.target"] + ++ optionals (cfg.redisUrl == "local") ["redis-teawiebot.service"]; + + script = '' + ${getExe cfg.package} + ''; + + environment = { + REDIS_URL = + if cfg.redisUrl == "local" + then "unix:${config.services.redis.servers.teawiebot.unixSocket}" + else cfg.redisUrl; + }; + + serviceConfig = { + Type = "simple"; + Restart = "always"; + + EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile; + + User = cfg.user; + Group = cfg.group; + + # hardening + NoNewPrivileges = true; + PrivateDevices = true; + PrivateTmp = true; + PrivateUsers = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectSystem = "strict"; + RestrictNamespaces = "uts ipc pid user cgroup"; + RestrictSUIDSGID = true; + Umask = "0007"; + }; + }; + + users = { + users = mkIf (cfg.user == defaultUser) { + ${defaultUser} = { + isSystemUser = true; + inherit (cfg) group; + }; + }; + + groups = mkIf (cfg.group == defaultUser) { + ${defaultUser} = {}; + }; + }; + }; +} diff --git a/nix/packages.nix b/nix/packages.nix deleted file mode 100644 index f8087f9..0000000 --- a/nix/packages.nix +++ /dev/null @@ -1,12 +0,0 @@ -{self, ...}: { - perSystem = { - pkgs, - self', - ... - }: { - packages = { - teawiebot = pkgs.callPackage ./derivation.nix {inherit self;}; - default = self'.packages.teawiebot; - }; - }; -} diff --git a/src/api/guzzle.rs b/src/api/guzzle.rs index 437dbe6..c5093da 100644 --- a/src/api/guzzle.rs +++ b/src/api/guzzle.rs @@ -1,31 +1,19 @@ -use crate::api::REQWEST_CLIENT; - -use eyre::{eyre, Result}; +use eyre::Result; use log::debug; -use reqwest::StatusCode; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] -struct GuzzleResponse { - pub url: String, +struct RandomTeawieResponse { + url: String, } const GUZZLE: &str = "https://api.mydadleft.me"; const RANDOM_TEAWIE: &str = "/random_teawie"; -pub async fn get_random_teawie() -> Result { - let req = REQWEST_CLIENT - .get(format!("{GUZZLE}{RANDOM_TEAWIE}")) - .build()?; - - debug!("Making request to {}", req.url()); - let resp = REQWEST_CLIENT.execute(req).await?; - let status = resp.status(); +pub async fn random_teawie() -> Result { + let url = format!("{GUZZLE}{RANDOM_TEAWIE}"); + debug!("Making request to {url}"); + let json: RandomTeawieResponse = super::get_json(&url).await?; - if let StatusCode::OK = status { - let data: GuzzleResponse = resp.json().await?; - Ok(data.url) - } else { - Err(eyre!("Failed to get random Teawie with {status}")) - } + Ok(json.url) } diff --git a/src/api/mod.rs b/src/api/mod.rs index 6554553..dac9209 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,17 +1,29 @@ -use once_cell::sync::Lazy; +use std::sync::OnceLock; + +use eyre::Result; +use reqwest::Client; +use serde::de::DeserializeOwned; pub mod guzzle; pub mod shiggy; -pub static USER_AGENT: Lazy = Lazy::new(|| { - let version = option_env!("CARGO_PKG_VERSION").unwrap_or("development"); +pub fn client() -> &'static Client { + static USER_AGENT: OnceLock = OnceLock::new(); + static CLIENT: OnceLock = OnceLock::new(); + + let user_agent = USER_AGENT.get_or_init(|| { + let version = option_env!("CARGO_PKG_VERSION").unwrap_or("development"); + + format!("teawieBot/{version}") + }); + + CLIENT.get_or_init(|| Client::builder().user_agent(user_agent).build().unwrap()) +} - format!("teawieBot/{version}") -}); +async fn get_json(url: &str) -> Result { + let resp = client().get(url).send().await?; + resp.error_for_status_ref()?; + let json = resp.json().await?; -pub static REQWEST_CLIENT: Lazy = Lazy::new(|| { - reqwest::Client::builder() - .user_agent(USER_AGENT.to_string()) - .build() - .unwrap_or_default() -}); + Ok(json) +} diff --git a/src/api/shiggy.rs b/src/api/shiggy.rs index b1d4a87..d6a6238 100644 --- a/src/api/shiggy.rs +++ b/src/api/shiggy.rs @@ -1,8 +1,5 @@ -use crate::api::REQWEST_CLIENT; - -use eyre::{eyre, Result}; +use eyre::Result; use log::debug; -use reqwest::StatusCode; use serde::Deserialize; const SHIGGY: &str = "https://safebooru.donmai.us"; @@ -14,19 +11,10 @@ struct SafebooruResponse { } #[allow(clippy::module_name_repetitions)] -pub async fn get_random_shiggy() -> Result { - let req = REQWEST_CLIENT - .get(format!("{SHIGGY}{RANDOM_SHIGGY}")) - .build()?; - - debug!("Making request to {}", req.url()); - let resp = REQWEST_CLIENT.execute(req).await?; - let status = resp.status(); +pub async fn random_shiggy() -> Result { + let url = format!("{SHIGGY}{RANDOM_SHIGGY}"); + debug!("Making request to {url}"); - if let StatusCode::OK = status { - let data: SafebooruResponse = resp.json().await?; - Ok(data.file_url) - } else { - Err(eyre!("Failed to get random shiggy with {status}")) - } + let resp: SafebooruResponse = super::get_json(&url).await?; + Ok(resp.file_url) } diff --git a/src/colors.rs b/src/colors.rs deleted file mode 100644 index 2a3f402..0000000 --- a/src/colors.rs +++ /dev/null @@ -1,17 +0,0 @@ -use poise::serenity_prelude::Colour; - -pub enum Colors { - Blue, - Orange, - Red, -} - -impl From for Colour { - fn from(val: Colors) -> Self { - match val { - Colors::Blue => Colour::from((136, 199, 253)), - Colors::Orange => Colour::from((255, 179, 74)), - Colors::Red => Colour::from((255, 94, 74)), - } - } -} diff --git a/src/commands/general/ask.rs b/src/commands/general/ask.rs index 75560e0..c715e3a 100644 --- a/src/commands/general/ask.rs +++ b/src/commands/general/ask.rs @@ -1,15 +1,16 @@ -use crate::{consts, utils, Context}; +use crate::{consts, utils, Context, Error}; -use eyre::{Context as _, Result}; +use eyre::Context as _; /// Ask teawie a question! #[poise::command(prefix_command, slash_command)] +#[allow(clippy::no_effect_underscore_binding)] pub async fn ask( ctx: Context<'_>, #[rename = "question"] #[description = "The question you want to ask teawie"] _question: String, -) -> Result<()> { +) -> Result<(), Error> { let resp = utils::random_choice(consts::RESPONSES) .wrap_err("Couldn't choose from random responses!")?; diff --git a/src/commands/general/bing.rs b/src/commands/general/bing.rs index d55d8ee..d58404e 100644 --- a/src/commands/general/bing.rs +++ b/src/commands/general/bing.rs @@ -1,10 +1,10 @@ -use crate::Context; +use crate::{Context, Error}; use eyre::Result; /// Make sure the wie is alive #[poise::command(prefix_command)] -pub async fn bing(ctx: Context<'_>) -> Result<()> { +pub async fn bing(ctx: Context<'_>) -> Result<(), Error> { ctx.say("bong!").await?; Ok(()) } diff --git a/src/commands/general/config.rs b/src/commands/general/config.rs index ddc5cda..456e791 100644 --- a/src/commands/general/config.rs +++ b/src/commands/general/config.rs @@ -1,7 +1,7 @@ -use std::str::FromStr; +use crate::storage::settings::{Properties, Settings}; +use crate::{Context, Error}; -use crate::{storage, Context}; -use storage::{Properties, Settings}; +use std::str::FromStr; use eyre::{OptionExt as _, Result}; use log::debug; @@ -41,7 +41,7 @@ fn prop_to_val(setting: &Properties, settings: &Settings) -> String { required_permissions = "MANAGE_GUILD", default_member_permissions = "MANAGE_GUILD" )] -pub async fn config(_ctx: Context<'_>) -> Result<()> { +pub async fn config(_: Context<'_>) -> Result<(), Error> { Ok(()) } @@ -72,63 +72,67 @@ pub async fn set( #[description = "Toggle ReactBoard"] reactboard_enabled: Option, #[description = "Enables 'extra' commands like teawiespam and copypasta. Defaults to false."] optional_commands_enabled: Option, -) -> Result<()> { - let storage = &ctx.data().storage; - let gid = ctx.guild_id().unwrap_or_default(); - let mut settings = storage.get_guild_settings(&gid).await?; - let previous_settings = settings.clone(); - - if let Some(channel) = pinboard_channel { - debug!("Setting pinboard_channel to {channel} for {gid}"); - settings.pinboard_channel = Some(channel.id); - } +) -> Result<(), Error> { + if let Some(storage) = &ctx.data().storage { + let gid = ctx.guild_id().unwrap_or_default(); + let mut settings = storage.get_guild_settings(&gid).await?; + let previous_settings = settings.clone(); + + if let Some(channel) = pinboard_channel { + debug!("Setting pinboard_channel to {channel} for {gid}"); + settings.pinboard_channel = Some(channel.id); + } - if let Some(watch) = pinboard_watch { - let channels = split_argument(&watch); - settings.pinboard_watch = (!channels.is_empty()).then_some(channels); - } + if let Some(watch) = pinboard_watch { + let channels = split_argument(&watch); + settings.pinboard_watch = (!channels.is_empty()).then_some(channels); + } - if let Some(enabled) = pinboard_enabled { - debug!("Setting pinboard_enabled to {enabled} for {gid}"); - settings.pinboard_enabled = enabled; - } + if let Some(enabled) = pinboard_enabled { + debug!("Setting pinboard_enabled to {enabled} for {gid}"); + settings.pinboard_enabled = enabled; + } - if let Some(channel) = reactboard_channel { - debug!("Setting reactboard_channel to {channel} for {gid}"); - settings.reactboard_channel = Some(channel.id); - } + if let Some(channel) = reactboard_channel { + debug!("Setting reactboard_channel to {channel} for {gid}"); + settings.reactboard_channel = Some(channel.id); + } - if let Some(requirement) = reactboard_requirement { - debug!("Setting reactboard_requirement to {requirement} for {gid}"); - settings.reactboard_requirement = Some(requirement); - } + if let Some(requirement) = reactboard_requirement { + debug!("Setting reactboard_requirement to {requirement} for {gid}"); + settings.reactboard_requirement = Some(requirement); + } - if let Some(reaction) = reactboard_reaction { - let emojis: Vec = - reaction.split(',').filter_map(|r| r.parse().ok()).collect(); - debug!("Setting reactboard_reactions to {emojis:#?} for {gid}"); + if let Some(reaction) = reactboard_reaction { + let emojis: Vec = + reaction.split(',').filter_map(|r| r.parse().ok()).collect(); + debug!("Setting reactboard_reactions to {emojis:#?} for {gid}"); - settings.reactboard_reactions = Some(emojis); - } + settings.reactboard_reactions = Some(emojis); + } - if let Some(enabled) = reactboard_enabled { - debug!("Setting reactboard_enabled to {enabled} for {gid}"); - settings.reactboard_enabled = enabled; - } + if let Some(enabled) = reactboard_enabled { + debug!("Setting reactboard_enabled to {enabled} for {gid}"); + settings.reactboard_enabled = enabled; + } - if let Some(enabled) = optional_commands_enabled { - debug!("Setting optional_commands_enabled to {enabled} for {}", gid); - settings.optional_commands_enabled = enabled; - } + if let Some(enabled) = optional_commands_enabled { + debug!("Setting optional_commands_enabled to {enabled} for {}", gid); + settings.optional_commands_enabled = enabled; + } - if previous_settings == settings { - debug!("Not updating settings key for {gid} since no changes were made"); - ctx.reply("No changes made, so i'm not updating anything") - .await?; + if previous_settings == settings { + debug!("Not updating settings key for {gid} since no changes were made"); + ctx.reply("No changes made, so i'm not updating anything") + .await?; + } else { + debug!("Updating settings key for {gid}"); + storage.create_guild_settings(settings).await?; + ctx.reply("Configuration updated!").await?; + } } else { - debug!("Updating settings key for {gid}"); - storage.create_guild_settings(settings).await?; - ctx.reply("Configuration updated!").await?; + ctx.reply("I have no storage backend right now, so I can't set settings :(") + .await?; } Ok(()) @@ -145,18 +149,22 @@ pub async fn set( pub async fn get( ctx: Context<'_>, #[description = "The setting you want to get"] setting: Properties, -) -> Result<()> { +) -> Result<(), Error> { let gid = &ctx .guild_id() .ok_or_eyre("Failed to get GuildId from context!")?; - let settings = ctx.data().storage.get_guild_settings(gid).await?; - let value = prop_to_val(&setting, &settings); - - let embed = CreateEmbed::new().field(setting.name(), value, false); - let message = CreateReply::default().embed(embed); + if let Some(storage) = &ctx.data().storage { + let settings = storage.get_guild_settings(gid).await?; + let value = prop_to_val(&setting, &settings); - ctx.send(message).await?; + let embed = CreateEmbed::new().field(setting.name(), value, false); + let message = CreateReply::default().embed(embed); + ctx.send(message).await?; + } else { + ctx.reply("I have no storage backend right now, so I can't fetch settings :(") + .await?; + } Ok(()) } diff --git a/src/commands/general/convert.rs b/src/commands/general/convert.rs index 5e14175..4d38eb2 100644 --- a/src/commands/general/convert.rs +++ b/src/commands/general/convert.rs @@ -1,4 +1,4 @@ -use crate::Context; +use crate::{Context, Error}; use bottomify::bottom; use eyre::Result; @@ -9,7 +9,7 @@ use poise::serenity_prelude::constants::MESSAGE_CODE_LIMIT; slash_command, subcommands("to_fahrenheit", "to_celsius", "to_bottom", "from_bottom") )] -pub async fn convert(_ctx: Context<'_>) -> Result<()> { +pub async fn convert(_: Context<'_>) -> Result<(), Error> { Ok(()) } @@ -18,7 +18,7 @@ pub async fn convert(_ctx: Context<'_>) -> Result<()> { pub async fn to_celsius( ctx: Context<'_>, #[description = "What teawie will convert"] degrees_fahrenheit: f32, -) -> Result<()> { +) -> Result<(), Error> { let temp = (degrees_fahrenheit - 32.0) * (5.0 / 9.0); ctx.say(temp.to_string()).await?; Ok(()) @@ -29,7 +29,7 @@ pub async fn to_celsius( pub async fn to_fahrenheit( ctx: Context<'_>, #[description = "What teawie will convert"] degrees_celsius: f32, -) -> Result<()> { +) -> Result<(), Error> { let temp = (degrees_celsius * (9.0 / 5.0)) + 32.0; ctx.say(temp.to_string()).await?; Ok(()) @@ -40,7 +40,7 @@ pub async fn to_fahrenheit( pub async fn to_bottom( ctx: Context<'_>, #[description = "What teawie will translate into bottom"] message: String, -) -> Result<()> { +) -> Result<(), Error> { let encoded = bottom::encode_string(&message); ctx.say(encoded).await?; Ok(()) @@ -51,7 +51,7 @@ pub async fn to_bottom( pub async fn from_bottom( ctx: Context<'_>, #[description = "What teawie will translate from bottom"] message: String, -) -> Result<()> { +) -> Result<(), Error> { let resp: String; if let Ok(decoded) = bottom::decode_string(&message.clone()) { diff --git a/src/commands/general/mod.rs b/src/commands/general/mod.rs index c872272..82af4d2 100644 --- a/src/commands/general/mod.rs +++ b/src/commands/general/mod.rs @@ -1,22 +1,6 @@ -use crate::Data; - -use eyre::Report; -use poise::Command; - -mod ask; -mod bing; -mod config; -mod convert; -mod random; -mod version; - -pub fn to_comands() -> Vec> { - vec![ - ask::ask(), - bing::bing(), - config::config(), - convert::convert(), - random::random(), - version::version(), - ] -} +pub mod ask; +pub mod bing; +pub mod config; +pub mod convert; +pub mod random; +pub mod version; diff --git a/src/commands/general/random.rs b/src/commands/general/random.rs index 7c7ceff..92e9188 100644 --- a/src/commands/general/random.rs +++ b/src/commands/general/random.rs @@ -1,31 +1,34 @@ -use crate::{api, consts, utils, Context}; +use crate::{api, consts, utils, Context, Error}; -use eyre::Result; - -#[allow(clippy::unused_async)] #[poise::command(slash_command, subcommands("lore", "teawie", "shiggy"))] -pub async fn random(_ctx: Context<'_>) -> Result<()> { +#[allow(clippy::unused_async)] +pub async fn random(_: Context<'_>) -> Result<(), Error> { Ok(()) } /// Get a random piece of teawie lore! #[poise::command(prefix_command, slash_command)] -pub async fn lore(ctx: Context<'_>) -> Result<()> { +pub async fn lore(ctx: Context<'_>) -> Result<(), Error> { let resp = utils::random_choice(consts::LORE)?; ctx.say(resp).await?; + Ok(()) } /// Get a random teawie #[poise::command(prefix_command, slash_command)] -pub async fn teawie(ctx: Context<'_>) -> Result<()> { - let url = api::guzzle::get_random_teawie().await?; - utils::send_url_as_embed(ctx, url).await +pub async fn teawie(ctx: Context<'_>) -> Result<(), Error> { + let url = api::guzzle::random_teawie().await?; + utils::send_url_as_embed(ctx, url).await?; + + Ok(()) } /// Get a random shiggy #[poise::command(prefix_command, slash_command)] -pub async fn shiggy(ctx: Context<'_>) -> Result<()> { - let url = api::shiggy::get_random_shiggy().await?; - utils::send_url_as_embed(ctx, url).await +pub async fn shiggy(ctx: Context<'_>) -> Result<(), Error> { + let url = api::shiggy::random_shiggy().await?; + utils::send_url_as_embed(ctx, url).await?; + + Ok(()) } diff --git a/src/commands/general/version.rs b/src/commands/general/version.rs index e392903..5f8eac9 100644 --- a/src/commands/general/version.rs +++ b/src/commands/general/version.rs @@ -1,16 +1,13 @@ -use crate::colors::Colors; -use crate::Context; +use crate::{consts::Colors, Context, Error}; -use eyre::Result; -use poise::serenity_prelude::CreateEmbed; -use poise::CreateReply; +use std::env::consts::{ARCH, OS}; + +use poise::{serenity_prelude::CreateEmbed, CreateReply}; /// Get version info #[poise::command(slash_command)] -pub async fn version(ctx: Context<'_>) -> Result<()> { +pub async fn version(ctx: Context<'_>) -> Result<(), Error> { let sha = option_env!("GIT_SHA").unwrap_or("main"); - let target = option_env!("TARGET").unwrap_or("Unknown"); - let revision_url = format!( "[{}]({}/tree/{})", sha, @@ -18,15 +15,16 @@ pub async fn version(ctx: Context<'_>) -> Result<()> { sha, ); + let os_info = format!("{ARCH}-{OS}"); + let fields = [ ( "Version:", option_env!("CARGO_PKG_VERSION").unwrap_or("not found"), false, ), - ("Target:", target, false), + ("OS:", &os_info, false), ("Revision:", &revision_url, false), - ("User Agent:", &crate::api::USER_AGENT, false), ]; let embed = CreateEmbed::new() diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 88a47b3..e55419b 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,20 +1,53 @@ -use crate::Data; - -use eyre::Report; -use poise::Command; +use crate::{Data, Error}; mod general; mod moderation; mod optional; -pub fn global() -> Vec> { - general::to_comands() +type Command = poise::Command; + +#[macro_export] +macro_rules! cmd { + ($module: ident, $name: ident) => { + $module::$name::$name() + }; + + ($module: ident, $name: ident, $func: ident) => { + $module::$name::$func() + }; +} + +pub fn to_vec() -> Vec { + vec![ + cmd!(general, ask), + cmd!(general, bing), + cmd!(general, config), + cmd!(general, convert), + cmd!(general, random), + cmd!(general, version), + cmd!(moderation, clear_messages), + cmd!(optional, copypasta), + cmd!(optional, teawiespam), + cmd!(optional, uwurandom), + ] } -pub fn optional() -> Vec> { - optional::to_commands() +pub fn to_vec_global() -> Vec { + vec![ + cmd!(general, ask), + cmd!(general, bing), + cmd!(general, config), + cmd!(general, convert), + cmd!(general, random), + cmd!(general, version), + cmd!(moderation, clear_messages), + ] } -pub fn moderation() -> Vec> { - moderation::to_commands() +pub fn to_vec_optional() -> Vec { + vec![ + cmd!(optional, copypasta), + cmd!(optional, teawiespam), + cmd!(optional, uwurandom), + ] } diff --git a/src/commands/moderation/clear.rs b/src/commands/moderation/clear.rs deleted file mode 100644 index bfc9c38..0000000 --- a/src/commands/moderation/clear.rs +++ /dev/null @@ -1,39 +0,0 @@ -use crate::Context; - -use eyre::{Context as _, Result}; -use log::debug; -use poise::serenity_prelude::futures::{StreamExt, TryStreamExt}; - -#[poise::command( - slash_command, - ephemeral, - required_permissions = "MANAGE_MESSAGES", - default_member_permissions = "MANAGE_MESSAGES" -)] -pub async fn clear_messages( - ctx: Context<'_>, - #[description = "How many messages to delete"] num_messages: usize, -) -> Result<()> { - ctx.defer_ephemeral().await?; - - let channel = ctx.channel_id(); - let messages = channel - .messages_iter(ctx) - .take(num_messages) - .try_fold(Vec::new(), |mut acc, msg| async move { - acc.push(msg); - Ok(acc) - }) - .await - .wrap_err_with(|| { - format!("Couldn't collect {num_messages} messages from channel {channel}") - })?; - - debug!("Clearing {num_messages} messages from channel {channel}!"); - channel.delete_messages(ctx, messages).await?; - - ctx.reply(format!("Deleted {num_messages} message(s)")) - .await?; - - Ok(()) -} diff --git a/src/commands/moderation/clear_messages.rs b/src/commands/moderation/clear_messages.rs new file mode 100644 index 0000000..8761bcb --- /dev/null +++ b/src/commands/moderation/clear_messages.rs @@ -0,0 +1,30 @@ +use crate::{Context, Error}; + +use log::debug; +use poise::serenity_prelude::GetMessages; + +#[poise::command( + slash_command, + guild_only, + ephemeral, + required_permissions = "MANAGE_MESSAGES", + default_member_permissions = "MANAGE_MESSAGES" +)] +pub async fn clear_messages( + ctx: Context<'_>, + #[description = "How many messages to delete"] num_messages: u8, +) -> Result<(), Error> { + ctx.defer_ephemeral().await?; + + let channel = ctx.channel_id(); + let to_get = GetMessages::new().limit(num_messages); + let messages = channel.messages(ctx, to_get).await?; + + debug!("Clearing {num_messages} messages from channel {channel}!"); + channel.delete_messages(ctx, messages).await?; + + ctx.reply(format!("Deleted {num_messages} message(s)")) + .await?; + + Ok(()) +} diff --git a/src/commands/moderation/mod.rs b/src/commands/moderation/mod.rs index 5a8cd08..ed6a7c6 100644 --- a/src/commands/moderation/mod.rs +++ b/src/commands/moderation/mod.rs @@ -1,10 +1 @@ -use crate::Data; - -use eyre::Report; -use poise::Command; - -mod clear; - -pub fn to_commands() -> Vec> { - vec![clear::clear_messages()] -} +pub mod clear_messages; diff --git a/src/commands/optional/copypasta.rs b/src/commands/optional/copypasta.rs index 15171f8..06440b1 100644 --- a/src/commands/optional/copypasta.rs +++ b/src/commands/optional/copypasta.rs @@ -1,18 +1,14 @@ -use crate::Context; +use crate::{Context, Error}; -use std::collections::HashMap; - -use eyre::{eyre, OptionExt, Result}; use include_dir::{include_dir, Dir}; use log::debug; -const FILES: Dir = include_dir!("src/copypastas"); +const COPYPASTAS: Dir = include_dir!("src/copypastas"); -#[allow(clippy::upper_case_acronyms)] #[derive(Debug, poise::ChoiceParameter)] -pub enum Copypastas { +pub enum Copypasta { Astral, - DVD, + Dvd, Egrill, HappyMeal, Sus, @@ -20,43 +16,27 @@ pub enum Copypastas { Twitter, } -impl Copypastas { - fn as_str(&self) -> &str { - match self { - Copypastas::Astral => "astral", - Copypastas::DVD => "dvd", - Copypastas::Egrill => "egrill", - Copypastas::HappyMeal => "happymeal", - Copypastas::Sus => "sus", - Copypastas::TickTock => "ticktock", - Copypastas::Twitter => "twitter", - } +impl ToString for Copypasta { + fn to_string(&self) -> String { + let str = match self { + Self::Astral => "astral", + Self::Dvd => "dvd", + Self::Egrill => "egrill", + Self::HappyMeal => "happymeal", + Self::Sus => "sus", + Self::TickTock => "ticktock", + Self::Twitter => "twitter", + }; + str.to_string() } } -fn get_copypasta(name: &Copypastas) -> Result { - let mut files: HashMap<&str, &str> = HashMap::new(); - - for file in FILES.files() { - let name = file - .path() - .file_stem() - .ok_or_else(|| eyre!("Couldn't get file stem from {file:#?}"))? - .to_str() - .ok_or_eyre("Couldn't convert file stem to str!")?; - - let contents = file - .contents_utf8() - .ok_or_eyre("Couldnt get contents from copypasta!")?; - - // refer to files by their name w/o extension - files.insert(name, contents); - } - - if files.contains_key(name.as_str()) { - Ok(files[name.as_str()].to_string()) - } else { - Err(eyre!("Couldnt find copypasta {}!", name.as_str())) +impl Copypasta { + fn contents(&self) -> Option<&str> { + let file_name = format!("{}.txt", self.to_string()); + COPYPASTAS + .get_file(file_name) + .and_then(|file| file.contents_utf8()) } } @@ -64,18 +44,30 @@ fn get_copypasta(name: &Copypastas) -> Result { #[poise::command(slash_command)] pub async fn copypasta( ctx: Context<'_>, - #[description = "the copypasta you want to send"] copypasta: Copypastas, -) -> Result<()> { - let gid = ctx.guild_id().unwrap_or_default(); - let settings = ctx.data().storage.get_guild_settings(&gid).await?; + #[description = "the copypasta you want to send"] copypasta: Copypasta, +) -> Result<(), Error> { + if let Some(guild_id) = ctx.guild_id() { + if let Some(storage) = &ctx.data().storage { + let settings = storage.get_guild_settings(&guild_id).await?; + + if !settings.optional_commands_enabled { + debug!("Not running command in {guild_id} since it's disabled"); + ctx.reply("I'm not allowed to do that here").await?; - if !settings.optional_commands_enabled { - debug!("Exited copypasta command in {gid} since it's disabled"); - ctx.say("I'm not allowed to do that here").await?; - return Ok(()); + return Ok(()); + } + } else { + debug!("Ignoring restrictions on command; no storage backend is attached!"); + } + } else { + debug!("Ignoring restrictions on command; we're not in a guild"); } - ctx.say(get_copypasta(©pasta)?).await?; + if let Some(contents) = copypasta.contents() { + ctx.say(contents).await?; + } else { + ctx.reply("I couldn't find that copypasta :(").await?; + } Ok(()) } diff --git a/src/commands/optional/mod.rs b/src/commands/optional/mod.rs index 39abdcb..95c39bd 100644 --- a/src/commands/optional/mod.rs +++ b/src/commands/optional/mod.rs @@ -1,16 +1,3 @@ -use crate::Data; - -use eyre::Report; -use poise::Command; - -mod copypasta; -mod teawiespam; -mod uwurandom; - -pub fn to_commands() -> Vec> { - vec![ - copypasta::copypasta(), - teawiespam::teawiespam(), - uwurandom::uwurandom(), - ] -} +pub mod copypasta; +pub mod teawiespam; +pub mod uwurandom; diff --git a/src/commands/optional/teawiespam.rs b/src/commands/optional/teawiespam.rs index 7f7ba79..3a9a387 100644 --- a/src/commands/optional/teawiespam.rs +++ b/src/commands/optional/teawiespam.rs @@ -1,21 +1,29 @@ -use crate::Context; +use crate::{Context, Error}; -use eyre::Result; use log::debug; /// teawie will spam you. #[poise::command(slash_command)] -pub async fn teawiespam(ctx: Context<'_>) -> Result<()> { - let gid = ctx.guild_id().unwrap_or_default(); - let settings = ctx.data().storage.get_guild_settings(&gid).await?; +pub async fn teawiespam(ctx: Context<'_>) -> Result<(), Error> { + if let Some(guild_id) = ctx.guild_id() { + if let Some(storage) = &ctx.data().storage { + let settings = storage.get_guild_settings(&guild_id).await?; - if !settings.optional_commands_enabled { - debug!("Not running teawiespam in {gid} since it's disabled"); - ctx.say("I'm not allowed to do that here").await?; - return Ok(()); + if !settings.optional_commands_enabled { + debug!("Not running command in {guild_id} since it's disabled"); + ctx.say("I'm not allowed to do that here").await?; + + return Ok(()); + } + } else { + debug!("Ignoring restrictions on command; no storage backend is attached!"); + } + } else { + debug!("Ignoring restrictions on command; we're not in a guild."); } let wies = "<:teawiesmile:1056438046440042546>".repeat(50); ctx.say(wies).await?; + Ok(()) } diff --git a/src/commands/optional/uwurandom.rs b/src/commands/optional/uwurandom.rs index 312e54f..e717d5e 100644 --- a/src/commands/optional/uwurandom.rs +++ b/src/commands/optional/uwurandom.rs @@ -1,4 +1,4 @@ -use crate::Context; +use crate::{Context, Error}; use eyre::Result; use log::debug; @@ -12,14 +12,22 @@ pub async fn uwurandom( #[min = 1] #[max = 2000] length: Option, -) -> Result<()> { - let gid = ctx.guild_id().unwrap_or_default(); - let settings = ctx.data().storage.get_guild_settings(&gid).await?; +) -> Result<(), Error> { + if let Some(guild_id) = ctx.guild_id() { + if let Some(storage) = &ctx.data().storage { + let settings = storage.get_guild_settings(&guild_id).await?; - if !settings.optional_commands_enabled { - debug!("Not running uwurandom in {gid} since it's disabled"); - ctx.say("I'm not allowed to do that here").await?; - return Ok(()); + if !settings.optional_commands_enabled { + debug!("Not running command in {guild_id} since it's disabled"); + ctx.say("I'm not allowed to do that here").await?; + + return Ok(()); + } + } else { + debug!("Ignoring restrictions on command; no storage backend is attached!"); + } + } else { + debug!("Ignoring restrictions on command; we're not in a guild"); } let length = length.unwrap_or(rand::thread_rng().gen_range(1..50)); diff --git a/src/consts.rs b/src/consts.rs index b108f34..0b12334 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -1,3 +1,36 @@ +#![allow(clippy::unreadable_literal)] +use std::sync::OnceLock; + +use poise::serenity_prelude::{Colour, Permissions, Scope}; + +pub fn bot_permissions() -> &'static Permissions { + static BOT_PERMISSIONS: OnceLock = OnceLock::new(); + BOT_PERMISSIONS.get_or_init(|| { + Permissions::MANAGE_ROLES + | Permissions::MANAGE_CHANNELS + | Permissions::KICK_MEMBERS + | Permissions::BAN_MEMBERS + | Permissions::MANAGE_NICKNAMES + | Permissions::VIEW_CHANNEL + | Permissions::MODERATE_MEMBERS + | Permissions::SEND_MESSAGES + | Permissions::CREATE_PUBLIC_THREADS + | Permissions::CREATE_PRIVATE_THREADS + | Permissions::SEND_MESSAGES_IN_THREADS + | Permissions::MANAGE_MESSAGES + | Permissions::MANAGE_THREADS + | Permissions::EMBED_LINKS + | Permissions::ATTACH_FILES + | Permissions::READ_MESSAGE_HISTORY + | Permissions::ADD_REACTIONS + }) +} + +pub fn bot_scopes() -> &'static Vec { + static BOT_SCOPES: OnceLock> = OnceLock::new(); + BOT_SCOPES.get_or_init(|| vec![Scope::Bot, Scope::ApplicationsCommands]) +} + pub const TEAMOJIS: [&str; 15] = [ "<:teawiecry:1056438041872433303>", "<:teawiederp:1056438043109757018>", @@ -64,3 +97,19 @@ pub const LORE: [&str; 22] = [ "Teawie is friends with BlÃ¥haj.", "Consuming Teawie, and other Teawie variants is not recommended, since it will allow Teawie to take over the consumer in the same way as being shot with a Teawie \"bullet\".", ]; + +pub enum Colors { + Blue, + Orange, + Red, +} + +impl From for Colour { + fn from(val: Colors) -> Self { + match val { + Colors::Blue => Colour::from(0x88C7FD), + Colors::Orange => Colour::from(0xFFB34A), + Colors::Red => Colour::from(0xFF5E4A), + } + } +} diff --git a/src/copypastas/ticktock.txt b/src/copypastas/ticktock.txt index 9fdd334..bd4f36e 100644 --- a/src/copypastas/ticktock.txt +++ b/src/copypastas/ticktock.txt @@ -1,8 +1,8 @@ -Tick-tock -Heavy like a Brinks truck -Looking like I'm tip-top -Shining like a wristwatch -Time will grab your wrist -Lock it down 'til the thing pop -Can you stick around for a minute 'til the ring stop? +Tick-tock +Heavy like a Brinks truck +Looking like I'm tip-top +Shining like a wristwatch +Time will grab your wrist +Lock it down 'til the thing pop +Can you stick around for a minute 'til the ring stop? Please, God diff --git a/src/handlers/error.rs b/src/handlers/error.rs index afd1241..e706fec 100644 --- a/src/handlers/error.rs +++ b/src/handlers/error.rs @@ -1,12 +1,10 @@ -use crate::colors::Colors; -use crate::Data; +use crate::{consts::Colors, Data, Error}; -use eyre::Report; use log::error; use poise::serenity_prelude::{CreateEmbed, Timestamp}; use poise::{CreateReply, FrameworkError}; -pub async fn handle(error: poise::FrameworkError<'_, Data, Report>) { +pub async fn handle(error: poise::FrameworkError<'_, Data, Error>) { match error { FrameworkError::Setup { error, framework, .. @@ -46,7 +44,7 @@ pub async fn handle(error: poise::FrameworkError<'_, Data, Report>) { error => { if let Err(e) = poise::builtins::on_error(error).await { - error!("Unhandled error occured:\n{e:#?}"); + error!("Unhandled error occurred:\n{e:#?}"); } } } diff --git a/src/handlers/event/guild.rs b/src/handlers/event/guild.rs index 51ae3b7..774179c 100644 --- a/src/handlers/event/guild.rs +++ b/src/handlers/event/guild.rs @@ -3,30 +3,35 @@ use log::{debug, warn}; use poise::serenity_prelude::{Guild, UnavailableGuild}; use crate::{storage, Data}; -use storage::Settings; - -pub async fn handle_create(guild: &Guild, _is_new: &bool, data: &Data) -> Result<()> { - let storage = &data.storage; - - if storage.guild_settings_exist(&guild.id).await? { - debug!("Not recreating settings key for {}", guild.id); - return Ok(()); +use storage::settings::Settings; + +pub async fn handle_create(guild: &Guild, data: &Data) -> Result<()> { + if let Some(storage) = &data.storage { + if storage.guild_settings_exist(&guild.id).await? { + debug!("Not recreating settings key for {}", guild.id); + return Ok(()); + } + + let settings = Settings { + guild_id: guild.id, + ..Default::default() + }; + + warn!("Creating new settings key for {}:\n{settings:#?}", guild.id); + storage.create_guild_settings(settings).await?; + } else { + warn!("Can't create guild settings; no storage backend found!"); } - let settings = Settings { - guild_id: guild.id, - optional_commands_enabled: false, - ..Default::default() - }; - - warn!("Creating new settings key for {}:\n{settings:#?}", guild.id); - storage.create_guild_settings(settings).await?; - Ok(()) } pub async fn handle_delete(guild: &UnavailableGuild, data: &Data) -> Result<()> { - data.storage.delete_guild_settings(&guild.id).await?; + if let Some(storage) = &data.storage { + storage.delete_guild_settings(&guild.id).await?; + } else { + warn!("Can't delete guild settings; no storage backend found!"); + } Ok(()) } diff --git a/src/handlers/event/message.rs b/src/handlers/event/message.rs index 3054e27..67dbb21 100644 --- a/src/handlers/event/message.rs +++ b/src/handlers/event/message.rs @@ -1,16 +1,10 @@ use crate::{consts, Data}; -use eyre::{eyre, Report, Result}; -use log::debug; +use eyre::{eyre, Result}; +use log::{debug, warn}; use poise::serenity_prelude::{Context, Message}; -use poise::FrameworkContext; - -pub async fn handle( - ctx: &Context, - _framework: FrameworkContext<'_, Data, Report>, - msg: &Message, - data: &Data, -) -> Result<()> { + +pub async fn handle(ctx: &Context, msg: &Message, data: &Data) -> Result<()> { if should_echo(ctx, msg, data).await? { msg.reply(ctx, &msg.content).await?; } @@ -27,11 +21,16 @@ async fn should_echo(ctx: &Context, msg: &Message, data: &Data) -> Result let gid = msg .guild_id .ok_or_else(|| eyre!("Couldn't get GuildId from {}!", msg.id))?; - let settings = data.storage.get_guild_settings(&gid).await?; - if !settings.optional_commands_enabled { - debug!("Not echoing in guild {gid}"); - return Ok(false); + if let Some(storage) = &data.storage { + let settings = storage.get_guild_settings(&gid).await?; + + if !settings.optional_commands_enabled { + debug!("Not echoing in guild {gid}"); + return Ok(false); + } + } else { + warn!("Ignoring restrictions on echoing messages; no storage backend is attached!"); } let content = &msg.content; diff --git a/src/handlers/event/mod.rs b/src/handlers/event/mod.rs index 9c986e1..cc7d727 100644 --- a/src/handlers/event/mod.rs +++ b/src/handlers/event/mod.rs @@ -1,9 +1,8 @@ -use crate::Data; +use crate::{consts, Data, Error}; -use eyre::{Report, Result}; -use log::info; -use poise::serenity_prelude as serenity; -use poise::FrameworkContext; +use eyre::Result; +use log::{debug, info}; +use poise::serenity_prelude::{self as serenity, CreateBotAuthParameters}; use serenity::FullEvent; mod guild; @@ -11,19 +10,24 @@ mod message; mod pinboard; mod reactboard; -pub async fn handle( - ctx: &serenity::Context, - event: &FullEvent, - framework: FrameworkContext<'_, Data, Report>, - data: &Data, -) -> Result<()> { +pub async fn handle(ctx: &serenity::Context, event: &FullEvent, data: &Data) -> Result<(), Error> { match event { FullEvent::Ready { data_about_bot } => { info!("Logged in as {}!", data_about_bot.user.name); + + if let Ok(invite_link) = CreateBotAuthParameters::new().auto_client_id(ctx).await { + let link = invite_link + .scopes(consts::bot_scopes()) + .permissions(*consts::bot_permissions()) + .build(); + info!("Invite me to your server at {link}"); + } else { + debug!("Not displaying invite_link since we couldn't find our client ID"); + } } FullEvent::Message { new_message } => { - message::handle(ctx, framework, new_message, data).await?; + message::handle(ctx, new_message, data).await?; pinboard::handle(ctx, new_message, data).await?; } @@ -31,8 +35,8 @@ pub async fn handle( reactboard::handle(ctx, add_reaction, data).await?; } - FullEvent::GuildCreate { guild, is_new } => { - guild::handle_create(guild, &is_new.unwrap_or_default(), data).await?; + FullEvent::GuildCreate { guild, is_new: _ } => { + guild::handle_create(guild, data).await?; } FullEvent::GuildDelete { diff --git a/src/handlers/event/pinboard.rs b/src/handlers/event/pinboard.rs index be10eac..5b7d454 100644 --- a/src/handlers/event/pinboard.rs +++ b/src/handlers/event/pinboard.rs @@ -1,7 +1,7 @@ use crate::{utils, Data}; use eyre::{eyre, Context as _, OptionExt as _, Result}; -use log::debug; +use log::{debug, warn}; use poise::serenity_prelude::{ ChannelId, Context, CreateAllowedMentions, CreateMessage, Message, MessageType, User, }; @@ -12,7 +12,12 @@ pub async fn handle(ctx: &Context, message: &Message, data: &Data) -> Result<()> } let gid = message.guild_id.unwrap_or_default(); - let settings = data.storage.get_guild_settings(&gid).await?; + let Some(storage) = &data.storage else { + warn!("Can't create PinBoard entry; no storage backend found!"); + return Ok(()); + }; + + let settings = storage.get_guild_settings(&gid).await?; if !settings.pinboard_enabled { debug!("PinBoard is disabled in {gid}, ignoring"); @@ -53,13 +58,13 @@ pub async fn handle(ctx: &Context, message: &Message, data: &Data) -> Result<()> .find(|pin| pin.id == reference_id) .ok_or_else(|| eyre!("Couldn't find a pin for message {reference_id}!"))?; - redirect(ctx, pin, &message.author, target).await?; + redirect(ctx, pin, &message.author, &target).await?; pin.unpin(ctx).await?; Ok(()) } -async fn redirect(ctx: &Context, pin: &Message, pinner: &User, target: ChannelId) -> Result<()> { +async fn redirect(ctx: &Context, pin: &Message, pinner: &User, target: &ChannelId) -> Result<()> { let embed = utils::resolve_message_to_embed(ctx, pin).await; let mentions = CreateAllowedMentions::new().empty_roles().empty_users(); let message = CreateMessage::default() diff --git a/src/handlers/event/reactboard.rs b/src/handlers/event/reactboard.rs index 4316633..75fc858 100644 --- a/src/handlers/event/reactboard.rs +++ b/src/handlers/event/reactboard.rs @@ -1,13 +1,14 @@ use crate::{storage, utils, Data}; -use storage::ReactBoardEntry; +use storage::reactboard::ReactBoardEntry; use eyre::{eyre, Context as _, Result}; -use log::debug; +use log::{debug, warn}; use poise::serenity_prelude::{ Context, CreateMessage, EditMessage, GuildId, Message, MessageReaction, Reaction, }; pub async fn handle(ctx: &Context, reaction: &Reaction, data: &Data) -> Result<()> { + // TODO @getchoo: don't do anything if this message is old let msg = reaction .message(&ctx.http) .await @@ -45,7 +46,11 @@ async fn send_to_reactboard( guild_id: &GuildId, data: &Data, ) -> Result<()> { - let storage = &data.storage; + let Some(storage) = &data.storage else { + warn!("Can't make ReactBoard entry; no storage backend found!"); + return Ok(()); + }; + let settings = storage.get_guild_settings(guild_id).await?; // make sure everything is in order... @@ -64,7 +69,17 @@ async fn send_to_reactboard( return Ok(()); } - if reaction.count < settings.reactboard_requirement.unwrap_or(5) { + let count = if msg + .reaction_users(ctx, reaction.reaction_type.clone(), None, None) + .await? + .contains(&msg.author) + { + reaction.count - 1 + } else { + reaction.count + }; + + if count < settings.reactboard_requirement.unwrap_or(5) { debug!( "Ignoring message {} on ReactBoard, not enough reactions", msg.id @@ -72,60 +87,57 @@ async fn send_to_reactboard( return Ok(()); } - let content = format!("{} **#{}**", reaction.reaction_type, reaction.count); + let content = format!("{} **#{}**", reaction.reaction_type, count); - // bump reaction count if previous entry exists - if storage.reactboard_entry_exists(guild_id, &msg.id).await? { - let old_entry = storage.get_reactboard_entry(guild_id, &msg.id).await?; + let entry = if storage.reactboard_entry_exists(guild_id, &msg.id).await? { + // bump reaction count if previous entry exists + let mut entry = storage.get_reactboard_entry(guild_id, &msg.id).await?; // bail if we don't need to edit anything - if old_entry.reaction_count >= reaction.count { + if entry.reaction_count >= count { debug!("Message {} doesn't need updating", msg.id); return Ok(()); } debug!( "Bumping {} reaction count from {} to {}", - msg.id, old_entry.reaction_count, reaction.count + msg.id, entry.reaction_count, count ); let edited = EditMessage::new().content(content); ctx.http - .get_message(old_entry.posted_channel_id, old_entry.posted_message_id) + .get_message(entry.posted_channel_id, entry.posted_message_id) .await .wrap_err_with(|| { format!( "Couldn't get previous message from ReactBoardEntry {} in Redis DB!", - old_entry.original_message_id + entry.original_message_id ) })? .edit(ctx, edited) .await?; // update reaction count in redis - let mut new_entry = old_entry.clone(); - new_entry.reaction_count = reaction.count; - - debug!("Updating ReactBoard entry\nOld entry:\n{old_entry:#?}\n\nNew:\n{new_entry:#?}\n",); - storage.create_reactboard_entry(guild_id, new_entry).await?; - // make new message and add entry to redis otherwise + entry.reaction_count = count; + entry } else { + // make new message and add entry to redis otherwise let embed = utils::resolve_message_to_embed(ctx, msg).await; let message = CreateMessage::default().content(content).embed(embed); let resp = target.send_message(ctx, message).await?; - let entry = ReactBoardEntry { + ReactBoardEntry { original_message_id: msg.id, - reaction_count: reaction.count, + reaction_count: count, posted_channel_id: resp.channel_id, posted_message_id: resp.id, - }; + } + }; - debug!("Creating new ReactBoard entry:\n{entry:#?}"); - storage.create_reactboard_entry(guild_id, entry).await?; - } + debug!("Creating new ReactBoard entry:\n{entry:#?}"); + storage.create_reactboard_entry(guild_id, entry).await?; Ok(()) } diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 2ae0539..1610d23 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,5 +1,2 @@ -mod error; -mod event; - -pub use error::handle as handle_error; -pub use event::handle as handle_event; +pub mod error; +pub mod event; diff --git a/src/main.rs b/src/main.rs index 806c7b3..7f19b9e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,106 +1,94 @@ -#![warn(clippy::all, clippy::pedantic, clippy::perf)] -#![allow(clippy::missing_errors_doc, clippy::used_underscore_binding)] -#![forbid(unsafe_code)] - -use std::sync::Arc; -use std::time::Duration; - -use eyre::{eyre, Context as _, Report, Result}; -use log::{info, warn}; -use owo_colors::OwoColorize; -use poise::serenity_prelude as serenity; -use poise::{EditTracker, Framework, FrameworkOptions, PrefixFrameworkOptions}; -use redis::ConnectionLike; -use storage::Storage; +use std::{sync::Arc, time::Duration}; + +use eyre::{Context as _, Report, Result}; +use log::{info, trace, warn}; +use poise::{ + serenity_prelude::{self as serenity}, + EditTracker, Framework, FrameworkOptions, PrefixFrameworkOptions, +}; use tokio::signal::ctrl_c; +#[cfg(target_family = "unix")] use tokio::signal::unix::{signal, SignalKind}; +#[cfg(target_family = "windows")] +use tokio::signal::windows::ctrl_close; mod api; -mod colors; mod commands; mod consts; mod handlers; mod storage; mod utils; -type Context<'a> = poise::Context<'a, Data, Report>; +use storage::Storage; + +type Error = Box; +type Context<'a> = poise::Context<'a, Data, Error>; -#[derive(Clone)] +#[derive(Clone, Debug, Default)] pub struct Data { - storage: Storage, + storage: Option, } -impl Data { - pub fn new() -> Result { - let redis_url = - std::env::var("REDIS_URL").wrap_err("Couldn't find Redis URL in environment!")?; - - let storage = Storage::new(&redis_url)?; - - Ok(Self { storage }) - } -} +async fn setup(ctx: &serenity::Context) -> Result { + let storage = Storage::from_env().ok(); -async fn setup( - ctx: &serenity::Context, - _ready: &serenity::Ready, - framework: &Framework, -) -> Result { - let data = Data::new()?; - let mut client = data.storage.client.clone(); - - if !client.check_connection() { - return Err(eyre!( - "Couldn't connect to storage! Is your daemon running?" - )); - } + if let Some(storage) = storage.as_ref() { + if !storage.clone().is_connected() { + return Err( + "You specified a storage backend but there's no connection! Is it running?".into(), + ); + } + trace!("Storage backend connected!"); - poise::builtins::register_globally(ctx, &framework.options().commands).await?; - info!("Registered global commands!"); + poise::builtins::register_globally(ctx, &commands::to_vec_global()).await?; + info!("Registered global commands!"); - // register "extra" commands in guilds that allow it - let guilds = data.storage.get_opted_guilds().await?; + // register "extra" commands in guilds that allow it + let guilds = storage.get_opted_guilds().await?; - for guild in guilds { - poise::builtins::register_in_guild(ctx, &commands::optional(), guild).await?; + for guild in guilds { + poise::builtins::register_in_guild(ctx, &commands::to_vec_optional(), guild).await?; - info!("Registered guild commands to {}", guild); + info!("Registered guild commands to {}", guild); + } + } else { + warn!("No storage backend was specified. Features requiring storage will be disabled"); + warn!("Registering optional commands globally since there's no storage backend"); + poise::builtins::register_globally(ctx, &commands::to_vec()).await?; } + let data = Data { storage }; + Ok(data) } async fn handle_shutdown(shard_manager: Arc, reason: &str) { warn!("{reason}! Shutting down bot..."); shard_manager.shutdown_all().await; - println!("{}", "Everything is shutdown. Goodbye!".green()); + println!("Everything is shutdown. Goodbye!"); } #[tokio::main] async fn main() -> Result<()> { - color_eyre::install()?; dotenvy::dotenv().ok(); + color_eyre::install()?; env_logger::init(); - let token = std::env::var("TOKEN").wrap_err_with(|| "Couldn't find token in environment!")?; + let token = std::env::var("TOKEN").wrap_err("Couldn't find bot token in environment!")?; let intents = serenity::GatewayIntents::non_privileged() | serenity::GatewayIntents::MESSAGE_CONTENT; let options = FrameworkOptions { - commands: { - let mut commands = commands::global(); - commands.append(&mut commands::moderation()); - commands - }, - on_error: |error| Box::pin(handlers::handle_error(error)), + commands: commands::to_vec(), + on_error: |error| Box::pin(handlers::error::handle(error)), command_check: Some(|ctx| { Box::pin(async move { Ok(ctx.author().id != ctx.framework().bot_id) }) }), - event_handler: |ctx, event, framework, data| { - Box::pin(handlers::handle_event(ctx, event, framework, data)) + event_handler: |ctx, event, _framework, data| { + Box::pin(handlers::event::handle(ctx, event, data)) }, prefix_options: PrefixFrameworkOptions { @@ -116,7 +104,7 @@ async fn main() -> Result<()> { let framework = Framework::builder() .options(options) - .setup(|ctx, ready, framework| Box::pin(setup(ctx, ready, framework))) + .setup(|ctx, _ready, _framework| Box::pin(setup(ctx))) .build(); let mut client = serenity::ClientBuilder::new(token, intents) @@ -124,12 +112,15 @@ async fn main() -> Result<()> { .await?; let shard_manager = client.shard_manager.clone(); + #[cfg(target_family = "unix")] let mut sigterm = signal(SignalKind::terminate())?; + #[cfg(target_family = "windows")] + let mut sigterm = ctrl_close()?; tokio::select! { result = client.start() => result.map_err(Report::from), _ = sigterm.recv() => { - handle_shutdown(shard_manager, "Recieved SIGTERM").await; + handle_shutdown(shard_manager, "Received SIGTERM").await; std::process::exit(0); }, _ = ctrl_c() => { diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 0e2f445..45c150a 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -1,138 +1,47 @@ -use std::fmt::Debug; +use std::{env, fmt::Debug, str::FromStr}; use eyre::Result; use log::debug; use poise::serenity_prelude::{GuildId, MessageId}; -use redis::{AsyncCommands, Client, FromRedisValue, ToRedisArgs}; +use redis::{AsyncCommands, Client, ConnectionLike, RedisError}; -mod reactboard; -mod settings; -// these are purposefully private. see the comment below -use reactboard::REACTBOARD_KEY; -use settings::SETTINGS_KEY; +pub mod reactboard; +pub mod settings; -pub use reactboard::ReactBoardEntry; -pub use settings::{Properties, Settings}; +use reactboard::ReactBoardEntry; +use settings::Settings; + +pub const REACTBOARD_KEY: &str = "reactboard-v2"; +pub const SETTINGS_KEY: &str = "settings-v1"; #[derive(Clone, Debug)] pub struct Storage { - pub client: Client, + client: Client, } impl Storage { - pub fn new(redis_url: &str) -> Result { - let client = Client::open(redis_url)?; - - Ok(Self { client }) - } - - /* - these are mainly light abstractions to avoid the `let mut con` - boilerplate, as well as not require the caller to format the - strings for keys - */ - - async fn get_key(&self, key: &str) -> Result - where - T: FromRedisValue, - { - debug!("Getting key {key}"); - - let mut con = self.client.get_async_connection().await?; - let res: T = con.get(key).await?; - - Ok(res) - } - - async fn set_key<'a>( - &self, - key: &str, - value: impl ToRedisArgs + Debug + Send + Sync + 'a, - ) -> Result<()> { - debug!("Creating key {key}:\n{value:#?}"); - - let mut con = self.client.get_async_connection().await?; - con.set(key, value).await?; - - Ok(()) - } - - async fn key_exists(&self, key: &str) -> Result { - debug!("Checking if key {key} exists"); - - let mut con = self.client.get_async_connection().await?; - let exists: u64 = con.exists(key).await?; - - Ok(exists > 0) - } - - async fn delete_key(&self, key: &str) -> Result<()> { - debug!("Deleting key {key}"); - - let mut con = self.client.get_async_connection().await?; - con.del(key).await?; - - Ok(()) - } - - async fn expire_key(&self, key: &str, expire_seconds: i64) -> Result<()> { - debug!("Expiring key {key} in {expire_seconds}"); - - let mut con = self.client.get_async_connection().await?; - con.expire(key, expire_seconds).await?; - - Ok(()) + pub fn new(client: Client) -> Self { + Self { client } } - async fn add_to_index<'a>( - &self, - key: &str, - member: impl ToRedisArgs + Debug + Send + Sync + 'a, - ) -> Result<()> { - let key = format!("{key}:index"); - debug!("Adding member {member:#?} to index {key}"); - - let mut con = self.client.get_async_connection().await?; - con.sadd(key, member).await?; + pub fn from_env() -> Result { + let redis_url = env::var("REDIS_URL")?; - Ok(()) + Ok(Self::from_str(&redis_url)?) } - async fn get_index(&self, key: &str) -> Result> - where - T: FromRedisValue, - { - let key = format!("{key}:index"); - debug!("Getting index {key}"); - - let mut con = self.client.get_async_connection().await?; - let members = con.smembers(key).await?; - - Ok(members) + pub fn is_connected(&mut self) -> bool { + self.client.check_connection() } - async fn delete_from_index<'a>( - &self, - key: &str, - member: impl ToRedisArgs + Debug + Send + Sync + 'a, - ) -> Result<()> { - let key = format!("{key}:index"); - debug!("Removing {member:#?} from index {key}"); - - let mut con = self.client.get_async_connection().await?; - con.srem(key, member).await?; - - Ok(()) - } - - // guild settings - pub async fn create_guild_settings(&self, settings: Settings) -> Result<()> { - let key = format!("{SETTINGS_KEY}:{}", settings.guild_id); + let guild_key = format!("{SETTINGS_KEY}:{}", settings.guild_id); - self.set_key(&key, &settings).await?; - // adding to index since we need to look all of these up sometimes - self.add_to_index(SETTINGS_KEY, u64::from(settings.guild_id)) + let mut con = self.client.get_multiplexed_async_connection().await?; + redis::pipe() + .set(&guild_key, &settings) + .sadd(SETTINGS_KEY, u64::from(settings.guild_id)) + .query_async(&mut con) .await?; Ok(()) @@ -140,32 +49,43 @@ impl Storage { pub async fn get_guild_settings(&self, guild_id: &GuildId) -> Result { debug!("Fetching guild settings for {guild_id}"); + let guild_key = format!("{SETTINGS_KEY}:{guild_id}"); - let key = format!("{SETTINGS_KEY}:{guild_id}"); - let settings: Settings = self.get_key(&key).await?; + let mut con = self.client.get_multiplexed_async_connection().await?; + let settings: Settings = con.get(&guild_key).await?; Ok(settings) } pub async fn delete_guild_settings(&self, guild_id: &GuildId) -> Result<()> { - let key = format!("{SETTINGS_KEY}:{guild_id}"); - - self.delete_key(&key).await?; - self.delete_from_index(SETTINGS_KEY, u64::from(*guild_id)) + debug!("Deleting guild settings for {guild_id}"); + let guild_key = format!("{SETTINGS_KEY}:{guild_id}"); + + let mut con = self.client.get_multiplexed_async_connection().await?; + redis::pipe() + .del(&guild_key) + .srem(SETTINGS_KEY, u64::from(*guild_id)) + .query_async(&mut con) .await?; Ok(()) } pub async fn guild_settings_exist(&self, guild_id: &GuildId) -> Result { - let key = format!("{SETTINGS_KEY}:{guild_id}"); - self.key_exists(&key).await + debug!("Checking if guild settings for {guild_id} exist"); + let guild_key = format!("{SETTINGS_KEY}:{guild_id}"); + + let mut con = self.client.get_multiplexed_async_connection().await?; + let exists = con.exists(&guild_key).await?; + + Ok(exists) } pub async fn get_all_guild_settings(&self) -> Result> { debug!("Fetching all guild settings"); - let found: Vec = self.get_index(SETTINGS_KEY).await?; + let mut con = self.client.get_multiplexed_async_connection().await?; + let found: Vec = con.smembers(SETTINGS_KEY).await?; let mut guilds = vec![]; for key in found { @@ -196,10 +116,14 @@ impl Storage { guild_id: &GuildId, entry: ReactBoardEntry, ) -> Result<()> { - let key = format!("{REACTBOARD_KEY}:{guild_id}:{}", entry.original_message_id); + debug!( + "Creating reactboard entry for {} in {guild_id}", + &entry.original_message_id + ); + let entry_key = format!("{REACTBOARD_KEY}:{guild_id}:{}", entry.original_message_id); - self.set_key(&key, &entry).await?; - self.expire_key(&key, 30 * 24 * 60 * 60).await?; // 30 days + let mut con = self.client.get_multiplexed_async_connection().await?; + con.set_ex(&entry_key, &entry, 30 * 24 * 60 * 60).await?; // 30 days Ok(()) } @@ -209,10 +133,11 @@ impl Storage { guild_id: &GuildId, message_id: &MessageId, ) -> Result { - debug!("Fetching reactboard entry in {guild_id}"); + debug!("Fetching reactboard entry {message_id} in {guild_id}"); + let entry_key = format!("{REACTBOARD_KEY}:{guild_id}:{message_id}"); - let key = format!("{REACTBOARD_KEY}:{guild_id}:{message_id}"); - let entry: ReactBoardEntry = self.get_key(&key).await?; + let mut con = self.client.get_multiplexed_async_connection().await?; + let entry: ReactBoardEntry = con.get(&entry_key).await?; Ok(entry) } @@ -222,7 +147,20 @@ impl Storage { guild_id: &GuildId, message_id: &MessageId, ) -> Result { - let key = format!("{REACTBOARD_KEY}:{guild_id}:{message_id}"); - self.key_exists(&key).await + debug!("Checking if reactboard entry {message_id} exists in {guild_id}"); + let entry_key = format!("{REACTBOARD_KEY}:{guild_id}:{message_id}"); + + let mut con = self.client.get_multiplexed_async_connection().await?; + let exists = con.exists(&entry_key).await?; + + Ok(exists) + } +} + +impl FromStr for Storage { + type Err = RedisError; + fn from_str(s: &str) -> Result { + let client = Client::open(s)?; + Ok(Self::new(client)) } } diff --git a/src/storage/reactboard.rs b/src/storage/reactboard.rs index 19453df..496739c 100644 --- a/src/storage/reactboard.rs +++ b/src/storage/reactboard.rs @@ -2,8 +2,6 @@ use poise::serenity_prelude::{ChannelId, MessageId}; use redis_macros::{FromRedisValue, ToRedisArgs}; use serde::{Deserialize, Serialize}; -pub const REACTBOARD_KEY: &str = "reactboard-v2"; - #[derive(Clone, Debug, Serialize, Deserialize, FromRedisValue, ToRedisArgs)] pub struct ReactBoardEntry { pub original_message_id: MessageId, diff --git a/src/storage/settings.rs b/src/storage/settings.rs index 76eacc7..cfeda64 100644 --- a/src/storage/settings.rs +++ b/src/storage/settings.rs @@ -2,8 +2,6 @@ use poise::serenity_prelude::{ChannelId, GuildId, ReactionType}; use redis_macros::{FromRedisValue, ToRedisArgs}; use serde::{Deserialize, Serialize}; -pub const SETTINGS_KEY: &str = "settings-v1"; - #[derive(poise::ChoiceParameter)] pub enum Properties { GuildId, @@ -21,12 +19,12 @@ pub enum Properties { pub struct Settings { pub guild_id: GuildId, pub pinboard_channel: Option, - pub pinboard_watch: Option>, pub pinboard_enabled: bool, + pub pinboard_watch: Option>, pub reactboard_channel: Option, + pub reactboard_enabled: bool, pub reactboard_requirement: Option, pub reactboard_reactions: Option>, - pub reactboard_enabled: bool, pub optional_commands_enabled: bool, } diff --git a/src/utils.rs b/src/utils.rs index 1f8e4a7..9b642a7 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,4 @@ -use crate::{colors, Context}; +use crate::{consts::Colors, Context}; use color_eyre::eyre::{eyre, Result}; use poise::serenity_prelude::{self as serenity, CreateEmbedAuthor, CreateEmbedFooter}; @@ -49,7 +49,7 @@ pub async fn send_url_as_embed(ctx: Context<'_>, url: String) -> Result<()> { .title(title) .image(&url) .url(url) - .color(colors::Colors::Blue); + .color(Colors::Blue); let reply = CreateReply::default().embed(embed); ctx.send(reply).await?; -- cgit v1.2.3