Merge pull request #303 from getchoo/feat/RIIR

This commit is contained in:
Sefa Eyeoglu 2024-04-30 22:31:52 +02:00 committed by GitHub
commit 4d12cccec7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
118 changed files with 6307 additions and 3160 deletions

View file

@ -1,6 +0,0 @@
node_modules/
.git/
.env
.env.*
!.env.example

View file

@ -1,2 +0,0 @@
DISCORD_TOKEN=
SAY_LOGS_CHANNEL=

5
.envrc
View file

@ -1,2 +1,5 @@
use flake
if has nix_direnv_version; then
use flake ./nix/dev
fi
dotenv_if_exists

View file

@ -1,2 +0,0 @@
node_modules/
dist/

View file

@ -1,11 +0,0 @@
/* eslint-env node */
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
rules: {
'@typescript-eslint/no-non-null-assertion': 0,
},
};

28
.github/workflows/autobot.yaml vendored Normal file
View file

@ -0,0 +1,28 @@
name: Auto-merge Dependabot
on: pull_request
jobs:
automerge:
name: Check and merge PR
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
if: github.actor == 'dependabot[bot]'
steps:
- uses: dependabot/fetch-metadata@v2
id: metadata
with:
github-token: ${{ github.token }}
# auto merge minor releases
- name: Enable auto-merge
if: steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor'
run: gh pr merge --auto --rebase "$PR"
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.html_url }}

68
.github/workflows/check.yml vendored Normal file
View file

@ -0,0 +1,68 @@
name: Check
on:
push:
branches: ['main']
pull_request:
jobs:
rustfmt:
name: Run rustfmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
components: rustfmt
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
- name: Run rustfmt
run: cargo fmt --all -- --check
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 -euo 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

View file

@ -3,112 +3,88 @@ name: Docker
on:
push:
branches: ['main']
env:
REGISTRY: ghcr.io
IMAGE_NAME: prismlauncher/refraction
permissions:
contents: read
packages: write
pull_request:
workflow_dispatch:
jobs:
build:
name: Build image
runs-on: ubuntu-latest
strategy:
matrix:
platform:
- linux/arm64
- linux/amd64
arch: [x86_64, aarch64]
steps:
- name: Checkout repository
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
if: ${{ matrix.platform != 'linux/amd64' }}
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@v10
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Setup Nix cache
uses: DeterminateSystems/magic-nix-cache-action@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=raw,value=latest
- name: Build and push by digest
uses: docker/build-push-action@v5
- name: Build Docker image
id: build
with:
context: .
provenance: false
labels: ${{ steps.meta.outputs.labels }}
platforms: ${{ matrix.platform }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
- name: Export digests
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
nix build --print-build-logs ./nix/dev#container-${{ matrix.arch }}
[ ! -L result ] && exit 1
echo "path=$(readlink -f result)" >> "$GITHUB_OUTPUT"
- name: Upload digests
uses: actions/upload-artifact@v3
- name: Upload image
uses: actions/upload-artifact@v4
with:
name: digests
path: /tmp/digests/*
name: container-${{ matrix.arch }}
path: ${{ steps.build.outputs.path }}
if-no-files-found: error
retention-days: 1
retention-days: 3
push:
name: Push image
needs: build
runs-on: ubuntu-latest
needs:
- build
permissions:
packages: write
env:
REGISTRY: ghcr.io
USERNAME: ${{ github.actor }}
IMAGE_NAME: ${{ github.repository }}
if: github.event_name == 'push'
steps:
- name: Download digests
uses: actions/download-artifact@v3
- uses: actions/checkout@v4
- name: Download images
uses: actions/download-artifact@v4
with:
name: digests
path: /tmp/digests
path: images
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
username: ${{ env.USERNAME }}
password: ${{ github.token }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=raw,value=latest
- name: Create manifest list and push
working-directory: /tmp/digests
- name: Push to registry
env:
TAG: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
set -eu
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
architectures=("x86_64" "aarch64")
for arch in "${architectures[@]}"; do
docker load < images/container-"$arch"/*.tar.gz
docker tag refraction:latest-"$arch" "$TAG"-"$arch"
docker push ${{ env.TAG }}-"$arch"
done
docker manifest create "$TAG" \
--amend "$TAG"-x86_64 \
--amend "$TAG"-aarch64
docker manifest push "$TAG"

View file

@ -1,25 +0,0 @@
name: Lint
on:
push:
branches: ['main']
pull_request:
branches: ['main']
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v2
- name: Install dependencies
run: pnpm install
- name: Lint
run: pnpm run lint

53
.github/workflows/nix.yml vendored Normal file
View file

@ -0,0 +1,53 @@
name: Nix
on:
push:
branches: ['main']
pull_request:
workflow_dispatch:
jobs:
build:
name: Build
strategy:
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: Build refraction
run: nix build --fallback --print-build-logs
check:
name: Check flake
strategy:
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 checks
run: |
cd ./nix/dev
nix flake check --print-build-logs --show-trace

72
.github/workflows/update-flake.yml vendored Normal file
View file

@ -0,0 +1,72 @@
name: Update flake.lock
on:
schedule:
# run every saturday
- cron: '0 0 * * 6'
workflow_dispatch:
jobs:
update:
name: Run update
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
env:
PR_BRANCH: 'update-lockfiles'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@v10
- 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 --squash
env:
GH_TOKEN: ${{ secrets.MERGE_TOKEN }}

25
.gitignore vendored
View file

@ -1,13 +1,32 @@
node_modules/
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# direnv secrets
.env
.env.*
!.env.example
# Nix
.direnv/
.pre-commit-config.yaml
eslint_report.json
result*
repl-result-out*
.DS_Store
*.rdb
# JetBrains
.idea/

1
.rustfmt.toml Normal file
View file

@ -0,0 +1 @@
hard_tabs = true

2801
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

49
Cargo.toml Normal file
View file

@ -0,0 +1,49 @@
[package]
name = "refraction"
version = "2.0.0"
edition = "2021"
repository = "https://github.com/PrismLauncher/refraction"
license = "GPL-3.0-or-later"
readme = "README.md"
build = "build.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
gray_matter = "0.2.6"
poise = "0.6.1"
serde = "1.0.196"
serde_json = "1.0.112"
[dependencies]
color-eyre = "0.6.2"
dotenvy = "0.15.7"
enum_dispatch = "0.3.12"
env_logger = "0.11.1"
eyre = "0.6.11"
log = "0.4.20"
poise = "0.6.1"
octocrab = "0.37.0"
redis = { version = "0.25.2", features = ["tokio-comp", "tokio-rustls-comp"] }
regex = "1.10.3"
reqwest = { version = "0.12.2", default-features = false, features = [
"rustls-tls",
"json",
] }
serde = "1.0.196"
serde_json = "1.0.112"
tokio = { version = "1.35.1", features = [
"macros",
"rt-multi-thread",
"signal",
] }
[lints.rust]
unsafe_code = "forbid"
[lints.clippy]
complexity = "warn"
correctness = "deny"
pedantic = "warn"
perf = "warn"
style = "warn"
suspicious = "deny"

View file

@ -1,11 +0,0 @@
FROM docker.io/library/node:21-alpine
RUN corepack enable
RUN corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml .
RUN pnpm install --frozen-lockfile
COPY . .
CMD [ "pnpm", "run", "start" ]

88
build.rs Normal file
View file

@ -0,0 +1,88 @@
use std::io::Write;
use std::path::Path;
use std::{env, fs};
use gray_matter::{engine, Matter};
include!("src/tags.rs");
/// generate the `ChoiceParameter` enum and tag data we will use in the `tag` command
#[allow(dead_code)]
fn main() {
let out_dir = env::var_os("OUT_DIR").unwrap();
let dest_file = Path::new(&out_dir).join("generated.rs");
let mut file = fs::File::create(dest_file).unwrap();
let tag_files: Vec<String> = fs::read_dir(TAG_DIR)
.unwrap()
.map(|f| f.unwrap().file_name().to_string_lossy().to_string())
.collect();
let mut tags: Vec<Tag> = tag_files
.clone()
.into_iter()
.map(|name| {
let file_name = format!("{TAG_DIR}/{name}");
let file_content = fs::read_to_string(&file_name).unwrap();
let matter = Matter::<engine::YAML>::new();
let parsed = matter.parse(&file_content);
let content = parsed.content;
let data = parsed
.data
.unwrap()
.deserialize()
.unwrap_or_else(|e| {
// actually handling the error since this is the most likely thing to fail -getchoo
panic!(
"Failed to parse file {file_name}! Here's what it looked like:\n{content}\n\nReported Error:\n{e}\n",
)
});
Tag {
content,
id: name.trim_end_matches(".md").to_string(),
frontmatter: data,
}
})
.collect();
tags.sort_by(|t1, t2| t1.id.cmp(&t2.id));
let tag_names: Vec<String> = tags.iter().map(|t| format!("{},", t.id)).collect();
let tag_matches: Vec<String> = tags
.iter()
.map(|t| format!("Self::{} => \"{}\",", t.id, t.id))
.collect();
writeln!(
file,
r#"#[allow(non_camel_case_types, clippy::upper_case_acronyms)]
#[derive(Clone, Debug, poise::ChoiceParameter)]
pub enum Choice {{
{}
}}"#,
tag_names.join("\n")
)
.unwrap();
writeln!(
file,
r#"impl Choice {{
fn as_str(&self) -> &str {{
match &self {{
{}
}}
}}
}}"#,
tag_matches.join("\n")
)
.unwrap();
println!(
"cargo:rustc-env=TAGS={}",
// make sure we can deserialize with env! at runtime
serde_json::to_string(&tags).unwrap()
);
}

156
flake.lock generated
View file

@ -1,85 +1,12 @@
{
"nodes": {
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1673956053,
"narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1696343447,
"narHash": "sha256-B2xAZKLkkeRFG5XcHHSXXcP7To9Xzr59KXeZiRf4vdQ=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "c9afaba3dfa4085dbd2ccb38dfade5141e33d9d4",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1685518550,
"narHash": "sha256-o2d0KcvaXzTrPRIo0kOLV0/QXHhDQ5DTi+OxcjO8xqY=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "a1720a10a6cfe8234c0e93907ffe81be440f4cef",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"pre-commit-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1660459072,
"narHash": "sha256-8DFJjXG8zqoONA1vXtgeKXy68KdJL5UaXR8NtVMUbx8=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "a20de23b925fd8264fd7fad6454652e142fd7f73",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1696757521,
"narHash": "sha256-cfgtLNCBLFx2qOzRLI6DHfqTdfWI+UbvsKYa3b3fvaA=",
"lastModified": 1711715736,
"narHash": "sha256-9slQ609YqT9bT/MNX9+5k5jltL9zgpn36DpFB7TkttM=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "2646b294a146df2781b1ca49092450e8a32814e1",
"rev": "807c549feabce7eddbf259dbdcec9e0600a0660d",
"type": "github"
},
"original": {
@ -89,84 +16,9 @@
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"dir": "lib",
"lastModified": 1696019113,
"narHash": "sha256-X3+DKYWJm93DRSdC5M6K5hLqzSya9BjibtBsuARoPco=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f5892ddac112a1e9b3612c39af1b72987ee5783a",
"type": "github"
},
"original": {
"dir": "lib",
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1685801374,
"narHash": "sha256-otaSUoFEMM+LjBI1XL/xGB5ao6IwnZOXc47qhIgJe8U=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c37ca420157f4abc31e26f436c1145f8951ff373",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-23.05",
"repo": "nixpkgs",
"type": "github"
}
},
"pre-commit-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"flake-utils": "flake-utils",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
],
"nixpkgs-stable": "nixpkgs-stable"
},
"locked": {
"lastModified": 1696846637,
"narHash": "sha256-0hv4kbXxci2+pxhuXlVgftj/Jq79VSmtAyvfabCCtYk=",
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "42e1b6095ef80a51f79595d9951eb38e91c4e6ca",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"type": "github"
}
},
"root": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs",
"pre-commit-hooks": "pre-commit-hooks"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
"nixpkgs": "nixpkgs"
}
}
},

View file

@ -1,52 +1,31 @@
{
description = "Discord bot for Prism Launcher";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
pre-commit-hooks = {
url = "github:cachix/pre-commit-hooks.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
outputs = {
flake-parts,
pre-commit-hooks,
self,
nixpkgs,
...
} @ inputs:
flake-parts.lib.mkFlake {inherit inputs;} {
imports = [
pre-commit-hooks.flakeModule
];
perSystem = {
config,
lib,
pkgs,
...
}: {
pre-commit.settings.hooks = {
alejandra.enable = true;
prettier = {
enable = true;
excludes = ["flake.lock" "pnpm-lock.yaml"];
};
};
devShells.default = pkgs.mkShell {
shellHook = ''
${config.pre-commit.installationScript}
'';
packages = with pkgs; [nodePackages.pnpm redis];
};
formatter = pkgs.alejandra;
};
}: 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: rec {
refraction = pkgs.callPackage ./nix/derivation.nix {inherit self;};
default = refraction;
});
overlays.default = _: prev: {
refraction = prev.callPackage ./nix/derivation.nix {inherit self;};
};
};
}

63
nix/derivation.nix Normal file
View file

@ -0,0 +1,63 @@
{
lib,
stdenv,
rustPlatform,
darwin,
self,
lto ? true,
optimizeSize ? false,
}:
rustPlatform.buildRustPackage {
pname = "refraction";
version =
(lib.importTOML ../Cargo.toml).package.version
+ "-${self.shortRev or self.dirtyShortRev or "unknown-dirty"}";
__structuredAttrs = true;
src = lib.fileset.toSource {
root = ../.;
fileset = lib.fileset.unions [
../src
../build.rs
../Cargo.lock
../Cargo.toml
../tags
];
};
cargoLock = {
lockFile = ../Cargo.lock;
};
buildInputs = lib.optionals stdenv.hostPlatform.isDarwin (with darwin.apple_sdk.frameworks; [
CoreFoundation
Security
SystemConfiguration
]);
env = let
toRustFlags = lib.mapAttrs' (
name:
lib.nameValuePair
"CARGO_PROFILE_RELEASE_${lib.toUpper (builtins.replaceStrings ["-"] ["_"] name)}"
);
in
lib.optionalAttrs lto (toRustFlags {
lto = "thin";
})
// lib.optionalAttrs optimizeSize (toRustFlags {
codegen-units = "1";
opt-level = "s";
panic = "abort";
strip = "symbols";
});
meta = with lib; {
mainProgram = "refraction";
description = "Discord bot for Prism Launcher";
homepage = "https://github.com/PrismLauncher/refraction";
license = licenses.gpl3Plus;
maintainers = with maintainers; [getchoo Scrumplex];
};
}

11
nix/dev/args.nix Normal file
View file

@ -0,0 +1,11 @@
{inputs, ...}: {
perSystem = {
lib,
system,
...
}: {
_module.args = {
refraction' = lib.mapAttrs (lib.const (v: v.${system} or v)) (inputs.get-flake ../../.);
};
};
}

25
nix/dev/docker.nix Normal file
View file

@ -0,0 +1,25 @@
{withSystem, ...}: {
perSystem = {
lib,
pkgs,
self',
...
}: let
containerFor = arch:
pkgs.dockerTools.buildLayeredImage {
name = "refraction";
tag = "latest-${arch}";
contents = [pkgs.dockerTools.caCertificates];
config.Cmd = [
(lib.getExe self'.packages."refraction-static-${arch}")
];
architecture = withSystem "${arch}-linux" ({pkgs, ...}: pkgs.pkgsStatic.go.GOARCH);
};
in {
packages = {
container-x86_64 = containerFor "x86_64";
container-aarch64 = containerFor "aarch64";
};
};
}

212
nix/dev/flake.lock generated Normal file
View file

@ -0,0 +1,212 @@
{
"nodes": {
"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"
}
},
"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"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"pre-commit-hooks",
"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,
"narHash": "sha256-9slQ609YqT9bT/MNX9+5k5jltL9zgpn36DpFB7TkttM=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "807c549feabce7eddbf259dbdcec9e0600a0660d",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"pre-commit-hooks": {
"inputs": {
"flake-compat": [],
"flake-utils": "flake-utils",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
],
"nixpkgs-stable": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1711850184,
"narHash": "sha256-rs5zMkTO+AlVBzgOaskAtY4zix7q3l8PpawfznHotcQ=",
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "9fc61b5eb0e50fc42f1d358f5240722907b79726",
"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": {
"flake-parts": "flake-parts",
"get-flake": "get-flake",
"nixpkgs": "nixpkgs",
"pre-commit-hooks": "pre-commit-hooks",
"procfile-nix": "procfile-nix",
"rust-overlay": "rust-overlay",
"treefmt-nix": "treefmt-nix"
}
},
"rust-overlay": {
"inputs": {
"flake-utils": [
"pre-commit-hooks",
"flake-utils"
],
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1711851236,
"narHash": "sha256-EJ03x3N9ihhonAttkaCrqxb0djDq3URCuDpmVPbNZhA=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "f258266af947599e8069df1c2e933189270f143a",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"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": 1711803027,
"narHash": "sha256-Qic3OvsVLpetchzaIe2hJqgliWXACq2Oee6mBXa/IZQ=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "1810d51a015c1730f2fe05a255258649799df416",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

65
nix/dev/flake.nix Normal file
View file

@ -0,0 +1,65 @@
{
description = "Discord bot for Prism Launcher";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
flake-parts = {
url = "github:hercules-ci/flake-parts";
inputs.nixpkgs-lib.follows = "nixpkgs";
};
get-flake.url = "github:ursi/get-flake";
pre-commit-hooks = {
url = "github:cachix/pre-commit-hooks.nix";
inputs = {
nixpkgs.follows = "nixpkgs";
nixpkgs-stable.follows = "nixpkgs";
flake-compat.follows = "";
};
};
procfile-nix = {
url = "github:getchoo/procfile-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs = {
nixpkgs.follows = "nixpkgs";
flake-utils.follows = "pre-commit-hooks/flake-utils";
};
};
treefmt-nix = {
url = "github:numtide/treefmt-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = {flake-parts, ...} @ inputs:
flake-parts.lib.mkFlake {inherit inputs;} {
debug = true;
imports = [
./args.nix
./docker.nix
./pre-commit.nix
./procfiles.nix
./shell.nix
./static.nix
./treefmt.nix
inputs.pre-commit-hooks.flakeModule
inputs.procfile-nix.flakeModule
inputs.treefmt-nix.flakeModule
];
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
};
}

20
nix/dev/pre-commit.nix Normal file
View file

@ -0,0 +1,20 @@
{
perSystem = {
config,
lib,
...
}: {
pre-commit.settings = {
rootSrc = lib.mkForce ../../.;
hooks = {
actionlint.enable = true;
nil.enable = true;
statix.enable = true;
treefmt = {
enable = true;
package = config.treefmt.build.wrapper;
};
};
};
};
}

11
nix/dev/procfiles.nix Normal file
View file

@ -0,0 +1,11 @@
{
perSystem = {
lib,
pkgs,
...
}: {
procfiles.daemons.processes = {
redis = lib.getExe' pkgs.redis "redis-server";
};
};
}

36
nix/dev/shell.nix Normal file
View file

@ -0,0 +1,36 @@
{
perSystem = {
pkgs,
config,
self',
refraction',
...
}: {
devShells.default = pkgs.mkShell {
shellHook = ''
${config.pre-commit.installationScript}
'';
packages = with pkgs; [
# general
actionlint
nodePackages.prettier
config.procfiles.daemons.package
# rust
clippy
rustfmt
rust-analyzer
# nix
self'.formatter
deadnix
nil
statix
];
inputsFrom = [refraction'.packages.refraction];
RUST_SRC_PATH = "${pkgs.rustPlatform.rustLibSrc}";
};
};
}

41
nix/dev/static.nix Normal file
View file

@ -0,0 +1,41 @@
{
perSystem = {
lib,
pkgs,
inputs',
refraction',
...
}: let
targets = with pkgs.pkgsCross; {
x86_64 = musl64.pkgsStatic;
aarch64 = aarch64-multiplatform.pkgsStatic;
};
toolchain = inputs'.rust-overlay.packages.rust.minimal.override {
extensions = ["rust-std"];
targets = map (pkgs: pkgs.stdenv.hostPlatform.config) (lib.attrValues targets);
};
rustPlatforms =
lib.mapAttrs (
lib.const (pkgs:
pkgs.makeRustPlatform (
lib.genAttrs ["cargo" "rustc"] (lib.const toolchain)
))
)
targets;
buildWith = rustPlatform:
refraction'.packages.refraction.override {
inherit rustPlatform;
optimizeSize = true;
};
in {
packages =
lib.mapAttrs' (
target: rustPlatform:
lib.nameValuePair "refraction-static-${target}" (buildWith rustPlatform)
)
rustPlatforms;
};
}

22
nix/dev/treefmt.nix Normal file
View file

@ -0,0 +1,22 @@
{
perSystem = {
treefmt = {
projectRootFile = ".git/config";
programs = {
alejandra.enable = true;
deadnix.enable = true;
prettier.enable = true;
rustfmt.enable = true;
};
settings.global = {
excludes = [
"./target"
"./flake.lock"
"./Cargo.lock"
];
};
};
};
}

149
nix/module.nix Normal file
View file

@ -0,0 +1,149 @@
self: {
config,
lib,
pkgs,
...
}: let
cfg = config.services.refraction;
defaultUser = "refraction";
inherit
(lib)
getExe
literalExpression
mdDoc
mkEnableOption
mkIf
mkOption
mkPackageOption
optionals
types
;
in {
options.services.refraction = {
enable = mkEnableOption "refraction";
package = mkPackageOption self.packages.${pkgs.stdenv.hostPlatform.system} "refraction" {};
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/refraction"
'';
};
};
config = mkIf cfg.enable {
services.redis.servers.refraction = mkIf (cfg.redisUrl == "local") {
enable = true;
inherit (cfg) user;
port = 0; # disable tcp listener
};
systemd.services."refraction" = {
enable = true;
wantedBy = ["multi-user.target"];
after =
["network.target"]
++ optionals (cfg.redisUrl == "local") ["redis-refraction.service"];
script = ''
${getExe cfg.package}
'';
environment = {
BOT_REDIS_URL =
if cfg.redisUrl == "local"
then "unix:${config.services.redis.servers.refraction.unixSocket}"
else cfg.redisUrl;
};
serviceConfig = {
Type = "simple";
Restart = "on-failure";
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 = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@resources"
"~@privileged"
];
};
};
users = {
users = mkIf (cfg.user == defaultUser) {
${defaultUser} = {
isSystemUser = true;
inherit (cfg) group;
};
};
groups = mkIf (cfg.group == defaultUser) {
${defaultUser} = {};
};
};
};
}

View file

@ -1,30 +0,0 @@
{
"name": "refraction",
"version": "1.0.0",
"license": "GPL-3.0",
"scripts": {
"dev": "NODE_ENV=development tsx watch src/index.ts",
"start": "tsx src/index.ts",
"reupload": "tsx src/_reupload.ts",
"lint": "tsc && eslint ."
},
"dependencies": {
"@discordjs/rest": "2.1.0",
"discord.js": "14.14.1",
"just-random": "3.2.0",
"kleur": "4.1.5",
"redis": "4.6.10",
"tsx": "4.1.1"
},
"devDependencies": {
"@types/node": "20.9.0",
"@typescript-eslint/eslint-plugin": "6.10.0",
"@typescript-eslint/parser": "6.10.0",
"dotenv": "16.3.1",
"eslint": "8.53.0",
"gray-matter": "4.0.3",
"prettier": "3.0.3",
"typescript": "5.2.2"
},
"packageManager": "pnpm@8.10.3"
}

1562
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:base", "config:js-app"]
"extends": ["config:base", "config:recommended"]
}

View file

@ -1,79 +0,0 @@
import {
SlashCommandBuilder,
Routes,
PermissionFlagsBits,
type RESTGetAPIOAuth2CurrentApplicationResult,
} from 'discord.js';
import { REST } from '@discordjs/rest';
import { getTags } from './tags';
export const reuploadCommands = async () => {
const tags = await getTags();
const commands = [
new SlashCommandBuilder()
.setName('ping')
.setDescription('Replies with pong!'),
new SlashCommandBuilder()
.setName('stars')
.setDescription('Returns GitHub stargazer count'),
new SlashCommandBuilder()
.setName('members')
.setDescription('Returns the number of members in the server'),
new SlashCommandBuilder()
.setName('tag')
.setDescription('Send a tag')
.addStringOption((option) =>
option
.setName('name')
.setDescription('The tag name')
.setRequired(true)
.addChoices(...tags.map((b) => ({ name: b.name, value: b.name })))
)
.addUserOption((option) =>
option
.setName('user')
.setDescription('The user to mention')
.setRequired(false)
),
new SlashCommandBuilder()
.setName('modrinth')
.setDescription('Get info on a Modrinth project')
.addStringOption((option) =>
option.setName('id').setDescription('The ID or slug').setRequired(true)
),
new SlashCommandBuilder()
.setName('say')
.setDescription('Say something through the bot')
.addStringOption((option) =>
option
.setName('content')
.setDescription('Just content?')
.setRequired(true)
)
.setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers)
.setDMPermission(false),
new SlashCommandBuilder().setName('joke').setDescription("it's a joke"),
new SlashCommandBuilder()
.setName('rory')
.setDescription('Gets a Rory photo!')
.addStringOption((option) =>
option
.setName('id')
.setDescription('specify a Rory ID')
.setRequired(false)
),
].map((command) => command.toJSON());
const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN!);
const { id: appId } = (await rest.get(
Routes.oauth2CurrentApplication()
)) as RESTGetAPIOAuth2CurrentApplicationResult;
await rest.put(Routes.applicationCommands(appId), {
body: commands,
});
console.log('Successfully registered application commands.');
};

11
src/api/dadjoke.rs Normal file
View file

@ -0,0 +1,11 @@
use super::{HttpClient, HttpClientExt};
use eyre::Result;
const DADJOKE: &str = "https://icanhazdadjoke.com";
pub async fn get_joke(http: &HttpClient) -> Result<String> {
let joke = http.get_request(DADJOKE).await?.text().await?;
Ok(joke)
}

30
src/api/github.rs Normal file
View file

@ -0,0 +1,30 @@
use eyre::{OptionExt, Result, WrapErr};
use log::debug;
use octocrab::Octocrab;
pub async fn get_latest_prism_version(octocrab: &Octocrab) -> Result<String> {
debug!("Fetching the latest version of Prism Launcher");
let version = octocrab
.repos("PrismLauncher", "PrismLauncher")
.releases()
.get_latest()
.await?
.tag_name;
Ok(version)
}
pub async fn get_prism_stargazers_count(octocrab: &Octocrab) -> Result<u32> {
debug!("Fetching Prism Launcher's stargazer count");
let stargazers_count = octocrab
.repos("PrismLauncher", "PrismLauncher")
.get()
.await
.wrap_err("Couldn't fetch PrismLauncher/PrismLauncher!")?
.stargazers_count
.ok_or_eyre("Couldn't retrieve stargazers_coutn from GitHub!")?;
Ok(stargazers_count)
}

36
src/api/mod.rs Normal file
View file

@ -0,0 +1,36 @@
use log::trace;
use reqwest::Response;
pub mod dadjoke;
pub mod github;
pub mod paste_gg;
pub mod pluralkit;
pub mod prism_meta;
pub mod rory;
pub type HttpClient = reqwest::Client;
pub trait HttpClientExt {
// sadly i can't implement the actual Default trait :/
fn default() -> Self;
async fn get_request(&self, url: &str) -> Result<Response, reqwest::Error>;
}
impl HttpClientExt for HttpClient {
fn default() -> Self {
let version = option_env!("CARGO_PKG_VERSION").unwrap_or("development");
let user_agent = format!("refraction/{version}");
reqwest::ClientBuilder::new()
.user_agent(user_agent)
.build()
.unwrap_or_default()
}
async fn get_request(&self, url: &str) -> Result<Response, reqwest::Error> {
trace!("Making request to {url}");
let resp = self.get(url).send().await?;
resp.error_for_status_ref()?;
Ok(resp)
}
}

55
src/api/paste_gg.rs Normal file
View file

@ -0,0 +1,55 @@
use super::{HttpClient, HttpClientExt};
use eyre::{eyre, OptionExt, Result};
use serde::{Deserialize, Serialize};
const PASTE_GG: &str = "https://api.paste.gg/v1";
const PASTES: &str = "/pastes";
#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord, Deserialize, Serialize)]
pub enum Status {
#[serde(rename = "success")]
Success,
#[serde(rename = "error")]
Error,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Response<T> {
pub status: Status,
pub result: Option<Vec<T>>,
pub error: Option<String>,
pub message: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Files {
pub id: String,
pub name: Option<String>,
}
pub async fn files_from(http: &HttpClient, id: &str) -> Result<Response<Files>> {
let url = format!("{PASTE_GG}{PASTES}/{id}/files");
let resp: Response<Files> = http.get_request(&url).await?.json().await?;
if resp.status == Status::Error {
let message = resp
.error
.ok_or_eyre("Paste.gg gave us an error but with no message!")?;
Err(eyre!(message))
} else {
Ok(resp)
}
}
pub async fn get_raw_file(
http: &HttpClient,
paste_id: &str,
file_id: &str,
) -> eyre::Result<String> {
let url = format!("{PASTE_GG}{PASTES}/{paste_id}/files/{file_id}/raw");
let text = http.get_request(&url).await?.text().await?;
Ok(text)
}

26
src/api/pluralkit.rs Normal file
View file

@ -0,0 +1,26 @@
use super::{HttpClient, HttpClientExt};
use eyre::{Context, Result};
use poise::serenity_prelude::{MessageId, UserId};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Message {
pub sender: String,
}
const PLURAL_KIT: &str = "https://api.pluralkit.me/v2";
const MESSAGES: &str = "/messages";
pub async fn sender_from(http: &HttpClient, message_id: MessageId) -> Result<UserId> {
let url = format!("{PLURAL_KIT}{MESSAGES}/{message_id}");
let resp: Message = http.get_request(&url).await?.json().await?;
let id: u64 =
resp.sender.parse().wrap_err_with(|| {
format!("Couldn't parse response from PluralKit as a UserId! Here's the response:\n{resp:#?}")
})?;
let sender = UserId::from(id);
Ok(sender)
}

28
src/api/prism_meta.rs Normal file
View file

@ -0,0 +1,28 @@
use super::{HttpClient, HttpClientExt};
use eyre::{OptionExt, Result};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MinecraftPackageJson {
pub format_version: u8,
pub name: String,
pub recommended: Vec<String>,
pub uid: String,
}
const META: &str = "https://meta.prismlauncher.org/v1";
const MINECRAFT_PACKAGEJSON: &str = "/net.minecraft/package.json";
pub async fn latest_minecraft_version(http: &HttpClient) -> Result<String> {
let url = format!("{META}{MINECRAFT_PACKAGEJSON}");
let data: MinecraftPackageJson = http.get_request(&url).await?.json().await?;
let version = data
.recommended
.first()
.ok_or_eyre("Couldn't find latest version of Minecraft!")?;
Ok(version.clone())
}

28
src/api/rory.rs Normal file
View file

@ -0,0 +1,28 @@
use super::{HttpClient, HttpClientExt};
use eyre::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Response {
pub id: u64,
pub url: String,
pub error: Option<String>,
}
const RORY: &str = "https://rory.cat";
const PURR: &str = "/purr";
pub async fn get(http: &HttpClient, id: Option<u64>) -> Result<Response> {
let target = id.map(|id| id.to_string()).unwrap_or_default();
let url = format!("{RORY}{PURR}/{target}");
let data: Response = http
.get_request(&url)
.await?
.json()
.await
.wrap_err("Couldn't parse the rory response!")?;
Ok(data)
}

View file

@ -0,0 +1,22 @@
use crate::{Context, Error};
use log::trace;
use poise::samples::HelpConfiguration;
/// View the help menu
#[poise::command(slash_command, prefix_command, track_edits = true)]
pub async fn help(
ctx: Context<'_>,
#[description = "Provide information about a specific command"] command: Option<String>,
) -> Result<(), Error> {
trace!("Running help command");
let configuration = HelpConfiguration {
extra_text_at_bottom: "Use /help for more information about a specific command!",
..Default::default()
};
poise::builtins::help(ctx, command.as_deref(), configuration).await?;
Ok(())
}

View file

@ -0,0 +1,16 @@
use crate::{api::dadjoke, Context, Error};
use eyre::Result;
use log::trace;
/// It's a joke
#[poise::command(slash_command, prefix_command, track_edits = true)]
pub async fn joke(ctx: Context<'_>) -> Result<(), Error> {
trace!("Running joke command");
ctx.defer().await?;
let joke = dadjoke::get_joke(&ctx.data().http_client).await?;
ctx.say(joke).await?;
Ok(())
}

View file

@ -0,0 +1,37 @@
use crate::{consts::Colors, Context, Error};
use eyre::{eyre, Context as _, OptionExt};
use log::trace;
use poise::serenity_prelude::CreateEmbed;
use poise::CreateReply;
/// Returns the number of members in the server
#[poise::command(slash_command, prefix_command, guild_only = true, track_edits = true)]
pub async fn members(ctx: Context<'_>) -> Result<(), Error> {
trace!("Running members command");
ctx.defer().await?;
let guild_id = ctx.guild_id().ok_or_eyre("Couldn't get guild ID!")?;
let guild = ctx
.http()
.get_guild_with_counts(guild_id)
.await
.wrap_err_with(|| format!("Couldn't fetch guild {guild_id} with counts!"))?;
let member_count = guild
.approximate_member_count
.ok_or_else(|| eyre!("Couldn't get member count for guild {guild_id}!"))?;
let online_count = guild
.approximate_presence_count
.ok_or_else(|| eyre!("Couldn't get online count for guild {guild_id}!"))?;
let embed = CreateEmbed::new()
.title(format!("{member_count} total members!",))
.description(format!("{online_count} online members",))
.color(Colors::Blue);
let reply = CreateReply::default().embed(embed);
ctx.send(reply).await?;
Ok(())
}

View file

@ -0,0 +1,8 @@
pub mod help;
pub mod joke;
pub mod members;
pub mod ping;
pub mod rory;
pub mod say;
pub mod stars;
pub mod tag;

View file

@ -0,0 +1,11 @@
use crate::{Context, Error};
use log::trace;
/// Replies with pong!
#[poise::command(slash_command, prefix_command, track_edits = true, ephemeral)]
pub async fn ping(ctx: Context<'_>) -> Result<(), Error> {
trace!("Running ping command!");
ctx.say("Pong!").await?;
Ok(())
}

View file

@ -0,0 +1,37 @@
use crate::{api::rory, Context, Error};
use log::trace;
use poise::serenity_prelude::{CreateEmbed, CreateEmbedFooter};
use poise::CreateReply;
/// Gets a Rory photo!
#[poise::command(slash_command, prefix_command, track_edits = true)]
pub async fn rory(
ctx: Context<'_>,
#[description = "specify a Rory ID"] id: Option<u64>,
) -> Result<(), Error> {
trace!("Running rory command");
ctx.defer().await?;
let rory = rory::get(&ctx.data().http_client, id).await?;
let embed = {
let embed = CreateEmbed::new();
if let Some(error) = rory.error {
embed.title("Error!").description(error)
} else {
let footer = CreateEmbedFooter::new(format!("ID {}", rory.id));
embed
.title("Rory :3")
.url(&rory.url)
.image(rory.url)
.footer(footer)
}
};
let reply = CreateReply::default().embed(embed);
ctx.send(reply).await?;
Ok(())
}

View file

@ -0,0 +1,37 @@
use crate::{utils, Context, Error};
use log::trace;
use poise::serenity_prelude::{CreateEmbed, CreateMessage};
/// Say something through the bot
#[poise::command(
slash_command,
ephemeral,
default_member_permissions = "MODERATE_MEMBERS",
required_permissions = "MODERATE_MEMBERS",
guild_only
)]
pub async fn say(
ctx: Context<'_>,
#[description = "the message content"] content: String,
) -> Result<(), Error> {
let channel = ctx.channel_id();
channel.say(ctx, &content).await?;
ctx.say("I said what you said!").await?;
if let Some(channel_id) = ctx.data().config.discord.channels.log_channel_id {
let author = utils::embed_author_from_user(ctx.author());
let embed = CreateEmbed::default()
.title("Say command used!")
.description(content)
.author(author);
let message = CreateMessage::new().embed(embed);
channel_id.send_message(ctx, message).await?;
} else {
trace!("Not sending /say log as no channel is set");
}
Ok(())
}

View file

@ -0,0 +1,36 @@
use crate::{api, consts::Colors, Context, Error};
use log::trace;
use poise::serenity_prelude::CreateEmbed;
use poise::CreateReply;
/// Returns GitHub stargazer count
#[poise::command(slash_command, prefix_command, track_edits = true)]
pub async fn stars(ctx: Context<'_>) -> Result<(), Error> {
trace!("Running stars command");
let octocrab = &ctx.data().octocrab;
ctx.defer().await?;
let count = if let Some(storage) = &ctx.data().storage {
if let Ok(count) = storage.launcher_stargazer_count().await {
count
} else {
let count = api::github::get_prism_stargazers_count(octocrab).await?;
storage.cache_launcher_stargazer_count(count).await?;
count
}
} else {
trace!("Not caching launcher stargazer count, as we're running without a storage backend");
api::github::get_prism_stargazers_count(octocrab).await?
};
let embed = CreateEmbed::new()
.title(format!("{count} total stars!"))
.color(Colors::Yellow);
let reply = CreateReply::default().embed(embed);
ctx.send(reply).await?;
Ok(())
}

View file

@ -0,0 +1,90 @@
#![allow(non_camel_case_types, clippy::upper_case_acronyms)]
use crate::{consts::Colors, tags::Tag, Context, Error};
use std::env;
use std::str::FromStr;
use std::sync::OnceLock;
use eyre::eyre;
use log::trace;
use poise::serenity_prelude::{Color, CreateEmbed, User};
use poise::CreateReply;
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
fn tags() -> &'static Vec<Tag> {
static TAGS: OnceLock<Vec<Tag>> = OnceLock::new();
TAGS.get_or_init(|| serde_json::from_str(env!("TAGS")).unwrap())
}
/// Send a tag
#[poise::command(
slash_command,
prefix_command,
track_edits = true,
help_text_fn = help
)]
pub async fn tag(
ctx: Context<'_>,
#[description = "the tag to send"] name: Choice,
#[description = "a user to mention"] user: Option<User>,
) -> Result<(), Error> {
trace!("Running tag command");
let tag_id = name.as_str();
let tag = tags()
.iter()
.find(|t| t.id == tag_id)
.ok_or_else(|| eyre!("Tried to get non-existent tag: {tag_id}"))?;
let frontmatter = &tag.frontmatter;
let embed = {
let mut e = CreateEmbed::new();
if let Some(color) = &frontmatter.color {
let color = Colors::from_str(color.as_str())
.map(Color::from)
.unwrap_or_default();
e = e.color(color);
}
if let Some(image) = &frontmatter.image {
e = e.image(image);
}
if let Some(fields) = &frontmatter.fields {
for field in fields {
e = e.field(&field.name, &field.value, field.inline);
}
}
e = e.title(&frontmatter.title);
e = e.description(&tag.content);
e
};
let reply = {
let mut r = CreateReply::default();
if let Some(user) = user {
r = r.content(format!("<@{}>", user.id));
}
r.embed(embed)
};
ctx.send(reply).await?;
Ok(())
}
fn help() -> String {
let tag_list = tags()
.iter()
.map(|tag| format!("`{}`", tag.id))
.collect::<Vec<String>>()
.join(", ");
format!("Available tags: {tag_list}")
}

View file

@ -1,11 +0,0 @@
import type { CacheType, ChatInputCommandInteraction } from 'discord.js';
export const jokeCommand = async (
i: ChatInputCommandInteraction<CacheType>
) => {
await i.deferReply();
const joke = await fetch('https://icanhazdadjoke.com', {
headers: { Accept: 'text/plain' },
}).then((r) => r.text());
await i.editReply(joke);
};

View file

@ -1,24 +0,0 @@
import type { CacheType, ChatInputCommandInteraction } from 'discord.js';
import { COLORS } from '../constants';
export const membersCommand = async (
i: ChatInputCommandInteraction<CacheType>
) => {
await i.deferReply();
const memes = await i.guild?.members.fetch().then((r) => r.toJSON());
if (!memes) return;
await i.editReply({
embeds: [
{
title: `${memes.length} total members!`,
description: `${
memes.filter((m) => m.presence?.status !== 'offline').length
} online members`,
color: COLORS.blue,
},
],
});
};

47
src/commands/mod.rs Normal file
View file

@ -0,0 +1,47 @@
use crate::{Data, Error};
mod general;
mod moderation;
macro_rules! command {
($module: ident, $name: ident) => {
$module::$name::$name()
};
($module: ident, $name: ident, $func: ident) => {
$module::$name::$func()
};
}
macro_rules! module_macro {
($module: ident) => {
macro_rules! $module {
($name: ident) => {
command!($module, $name)
};
($name: ident, $func: ident) => {
command!($module, $name, $func)
};
}
};
}
module_macro!(general);
module_macro!(moderation);
pub type Command = poise::Command<Data, Error>;
pub fn all() -> Vec<Command> {
vec![
general!(help),
general!(joke),
general!(members),
general!(ping),
general!(rory),
general!(say),
general!(stars),
general!(tag),
moderation!(set_welcome),
]
}

View file

@ -0,0 +1 @@
pub mod set_welcome;

View file

@ -0,0 +1,198 @@
use std::{fmt::Write, str::FromStr};
use crate::{api::HttpClientExt, utils, Context, Error};
use eyre::Result;
use log::trace;
use poise::serenity_prelude::{
futures::TryStreamExt, Attachment, CreateActionRow, CreateButton, CreateEmbed, CreateMessage,
Mentionable, Message, ReactionType,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct WelcomeEmbed {
title: String,
description: Option<String>,
url: Option<String>,
hex_color: Option<String>,
image: Option<String>,
}
impl From<WelcomeEmbed> for CreateMessage {
fn from(val: WelcomeEmbed) -> Self {
let mut embed = CreateEmbed::new();
embed = embed.title(val.title);
if let Some(description) = val.description {
embed = embed.description(description);
}
if let Some(url) = val.url {
embed = embed.url(url);
}
if let Some(color) = val.hex_color {
let hex = i32::from_str_radix(&color, 16).unwrap();
embed = embed.color(hex);
}
if let Some(image) = val.image {
embed = embed.image(image);
}
Self::new().embed(embed)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct WelcomeRole {
title: String,
id: u64,
emoji: Option<String>,
}
impl From<WelcomeRole> for CreateButton {
fn from(value: WelcomeRole) -> Self {
let mut button = Self::new(value.id.to_string()).label(value.title);
if let Some(emoji) = value.emoji {
button = button.emoji(ReactionType::from_str(&emoji).unwrap());
}
button
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct WelcomeRoleCategory {
title: String,
description: Option<String>,
roles: Vec<WelcomeRole>,
}
impl From<WelcomeRoleCategory> for CreateMessage {
fn from(value: WelcomeRoleCategory) -> Self {
let mut content = format!("**{}**", value.title);
if let Some(description) = value.description {
write!(content, "\n{description}").ok();
}
let buttons: Vec<CreateButton> = value
.roles
.iter()
.map(|role| CreateButton::from(role.clone()))
.collect();
let components = vec![CreateActionRow::Buttons(buttons)];
Self::new().content(content).components(components)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct WelcomeLayout {
embeds: Vec<WelcomeEmbed>,
messages: Vec<String>,
roles: Vec<WelcomeRoleCategory>,
}
/// Sets your welcome channel info
#[poise::command(
slash_command,
guild_only,
ephemeral,
default_member_permissions = "MANAGE_GUILD",
required_permissions = "MANAGE_GUILD"
)]
pub async fn set_welcome(
ctx: Context<'_>,
#[description = "A file to use"] file: Option<Attachment>,
#[description = "A URL for a file to use"] url: Option<String>,
) -> Result<(), Error> {
trace!("Running set_welcome command!");
let configured_channels = ctx.data().config.discord.channels;
let Some(channel_id) = configured_channels.welcome_channel_id else {
ctx.say("You don't have a welcome channel ID set, so I can't do anything :(")
.await?;
return Ok(());
};
ctx.defer_ephemeral().await?;
// download attachment from discord or URL
let file = if let Some(attachment) = file {
let Some(content_type) = &attachment.content_type else {
return Err("Welcome channel attachment was sent without a content type!".into());
};
if !content_type.starts_with("application/json;") {
trace!("Not attempting to read non-json content type {content_type}");
ctx.say("Invalid file! Please only send json").await?;
return Ok(());
}
let downloaded = attachment.download().await?;
String::from_utf8(downloaded)?
} else if let Some(url) = url {
ctx.data()
.http_client
.get_request(&url)
.await?
.text()
.await?
} else {
ctx.say("A text file or URL must be provided!").await?;
return Ok(());
};
// parse and create messages from file
let welcome_layout: WelcomeLayout = serde_json::from_str(&file)?;
let embed_messages: Vec<CreateMessage> = welcome_layout
.embeds
.iter()
.map(|e| CreateMessage::from(e.clone()))
.collect();
let roles_messages: Vec<CreateMessage> = welcome_layout
.roles
.iter()
.map(|c| CreateMessage::from(c.clone()))
.collect();
// clear previous messages
let prev_messages: Vec<Message> = channel_id.messages_iter(ctx).try_collect().await?;
channel_id.delete_messages(ctx, prev_messages).await?;
// send our new ones
for embed in embed_messages {
channel_id.send_message(ctx, embed).await?;
}
for message in roles_messages {
channel_id.send_message(ctx, message).await?;
}
for message in welcome_layout.messages {
channel_id.say(ctx, message).await?;
}
if let Some(log_channel) = configured_channels.log_channel_id {
let author = utils::embed_author_from_user(ctx.author());
let embed = CreateEmbed::new()
.title("set_welcome command used!")
.author(author);
let message = CreateMessage::new().embed(embed);
log_channel.send_message(ctx, message).await?;
} else {
trace!("Not sending /set_welcome log as no channel is set");
}
ctx.reply(format!("Updated {}!", channel_id.mention()))
.await?;
Ok(())
}

View file

@ -1,124 +0,0 @@
type Side = 'required' | 'optional' | 'unsupported';
export interface ModrinthProject {
slug: string;
title: string;
description: string;
categories: string[];
client_side: Side;
server_side: Side;
project_type: 'mod' | 'modpack';
downloads: number;
icon_url: string | null;
id: string;
team: string;
}
import {
EmbedBuilder,
type CacheType,
type ChatInputCommandInteraction,
} from 'discord.js';
import { COLORS } from '../constants';
export const modrinthCommand = async (
i: ChatInputCommandInteraction<CacheType>
) => {
await i.deferReply();
const { value: id } = i.options.get('id') ?? { value: null };
if (!id || typeof id !== 'string') {
await i.editReply({
embeds: [
new EmbedBuilder()
.setTitle('Error!')
.setDescription('You need to provide a valid mod ID!')
.setColor(COLORS.red),
],
});
return;
}
const res = await fetch('https://api.modrinth.com/v2/project/' + id);
if (!res.ok) {
await i.editReply({
embeds: [
new EmbedBuilder()
.setTitle('Error!')
.setDescription('Not found!')
.setColor(COLORS.red),
],
});
setTimeout(() => {
i.deleteReply();
}, 3000);
return;
}
const data = (await res.json()) as
| ModrinthProject
| { error: string; description: string };
if ('error' in data) {
console.error(data);
await i.editReply({
embeds: [
new EmbedBuilder()
.setTitle('Error!')
.setDescription(`\`${data.error}\` ${data.description}`)
.setColor(COLORS.red),
],
});
setTimeout(() => {
i.deleteReply();
}, 3000);
return;
}
await i.editReply({
embeds: [
new EmbedBuilder()
.setTitle(data.title)
.setDescription(data.description)
.setThumbnail(data.icon_url)
.setURL(`https://modrinth.com/project/${data.slug}`)
.setFields([
{
name: 'Categories',
value: data.categories.join(', '),
inline: true,
},
{
name: 'Project type',
value: data.project_type,
inline: true,
},
{
name: 'Downloads',
value: data.downloads.toString(),
inline: true,
},
{
name: 'Client',
value: data.client_side,
inline: true,
},
{
name: 'Server',
value: data.server_side,
inline: true,
},
])
.setColor(COLORS.green),
],
});
};

View file

@ -1,51 +0,0 @@
import type { CacheType, ChatInputCommandInteraction } from 'discord.js';
import { EmbedBuilder } from 'discord.js';
export interface RoryResponse {
/**
* The ID of this Rory
*/
id: number;
/**
* The URL to the image of this Rory
*/
url: string;
/**
* When error :(
*/
error: string | undefined;
}
export const roryCommand = async (
i: ChatInputCommandInteraction<CacheType>
) => {
await i.deferReply();
const { value: id } = i.options.get('id') ?? { value: '' };
const rory: RoryResponse = await fetch(`https://rory.cat/purr/${id}`, {
headers: { Accept: 'application/json' },
}).then((r) => r.json());
if (rory.error) {
await i.editReply({
embeds: [
new EmbedBuilder().setTitle('Error!').setDescription(rory.error),
],
});
return;
}
await i.editReply({
embeds: [
new EmbedBuilder()
.setTitle('Rory :3')
.setURL(`https://rory.cat/id/${rory.id}`)
.setImage(rory.url)
.setFooter({
text: `ID ${rory.id}`,
}),
],
});
};

View file

@ -1,37 +0,0 @@
import {
CacheType,
ChatInputCommandInteraction,
EmbedBuilder,
} from 'discord.js';
export const sayCommand = async (
interaction: ChatInputCommandInteraction<CacheType>
) => {
if (!interaction.guild || !interaction.channel) return;
const content = interaction.options.getString('content', true);
await interaction.deferReply({ ephemeral: true });
const message = await interaction.channel.send(content);
await interaction.editReply('I said what you said!');
if (process.env.SAY_LOGS_CHANNEL) {
const logsChannel = await interaction.guild.channels.fetch(
process.env.SAY_LOGS_CHANNEL
);
if (!logsChannel?.isTextBased()) return;
await logsChannel.send({
embeds: [
new EmbedBuilder()
.setTitle('Say command used')
.setDescription(content)
.setAuthor({
name: interaction.user.tag,
iconURL: interaction.user.avatarURL() ?? undefined,
})
.setURL(message.url),
],
});
}
};

View file

@ -1,23 +0,0 @@
import type { CacheType, ChatInputCommandInteraction } from 'discord.js';
import { COLORS } from '../constants';
export const starsCommand = async (
i: ChatInputCommandInteraction<CacheType>
) => {
await i.deferReply();
const count = await fetch(
'https://api.github.com/repos/PrismLauncher/PrismLauncher'
)
.then((r) => r.json() as Promise<{ stargazers_count: number }>)
.then((j) => j.stargazers_count);
await i.editReply({
embeds: [
{
title: `${count} total stars!`,
color: COLORS.yellow,
},
],
});
};

View file

@ -1,38 +0,0 @@
import {
type ChatInputCommandInteraction,
type CacheType,
EmbedBuilder,
} from 'discord.js';
import { getTags } from '../tags';
export const tagsCommand = async (
i: ChatInputCommandInteraction<CacheType>
) => {
const tags = await getTags();
const tagName = i.options.getString('name', true);
const mention = i.options.getUser('user', false);
const tag = tags.find(
(tag) => tag.name === tagName || tag.aliases?.includes(tagName)
);
if (!tag) {
await i.reply({
content: `Tag \`${tagName}\` does not exist.`,
ephemeral: true,
});
return;
}
const embed = new EmbedBuilder();
embed.setTitle(tag.title ?? tag.name);
embed.setDescription(tag.content);
if (tag.color) embed.setColor(tag.color);
if (tag.image) embed.setImage(tag.image);
if (tag.fields) embed.setFields(tag.fields);
await i.reply({
content: mention ? `<@${mention.id}> ` : undefined,
embeds: [embed],
});
};

24
src/config/bot.rs Normal file
View file

@ -0,0 +1,24 @@
use log::{info, warn};
#[derive(Clone, Debug, Default)]
pub struct Config {
pub redis_url: Option<String>,
}
impl Config {
pub fn new(redis_url: Option<String>) -> Self {
Self { redis_url }
}
pub fn from_env() -> Self {
let redis_url = std::env::var("BOT_REDIS_URL").ok();
if let Some(url) = &redis_url {
info!("Redis URL is {url}");
} else {
warn!("BOT_REDIS_URL is empty; features requiring storage will be disabled.");
}
Self::new(redis_url)
}
}

60
src/config/discord.rs Normal file
View file

@ -0,0 +1,60 @@
use std::str::FromStr;
use log::{info, warn};
use poise::serenity_prelude::ChannelId;
#[derive(Clone, Copy, Debug, Default)]
pub struct RefractionChannels {
pub log_channel_id: Option<ChannelId>,
pub welcome_channel_id: Option<ChannelId>,
}
#[derive(Clone, Debug, Default)]
pub struct Config {
pub channels: RefractionChannels,
}
impl RefractionChannels {
pub fn new(log_channel_id: Option<ChannelId>, welcome_channel_id: Option<ChannelId>) -> Self {
Self {
log_channel_id,
welcome_channel_id,
}
}
pub fn new_from_env() -> Self {
let log_channel_id = Self::get_channel_from_env("DISCORD_LOG_CHANNEL_ID");
if let Some(channel_id) = log_channel_id {
info!("Log channel is {channel_id}");
} else {
warn!("DISCORD_LOG_CHANNEL_ID is empty; this will disable logging in your server.");
}
let welcome_channel_id = Self::get_channel_from_env("DISCORD_WELCOME_CHANNEL_ID");
if let Some(channel_id) = welcome_channel_id {
info!("Welcome channel is {channel_id}");
} else {
warn!("DISCORD_WELCOME_CHANNEL_ID is empty; this will disable welcome channel features in your server");
}
Self::new(log_channel_id, welcome_channel_id)
}
fn get_channel_from_env(var: &str) -> Option<ChannelId> {
std::env::var(var)
.ok()
.and_then(|env_var| ChannelId::from_str(&env_var).ok())
}
}
impl Config {
pub fn new(channels: RefractionChannels) -> Self {
Self { channels }
}
pub fn from_env() -> Self {
let channels = RefractionChannels::new_from_env();
Self::new(channels)
}
}

24
src/config/mod.rs Normal file
View file

@ -0,0 +1,24 @@
mod bot;
mod discord;
#[derive(Debug, Clone, Default)]
pub struct Config {
pub bot: bot::Config,
pub discord: discord::Config,
}
impl Config {
pub fn new(bot_config: bot::Config, discord_config: discord::Config) -> Self {
Self {
bot: bot_config,
discord: discord_config,
}
}
pub fn new_from_env() -> Self {
let bot = bot::Config::from_env();
let discord = discord::Config::from_env();
Self::new(bot, discord)
}
}

View file

@ -1,27 +0,0 @@
export const ETA_REGEX = /\beta\b/i;
export const ETA_MESSAGES = [
'Sometime',
'Some day',
'Not far',
'The future',
'Never',
'Perhaps tomorrow?',
'There are no ETAs',
'No',
'Nah',
'Yes',
'Yas',
'Next month',
'Next year',
'Next week',
'In Prism Launcher 2.0.0',
'At the appropriate juncture, in due course, in the fullness of time',
];
export const COLORS = {
red: 0xef4444,
green: 0x22c55e,
blue: 0x60a5fa,
yellow: 0xfde047,
orange: 0xfb923c,
} as { [key: string]: number };

46
src/consts.rs Normal file
View file

@ -0,0 +1,46 @@
#![allow(clippy::unreadable_literal)]
use std::str::FromStr;
use poise::serenity_prelude::Colour;
const BLUE: u32 = 0x60A5FA;
const GREEN: u32 = 0x22C55E;
const ORANGE: u32 = 0xFB923C;
const RED: u32 = 0xEF4444;
const YELLOW: u32 = 0xFDE047;
#[derive(Clone, Copy, Debug, Default)]
pub enum Colors {
Blue,
#[default]
Green,
Orange,
Red,
Yellow,
}
impl From<Colors> for Colour {
fn from(value: Colors) -> Self {
Self::from(match &value {
Colors::Blue => BLUE,
Colors::Green => GREEN,
Colors::Orange => ORANGE,
Colors::Red => RED,
Colors::Yellow => YELLOW,
})
}
}
impl FromStr for Colors {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"blue" => Ok(Self::Blue),
"green" => Ok(Self::Green),
"orange" => Ok(Self::Orange),
"red" => Ok(Self::Red),
"yellow" => Ok(Self::Yellow),
_ => Err(()),
}
}
}

98
src/handlers/error.rs Normal file
View file

@ -0,0 +1,98 @@
use crate::{consts::Colors, Data, Error};
use std::fmt::Write;
use log::error;
use poise::serenity_prelude::{CreateEmbed, Timestamp};
use poise::{CreateReply, FrameworkError};
// getchoo: i like writeln! and don't like
macro_rules! writelne {
($dst:expr, $($arg:tt)*) => {
if let Err(why) = writeln!($dst, $($arg)*) {
error!("We somehow cannot write to what should be on the heap. What are you using this macro with? Anyways, here's the error:\n{why:#?}");
}
}
}
pub async fn handle(error: FrameworkError<'_, Data, Error>) {
match error {
FrameworkError::Setup {
error, framework, ..
} => {
error!("Error setting up client! Bailing out");
framework.shard_manager().shutdown_all().await;
panic!("{error}")
}
FrameworkError::Command { error, ctx, .. } => {
error!("Error in command {}:\n{error:?}", ctx.command().name);
let embed = CreateEmbed::new()
.title("Something went wrong!")
.description("oopsie")
.timestamp(Timestamp::now())
.color(Colors::Red);
let reply = CreateReply::default().embed(embed);
ctx.send(reply).await.ok();
}
FrameworkError::EventHandler {
error,
ctx: _,
event,
framework: _,
..
} => {
error!(
"Error while handling event {}:\n{error:?}",
event.snake_case_name()
);
}
FrameworkError::ArgumentParse {
error, input, ctx, ..
} => {
let mut response = String::new();
if let Some(input) = input {
writelne!(
&mut response,
"**Cannot parse `{input}` as argument: {error}**\n"
);
} else {
writelne!(&mut response, "**{error}**\n");
}
if let Some(help_text) = ctx.command().help_text.as_ref() {
writelne!(&mut response, "{help_text}\n");
}
if ctx.command().invoke_on_edit {
writelne!(
&mut response,
"**Tip:** Edit your message to update the response."
);
}
writelne!(
&mut response,
"For more information, refer to /help {}.",
ctx.command().name
);
if let Err(why) = ctx.say(response).await {
error!("Unhandled error displaying ArgumentParse error\n{why:#?}");
}
}
error => {
if let Err(e) = poise::builtins::on_error(error).await {
error!("Unhandled error occurred:\n{e:#?}");
}
}
}
}

View file

@ -0,0 +1,266 @@
use crate::{api, Data};
use std::sync::OnceLock;
use eyre::Result;
use log::trace;
use regex::Regex;
pub type Issue = Option<(String, String)>;
pub async fn find(log: &str, data: &Data) -> Result<Vec<(String, String)>> {
trace!("Checking log for issues");
let issues = [
fabric_internal,
flatpak_nvidia,
forge_java,
intel_hd,
java_option,
lwjgl_2_java_9,
macos_ns,
oom,
optinotfine,
pre_1_12_native_transport_java_9,
wrong_java,
];
let mut res: Vec<(String, String)> = issues.iter().filter_map(|issue| issue(log)).collect();
if let Some(issues) = outdated_launcher(log, data).await? {
res.push(issues);
}
Ok(res)
}
fn fabric_internal(log: &str) -> Issue {
const CLASS_NOT_FOUND: &str = "Caused by: java.lang.ClassNotFoundException: ";
let issue = (
"Fabric Internal Access".to_string(),
"The mod you are using is using fabric internals that are not meant \
to be used by anything but the loader itself.
Those mods break both on Quilt and with fabric updates.
If you're using fabric, downgrade your fabric loader could work, \
on Quilt you can try updating to the latest beta version, \
but there's nothing much to do unless the mod author stops using them."
.to_string(),
);
let errors = [
&format!("{CLASS_NOT_FOUND}net.fabricmc.fabric.impl"),
&format!("{CLASS_NOT_FOUND}net.fabricmc.fabric.mixin"),
&format!("{CLASS_NOT_FOUND}net.fabricmc.fabric.loader.impl"),
&format!("{CLASS_NOT_FOUND}net.fabricmc.fabric.loader.mixin"),
"org.quiltmc.loader.impl.FormattedException: java.lang.NoSuchMethodError:",
];
let found = errors.iter().any(|e| log.contains(e));
found.then_some(issue)
}
fn flatpak_nvidia(log: &str) -> Issue {
let issue = (
"Outdated Nvidia Flatpak Driver".to_string(),
"The Nvidia driver for flatpak is outdated.
Please run `flatpak update` to fix this issue. \
If that does not solve it, \
please wait until the driver is added to Flathub and run it again."
.to_string(),
);
let found = log.contains("org.lwjgl.LWJGLException: Could not choose GLX13 config")
|| log.contains("GLFW error 65545: GLX: Failed to find a suitable GLXFBConfig");
found.then_some(issue)
}
fn forge_java(log: &str) -> Issue {
let issue = (
"Forge Java Bug".to_string(),
"Old versions of Forge crash with Java 8u321+.
To fix this, update forge to the latest version via the Versions tab
(right click on Forge, click Change Version, and choose the latest one)
Alternatively, you can download 8u312 or lower. \
See [archive](https://github.com/adoptium/temurin8-binaries/releases/tag/jdk8u312-b07)"
.to_string(),
);
let found = log.contains("java.lang.NoSuchMethodError: sun.security.util.ManifestEntryVerifier.<init>(Ljava/util/jar/Manifest;)V");
found.then_some(issue)
}
fn intel_hd(log: &str) -> Issue {
let issue =
(
"Intel HD Windows 10".to_string(),
"Your drivers don't support windows 10 officially
See https://prismlauncher.org/wiki/getting-started/installing-java/#a-note-about-intel-hd-20003000-on-windows-10 for more info".to_string()
);
let found = log.contains("org.lwjgl.LWJGLException: Pixel format not accelerated");
found.then_some(issue)
}
fn java_option(log: &str) -> Issue {
static VM_OPTION_REGEX: OnceLock<Regex> = OnceLock::new();
static UNRECOGNIZED_OPTION_REGEX: OnceLock<Regex> = OnceLock::new();
let vm_option =
VM_OPTION_REGEX.get_or_init(|| Regex::new(r"Unrecognized VM option '(.+)'[\r\n]").unwrap());
let unrecognized_option = UNRECOGNIZED_OPTION_REGEX
.get_or_init(|| Regex::new(r"Unrecognized option: (.+)[\r\n]").unwrap());
if let Some(captures) = vm_option.captures(log) {
let title = if &captures[1] == "UseShenandoahGC" {
"Wrong Java Arguments"
} else {
"Java 8 and below don't support ShenandoahGC"
};
return Some((
title.to_string(),
format!("Remove `-XX:{}` from your Java arguments", &captures[1]),
));
}
if let Some(captures) = unrecognized_option.captures(log) {
return Some((
"Wrong Java Arguments".to_string(),
format!("Remove `{}` from your Java arguments", &captures[1]),
));
}
None
}
fn lwjgl_2_java_9(log: &str) -> Issue {
let issue = (
"Linux: crash with pre-1.13 and Java 9+".to_string(),
"Using pre-1.13 (which uses LWJGL 2) with Java 9 or later usually causes a crash. \
Switching to Java 8 or below will fix your issue.
Alternatively, you can use [Temurin](https://adoptium.net/temurin/releases). \
However, multiplayer will not work in versions from 1.8 to 1.11.
For more information, type `/tag java`."
.to_string(),
);
let found = log.contains("check_match: Assertion `version->filename == NULL || ! _dl_name_match_p (version->filename, map)' failed!");
found.then_some(issue)
}
fn macos_ns(log: &str) -> Issue {
let issue = (
"MacOS NSInternalInconsistencyException".to_string(),
"You need to downgrade your Java 8 version. See https://prismlauncher.org/wiki/getting-started/installing-java/#older-minecraft-on-macos".to_string()
);
let found =
log.contains("Terminating app due to uncaught exception 'NSInternalInconsistencyException");
found.then_some(issue)
}
fn oom(log: &str) -> Issue {
let issue = (
"Out of Memory".to_string(),
"Allocating more RAM to your instance could help prevent this crash.".to_string(),
);
let found = log.contains("java.lang.OutOfMemoryError");
found.then_some(issue)
}
fn optinotfine(log: &str) -> Issue {
let issue = (
"Potential OptiFine Incompatibilities".to_string(),
"OptiFine is known to cause problems when paired with other mods. \
Try to disable OptiFine and see if the issue persists.
Check `/tag optifine` for more info & some typically more compatible alternatives you can use."
.to_string(),
);
let found = log.contains("[✔] OptiFine_") || log.contains("[✔] optifabric-");
found.then_some(issue)
}
async fn outdated_launcher(log: &str, data: &Data) -> Result<Issue> {
static OUTDATED_LAUNCHER_REGEX: OnceLock<Regex> = OnceLock::new();
let outdated_launcher = OUTDATED_LAUNCHER_REGEX
.get_or_init(|| Regex::new("Prism Launcher version: [0-9].[0-9].[0-9]").unwrap());
let Some(captures) = outdated_launcher.captures(log) else {
return Ok(None);
};
let octocrab = &data.octocrab;
let version_from_log = captures[0].replace("Prism Launcher version: ", "");
let latest_version = if let Some(storage) = &data.storage {
if let Ok(version) = storage.launcher_version().await {
version
} else {
let version = api::github::get_latest_prism_version(octocrab).await?;
storage.cache_launcher_version(&version).await?;
version
}
} else {
trace!("Not caching launcher version, as we're running without a storage backend");
api::github::get_latest_prism_version(octocrab).await?
};
if version_from_log < latest_version {
let issue = (
"Outdated Prism Launcher".to_string(),
format!("Your installed version is {version_from_log}, while the newest version is {latest_version}.\nPlease update, for more info see https://prismlauncher.org/download/")
);
Ok(Some(issue))
} else {
Ok(None)
}
}
fn pre_1_12_native_transport_java_9(log: &str) -> Issue {
let issue = (
"Linux: broken multiplayer with 1.8-1.11 and Java 9+".to_string(),
"These versions of Minecraft use an outdated version of Netty which does not properly support Java 9.
Switching to Java 8 or below will fix this issue. For more information, type `/tag java`.
If you must use a newer version, do the following:
- Open `options.txt` (in the main window Edit -> Open .minecraft) and change.
- Find `useNativeTransport:true` and change it to `useNativeTransport:false`.
Note: whilst Netty was introduced in 1.7, this option did not exist \
which is why the issue was not present."
.to_string(),
);
let found = log.contains(
"java.lang.RuntimeException: Unable to access address of buffer\n\tat io.netty.channel.epoll"
);
found.then_some(issue)
}
fn wrong_java(log: &str) -> Issue {
static SWITCH_VERSION_REGEX: OnceLock<Regex> = OnceLock::new();
let switch_version = SWITCH_VERSION_REGEX.get_or_init(|| Regex::new(
r"(?m)Please switch to one of the following Java versions for this instance:[\r\n]+(Java version [\d.]+)",
).unwrap());
if let Some(captures) = switch_version.captures(log) {
let versions = captures[1].split('\n').collect::<Vec<&str>>().join(", ");
return Some((
"Wrong Java Version".to_string(),
format!("Please switch to one of the following: `{versions}`\nFor more information, type `/tag java`"),
));
}
let issue = (
"Java compatibility check skipped".to_string(),
"The Java major version may not work with your Minecraft instance. Please switch to a compatible version".to_string()
);
log.contains("Java major version is incompatible. Things might break.")
.then_some(issue)
}

View file

@ -0,0 +1,73 @@
use crate::{consts::Colors, Data};
use eyre::Result;
use log::{debug, trace};
use poise::serenity_prelude::{
Context, CreateAllowedMentions, CreateEmbed, CreateMessage, Message,
};
mod issues;
mod providers;
use providers::find_log;
pub async fn handle(ctx: &Context, message: &Message, data: &Data) -> Result<()> {
trace!(
"Checking message {} from {} for logs",
message.id,
message.author.id
);
let channel = message.channel_id;
let log = find_log(&data.http_client, message).await;
if log.is_err() {
let embed = CreateEmbed::new()
.title("Analysis failed!")
.description("Couldn't download log");
let allowed_mentions = CreateAllowedMentions::new().replied_user(true);
let our_message = CreateMessage::new()
.reference_message(message)
.allowed_mentions(allowed_mentions)
.embed(embed);
channel.send_message(ctx, our_message).await?;
return Ok(());
}
let Some(log) = log? else {
debug!("No log found in message! Skipping analysis");
return Ok(());
};
let issues = issues::find(&log, data).await?;
let embed = {
let mut e = CreateEmbed::new().title("Log analysis");
if issues.is_empty() {
e = e
.color(Colors::Green)
.description("No issues found automatically");
} else {
e = e.color(Colors::Red);
for (title, description) in issues {
e = e.field(title, description, false);
}
}
e
};
let allowed_mentions = CreateAllowedMentions::new().replied_user(true);
let message = CreateMessage::new()
.reference_message(message)
.allowed_mentions(allowed_mentions)
.embed(embed);
channel.send_message(ctx, message).await?;
Ok(())
}

View file

@ -0,0 +1,29 @@
use crate::api::{HttpClient, HttpClientExt};
use std::sync::OnceLock;
use eyre::Result;
use log::trace;
use poise::serenity_prelude::Message;
use regex::Regex;
pub struct _0x0;
impl super::LogProvider for _0x0 {
async fn find_match(&self, message: &Message) -> Option<String> {
static REGEX: OnceLock<Regex> = OnceLock::new();
let regex = REGEX.get_or_init(|| Regex::new(r"https://0x0\.st/\w*.\w*").unwrap());
trace!("Checking if message {} is a 0x0 paste", message.id);
regex
.find_iter(&message.content)
.map(|m| m.as_str().to_string())
.nth(0)
}
async fn fetch(&self, http: &HttpClient, content: &str) -> Result<String> {
let log = http.get_request(content).await?.text().await?;
Ok(log)
}
}

View file

@ -0,0 +1,30 @@
use crate::api::{HttpClient, HttpClientExt};
use eyre::Result;
use log::trace;
use poise::serenity_prelude::Message;
pub struct Attachment;
impl super::LogProvider for Attachment {
async fn find_match(&self, message: &Message) -> Option<String> {
trace!("Checking if message {} has text attachments", message.id);
message
.attachments
.iter()
.filter_map(|a| {
a.content_type
.as_ref()
.and_then(|ct| ct.starts_with("text/").then_some(a.url.clone()))
})
.nth(0)
}
async fn fetch(&self, http: &HttpClient, content: &str) -> Result<String> {
let attachment = http.get_request(content).await?.bytes().await?.to_vec();
let log = String::from_utf8(attachment)?;
Ok(log)
}
}

View file

@ -0,0 +1,31 @@
use crate::api::{HttpClient, HttpClientExt};
use std::sync::OnceLock;
use eyre::Result;
use log::trace;
use poise::serenity_prelude::Message;
use regex::Regex;
const HASTE: &str = "https://hst.sh";
const RAW: &str = "/raw";
pub struct Haste;
impl super::LogProvider for Haste {
async fn find_match(&self, message: &Message) -> Option<String> {
static REGEX: OnceLock<Regex> = OnceLock::new();
let regex =
REGEX.get_or_init(|| Regex::new(r"https://hst\.sh(?:/raw)?/(\w+(?:\.\w*)?)").unwrap());
trace!("Checking if message {} is a hst.sh paste", message.id);
super::get_first_capture(regex, &message.content)
}
async fn fetch(&self, http: &HttpClient, content: &str) -> Result<String> {
let url = format!("{HASTE}{RAW}/{content}");
let log = http.get_request(&url).await?.text().await?;
Ok(log)
}
}

View file

@ -0,0 +1,30 @@
use crate::api::{HttpClient, HttpClientExt};
use std::sync::OnceLock;
use eyre::Result;
use log::trace;
use poise::serenity_prelude::Message;
use regex::Regex;
const MCLOGS: &str = "https://api.mclo.gs/1";
const RAW: &str = "/raw";
pub struct MCLogs;
impl super::LogProvider for MCLogs {
async fn find_match(&self, message: &Message) -> Option<String> {
static REGEX: OnceLock<Regex> = OnceLock::new();
let regex = REGEX.get_or_init(|| Regex::new(r"https://mclo\.gs/(\w+)").unwrap());
trace!("Checking if message {} is an mclo.gs paste", message.id);
super::get_first_capture(regex, &message.content)
}
async fn fetch(&self, http: &HttpClient, content: &str) -> Result<String> {
let url = format!("{MCLOGS}{RAW}/{content}");
let log = http.get_request(&url).await?.text().await?;
Ok(log)
}
}

View file

@ -0,0 +1,70 @@
use crate::api::HttpClient;
use std::slice::Iter;
use enum_dispatch::enum_dispatch;
use eyre::Result;
use poise::serenity_prelude::Message;
use regex::Regex;
use self::{
_0x0::_0x0 as _0x0st, attachment::Attachment, haste::Haste, mclogs::MCLogs, paste_gg::PasteGG,
pastebin::PasteBin,
};
#[path = "0x0.rs"]
mod _0x0;
mod attachment;
mod haste;
mod mclogs;
mod paste_gg;
mod pastebin;
#[enum_dispatch]
pub trait LogProvider {
async fn find_match(&self, message: &Message) -> Option<String>;
async fn fetch(&self, http: &HttpClient, content: &str) -> Result<String>;
}
fn get_first_capture(regex: &Regex, string: &str) -> Option<String> {
regex
.captures_iter(string)
.find_map(|c| c.get(1).map(|c| c.as_str().to_string()))
}
#[enum_dispatch(LogProvider)]
enum Provider {
_0x0st,
Attachment,
Haste,
MCLogs,
PasteGG,
PasteBin,
}
impl Provider {
pub fn iterator() -> Iter<'static, Provider> {
static PROVIDERS: [Provider; 6] = [
Provider::_0x0st(_0x0st),
Provider::Attachment(Attachment),
Provider::Haste(Haste),
Provider::MCLogs(MCLogs),
Provider::PasteBin(PasteBin),
Provider::PasteGG(PasteGG),
];
PROVIDERS.iter()
}
}
pub async fn find_log(http: &HttpClient, message: &Message) -> Result<Option<String>> {
let providers = Provider::iterator();
for provider in providers {
if let Some(found) = provider.find_match(message).await {
let log = provider.fetch(http, &found).await?;
return Ok(Some(log));
}
}
Ok(None)
}

View file

@ -0,0 +1,37 @@
use crate::api::{paste_gg, HttpClient};
use std::sync::OnceLock;
use eyre::{OptionExt, Result};
use log::trace;
use poise::serenity_prelude::Message;
use regex::Regex;
pub struct PasteGG;
impl super::LogProvider for PasteGG {
async fn find_match(&self, message: &Message) -> Option<String> {
static REGEX: OnceLock<Regex> = OnceLock::new();
let regex = REGEX.get_or_init(|| Regex::new(r"https://paste.gg/p/\w+/(\w+)").unwrap());
trace!("Checking if message {} is a paste.gg paste", message.id);
super::get_first_capture(regex, &message.content)
}
async fn fetch(&self, http: &HttpClient, content: &str) -> Result<String> {
let files = paste_gg::files_from(http, content).await?;
let result = files
.result
.ok_or_eyre("Got an empty result from paste.gg!")?;
let file_id = result
.iter()
.map(|f| f.id.as_str())
.nth(0)
.ok_or_eyre("Couldn't get file id from empty paste.gg response!")?;
let log = paste_gg::get_raw_file(http, content, file_id).await?;
Ok(log)
}
}

View file

@ -0,0 +1,31 @@
use crate::api::{HttpClient, HttpClientExt};
use std::sync::OnceLock;
use eyre::Result;
use log::trace;
use poise::serenity_prelude::Message;
use regex::Regex;
const PASTEBIN: &str = "https://pastebin.com";
const RAW: &str = "/raw";
pub struct PasteBin;
impl super::LogProvider for PasteBin {
async fn find_match(&self, message: &Message) -> Option<String> {
static REGEX: OnceLock<Regex> = OnceLock::new();
let regex =
REGEX.get_or_init(|| Regex::new(r"https://pastebin\.com(?:/raw)?/(\w+)").unwrap());
trace!("Checking if message {} is a pastebin paste", message.id);
super::get_first_capture(regex, &message.content)
}
async fn fetch(&self, http: &HttpClient, content: &str) -> Result<String> {
let url = format!("{PASTEBIN}{RAW}/{content}");
let log = http.get_request(&url).await?.text().await?;
Ok(log)
}
}

View file

@ -0,0 +1,27 @@
use eyre::{Context as _, Result};
use log::trace;
use poise::serenity_prelude::{Context, InteractionType, Reaction};
pub async fn handle(ctx: &Context, reaction: &Reaction) -> Result<()> {
let user = reaction
.user(ctx)
.await
.wrap_err("Couldn't fetch user from reaction!")?;
let message = reaction
.message(ctx)
.await
.wrap_err("Couldn't fetch message from reaction!")?;
if let Some(interaction) = &message.interaction {
if interaction.kind == InteractionType::Command
&& interaction.user == user
&& reaction.emoji.unicode_eq("")
{
trace!("Deleting our own message at the request of {}", user.tag());
message.delete(ctx).await?;
}
}
Ok(())
}

51
src/handlers/event/eta.rs Normal file
View file

@ -0,0 +1,51 @@
use std::{sync::OnceLock, time::SystemTime};
use eyre::Result;
use log::trace;
use poise::serenity_prelude::{Context, Message};
use regex::Regex;
fn regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| Regex::new(r"\beta\b").unwrap())
}
const MESSAGES: [&str; 16] = [
"Sometime",
"Some day",
"Not far",
"The future",
"Never",
"Perhaps tomorrow?",
"There are no ETAs",
"No",
"Nah",
"Yes",
"Yas",
"Next month",
"Next year",
"Next week",
"In Prism Launcher 2.0.0",
"At the appropriate juncture, in due course, in the fullness of time",
];
pub async fn handle(ctx: &Context, message: &Message) -> Result<()> {
if !regex().is_match(&message.content) {
trace!(
"The message '{}' (probably) doesn't say ETA",
message.content
);
return Ok(());
}
// no, this isn't actually random. we don't need it to be, though -getchoo
let current_time = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)?
.as_millis();
let random_pos = (current_time % MESSAGES.len() as u128) as usize;
let response = format!("{} <:pofat:1031701005559144458>", MESSAGES[random_pos]);
message.reply(ctx, response).await?;
Ok(())
}

View file

@ -0,0 +1,20 @@
use crate::{api::HttpClient, utils};
use eyre::Result;
use poise::serenity_prelude::{Context, CreateAllowedMentions, CreateMessage, Message};
pub async fn handle(ctx: &Context, http: &HttpClient, message: &Message) -> Result<()> {
let embeds = utils::messages::from_message(ctx, http, message).await?;
if !embeds.is_empty() {
let allowed_mentions = CreateAllowedMentions::new().replied_user(false);
let reply = CreateMessage::new()
.reference_message(message)
.embeds(embeds)
.allowed_mentions(allowed_mentions);
message.channel_id.send_message(ctx, reply).await?;
}
Ok(())
}

View file

@ -0,0 +1,45 @@
use std::str::FromStr;
use eyre::Result;
use log::debug;
use poise::serenity_prelude::{
ComponentInteraction, Context, CreateEmbed, CreateInteractionResponseFollowup, RoleId,
};
pub async fn handle(ctx: &Context, component_interaction: &ComponentInteraction) -> Result<()> {
let Some(guild_id) = component_interaction.guild_id else {
debug!("Ignoring component interaction not from guild!");
return Ok(());
};
let Ok(role_id) = RoleId::from_str(&component_interaction.data.custom_id) else {
debug!("Ignoring component interaction that doesn't contain a role as it's ID");
return Ok(());
};
component_interaction.defer_ephemeral(ctx).await?;
let mut followup = CreateInteractionResponseFollowup::new().ephemeral(true);
if let Some(role) = guild_id.roles(ctx).await?.get(&role_id) {
let guild_member = guild_id.member(ctx, component_interaction.user.id).await?;
let mut embed = CreateEmbed::new();
if guild_member.roles.contains(&role_id) {
guild_member.remove_role(ctx, role_id).await?;
embed = embed.description(format!("❌ Removed `{}`", role.name));
} else {
guild_member.add_role(ctx, role_id).await?;
embed = embed.description(format!("✅ Added `{}`", role.name));
}
followup = followup.add_embed(embed);
} else {
followup = followup.content(format!(
"Role ID {role_id} doesn't seem to exist. Please let the moderators know!"
));
}
component_interaction.create_followup(ctx, followup).await?;
Ok(())
}

88
src/handlers/event/mod.rs Normal file
View file

@ -0,0 +1,88 @@
use crate::{api, Data, Error};
use log::{debug, info, trace};
use poise::serenity_prelude::{ActivityData, Context, FullEvent, OnlineStatus};
use poise::FrameworkContext;
mod analyze_logs;
mod delete_on_reaction;
mod eta;
mod expand_link;
mod give_role;
mod pluralkit;
mod support_onboard;
pub async fn handle(
ctx: &Context,
event: &FullEvent,
_: FrameworkContext<'_, Data, Error>,
data: &Data,
) -> Result<(), Error> {
match event {
FullEvent::Ready { data_about_bot } => {
info!("Logged in as {}!", data_about_bot.user.name);
let latest_minecraft_version =
api::prism_meta::latest_minecraft_version(&data.http_client).await?;
let activity = ActivityData::playing(format!("Minecraft {latest_minecraft_version}"));
info!("Setting presence to activity {activity:#?}");
ctx.set_presence(Some(activity), OnlineStatus::Online);
}
FullEvent::InteractionCreate { interaction } => {
if let Some(component_interaction) = interaction.as_message_component() {
give_role::handle(ctx, component_interaction).await?;
}
}
FullEvent::Message { new_message } => {
trace!("Received message {}", new_message.content);
// ignore new messages from bots
// note: the webhook_id check allows us to still respond to PK users
if (new_message.author.bot && new_message.webhook_id.is_none())
|| new_message.is_own(ctx)
{
trace!("Ignoring message {} from bot", new_message.id);
return Ok(());
}
if let Some(storage) = &data.storage {
let http = &data.http_client;
// detect PK users first to make sure we don't respond to unproxied messages
pluralkit::handle(ctx, http, storage, new_message).await?;
if storage.is_user_plural(new_message.author.id).await?
&& pluralkit::is_message_proxied(http, new_message).await?
{
debug!("Not replying to unproxied PluralKit message");
return Ok(());
}
}
eta::handle(ctx, new_message).await?;
expand_link::handle(ctx, &data.http_client, new_message).await?;
analyze_logs::handle(ctx, new_message, data).await?;
}
FullEvent::ReactionAdd { add_reaction } => {
trace!(
"Received reaction {} on message {} from {}",
add_reaction.emoji,
add_reaction.message_id.to_string(),
add_reaction.user_id.unwrap_or_default().to_string()
);
delete_on_reaction::handle(ctx, add_reaction).await?;
}
FullEvent::ThreadCreate { thread } => {
trace!("Received thread {}", thread.id);
support_onboard::handle(ctx, thread).await?;
}
_ => {}
}
Ok(())
}

View file

@ -0,0 +1,53 @@
use crate::{
api::{self, HttpClient},
storage::Storage,
};
use std::time::Duration;
use eyre::Result;
use log::{debug, trace};
use poise::serenity_prelude::{Context, Message};
use tokio::time::sleep;
const PK_DELAY: Duration = Duration::from_secs(1);
pub async fn is_message_proxied(http: &HttpClient, message: &Message) -> Result<bool> {
trace!(
"Waiting on PluralKit API for {} seconds",
PK_DELAY.as_secs()
);
sleep(PK_DELAY).await;
let proxied = api::pluralkit::sender_from(http, message.id).await.is_ok();
Ok(proxied)
}
pub async fn handle(
_: &Context,
http: &HttpClient,
storage: &Storage,
msg: &Message,
) -> Result<()> {
if msg.webhook_id.is_none() {
return Ok(());
}
debug!(
"Message {} has a webhook ID. Checking if it was sent through PluralKit",
msg.id
);
trace!(
"Waiting on PluralKit API for {} seconds",
PK_DELAY.as_secs()
);
sleep(PK_DELAY).await;
if let Ok(sender) = api::pluralkit::sender_from(http, msg.id).await {
storage.store_user_plurality(sender).await?;
}
Ok(())
}

View file

@ -0,0 +1,52 @@
use eyre::{eyre, OptionExt, Result};
use log::{debug, trace};
use poise::serenity_prelude::{
ChannelType, Context, CreateAllowedMentions, CreateMessage, GuildChannel,
};
pub async fn handle(ctx: &Context, thread: &GuildChannel) -> Result<()> {
if thread.kind != ChannelType::PublicThread {
trace!("Not doing support onboard in non-public thread channel");
return Ok(());
}
if thread.last_message_id.is_some() {
debug!("Ignoring duplicate thread creation event");
return Ok(());
}
if thread
.parent_id
.ok_or_else(|| eyre!("Couldn't get parent ID from thread {}!", thread.name))?
.name(ctx)
.await
.unwrap_or_default()
!= "support"
{
debug!("Not posting onboarding message to threads outside of support");
return Ok(());
}
let owner = thread
.owner_id
.ok_or_eyre("Couldn't get owner of thread!")?;
let msg = format!(
"<@{}> We've received your support ticket! {} {}",
owner,
"Please upload your logs and post the link here if possible (run `tag log` to find out how).",
"Please don't ping people for support questions, unless you have their permission."
);
let allowed_mentions = CreateAllowedMentions::new()
.replied_user(true)
.users(Vec::from([owner]));
let message = CreateMessage::new()
.content(msg)
.allowed_mentions(allowed_mentions);
thread.send_message(ctx, message).await?;
Ok(())
}

5
src/handlers/mod.rs Normal file
View file

@ -0,0 +1,5 @@
mod error;
mod event;
pub use error::handle as handle_error;
pub use event::handle as handle_event;

View file

@ -1,223 +0,0 @@
import {
Client,
GatewayIntentBits,
Partials,
OAuth2Scopes,
InteractionType,
PermissionFlagsBits,
ChannelType,
Events,
} from 'discord.js';
import { reuploadCommands } from './_reupload';
import {
connect as connectStorage,
isUserPlural,
storeUserPlurality,
} from './storage';
import * as BuildConfig from './constants';
import { parseLog } from './logs';
import { getLatestMinecraftVersion } from './utils/remoteVersions';
import { expandDiscordLink } from './utils/resolveMessage';
import { membersCommand } from './commands/members';
import { starsCommand } from './commands/stars';
import { modrinthCommand } from './commands/modrinth';
import { tagsCommand } from './commands/tags';
import { jokeCommand } from './commands/joke';
import { roryCommand } from './commands/rory';
import { sayCommand } from './commands/say';
import random from 'just-random';
import { green, bold, yellow, cyan } from 'kleur/colors';
import 'dotenv/config';
import {
fetchPluralKitMessage,
isMessageProxied,
pkDelay,
} from './utils/pluralKit';
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.DirectMessages,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildPresences,
GatewayIntentBits.GuildMessageReactions,
GatewayIntentBits.GuildModeration,
],
partials: [Partials.Channel],
});
client.once('ready', async () => {
console.log(green('Discord bot ready!'));
console.log(
cyan(
client.generateInvite({
scopes: [OAuth2Scopes.Bot],
permissions: [
PermissionFlagsBits.AddReactions,
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.BanMembers,
PermissionFlagsBits.KickMembers,
PermissionFlagsBits.CreatePublicThreads,
PermissionFlagsBits.CreatePrivateThreads,
PermissionFlagsBits.EmbedLinks,
PermissionFlagsBits.ManageChannels,
PermissionFlagsBits.ManageRoles,
PermissionFlagsBits.ModerateMembers,
PermissionFlagsBits.MentionEveryone,
PermissionFlagsBits.MuteMembers,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.SendMessagesInThreads,
PermissionFlagsBits.ReadMessageHistory,
],
})
)
);
if (process.env.NODE_ENV !== 'development')
console.warn(yellow(bold('Running in production mode!')));
const mcVersion = await getLatestMinecraftVersion();
client.user?.presence.set({
activities: [{ name: `Minecraft ${mcVersion}` }],
status: 'online',
});
client.on(Events.MessageCreate, async (e) => {
try {
if (e.channel.partial) await e.channel.fetch();
if (e.author.partial) await e.author.fetch();
if (!e.content) return;
if (!e.channel.isTextBased()) return;
if (e.author === client.user) return;
if (e.webhookId !== null) {
// defer PK detection
setTimeout(async () => {
const pkMessage = await fetchPluralKitMessage(e);
if (pkMessage !== null) storeUserPlurality(pkMessage.sender);
}, pkDelay);
}
if ((await isUserPlural(e.author.id)) && (await isMessageProxied(e)))
return;
if (e.cleanContent.match(BuildConfig.ETA_REGEX)) {
await e.reply(
`${random(BuildConfig.ETA_MESSAGES)} <:pofat:1031701005559144458>`
);
}
const log = await parseLog(e.content);
if (log != null) {
e.reply({ embeds: [log] });
return;
}
await expandDiscordLink(e);
} catch (error) {
console.error('Unhandled exception on MessageCreate', error);
}
});
});
client.on(Events.InteractionCreate, async (interaction) => {
try {
if (!interaction.isChatInputCommand()) return;
const { commandName } = interaction;
if (commandName === 'ping') {
await interaction.reply({
content: `Pong! \`${client.ws.ping}ms\``,
ephemeral: true,
});
} else if (commandName === 'members') {
await membersCommand(interaction);
} else if (commandName === 'stars') {
await starsCommand(interaction);
} else if (commandName === 'modrinth') {
await modrinthCommand(interaction);
} else if (commandName === 'say') {
await sayCommand(interaction);
} else if (commandName === 'tag') {
await tagsCommand(interaction);
} else if (commandName === 'joke') {
await jokeCommand(interaction);
} else if (commandName === 'rory') {
await roryCommand(interaction);
}
} catch (error) {
console.error('Unhandled exception on InteractionCreate', error);
}
});
client.on(Events.MessageReactionAdd, async (reaction, user) => {
try {
if (reaction.partial) {
try {
await reaction.fetch();
} catch (error) {
console.error('Something went wrong when fetching the message:', error);
return;
}
}
if (
reaction.message.interaction &&
reaction.message.interaction?.type ===
InteractionType.ApplicationCommand &&
reaction.message.interaction?.user === user &&
reaction.emoji.name === '❌'
) {
await reaction.message.delete();
}
} catch (error) {
console.error('Unhandled exception on MessageReactionAdd', error);
}
});
client.on(Events.ThreadCreate, async (channel) => {
try {
if (
channel.type === ChannelType.PublicThread &&
channel.parent &&
channel.parent.name === 'support' &&
channel.guild
) {
const pingRole = channel.guild.roles.cache.find(
(r) => r.name === 'Moderators'
);
if (!pingRole) return;
await channel.send({
content: `
<@${channel.ownerId}> We've received your support ticket! Please upload your logs and post the link here if possible (run \`/tag log\` to find out how). Please don't ping people for support questions, unless you have their permission.
`.trim(),
allowedMentions: {
repliedUser: true,
roles: [],
users: channel.ownerId ? [channel.ownerId] : [],
},
});
}
} catch (error) {
console.error('Error handling ThreadCreate', error);
}
});
reuploadCommands()
.then(() => {
connectStorage();
client.login(process.env.DISCORD_TOKEN);
})
.catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -1,19 +0,0 @@
const reg = /https:\/\/0x0.st\/\w*.\w*/;
export async function read0x0(s: string): Promise<null | string> {
const r = s.match(reg);
if (r == null || !r[0]) return null;
const link = r[0];
let log: string;
try {
const f = await fetch(link);
if (f.status != 200) {
throw 'nope';
}
log = await f.text();
} catch (err) {
console.log('Log analyze fail', err);
return null;
}
return log;
}

View file

@ -1,21 +0,0 @@
const reg = /https:\/\/hst.sh\/[\w]*/;
export async function readHastebin(s: string): Promise<string | null> {
const r = s.match(reg);
if (r == null || !r[0]) return null;
const link = r[0];
const id = link.replace('https://hst.sh/', '');
if (!id) return null;
let log: string;
try {
const f = await fetch(`https://hst.sh/raw/${id}`);
if (f.status != 200) {
throw 'nope';
}
log = await f.text();
} catch (err) {
console.log('Log analyze fail', err);
return null;
}
return log;
}

View file

@ -1,22 +0,0 @@
const reg = /https:\/\/mclo.gs\/\w*/;
export async function readMcLogs(s: string): Promise<null | string> {
const r = s.match(reg);
if (r == null || !r[0]) return null;
const link = r[0];
const id = link.replace('https://mclo.gs/', '');
if (!id) return null;
const apiUrl = 'https://api.mclo.gs/1/raw/' + id;
let log: string;
try {
const f = await fetch(apiUrl);
if (f.status != 200) {
throw 'nope';
}
log = await f.text();
} catch (err) {
console.log('Log analyze fail', err);
return null;
}
return log;
}

View file

@ -1,30 +0,0 @@
const reg = /https:\/\/paste.gg\/p\/[\w]*\/[\w]*/;
export async function readPasteGG(s: string): Promise<null | string> {
const r = s.match(reg);
if (r == null || !r[0]) return null;
const link = r[0];
const id = link.replace(/https:\/\/paste.gg\/p\/[\w]*\//, '');
if (!id) return null;
let log: string;
try {
const pasteJson = await (
await fetch('https://api.paste.gg/v1/pastes/' + id)
).json();
if (pasteJson.status != 'success') throw 'up';
const pasteData = await (
await fetch(
'https://api.paste.gg/v1/pastes/' +
id +
'/files/' +
pasteJson.result.files[0].id
)
).json();
if (pasteData.status != 'success') throw 'up';
return pasteData.result.content.value;
} catch (err) {
console.log('Log analyze fail', err);
return null;
}
return log;
}

View file

@ -1,251 +0,0 @@
import { getLatestPrismLauncherVersion } from './utils/remoteVersions';
import { EmbedBuilder } from 'discord.js';
// log providers
import { readMcLogs } from './logproviders/mclogs';
import { read0x0 } from './logproviders/0x0';
import { readPasteGG } from './logproviders/pastegg';
import { readHastebin } from './logproviders/haste';
import { COLORS } from './constants';
type Analyzer = (text: string) => Promise<Array<string> | null>;
type LogProvider = (text: string) => Promise<null | string>;
const javaAnalyzer: Analyzer = async (text) => {
if (text.includes('This instance is not compatible with Java version')) {
const xp =
/Please switch to one of the following Java versions for this instance:[\r\n]+(Java version (?:\d|\.)+)/g;
let ver: string;
const m = text.match(xp);
if (!m || !m[0]) {
ver = '';
} else {
ver = m[0].split('\n')[1];
}
return [
'Wrong Java Version',
`Please switch to the following: \`${ver}\`\nFor more information, type \`/tag java\``,
];
} else if (
text.includes('Java major version is incompatible. Things might break.')
) {
return [
'Java compatibility check skipped',
'The Java major version may not work with your Minecraft instance. Please switch to a compatible version',
];
}
return null;
};
const versionAnalyzer: Analyzer = async (text) => {
const vers = text.match(/Prism Launcher version: [0-9].[0-9].[0-9]/g);
if (vers && vers[0]) {
const latest = await getLatestPrismLauncherVersion();
const current = vers[0].replace('Prism Launcher version: ', '');
if (current < latest) {
return [
'Outdated Prism Launcher',
`Your installed version is ${current}, while the newest version is ${latest}.\nPlease update, for more info see https://prismlauncher.org/download/`,
];
}
}
return null;
};
const flatpakNvidiaAnalyzer: Analyzer = async (text) => {
if (
text.includes('org.lwjgl.LWJGLException: Could not choose GLX13 config') ||
text.includes(
'GLFW error 65545: GLX: Failed to find a suitable GLXFBConfig'
)
) {
return [
'Outdated Nvidia Flatpak Driver',
`The Nvidia driver for flatpak is outdated.\nPlease run \`flatpak update\` to fix this issue. If that does not solve it, please wait until the driver is added to Flathub and run it again.`,
];
}
return null;
};
const forgeJavaAnalyzer: Analyzer = async (text) => {
if (
text.includes(
'java.lang.NoSuchMethodError: sun.security.util.ManifestEntryVerifier.<init>(Ljava/util/jar/Manifest;)V'
)
) {
return [
'Forge Java Bug',
'Old versions of Forge crash with Java 8u321+.\nTo fix this, update forge to the latest version via the Versions tab \n(right click on Forge, click Change Version, and choose the latest one)\nAlternatively, you can download 8u312 or lower. See [archive](https://github.com/adoptium/temurin8-binaries/releases/tag/jdk8u312-b07)',
];
}
return null;
};
const intelHDAnalyzer: Analyzer = async (text) => {
if (text.includes('org.lwjgl.LWJGLException: Pixel format not accelerated')) {
return [
'Intel HD Windows 10',
"Your drivers don't support windows 10 officially\nSee https://prismlauncher.org/wiki/getting-started/installing-java/#a-note-about-intel-hd-20003000-on-windows-10 for more info",
];
}
return null;
};
const macOSNSWindowAnalyzer: Analyzer = async (text) => {
if (
text.includes(
"Terminating app due to uncaught exception 'NSInternalInconsistencyException'"
)
) {
return [
'MacOS NSInternalInconsistencyException',
'You need to downgrade your Java 8 version. See https://prismlauncher.org/wiki/getting-started/installing-java/#older-minecraft-on-macos',
];
}
return null;
};
const quiltFabricInternalsAnalyzer: Analyzer = async (text) => {
const base = 'Caused by: java.lang.ClassNotFoundException: ';
if (
text.includes(base + 'net.fabricmc.fabric.impl') ||
text.includes(base + 'net.fabricmc.fabric.mixin') ||
text.includes(base + 'net.fabricmc.loader.impl') ||
text.includes(base + 'net.fabricmc.loader.mixin') ||
text.includes(
'org.quiltmc.loader.impl.FormattedException: java.lang.NoSuchMethodError:'
)
) {
return [
'Fabric Internal Access',
`The mod you are using is using fabric internals that are not meant to be used by anything but the loader itself.
Those mods break both on Quilt and with fabric updates.
If you're using fabric, downgrade your fabric loader could work, on Quilt you can try updating to the latest beta version, but there's nothing much to do unless the mod author stops using them.`,
];
}
return null;
};
const oomAnalyzer: Analyzer = async (text) => {
if (text.includes('java.lang.OutOfMemoryError')) {
return [
'Out of Memory',
'Allocating more RAM to your instance could help prevent this crash.',
];
}
return null;
};
const javaOptionsAnalyzer: Analyzer = async (text) => {
const r1 = /Unrecognized VM option '(\w*)'/;
const r2 = /Unrecognized option: \w*/;
const m1 = text.match(r1);
const m2 = text.match(r2);
if (m1) {
return [
'Wrong Java Arguments',
`Remove \`-XX:${m1[1]}\` from your Java arguments.`,
];
}
if (m2) {
return [
'Wrong Java Arguments',
`Remove \`${m2[1]}\` from your Java arguments.`,
];
}
return null;
};
const shenadoahGCAnalyzer: Analyzer = async (text) => {
if (text.includes("Unrecognized VM option 'UseShenandoahGC'")) {
return [
"Java 8 doesn't support ShenandoahGC",
'Remove `UseShenandoahGC` from your Java Arguments',
];
}
return null;
};
const optifineAnalyzer: Analyzer = async (text) => {
const matchesOpti = text.match(/\[✔️\] OptiFine_[\w,.]*/);
const matchesOptiFabric = text.match(/\[✔️\] optifabric-[\w,.]*/);
if (matchesOpti || matchesOptiFabric) {
return [
'Possible Optifine Problems',
'OptiFine is known to cause problems when paired with other mods. Try to disable OptiFine and see if the issue persists.\nCheck `/tag optifine` for more info & alternatives you can use.',
];
}
return null;
};
const analyzers: Analyzer[] = [
javaAnalyzer,
versionAnalyzer,
flatpakNvidiaAnalyzer,
forgeJavaAnalyzer,
intelHDAnalyzer,
macOSNSWindowAnalyzer,
quiltFabricInternalsAnalyzer,
oomAnalyzer,
shenadoahGCAnalyzer,
optifineAnalyzer,
javaOptionsAnalyzer,
];
const providers: LogProvider[] = [
readMcLogs,
read0x0,
readPasteGG,
readHastebin,
];
export async function parseLog(s: string): Promise<EmbedBuilder | null> {
if (/(https?:\/\/)?pastebin\.com\/(raw\/)?[^/\s]{8}/g.test(s)) {
const embed = new EmbedBuilder()
.setTitle('pastebin.com detected')
.setDescription(
'Please use https://mclo.gs or another paste provider and send logs using the Log Upload feature in Prism Launcher. (See `/tag log`)'
)
.setColor(COLORS.red);
return embed;
}
let log = '';
for (const i in providers) {
const provider = providers[i];
const res = await provider(s);
if (res) {
log = res;
break;
} else {
continue;
}
}
if (!log) return null;
const embed = new EmbedBuilder().setTitle('Log analysis');
let thereWasAnIssue = false;
for (const i in analyzers) {
const Analyzer = analyzers[i];
const out = await Analyzer(log);
if (out) {
embed.addFields({ name: out[0], value: out[1] });
thereWasAnIssue = true;
}
}
if (thereWasAnIssue) {
embed.setColor(COLORS.red);
return embed;
} else {
embed.setColor(COLORS.green);
embed.addFields({
name: 'Analyze failed',
value: 'No issues found automatically',
});
return embed;
}
}

145
src/main.rs Normal file
View file

@ -0,0 +1,145 @@
use std::{sync::Arc, time::Duration};
use eyre::Context as _;
use log::{info, trace, warn};
use poise::{
serenity_prelude 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 commands;
mod config;
mod consts;
mod handlers;
mod storage;
mod tags;
mod utils;
use config::Config;
use storage::Storage;
type Error = Box<dyn std::error::Error + Send + Sync>;
type Context<'a> = poise::Context<'a, Data, Error>;
#[derive(Clone, Debug, Default)]
pub struct Data {
config: Config,
storage: Option<Storage>,
http_client: api::HttpClient,
octocrab: Arc<octocrab::Octocrab>,
}
async fn setup(
ctx: &serenity::Context,
_: &serenity::Ready,
framework: &Framework<Data, Error>,
) -> Result<Data, Error> {
let config = Config::new_from_env();
let storage = if let Some(url) = &config.bot.redis_url {
Some(Storage::from_url(url)?)
} else {
None
};
if let Some(storage) = storage.as_ref() {
if !storage.clone().has_connection() {
return Err(
"You specified a storage backend but there's no connection! Is it running?".into(),
);
}
trace!("Redis connection looks good!");
}
let http_client = api::HttpClient::default();
let octocrab = octocrab::instance();
let data = Data {
config,
storage,
http_client,
octocrab,
};
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
info!("Registered global commands!");
Ok(data)
}
async fn handle_shutdown(shard_manager: Arc<serenity::ShardManager>, reason: &str) {
warn!("{reason}! Shutting down bot...");
shard_manager.shutdown_all().await;
println!("Everything is shutdown. Goodbye!");
}
#[tokio::main]
async fn main() -> eyre::Result<()> {
dotenvy::dotenv().ok();
color_eyre::install()?;
env_logger::init();
let token =
std::env::var("DISCORD_BOT_TOKEN").wrap_err("Couldn't find bot token in environment!")?;
let intents =
serenity::GatewayIntents::non_privileged() | serenity::GatewayIntents::MESSAGE_CONTENT;
let options = FrameworkOptions {
commands: commands::all(),
on_error: |error| Box::pin(handlers::handle_error(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))
},
prefix_options: PrefixFrameworkOptions {
prefix: Some(".".into()),
edit_tracker: Some(Arc::from(EditTracker::for_timespan(Duration::from_secs(
3600,
)))),
..Default::default()
},
..Default::default()
};
let framework = Framework::builder()
.options(options)
.setup(|ctx, ready, framework| Box::pin(setup(ctx, ready, framework)))
.build();
let mut client = serenity::ClientBuilder::new(token, intents)
.framework(framework)
.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(eyre::Report::from),
_ = sigterm.recv() => {
handle_shutdown(shard_manager, "Received SIGTERM").await;
std::process::exit(0);
}
_ = ctrl_c() => {
handle_shutdown(shard_manager, "Interrupted").await;
std::process::exit(130);
}
}
}

View file

@ -1,22 +0,0 @@
import { createClient } from 'redis';
export const client = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379',
});
export const storeUserPlurality = async (userId: string) => {
// Just store some value. We only care about the presence of this key
await client
.multi()
.set(`user:${userId}:pk`, '0')
.expire(`user:${userId}:pk`, 7 * 24 * 60 * 60)
.exec();
};
export const isUserPlural = async (userId: string) => {
return (await client.exists(`user:${userId}:pk`)) > 0;
};
export const connect = () => {
client.connect();
};

90
src/storage/mod.rs Normal file
View file

@ -0,0 +1,90 @@
use std::fmt::Debug;
use eyre::Result;
use log::debug;
use poise::serenity_prelude::UserId;
use redis::{AsyncCommands, Client, ConnectionLike};
const PK_KEY: &str = "pluralkit-v1";
const LAUNCHER_VERSION_KEY: &str = "launcher-version-v1";
const LAUNCHER_STARGAZER_KEY: &str = "launcher-stargazer-v1";
#[derive(Clone, Debug)]
pub struct Storage {
client: Client,
}
impl Storage {
pub fn new(client: Client) -> Self {
Self { client }
}
pub fn from_url(redis_url: &str) -> Result<Self> {
let client = Client::open(redis_url)?;
Ok(Self::new(client))
}
pub fn has_connection(&mut self) -> bool {
self.client.check_connection()
}
pub async fn store_user_plurality(&self, sender: UserId) -> Result<()> {
debug!("Marking {sender} as a PluralKit user");
let key = format!("{PK_KEY}:{sender}");
let mut con = self.client.get_multiplexed_async_connection().await?;
// Just store some value. We only care about the presence of this key
con.set_ex(key, 0, 7 * 24 * 60 * 60).await?; // 1 week
Ok(())
}
pub async fn is_user_plural(&self, user_id: UserId) -> Result<bool> {
debug!("Checking if user {user_id} is plural");
let key = format!("{PK_KEY}:{user_id}");
let mut con = self.client.get_multiplexed_async_connection().await?;
let exists = con.exists(key).await?;
Ok(exists)
}
pub async fn cache_launcher_version(&self, version: &str) -> Result<()> {
debug!("Caching launcher version as {version}");
let mut con = self.client.get_multiplexed_async_connection().await?;
con.set_ex(LAUNCHER_VERSION_KEY, version, 24 * 60 * 60)
.await?; // 1 day
Ok(())
}
pub async fn launcher_version(&self) -> Result<String> {
debug!("Fetching launcher version");
let mut con = self.client.get_multiplexed_async_connection().await?;
let res = con.get(LAUNCHER_VERSION_KEY).await?;
Ok(res)
}
pub async fn cache_launcher_stargazer_count(&self, stargazers: u32) -> Result<()> {
debug!("Caching stargazer count as {stargazers}");
let mut con = self.client.get_multiplexed_async_connection().await?;
con.set_ex(LAUNCHER_STARGAZER_KEY, stargazers, 60 * 60)
.await?;
Ok(())
}
pub async fn launcher_stargazer_count(&self) -> Result<u32> {
debug!("Fetching launcher stargazer count");
let mut con = self.client.get_multiplexed_async_connection().await?;
let res: u32 = con.get(LAUNCHER_STARGAZER_KEY).await?;
Ok(res)
}
}

20
src/tags.rs Normal file
View file

@ -0,0 +1,20 @@
use poise::serenity_prelude::EmbedField;
use serde::{Deserialize, Serialize};
#[allow(dead_code)]
pub const TAG_DIR: &str = "tags";
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct TagFrontmatter {
pub title: String,
pub color: Option<String>,
pub image: Option<String>,
pub fields: Option<Vec<EmbedField>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Tag {
pub content: String,
pub id: String,
pub frontmatter: TagFrontmatter,
}

View file

@ -1,37 +0,0 @@
import matter from 'gray-matter';
import { readdir, readFile } from 'fs/promises';
import { join } from 'path';
import { COLORS } from './constants';
import { type EmbedField } from 'discord.js';
interface Tag {
name: string;
aliases?: string[];
title?: string;
color?: number;
content: string;
image?: string;
fields?: EmbedField[];
}
const TAG_DIR = join(process.cwd(), 'tags');
export const getTags = async (): Promise<Tag[]> => {
const filenames = await readdir(TAG_DIR);
const tags: Tag[] = [];
for (const _file of filenames) {
const file = join(TAG_DIR, _file);
const { data, content } = matter(await readFile(file));
tags.push({
...data,
name: _file.replace('.md', ''),
content: content.trim(),
color: data.color ? COLORS[data.color] : undefined,
});
}
return tags;
};

175
src/utils/messages.rs Normal file
View file

@ -0,0 +1,175 @@
use crate::api::{pluralkit, HttpClient};
use std::{str::FromStr, sync::OnceLock};
use eyre::{eyre, Context as _, Result};
use log::{debug, trace};
use poise::serenity_prelude::{
Cache, CacheHttp, ChannelId, ChannelType, Colour, Context, CreateEmbed, CreateEmbedAuthor,
CreateEmbedFooter, GuildChannel, Member, Message, MessageId, Permissions, UserId,
};
use regex::Regex;
fn find_first_image(message: &Message) -> Option<String> {
message
.attachments
.iter()
.find(|a| {
a.content_type
.as_ref()
.unwrap_or(&String::new())
.starts_with("image/")
})
.map(|res| res.url.clone())
}
async fn find_real_author_id(http: &HttpClient, message: &Message) -> UserId {
if let Ok(sender) = pluralkit::sender_from(http, message.id).await {
sender
} else {
message.author.id
}
}
async fn member_can_view_channel(
ctx: impl CacheHttp + AsRef<Cache>,
member: &Member,
channel: &GuildChannel,
) -> Result<bool> {
static REQUIRED_PERMISSIONS: OnceLock<Permissions> = OnceLock::new();
let required_permissions = REQUIRED_PERMISSIONS
.get_or_init(|| Permissions::VIEW_CHANNEL | Permissions::READ_MESSAGE_HISTORY);
let guild = ctx.http().get_guild(channel.guild_id).await?;
let channel_to_check = match &channel.kind {
ChannelType::PublicThread => {
let parent_id = channel
.parent_id
.ok_or_else(|| eyre!("Couldn't get parent of thread {}", channel.id))?;
parent_id
.to_channel(ctx)
.await?
.guild()
.ok_or_else(|| eyre!("Couldn't get GuildChannel from ChannelID {parent_id}!"))?
}
ChannelType::Text | ChannelType::News => channel.to_owned(),
_ => return Ok(false),
};
let can_view = guild
.user_permissions_in(&channel_to_check, member)
.contains(*required_permissions);
Ok(can_view)
}
pub async fn to_embed(
ctx: impl CacheHttp + AsRef<Cache>,
message: &Message,
) -> Result<CreateEmbed> {
let author = CreateEmbedAuthor::new(message.author.tag()).icon_url(
message
.author
.avatar_url()
.unwrap_or_else(|| message.author.default_avatar_url()),
);
let footer = CreateEmbedFooter::new(format!(
"#{}",
message.channel(ctx).await?.guild().unwrap_or_default().name
));
let mut embed = CreateEmbed::new()
.author(author)
.color(Colour::BLITZ_BLUE)
.timestamp(message.timestamp)
.footer(footer)
.description(format!(
"{}\n\n[Jump to original message]({})",
message.content,
message.link()
));
if !message.attachments.is_empty() {
embed = embed.fields(message.attachments.iter().map(|a| {
(
"Attachments".to_string(),
format!("[{}]({})", a.filename, a.url),
false,
)
}));
if let Some(image) = find_first_image(message) {
embed = embed.image(image);
}
}
Ok(embed)
}
pub async fn from_message(
ctx: &Context,
http: &HttpClient,
msg: &Message,
) -> Result<Vec<CreateEmbed>> {
static MESSAGE_PATTERN: OnceLock<Regex> = OnceLock::new();
let message_pattern = MESSAGE_PATTERN.get_or_init(|| Regex::new(r"(?:https?:\/\/)?(?:canary\.|ptb\.)?discord(?:app)?\.com\/channels\/(?<server_id>\d+)\/(?<channel_id>\d+)\/(?<message_id>\d+)").unwrap());
let Some(guild_id) = msg.guild_id else {
debug!("Not resolving message in DM");
return Ok(Vec::new());
};
// if the message was sent through pluralkit, we'll want
// to reference the Member of the unproxied account
let author_id = if msg.webhook_id.is_some() {
find_real_author_id(http, msg).await
} else {
msg.author.id
};
let author = guild_id.member(ctx, author_id).await?;
let matches = message_pattern
.captures_iter(&msg.content)
.map(|capture| capture.extract());
let mut embeds: Vec<CreateEmbed> = vec![];
for (url, [target_guild_id, target_channel_id, target_message_id]) in matches {
if target_guild_id != guild_id.to_string() {
debug!("Not resolving message from other server");
continue;
}
trace!("Attempting to resolve message {target_message_id} from URL {url}");
let target_channel = ChannelId::from_str(target_channel_id)?
.to_channel(ctx)
.await?
.guild()
.ok_or_else(|| {
eyre!("Couldn't find GuildChannel from ChannelId {target_channel_id}!")
})?;
if !member_can_view_channel(ctx, &author, &target_channel).await? {
debug!("Not resolving message for author who can't see it");
continue;
}
let target_message_id = MessageId::from_str(target_message_id)?;
let target_message = target_channel
.message(ctx, target_message_id)
.await
.wrap_err_with(|| {
eyre!("Couldn't find channel message from ID {target_message_id}!")
})?;
let embed = to_embed(ctx, &target_message).await?;
embeds.push(embed);
}
Ok(embeds)
}

10
src/utils/mod.rs Normal file
View file

@ -0,0 +1,10 @@
use poise::serenity_prelude::{CreateEmbedAuthor, User};
pub mod messages;
pub fn embed_author_from_user(user: &User) -> CreateEmbedAuthor {
CreateEmbedAuthor::new(user.tag()).icon_url(
user.avatar_url()
.unwrap_or_else(|| user.default_avatar_url()),
)
}

View file

@ -1,22 +0,0 @@
import { Message } from 'discord.js';
interface PkMessage {
sender: string;
}
export const pkDelay = 1000;
export async function fetchPluralKitMessage(message: Message) {
const response = await fetch(
`https://api.pluralkit.me/v2/messages/${message.id}`
);
if (!response.ok) return null;
return (await response.json()) as PkMessage;
}
export async function isMessageProxied(message: Message) {
await new Promise((resolve) => setTimeout(resolve, pkDelay));
return (await fetchPluralKitMessage(message)) !== null;
}

View file

@ -1,30 +0,0 @@
interface MetaPackage {
formatVersion: number;
name: string;
recommended: string[];
uid: string;
}
interface SimplifiedGHReleases {
tag_name: string;
}
// TODO: caching
export async function getLatestMinecraftVersion(): Promise<string> {
const f = await fetch(
'https://meta.prismlauncher.org/v1/net.minecraft/package.json'
);
const minecraft = (await f.json()) as MetaPackage;
return minecraft.recommended[0];
}
// TODO: caching
export async function getLatestPrismLauncherVersion(): Promise<string> {
const f = await fetch(
'https://api.github.com/repos/PrismLauncher/PrismLauncher/releases'
);
const versions = (await f.json()) as SimplifiedGHReleases[];
return versions[0].tag_name;
}

View file

@ -1,106 +0,0 @@
import {
Colors,
EmbedBuilder,
type Message,
ThreadChannel,
ReactionCollector,
} from 'discord.js';
function findFirstImage(message: Message): string | undefined {
const result = message.attachments.find((attach) => {
return attach.contentType?.startsWith('image/');
});
if (result === undefined) {
return undefined;
} else {
return result.url;
}
}
export async function expandDiscordLink(message: Message): Promise<void> {
if (message.author.bot && !message.webhookId) return;
const re =
/(https?:\/\/)?(?:canary\.|ptb\.)?discord(?:app)?\.com\/channels\/(?<serverId>\d+)\/(?<channelId>\d+)\/(?<messageId>\d+)/g;
const results = message.content.matchAll(re);
const resultEmbeds: EmbedBuilder[] = [];
for (const r of results) {
if (resultEmbeds.length >= 3) break; // only process three previews
if (r.groups == undefined || r.groups.serverId != message.guildId) continue; // do not let the bot leak messages from one server to another
try {
const channel = await message.guild?.channels.fetch(r.groups.channelId);
if (!channel || !channel.isTextBased()) continue;
if (channel instanceof ThreadChannel) {
if (
!channel.parent?.members?.some((user) => user.id == message.author.id)
)
continue; // do not reveal a message to a user who can't see it
} else {
if (!channel.members?.some((user) => user.id == message.author.id))
continue; // do not reveal a message to a user who can't see it
}
const originalMessage = await channel.messages.fetch(r.groups.messageId);
const embed = new EmbedBuilder()
.setAuthor({
name: originalMessage.author.tag,
iconURL: originalMessage.author.displayAvatarURL(),
})
.setColor(Colors.Aqua)
.setTimestamp(originalMessage.createdTimestamp)
.setFooter({ text: `#${originalMessage.channel.name}` });
embed.setDescription(
(originalMessage.content ? originalMessage.content + '\n\n' : '') +
`[Jump to original message](${originalMessage.url})`
);
if (originalMessage.attachments.size > 0) {
embed.addFields({
name: 'Attachments',
value: originalMessage.attachments
.map((att) => `[${att.name}](${att.url})`)
.join('\n'),
});
const firstImage = findFirstImage(originalMessage);
if (firstImage) {
embed.setImage(firstImage);
}
}
resultEmbeds.push(embed);
} catch (ignored) {
/* */
}
}
if (resultEmbeds.length > 0) {
const reply = await message.reply({
embeds: resultEmbeds,
allowedMentions: { repliedUser: false },
});
const collector = new ReactionCollector(reply, {
filter: (reaction) => {
return reaction.emoji.name === '❌';
},
time: 5 * 60 * 1000,
});
collector.on('collect', async (_, user) => {
if (user === message.author) {
await reply.delete();
collector.stop();
}
});
}
}

View file

@ -1,7 +1,6 @@
---
title: Prism Launcher is always offline?
color: orange
aliases: ['reinstall']
---
If you recently updated Prism Launcher on Windows and you are now unable to do anything in Prism Launcher that would require an internet connection (i.e. logging in, downloading mods/modpacks and more), try uninstalling the launcher and then reinstalling it again.

Some files were not shown because too many files have changed in this diff Show more