Merge pull request #303 from getchoo/feat/RIIR
This commit is contained in:
commit
4d12cccec7
118 changed files with 6307 additions and 3160 deletions
|
@ -1,6 +0,0 @@
|
|||
node_modules/
|
||||
.git/
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
|
@ -1,2 +0,0 @@
|
|||
DISCORD_TOKEN=
|
||||
SAY_LOGS_CHANNEL=
|
5
.envrc
5
.envrc
|
@ -1,2 +1,5 @@
|
|||
use flake
|
||||
if has nix_direnv_version; then
|
||||
use flake ./nix/dev
|
||||
fi
|
||||
|
||||
dotenv_if_exists
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
node_modules/
|
||||
dist/
|
|
@ -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
28
.github/workflows/autobot.yaml
vendored
Normal 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
68
.github/workflows/check.yml
vendored
Normal 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
|
136
.github/workflows/docker.yml
vendored
136
.github/workflows/docker.yml
vendored
|
@ -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"
|
||||
|
|
25
.github/workflows/lint.yml
vendored
25
.github/workflows/lint.yml
vendored
|
@ -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
53
.github/workflows/nix.yml
vendored
Normal 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
72
.github/workflows/update-flake.yml
vendored
Normal 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
25
.gitignore
vendored
|
@ -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
1
.rustfmt.toml
Normal file
|
@ -0,0 +1 @@
|
|||
hard_tabs = true
|
2801
Cargo.lock
generated
Normal file
2801
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
49
Cargo.toml
Normal file
49
Cargo.toml
Normal 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"
|
11
Dockerfile
11
Dockerfile
|
@ -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
88
build.rs
Normal 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
156
flake.lock
generated
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
55
flake.nix
55
flake.nix
|
@ -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
63
nix/derivation.nix
Normal 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
11
nix/dev/args.nix
Normal 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
25
nix/dev/docker.nix
Normal 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
212
nix/dev/flake.lock
generated
Normal 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
65
nix/dev/flake.nix
Normal 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
20
nix/dev/pre-commit.nix
Normal 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
11
nix/dev/procfiles.nix
Normal 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
36
nix/dev/shell.nix
Normal 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
41
nix/dev/static.nix
Normal 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
22
nix/dev/treefmt.nix
Normal 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
149
nix/module.nix
Normal 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} = {};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
30
package.json
30
package.json
|
@ -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
1562
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:base", "config:js-app"]
|
||||
"extends": ["config:base", "config:recommended"]
|
||||
}
|
||||
|
|
|
@ -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
11
src/api/dadjoke.rs
Normal 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
30
src/api/github.rs
Normal 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
36
src/api/mod.rs
Normal 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
55
src/api/paste_gg.rs
Normal 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
26
src/api/pluralkit.rs
Normal 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
28
src/api/prism_meta.rs
Normal 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
28
src/api/rory.rs
Normal 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)
|
||||
}
|
22
src/commands/general/help.rs
Normal file
22
src/commands/general/help.rs
Normal 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(())
|
||||
}
|
16
src/commands/general/joke.rs
Normal file
16
src/commands/general/joke.rs
Normal 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(())
|
||||
}
|
37
src/commands/general/members.rs
Normal file
37
src/commands/general/members.rs
Normal 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(())
|
||||
}
|
8
src/commands/general/mod.rs
Normal file
8
src/commands/general/mod.rs
Normal 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;
|
11
src/commands/general/ping.rs
Normal file
11
src/commands/general/ping.rs
Normal 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(())
|
||||
}
|
37
src/commands/general/rory.rs
Normal file
37
src/commands/general/rory.rs
Normal 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(())
|
||||
}
|
37
src/commands/general/say.rs
Normal file
37
src/commands/general/say.rs
Normal 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(())
|
||||
}
|
36
src/commands/general/stars.rs
Normal file
36
src/commands/general/stars.rs
Normal 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(())
|
||||
}
|
90
src/commands/general/tag.rs
Normal file
90
src/commands/general/tag.rs
Normal 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}")
|
||||
}
|
|
@ -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);
|
||||
};
|
|
@ -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
47
src/commands/mod.rs
Normal 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),
|
||||
]
|
||||
}
|
1
src/commands/moderation/mod.rs
Normal file
1
src/commands/moderation/mod.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub mod set_welcome;
|
198
src/commands/moderation/set_welcome.rs
Normal file
198
src/commands/moderation/set_welcome.rs
Normal 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(())
|
||||
}
|
|
@ -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),
|
||||
],
|
||||
});
|
||||
};
|
|
@ -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}`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
};
|
|
@ -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),
|
||||
],
|
||||
});
|
||||
}
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
|
@ -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
24
src/config/bot.rs
Normal 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
60
src/config/discord.rs
Normal 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
24
src/config/mod.rs
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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
46
src/consts.rs
Normal 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
98
src/handlers/error.rs
Normal 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:#?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
266
src/handlers/event/analyze_logs/issues.rs
Normal file
266
src/handlers/event/analyze_logs/issues.rs
Normal 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)
|
||||
}
|
73
src/handlers/event/analyze_logs/mod.rs
Normal file
73
src/handlers/event/analyze_logs/mod.rs
Normal 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(())
|
||||
}
|
29
src/handlers/event/analyze_logs/providers/0x0.rs
Normal file
29
src/handlers/event/analyze_logs/providers/0x0.rs
Normal 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)
|
||||
}
|
||||
}
|
30
src/handlers/event/analyze_logs/providers/attachment.rs
Normal file
30
src/handlers/event/analyze_logs/providers/attachment.rs
Normal 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)
|
||||
}
|
||||
}
|
31
src/handlers/event/analyze_logs/providers/haste.rs
Normal file
31
src/handlers/event/analyze_logs/providers/haste.rs
Normal 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)
|
||||
}
|
||||
}
|
30
src/handlers/event/analyze_logs/providers/mclogs.rs
Normal file
30
src/handlers/event/analyze_logs/providers/mclogs.rs
Normal 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)
|
||||
}
|
||||
}
|
70
src/handlers/event/analyze_logs/providers/mod.rs
Normal file
70
src/handlers/event/analyze_logs/providers/mod.rs
Normal 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)
|
||||
}
|
37
src/handlers/event/analyze_logs/providers/paste_gg.rs
Normal file
37
src/handlers/event/analyze_logs/providers/paste_gg.rs
Normal 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)
|
||||
}
|
||||
}
|
31
src/handlers/event/analyze_logs/providers/pastebin.rs
Normal file
31
src/handlers/event/analyze_logs/providers/pastebin.rs
Normal 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)
|
||||
}
|
||||
}
|
27
src/handlers/event/delete_on_reaction.rs
Normal file
27
src/handlers/event/delete_on_reaction.rs
Normal 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
51
src/handlers/event/eta.rs
Normal 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(())
|
||||
}
|
20
src/handlers/event/expand_link.rs
Normal file
20
src/handlers/event/expand_link.rs
Normal 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(())
|
||||
}
|
45
src/handlers/event/give_role.rs
Normal file
45
src/handlers/event/give_role.rs
Normal 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
88
src/handlers/event/mod.rs
Normal 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(())
|
||||
}
|
53
src/handlers/event/pluralkit.rs
Normal file
53
src/handlers/event/pluralkit.rs
Normal 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(())
|
||||
}
|
52
src/handlers/event/support_onboard.rs
Normal file
52
src/handlers/event/support_onboard.rs
Normal 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
5
src/handlers/mod.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
mod error;
|
||||
mod event;
|
||||
|
||||
pub use error::handle as handle_error;
|
||||
pub use event::handle as handle_event;
|
223
src/index.ts
223
src/index.ts
|
@ -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);
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
251
src/logs.ts
251
src/logs.ts
|
@ -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
145
src/main.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
90
src/storage/mod.rs
Normal 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
20
src/tags.rs
Normal 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,
|
||||
}
|
37
src/tags.ts
37
src/tags.ts
|
@ -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
175
src/utils/messages.rs
Normal 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
10
src/utils/mod.rs
Normal 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()),
|
||||
)
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue