initial rewrite in rust & moderation commands
Signed-off-by: seth <getchoo@tuta.io>
This commit is contained in:
parent
b17e357b75
commit
45403e9d9b
53 changed files with 3297 additions and 2820 deletions
10
.envrc
10
.envrc
|
@ -1,2 +1,10 @@
|
|||
use flake
|
||||
# only use flake when `nix` is present
|
||||
if command -v nix &> /dev/null; then
|
||||
if ! has nix_direnv_version || ! nix_direnv_version 2.2.1; then
|
||||
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/2.2.1/direnvrc" "sha256-zelF0vLbEl5uaqrfIzbgNzJWGmLzCmYAkInj/LNxvKs="
|
||||
fi
|
||||
|
||||
use flake
|
||||
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,
|
||||
},
|
||||
};
|
22
.gitignore
vendored
22
.gitignore
vendored
|
@ -1,13 +1,29 @@
|
|||
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
|
||||
|
|
2418
Cargo.lock
generated
Normal file
2418
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
33
Cargo.toml
Normal file
33
Cargo.toml
Normal file
|
@ -0,0 +1,33 @@
|
|||
[package]
|
||||
name = "refraction"
|
||||
version = "2.0.0"
|
||||
edition = "2021"
|
||||
repository = "https://github.com/PrismLauncher/refraction"
|
||||
license = "GPL-3.0-or-later"
|
||||
readme = "README.md"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
color-eyre = "0.6.2"
|
||||
dotenvy = "0.15.7"
|
||||
env_logger = "0.10.0"
|
||||
log = "0.4.20"
|
||||
poise = "0.5.7"
|
||||
octocrab = "0.32.0"
|
||||
once_cell = "1.18.0"
|
||||
rand = "0.8.5"
|
||||
redis = { version = "0.23.3", features = ["tokio-comp", "tokio-rustls-comp"] }
|
||||
redis-macros = "0.2.1"
|
||||
reqwest = { version = "0.11.22", default-features = false, features = [
|
||||
"rustls-tls",
|
||||
"json",
|
||||
] }
|
||||
serde = "1.0.193"
|
||||
serde_json = "1.0.108"
|
||||
tokio = { version = "1.33.0", features = [
|
||||
"macros",
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
] }
|
||||
url = { version = "2.5.0", features = ["serde"] }
|
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.');
|
||||
};
|
21
src/api/dadjoke.rs
Normal file
21
src/api/dadjoke.rs
Normal file
|
@ -0,0 +1,21 @@
|
|||
use crate::api::REQWEST_CLIENT;
|
||||
|
||||
use color_eyre::eyre::{eyre, Result};
|
||||
use log::*;
|
||||
use reqwest::StatusCode;
|
||||
|
||||
const DADJOKE: &str = "https://icanhazdadjoke.com";
|
||||
|
||||
pub async fn get_joke() -> Result<String> {
|
||||
let req = REQWEST_CLIENT.get(DADJOKE).build()?;
|
||||
|
||||
info!("making request to {}", req.url());
|
||||
let resp = REQWEST_CLIENT.execute(req).await?;
|
||||
let status = resp.status();
|
||||
|
||||
if let StatusCode::OK = status {
|
||||
Ok(resp.text().await?)
|
||||
} else {
|
||||
Err(eyre!("Failed to fetch joke from {DADJOKE} with {status}"))
|
||||
}
|
||||
}
|
17
src/api/mod.rs
Normal file
17
src/api/mod.rs
Normal file
|
@ -0,0 +1,17 @@
|
|||
use once_cell::sync::Lazy;
|
||||
|
||||
pub mod dadjoke;
|
||||
pub mod rory;
|
||||
|
||||
pub static USER_AGENT: Lazy<String> = Lazy::new(|| {
|
||||
let version = option_env!("CARGO_PKG_VERSION").unwrap_or("development");
|
||||
|
||||
format!("refraction/{version}")
|
||||
});
|
||||
|
||||
pub static REQWEST_CLIENT: Lazy<reqwest::Client> = Lazy::new(|| {
|
||||
reqwest::Client::builder()
|
||||
.user_agent(USER_AGENT.to_string())
|
||||
.build()
|
||||
.unwrap_or_default()
|
||||
});
|
42
src/api/rory.rs
Normal file
42
src/api/rory.rs
Normal file
|
@ -0,0 +1,42 @@
|
|||
use crate::api::REQWEST_CLIENT;
|
||||
|
||||
use color_eyre::eyre::{eyre, Result};
|
||||
use log::*;
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct RoryResponse {
|
||||
pub id: u64,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
const RORY: &str = "https://rory.cat";
|
||||
const ENDPOINT: &str = "/purr";
|
||||
|
||||
pub async fn get_rory(id: Option<u64>) -> Result<RoryResponse> {
|
||||
let target = {
|
||||
if let Some(id) = id {
|
||||
id.to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let req = REQWEST_CLIENT
|
||||
.get(format!("{RORY}{ENDPOINT}/{target}"))
|
||||
.build()?;
|
||||
|
||||
info!("making request to {}", req.url());
|
||||
let resp = REQWEST_CLIENT.execute(req).await?;
|
||||
let status = resp.status();
|
||||
|
||||
if let StatusCode::OK = status {
|
||||
let data = resp.json::<RoryResponse>().await?;
|
||||
Ok(data)
|
||||
} else {
|
||||
Err(eyre!(
|
||||
"Failed to get rory from {RORY}{ENDPOINT}/{target} with {status}",
|
||||
))
|
||||
}
|
||||
}
|
12
src/commands/general/joke.rs
Normal file
12
src/commands/general/joke.rs
Normal file
|
@ -0,0 +1,12 @@
|
|||
use crate::api::dadjoke;
|
||||
use crate::Context;
|
||||
|
||||
use color_eyre::eyre::Result;
|
||||
|
||||
#[poise::command(slash_command, prefix_command)]
|
||||
pub async fn joke(ctx: Context<'_>) -> Result<()> {
|
||||
let joke = dadjoke::get_joke().await?;
|
||||
|
||||
ctx.reply(joke).await?;
|
||||
Ok(())
|
||||
}
|
25
src/commands/general/members.rs
Normal file
25
src/commands/general/members.rs
Normal file
|
@ -0,0 +1,25 @@
|
|||
use crate::{consts, Context};
|
||||
|
||||
use color_eyre::eyre::{eyre, Result};
|
||||
|
||||
#[poise::command(slash_command, prefix_command)]
|
||||
pub async fn members(ctx: Context<'_>) -> Result<()> {
|
||||
let guild = ctx.guild().ok_or_else(|| eyre!("Couldn't fetch guild!"))?;
|
||||
|
||||
let count = guild.member_count;
|
||||
let online = if let Some(count) = guild.approximate_presence_count {
|
||||
count.to_string()
|
||||
} else {
|
||||
"Undefined".to_string()
|
||||
};
|
||||
|
||||
ctx.send(|m| {
|
||||
m.embed(|e| {
|
||||
e.title(format!("{count} total members!"))
|
||||
.description(format!("{online} online members"))
|
||||
.color(consts::COLORS["blue"])
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
13
src/commands/general/mod.rs
Normal file
13
src/commands/general/mod.rs
Normal file
|
@ -0,0 +1,13 @@
|
|||
mod joke;
|
||||
mod members;
|
||||
mod modrinth;
|
||||
mod rory;
|
||||
mod say;
|
||||
mod stars;
|
||||
|
||||
pub use joke::joke;
|
||||
pub use members::members;
|
||||
pub use modrinth::modrinth;
|
||||
pub use rory::rory;
|
||||
pub use say::say;
|
||||
pub use stars::stars;
|
8
src/commands/general/modrinth.rs
Normal file
8
src/commands/general/modrinth.rs
Normal file
|
@ -0,0 +1,8 @@
|
|||
use crate::Context;
|
||||
|
||||
use color_eyre::eyre::Result;
|
||||
|
||||
#[poise::command(slash_command, prefix_command)]
|
||||
pub async fn modrinth(ctx: Context<'_>) -> Result<()> {
|
||||
todo!()
|
||||
}
|
21
src/commands/general/rory.rs
Normal file
21
src/commands/general/rory.rs
Normal file
|
@ -0,0 +1,21 @@
|
|||
use crate::api::rory::get_rory;
|
||||
use crate::Context;
|
||||
|
||||
use color_eyre::eyre::Result;
|
||||
|
||||
#[poise::command(slash_command, prefix_command)]
|
||||
pub async fn rory(ctx: Context<'_>, id: Option<u64>) -> Result<()> {
|
||||
let resp = get_rory(id).await?;
|
||||
|
||||
ctx.send(|m| {
|
||||
m.embed(|e| {
|
||||
e.title("Rory :3")
|
||||
.url(&resp.url)
|
||||
.image(resp.url)
|
||||
.footer(|f| f.text(format!("ID {}", resp.id)))
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
43
src/commands/general/say.rs
Normal file
43
src/commands/general/say.rs
Normal file
|
@ -0,0 +1,43 @@
|
|||
use crate::Context;
|
||||
|
||||
use color_eyre::eyre::{eyre, Result};
|
||||
|
||||
#[poise::command(slash_command, prefix_command, ephemeral)]
|
||||
pub async fn say(ctx: Context<'_>, content: String) -> Result<()> {
|
||||
let guild = ctx.guild().ok_or_else(|| eyre!("Couldn't get guild!"))?;
|
||||
let channel = ctx
|
||||
.guild_channel()
|
||||
.await
|
||||
.ok_or_else(|| eyre!("Couldn't get channel!"))?;
|
||||
|
||||
channel.say(ctx, &content).await?;
|
||||
ctx.say("I said what you said!").await?;
|
||||
|
||||
if let Some(channel_id) = ctx.data().config.discord.channels.say_log_channel_id {
|
||||
let log_channel = guild
|
||||
.channels
|
||||
.iter()
|
||||
.find(|c| c.0 == &channel_id)
|
||||
.ok_or_else(|| eyre!("Couldn't get log channel from guild!"))?;
|
||||
|
||||
log_channel
|
||||
.1
|
||||
.clone()
|
||||
.guild()
|
||||
.ok_or_else(|| eyre!("Couldn't cast channel we found from guild as GuildChannel?????"))?
|
||||
.send_message(ctx, |m| {
|
||||
m.embed(|e| {
|
||||
e.title("Say command used!")
|
||||
.description(content)
|
||||
.author(|a| {
|
||||
a.name(ctx.author().tag()).icon_url(
|
||||
ctx.author().avatar_url().unwrap_or("undefined".to_string()),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
30
src/commands/general/stars.rs
Normal file
30
src/commands/general/stars.rs
Normal file
|
@ -0,0 +1,30 @@
|
|||
use crate::{consts::COLORS, Context};
|
||||
|
||||
use color_eyre::eyre::{Context as _, Result};
|
||||
|
||||
#[poise::command(slash_command, prefix_command)]
|
||||
pub async fn stars(ctx: Context<'_>) -> Result<()> {
|
||||
let prismlauncher = ctx
|
||||
.data()
|
||||
.octocrab
|
||||
.repos("PrismLauncher", "PrismLauncher")
|
||||
.get()
|
||||
.await
|
||||
.wrap_err_with(|| "Couldn't get PrismLauncher/PrismLauncher from GitHub!")?;
|
||||
|
||||
let count = if let Some(count) = prismlauncher.stargazers_count {
|
||||
count.to_string()
|
||||
} else {
|
||||
"undefined".to_string()
|
||||
};
|
||||
|
||||
ctx.send(|m| {
|
||||
m.embed(|e| {
|
||||
e.title(format!("⭐ {count} total stars!"))
|
||||
.color(COLORS["yellow"])
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
20
src/commands/mod.rs
Normal file
20
src/commands/mod.rs
Normal file
|
@ -0,0 +1,20 @@
|
|||
use crate::Data;
|
||||
|
||||
use color_eyre::eyre::Report;
|
||||
use poise::Command;
|
||||
|
||||
mod general;
|
||||
mod moderation;
|
||||
|
||||
pub fn to_global_commands() -> Vec<Command<Data, Report>> {
|
||||
vec![
|
||||
general::joke(),
|
||||
general::members(),
|
||||
general::modrinth(),
|
||||
general::rory(),
|
||||
general::say(),
|
||||
general::stars(),
|
||||
moderation::ban_user(),
|
||||
moderation::kick_user(),
|
||||
]
|
||||
}
|
80
src/commands/moderation/actions.rs
Normal file
80
src/commands/moderation/actions.rs
Normal file
|
@ -0,0 +1,80 @@
|
|||
use crate::{consts::COLORS, Context};
|
||||
|
||||
use color_eyre::eyre::{eyre, Result};
|
||||
use poise::serenity_prelude::{CreateEmbed, User};
|
||||
|
||||
fn create_moderation_embed(
|
||||
title: String,
|
||||
user: &User,
|
||||
delete_messages_days: Option<u8>,
|
||||
reason: String,
|
||||
) -> impl FnOnce(&mut CreateEmbed) -> &mut CreateEmbed {
|
||||
let fields = [
|
||||
("User", format!("{} ({})", user.name, user.id), false),
|
||||
("Reason", reason, false),
|
||||
(
|
||||
"Deleted messages",
|
||||
format!("Last {} days", delete_messages_days.unwrap_or(0)),
|
||||
false,
|
||||
),
|
||||
];
|
||||
|
||||
|e: &mut CreateEmbed| e.title(title).fields(fields).color(COLORS["red"])
|
||||
}
|
||||
|
||||
// ban a user
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
prefix_command,
|
||||
default_member_permissions = "BAN_MEMBERS"
|
||||
)]
|
||||
pub async fn ban_user(
|
||||
ctx: Context<'_>,
|
||||
user: User,
|
||||
delete_messages_days: Option<u8>,
|
||||
reason: Option<String>,
|
||||
) -> Result<()> {
|
||||
let days = delete_messages_days.unwrap_or(1);
|
||||
let guild = ctx
|
||||
.guild()
|
||||
.ok_or_else(|| eyre!("Couldn't get guild from message; Unable to ban!"))?;
|
||||
|
||||
let reason = reason.unwrap_or("n/a".to_string());
|
||||
|
||||
if reason != "n/a" {
|
||||
guild.ban_with_reason(ctx, &user, days, &reason).await?;
|
||||
} else {
|
||||
guild.ban(ctx, &user, days).await?;
|
||||
}
|
||||
|
||||
let embed = create_moderation_embed("User banned!".to_string(), &user, Some(days), reason);
|
||||
|
||||
ctx.send(|m| m.embed(embed)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// kick a user
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
prefix_command,
|
||||
default_member_permissions = "KICK_MEMBERS"
|
||||
)]
|
||||
pub async fn kick_user(ctx: Context<'_>, user: User, reason: Option<String>) -> Result<()> {
|
||||
let guild = ctx
|
||||
.guild()
|
||||
.ok_or_else(|| eyre!("Couldn't get guild from message; Unable to ban!"))?;
|
||||
|
||||
let reason = reason.unwrap_or("n/a".to_string());
|
||||
|
||||
if reason != "n/a" {
|
||||
guild.kick_with_reason(ctx, &user, &reason).await?;
|
||||
} else {
|
||||
guild.kick(ctx, &user).await?;
|
||||
}
|
||||
|
||||
let embed = create_moderation_embed("User kicked!".to_string(), &user, None, reason);
|
||||
ctx.send(|m| m.embed(embed)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
3
src/commands/moderation/mod.rs
Normal file
3
src/commands/moderation/mod.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
mod actions;
|
||||
|
||||
pub use actions::*;
|
|
@ -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],
|
||||
});
|
||||
};
|
87
src/config/discord.rs
Normal file
87
src/config/discord.rs
Normal file
|
@ -0,0 +1,87 @@
|
|||
use crate::required_var;
|
||||
|
||||
use color_eyre::eyre::{Context as _, Result};
|
||||
use log::*;
|
||||
use poise::serenity_prelude::{ApplicationId, ChannelId};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RefractionOAuth2 {
|
||||
pub redirect_uri: Url,
|
||||
pub scope: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RefractionChannels {
|
||||
pub say_log_channel_id: Option<ChannelId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DiscordConfig {
|
||||
pub client_id: ApplicationId,
|
||||
pub client_secret: String,
|
||||
pub bot_token: String,
|
||||
pub oauth2: RefractionOAuth2,
|
||||
pub channels: RefractionChannels,
|
||||
}
|
||||
|
||||
impl Default for RefractionOAuth2 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
scope: "identify connections role_connections.write".to_string(),
|
||||
redirect_uri: Url::parse("https://google.com").unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RefractionOAuth2 {
|
||||
pub fn new_from_env() -> Result<Self> {
|
||||
let unparsed = format!("{}/oauth2/callback", required_var!("PUBLIC_URI"));
|
||||
let redirect_uri = Url::parse(&unparsed)?;
|
||||
|
||||
debug!("OAuth2 Redirect URI is {redirect_uri}");
|
||||
Ok(Self {
|
||||
redirect_uri,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RefractionChannels {
|
||||
pub fn new_from_env() -> Result<Self> {
|
||||
let unparsed = std::env::var("DISCORD_SAY_LOG_CHANNELID");
|
||||
if let Ok(unparsed) = unparsed {
|
||||
let id = unparsed.parse::<u64>()?;
|
||||
let channel_id = ChannelId::from(id);
|
||||
|
||||
debug!("Log channel is {id}");
|
||||
Ok(Self {
|
||||
say_log_channel_id: Some(channel_id),
|
||||
})
|
||||
} else {
|
||||
warn!("DISCORD_SAY_LOG_CHANNELID is empty; this will disable logging in your server.");
|
||||
Ok(Self {
|
||||
say_log_channel_id: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DiscordConfig {
|
||||
pub fn new_from_env() -> Result<Self> {
|
||||
let unparsed_client = required_var!("DISCORD_CLIENT_ID").parse::<u64>()?;
|
||||
let client_id = ApplicationId::from(unparsed_client);
|
||||
let client_secret = required_var!("DISCORD_CLIENT_SECRET");
|
||||
let bot_token = required_var!("DISCORD_BOT_TOKEN");
|
||||
let oauth2 = RefractionOAuth2::new_from_env()?;
|
||||
let channels = RefractionChannels::new_from_env()?;
|
||||
|
||||
Ok(Self {
|
||||
client_id,
|
||||
client_secret,
|
||||
bot_token,
|
||||
oauth2,
|
||||
channels,
|
||||
})
|
||||
}
|
||||
}
|
65
src/config/github.rs
Normal file
65
src/config/github.rs
Normal file
|
@ -0,0 +1,65 @@
|
|||
use color_eyre::eyre::{Context as _, Result};
|
||||
|
||||
use crate::required_var;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RefractionRepo {
|
||||
pub owner: String,
|
||||
pub repo: String,
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GithubConfig {
|
||||
pub token: String,
|
||||
pub repos: Vec<RefractionRepo>,
|
||||
pub cache_sec: u16,
|
||||
pub update_cron_job: String,
|
||||
}
|
||||
|
||||
impl Default for GithubConfig {
|
||||
fn default() -> Self {
|
||||
let owner = "PrismLauncher".to_string();
|
||||
let repos = Vec::<RefractionRepo>::from([
|
||||
RefractionRepo {
|
||||
owner: owner.clone(),
|
||||
repo: "PrismLauncher".to_string(),
|
||||
key: "launcher".to_string(),
|
||||
name: "Launcher contributor".to_string(),
|
||||
},
|
||||
RefractionRepo {
|
||||
owner: owner.clone(),
|
||||
repo: "prismlauncher.org".to_string(),
|
||||
|
||||
key: "website".to_string(),
|
||||
name: "Web developer".to_string(),
|
||||
},
|
||||
RefractionRepo {
|
||||
owner: owner.clone(),
|
||||
repo: "Translations".to_string(),
|
||||
|
||||
key: "translations".to_string(),
|
||||
name: "Translator".to_string(),
|
||||
},
|
||||
]);
|
||||
|
||||
Self {
|
||||
repos,
|
||||
cache_sec: 3600,
|
||||
update_cron_job: "0 */10 * * * *".to_string(), // every 10 minutes
|
||||
token: String::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GithubConfig {
|
||||
pub fn new_from_env() -> Result<Self> {
|
||||
let token = required_var!("GITHUB_TOKEN");
|
||||
|
||||
Ok(Self {
|
||||
token,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
39
src/config/mod.rs
Normal file
39
src/config/mod.rs
Normal file
|
@ -0,0 +1,39 @@
|
|||
use color_eyre::eyre::Result;
|
||||
|
||||
mod discord;
|
||||
mod github;
|
||||
|
||||
pub use discord::*;
|
||||
pub use github::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub discord: DiscordConfig,
|
||||
pub github: GithubConfig,
|
||||
pub http_port: u16,
|
||||
pub redis_url: String,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
discord: DiscordConfig::default(),
|
||||
github: GithubConfig::default(),
|
||||
http_port: 3000,
|
||||
redis_url: "redis://localhost:6379".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn new_from_env() -> Result<Self> {
|
||||
let discord = DiscordConfig::new_from_env()?;
|
||||
let github = GithubConfig::new_from_env()?;
|
||||
|
||||
Ok(Self {
|
||||
discord,
|
||||
github,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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 };
|
13
src/consts.rs
Normal file
13
src/consts.rs
Normal file
|
@ -0,0 +1,13 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
pub static COLORS: Lazy<HashMap<&str, (u8, u8, u8)>> = Lazy::new(|| {
|
||||
HashMap::from([
|
||||
("red", (239, 68, 68)),
|
||||
("green", (34, 197, 94)),
|
||||
("blue", (96, 165, 250)),
|
||||
("yellow", (253, 224, 71)),
|
||||
("orange", (251, 146, 60)),
|
||||
])
|
||||
});
|
42
src/handlers/error.rs
Normal file
42
src/handlers/error.rs
Normal file
|
@ -0,0 +1,42 @@
|
|||
use crate::consts::COLORS;
|
||||
use crate::Data;
|
||||
|
||||
use color_eyre::eyre::Report;
|
||||
use log::*;
|
||||
use poise::serenity_prelude::Timestamp;
|
||||
use poise::FrameworkError;
|
||||
|
||||
pub async fn handle(error: poise::FrameworkError<'_, Data, Report>) {
|
||||
match error {
|
||||
FrameworkError::Setup { error, .. } => error!("Error setting up client!\n{error:#?}"),
|
||||
|
||||
FrameworkError::Command { error, ctx } => {
|
||||
error!("Error in command {}:\n{error:?}", ctx.command().name);
|
||||
ctx.send(|c| {
|
||||
c.embed(|e| {
|
||||
e.title("Something went wrong!")
|
||||
.description("oopsie")
|
||||
.timestamp(Timestamp::now())
|
||||
.color(COLORS["orange"])
|
||||
})
|
||||
})
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
|
||||
FrameworkError::EventHandler {
|
||||
error,
|
||||
ctx: _,
|
||||
event,
|
||||
framework: _,
|
||||
} => {
|
||||
error!("Error while handling event {}:\n{error:?}", event.name());
|
||||
}
|
||||
|
||||
error => {
|
||||
if let Err(e) = poise::builtins::on_error(error).await {
|
||||
error!("Unhandled error occured:\n{e:#?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
24
src/handlers/event/mod.rs
Normal file
24
src/handlers/event/mod.rs
Normal file
|
@ -0,0 +1,24 @@
|
|||
use crate::Data;
|
||||
|
||||
use color_eyre::eyre::{Report, Result};
|
||||
use poise::serenity_prelude as serenity;
|
||||
use poise::{Event, FrameworkContext};
|
||||
|
||||
pub async fn handle(
|
||||
ctx: &serenity::Context,
|
||||
event: &Event<'_>,
|
||||
framework: FrameworkContext<'_, Data, Report>,
|
||||
data: &Data,
|
||||
) -> Result<()> {
|
||||
match event {
|
||||
Event::Ready { data_about_bot } => {
|
||||
log::info!("Logged in as {}!", data_about_bot.user.name)
|
||||
}
|
||||
|
||||
Event::Message { new_message } => {}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
91
src/main.rs
Normal file
91
src/main.rs
Normal file
|
@ -0,0 +1,91 @@
|
|||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use color_eyre::eyre::{eyre, Context as _, Report, Result};
|
||||
use config::Config;
|
||||
use log::*;
|
||||
use poise::{
|
||||
serenity_prelude as serenity, EditTracker, Framework, FrameworkOptions, PrefixFrameworkOptions,
|
||||
};
|
||||
|
||||
mod api;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod consts;
|
||||
mod handlers;
|
||||
mod utils;
|
||||
|
||||
type Context<'a> = poise::Context<'a, Data, Report>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Data {
|
||||
config: config::Config,
|
||||
redis: redis::Client,
|
||||
octocrab: Arc<octocrab::Octocrab>,
|
||||
}
|
||||
|
||||
impl Data {
|
||||
pub fn new() -> Result<Self> {
|
||||
let config = Config::new_from_env()?;
|
||||
let redis = redis::Client::open(config.redis_url.clone())?;
|
||||
let octocrab = octocrab::instance();
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
redis,
|
||||
octocrab,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
color_eyre::install()?;
|
||||
env_logger::init();
|
||||
|
||||
let token =
|
||||
std::env::var("TOKEN").wrap_err_with(|| eyre!("Couldn't find token in environment!"))?;
|
||||
|
||||
let intents =
|
||||
serenity::GatewayIntents::non_privileged() | serenity::GatewayIntents::MESSAGE_CONTENT;
|
||||
|
||||
let options = FrameworkOptions {
|
||||
commands: commands::to_global_commands(),
|
||||
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(EditTracker::for_timespan(Duration::from_secs(3600))),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let framework = Framework::builder()
|
||||
.token(token)
|
||||
.intents(intents)
|
||||
.options(options)
|
||||
.setup(|ctx, _ready, framework| {
|
||||
Box::pin(async move {
|
||||
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
|
||||
info!("Registered global commands!");
|
||||
|
||||
let data = Data::new()?;
|
||||
|
||||
Ok(data)
|
||||
})
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
result = framework.run() => { result.map_err(Report::from) },
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
info!("Interrupted! Exiting...");
|
||||
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();
|
||||
};
|
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;
|
||||
};
|
6
src/utils/macros.rs
Normal file
6
src/utils/macros.rs
Normal file
|
@ -0,0 +1,6 @@
|
|||
#[macro_export]
|
||||
macro_rules! required_var {
|
||||
($name: expr) => {
|
||||
std::env::var($name).wrap_err_with(|| format!("Couldn't find {} in environment!", $name))?
|
||||
};
|
||||
}
|
110
src/utils/mod.rs
Normal file
110
src/utils/mod.rs
Normal file
|
@ -0,0 +1,110 @@
|
|||
use crate::Context;
|
||||
|
||||
use color_eyre::eyre::{eyre, Result};
|
||||
use poise::serenity_prelude as serenity;
|
||||
use rand::seq::SliceRandom;
|
||||
use serenity::{CreateEmbed, Message};
|
||||
use url::Url;
|
||||
|
||||
#[macro_use]
|
||||
mod macros;
|
||||
|
||||
/*
|
||||
* chooses a random element from an array
|
||||
*/
|
||||
pub fn random_choice<const N: usize>(arr: [&str; N]) -> Result<String> {
|
||||
let mut rng = rand::thread_rng();
|
||||
let resp = arr
|
||||
.choose(&mut rng)
|
||||
.ok_or_else(|| eyre!("Couldn't choose random object from array:\n{arr:#?}!"))?;
|
||||
|
||||
Ok((*resp).to_string())
|
||||
}
|
||||
|
||||
// waiting for `round_char_boundary` to stabilize
|
||||
pub fn floor_char_boundary(s: &str, index: usize) -> usize {
|
||||
if index >= s.len() {
|
||||
s.len()
|
||||
} else {
|
||||
let lower_bound = index.saturating_sub(3);
|
||||
let new_index = s.as_bytes()[lower_bound..=index]
|
||||
.iter()
|
||||
.rposition(|&b| (b as i8) >= -0x40); // b.is_utf8_char_boundary
|
||||
|
||||
// Can be made unsafe but whatever
|
||||
lower_bound + new_index.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_url_as_embed(ctx: Context<'_>, url: String) -> Result<()> {
|
||||
let parsed = Url::parse(&url)?;
|
||||
|
||||
let title = parsed
|
||||
.path_segments()
|
||||
.unwrap()
|
||||
.last()
|
||||
.unwrap_or("image")
|
||||
.replace("%20", " ");
|
||||
|
||||
ctx.send(|c| c.embed(|e| e.title(title).image(&url).url(url)))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn resolve_message_to_embed(ctx: &serenity::Context, msg: &Message) -> CreateEmbed {
|
||||
let truncation_point = floor_char_boundary(&msg.content, 700);
|
||||
let truncated_content = if msg.content.len() <= truncation_point {
|
||||
msg.content.to_string()
|
||||
} else {
|
||||
format!("{}...", &msg.content[..truncation_point])
|
||||
};
|
||||
|
||||
let color = msg
|
||||
.member(ctx)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|m| m.highest_role_info(&ctx.cache))
|
||||
.and_then(|(role, _)| role.to_role_cached(&ctx.cache))
|
||||
.map(|role| role.colour);
|
||||
|
||||
let attached_image = msg
|
||||
.attachments
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
a.content_type
|
||||
.as_ref()
|
||||
.filter(|ct| ct.contains("image/"))
|
||||
.is_some()
|
||||
})
|
||||
.map(|a| &a.url)
|
||||
.next();
|
||||
|
||||
let attachments_len = msg.attachments.len();
|
||||
|
||||
let mut embed = msg
|
||||
.embeds
|
||||
.first()
|
||||
.map(|embed| CreateEmbed::from(embed.clone()))
|
||||
.unwrap_or_default();
|
||||
|
||||
embed.author(|author| author.name(&msg.author.name).icon_url(&msg.author.face()));
|
||||
|
||||
if let Some(color) = color {
|
||||
embed.color(color);
|
||||
}
|
||||
|
||||
if let Some(attachment) = attached_image {
|
||||
embed.image(attachment);
|
||||
}
|
||||
|
||||
if attachments_len > 1 {
|
||||
embed.footer(|footer| {
|
||||
// yes it will say '1 attachments' no i do not care
|
||||
footer.text(format!("{} attachments", attachments_len))
|
||||
});
|
||||
}
|
||||
|
||||
embed.description(truncated_content);
|
||||
embed
|
||||
}
|
|
@ -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,13 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"downlevelIteration": true,
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue