initial rewrite in rust & moderation commands

Signed-off-by: seth <getchoo@tuta.io>
This commit is contained in:
seth 2023-12-03 04:11:57 -05:00
parent b17e357b75
commit 45403e9d9b
No known key found for this signature in database
GPG key ID: D31BD0D494BBEE86
53 changed files with 3297 additions and 2820 deletions

10
.envrc
View file

@ -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

View file

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

View file

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

22
.gitignore vendored
View file

@ -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

File diff suppressed because it is too large Load diff

33
Cargo.toml Normal file
View 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"] }

View file

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

1562
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

21
src/api/dadjoke.rs Normal file
View 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
View 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
View 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}",
))
}
}

View 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(())
}

View 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(())
}

View 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;

View 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!()
}

View 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(())
}

View 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(())
}

View 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(())
}

View file

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

View file

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

20
src/commands/mod.rs Normal file
View 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(),
]
}

View 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(())
}

View file

@ -0,0 +1,3 @@
mod actions;
pub use actions::*;

View file

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

View file

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

View file

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

View file

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

View file

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

87
src/config/discord.rs Normal file
View 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
View 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
View 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()
})
}
}

View file

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

13
src/consts.rs Normal file
View 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
View 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
View 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
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

91
src/main.rs Normal file
View 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);
}
}
}

View file

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

View file

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

6
src/utils/macros.rs Normal file
View 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
View 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
}

View file

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

View file

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

View file

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

View file

@ -1,13 +0,0 @@
{
"compilerOptions": {
"strict": true,
"downlevelIteration": true,
"module": "esnext",
"target": "esnext",
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"noEmit": true
}
}