feat: v1.0.0 contrition
parent
f8375dba6e
commit
4c53043c4b
@ -0,0 +1,9 @@
|
|||||||
|
CORS_HOST=https://theprogrammershangout.com
|
||||||
|
REDIRECT_URI=https://theprogrammershangout.com/appeals/authorize
|
||||||
|
|
||||||
|
API_PORT=80
|
||||||
|
API_CLIENT_SECRET=<enter>
|
||||||
|
API_CLIENT_ID=<enter>
|
||||||
|
|
||||||
|
BOT_TOKEN=<enter>
|
||||||
|
BOT_PREFIX=!
|
@ -0,0 +1,10 @@
|
|||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
temp/
|
||||||
|
|
||||||
|
.env
|
||||||
|
ormlogs.log
|
||||||
|
*.sqlite
|
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"type": "sqlite",
|
||||||
|
"database": "./database.sqlite",
|
||||||
|
"synchronize": true,
|
||||||
|
"logging": [
|
||||||
|
"error",
|
||||||
|
"warn",
|
||||||
|
"info",
|
||||||
|
"log"
|
||||||
|
],
|
||||||
|
"logger": "file",
|
||||||
|
"entities": [
|
||||||
|
"src/entity/**/*.ts"
|
||||||
|
],
|
||||||
|
"cli": {
|
||||||
|
"entitiesDir": "src/entity"
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "contrition",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "A group of projects that will be used to manage appeals for punished users on [The Programmer's Hangout](https://discord.gg/programming).",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/index.ts",
|
||||||
|
"build": "ts-node --transpile-only src/index.ts",
|
||||||
|
"typeorm": "ts-node ./node_modules/typeorm/cli.js"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "ssh://git@dev.teamortix.com:222/hamza/contrition"
|
||||||
|
},
|
||||||
|
"author": "Hamza Ali",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/dotenv": "^8.2.0",
|
||||||
|
"axios": "^0.21.1",
|
||||||
|
"base64url": "^3.0.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"discord-akairo": "^8.1.0",
|
||||||
|
"discord.js": "^12.5.1",
|
||||||
|
"dotenv": "^8.2.0",
|
||||||
|
"express": "^4.17.1",
|
||||||
|
"prettier": "^2.3.2",
|
||||||
|
"reflect-metadata": "^0.1.10",
|
||||||
|
"sqlite3": "^5.0.0",
|
||||||
|
"typeorm": "0.2.29"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.12",
|
||||||
|
"@types/express": "^4.17.9",
|
||||||
|
"@types/luxon": "^1.25.0",
|
||||||
|
"@types/node": "^8.10.66",
|
||||||
|
"concurrently": "^5.3.0",
|
||||||
|
"nodemon": "^2.0.6",
|
||||||
|
"ts-node": "^9.1.1",
|
||||||
|
"typescript": "^4.1.3"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,94 @@
|
|||||||
|
import {
|
||||||
|
AkairoClient,
|
||||||
|
CommandHandler,
|
||||||
|
InhibitorHandler,
|
||||||
|
ListenerHandler,
|
||||||
|
} from "discord-akairo";
|
||||||
|
import { EventEmitter } from "events";
|
||||||
|
import { Config } from "../config/Config";
|
||||||
|
import { parseDuration } from "../config/Constants";
|
||||||
|
import { Appeal } from "../entity/Appeal";
|
||||||
|
import { errorEmbed } from "./Util";
|
||||||
|
|
||||||
|
class Bot extends AkairoClient {
|
||||||
|
static config: Config;
|
||||||
|
|
||||||
|
inhibitorHandler: InhibitorHandler;
|
||||||
|
commandHandler: CommandHandler;
|
||||||
|
listenerHandler: ListenerHandler;
|
||||||
|
|
||||||
|
constructor(config: Config, appealEmitter: EventEmitter) {
|
||||||
|
super({}, { partials: ["REACTION", "MESSAGE"] });
|
||||||
|
|
||||||
|
Bot.config = config;
|
||||||
|
super.login(config.bot.token);
|
||||||
|
|
||||||
|
this.inhibitorHandler = new InhibitorHandler(this, {
|
||||||
|
directory: `${__dirname}/inhibitors`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.commandHandler = new CommandHandler(this, {
|
||||||
|
directory: `${__dirname}/commands`,
|
||||||
|
prefix: config.bot.prefix,
|
||||||
|
argumentDefaults: {
|
||||||
|
prompt: {
|
||||||
|
retries: 0,
|
||||||
|
time: 15000,
|
||||||
|
cancel: () => errorEmbed("Command Cancelled"),
|
||||||
|
ended: () =>
|
||||||
|
errorEmbed("Invalid input").setDescription("Command ended."),
|
||||||
|
timeout: () =>
|
||||||
|
errorEmbed("Timed out").setDescription(
|
||||||
|
"No response received for 15 seconds."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.listenerHandler = new ListenerHandler(this, {
|
||||||
|
directory: `${__dirname}/listeners`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.listenerHandler.setEmitters({
|
||||||
|
commandHandler: this.commandHandler,
|
||||||
|
inhibitorHandler: this.inhibitorHandler,
|
||||||
|
listenerHandler: this.listenerHandler,
|
||||||
|
appealHandler: appealEmitter,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.commandHandler.useInhibitorHandler(this.inhibitorHandler);
|
||||||
|
|
||||||
|
this.commandHandler.resolver.addType("timestring", (_, phrase) => {
|
||||||
|
if (!phrase) return null;
|
||||||
|
|
||||||
|
const d = parseDuration(phrase);
|
||||||
|
if (d === "") return null;
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.commandHandler.resolver.addType("appeal", async (message, phrase) => {
|
||||||
|
if (!phrase) return null;
|
||||||
|
if (!message.guild) return null;
|
||||||
|
|
||||||
|
const appeal = await Appeal.appeal(phrase, message.guild.id)
|
||||||
|
.then((a) => a)
|
||||||
|
.catch((_) => null);
|
||||||
|
return appeal;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.commandHandler.resolver.addType("optionalDate", (_, phrase) => {
|
||||||
|
if (!phrase) return null;
|
||||||
|
if (phrase === "never") return "never";
|
||||||
|
if (phrase === "now") return "now";
|
||||||
|
const timestamp = Date.parse(phrase);
|
||||||
|
if (isNaN(timestamp)) return null;
|
||||||
|
return new Date(timestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.inhibitorHandler.loadAll();
|
||||||
|
this.commandHandler.loadAll();
|
||||||
|
this.listenerHandler.loadAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Bot;
|
@ -0,0 +1,85 @@
|
|||||||
|
import { Message, MessageEmbed, User } from "discord.js";
|
||||||
|
import Bot from "./Bot";
|
||||||
|
import { Guild } from "../entity/Guild";
|
||||||
|
|
||||||
|
export const successEmbed = (title: string) =>
|
||||||
|
new MessageEmbed().setTitle(title).setColor("#00AE86");
|
||||||
|
|
||||||
|
export const infoEmbed = (title: string) =>
|
||||||
|
new MessageEmbed().setTitle(title).setColor("#3498DB");
|
||||||
|
|
||||||
|
export const errorEmbed = (title: string) =>
|
||||||
|
new MessageEmbed().setTitle(title).setColor("#B93758");
|
||||||
|
|
||||||
|
export const decision = (
|
||||||
|
message: Message,
|
||||||
|
user: User,
|
||||||
|
filter: (m: Message) => boolean,
|
||||||
|
initial: MessageEmbed,
|
||||||
|
accept: (m: Message) => void,
|
||||||
|
cancel: () => void,
|
||||||
|
time: number = 15000
|
||||||
|
) => {
|
||||||
|
const init = message.channel.send(initial);
|
||||||
|
let done = false;
|
||||||
|
const collector = message.channel.createMessageCollector(
|
||||||
|
(m: Message) => m.author == user && !done,
|
||||||
|
{ time }
|
||||||
|
);
|
||||||
|
|
||||||
|
collector.on("collect", (m: Message) => {
|
||||||
|
collector.stop();
|
||||||
|
|
||||||
|
done = true;
|
||||||
|
m.delete().catch(() => {});
|
||||||
|
if (!filter(m)) return cancel();
|
||||||
|
accept(m);
|
||||||
|
});
|
||||||
|
collector.on("end", async () => {
|
||||||
|
(await init).delete();
|
||||||
|
!done && cancel();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const statusEmbed = async (message: Message) => {
|
||||||
|
const author = message.member;
|
||||||
|
const client = message.client;
|
||||||
|
|
||||||
|
const embed = infoEmbed("Contrition")
|
||||||
|
.setDescription("A bot designed to give you a second chance.")
|
||||||
|
.setThumbnail(client.user.displayAvatarURL())
|
||||||
|
.addField("Prefix", Bot.config.bot.prefix, true)
|
||||||
|
.addField("Ping", `${Date.now() - message.createdTimestamp}ms`, true)
|
||||||
|
.addField("Author", "`elkell#6226`", true)
|
||||||
|
.addField("\u200B", "\u200B");
|
||||||
|
|
||||||
|
const guild = await Guild.findOne({ where: { guild: author.guild.id } });
|
||||||
|
if (!guild) {
|
||||||
|
embed.addField("Configuration", "This guild is not configured yet.");
|
||||||
|
} else {
|
||||||
|
const channel = author.guild.channels.cache.get(guild.channel);
|
||||||
|
const staff = author.guild.roles.cache.get(guild.staffRole);
|
||||||
|
const jailed = author.guild.roles.cache.get(guild.jailedRole);
|
||||||
|
|
||||||
|
if (
|
||||||
|
author.hasPermission("ADMINISTRATOR") ||
|
||||||
|
author.roles.cache.some((role) => role == staff)
|
||||||
|
) {
|
||||||
|
embed.addField(
|
||||||
|
"Configuration",
|
||||||
|
"```fix\n" +
|
||||||
|
`Guild Name: ${guild.name}\n` +
|
||||||
|
`Logging Channel: #${channel.name}\n` +
|
||||||
|
`Staff Role: @${staff.name}\n` +
|
||||||
|
`Restricted Role: @${jailed.name}\n` +
|
||||||
|
`Default Rejection Expiry: ${guild.defaultExpiry}s\n` +
|
||||||
|
"```"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uptime = (new Date().getTime() - client.uptime) / 1e3;
|
||||||
|
embed.addField("Uptime", `Since <t:${Math.trunc(uptime)}:R>`, true);
|
||||||
|
embed.addField("Source", "[GitHub](https://github.com/hhhapz)", true);
|
||||||
|
return embed;
|
||||||
|
};
|
@ -0,0 +1,126 @@
|
|||||||
|
import { Command } from "discord-akairo";
|
||||||
|
import { TextChannel } from "discord.js";
|
||||||
|
import { Message } from "discord.js";
|
||||||
|
import { cancelString } from "../../config/Constants";
|
||||||
|
import { Appeal } from "../../entity/Appeal";
|
||||||
|
import { errorEmbed, infoEmbed, successEmbed } from "../Util";
|
||||||
|
|
||||||
|
class AcceptCommand extends Command {
|
||||||
|
constructor() {
|
||||||
|
super("accept", {
|
||||||
|
category: "Review",
|
||||||
|
aliases: ["Accept"],
|
||||||
|
description: "<appeal> [decision]",
|
||||||
|
clientPermissions: ["CREATE_INSTANT_INVITE", "BAN_MEMBERS"],
|
||||||
|
channel: "guild",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
id: "appeal",
|
||||||
|
type: "appeal",
|
||||||
|
prompt: {
|
||||||
|
start: () => infoEmbed("The appeal ID:").setFooter(cancelString),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "reason",
|
||||||
|
type: "string",
|
||||||
|
prompt: {
|
||||||
|
start: () =>
|
||||||
|
infoEmbed("(Optional) Decision note for appeal:")
|
||||||
|
.setDescription("Enter `none` to provide no reason")
|
||||||
|
.setFooter(cancelString),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async banned(
|
||||||
|
message: Message,
|
||||||
|
args: { appeal: Appeal; reason: string },
|
||||||
|
origMsg: Message,
|
||||||
|
): Promise<boolean> {
|
||||||
|
let { appeal, reason } = args;
|
||||||
|
if (reason === "none") reason = null;
|
||||||
|
|
||||||
|
const banned = await message.guild
|
||||||
|
.fetchBan(appeal.userID)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
if (!banned) {
|
||||||
|
message.channel.send(
|
||||||
|
infoEmbed("The user is not banned. Appeal status updated.")
|
||||||
|
.addField("Original Message", `[link](${appeal.link()}).`)
|
||||||
|
.setFooter(appeal.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
appeal.accept(message.author.id, reason);
|
||||||
|
const embed = appeal.embed(true).setColor("#00AE86");
|
||||||
|
origMsg.edit(embed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return banned;
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(message: Message, args: { appeal: Appeal; reason: string }) {
|
||||||
|
let { appeal, reason } = args;
|
||||||
|
if (reason === "none") reason = null;
|
||||||
|
|
||||||
|
const channel = await message.client.channels
|
||||||
|
.fetch(appeal.guild.channel)
|
||||||
|
.catch(() => null);
|
||||||
|
if (!channel || !channel?.isText()) {
|
||||||
|
return errorEmbed("Could not update. Guild configuration invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const origMsg = await (channel as TextChannel).messages
|
||||||
|
.fetch(appeal.message)
|
||||||
|
.catch(() => null);
|
||||||
|
if (!origMsg) {
|
||||||
|
return errorEmbed("Could not update original message");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["accepted", "rejected"].includes(appeal.status)) {
|
||||||
|
return message.channel.send(
|
||||||
|
errorEmbed("Could not accept")
|
||||||
|
.addField("Error", "Cannot accept appeal with decision already made.")
|
||||||
|
.addField("Status", appeal.status)
|
||||||
|
.setFooter(appeal.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await this.banned(message, args, origMsg))) return;
|
||||||
|
|
||||||
|
let err: Error = await message.guild.members
|
||||||
|
.unban(appeal.userID)
|
||||||
|
.then(() => null)
|
||||||
|
.catch((e) => e);
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
message.channel.send(
|
||||||
|
errorEmbed("An error occurred")
|
||||||
|
.setDescription("Could not unban user.")
|
||||||
|
.addField("Original Message", `[link](${appeal.link()}).`)
|
||||||
|
.addField("Status", "The appeal NOT updated.")
|
||||||
|
.addField("Error", err.message)
|
||||||
|
.setFooter(appeal.id)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
appeal.accept(message.author.id, reason);
|
||||||
|
origMsg.edit(appeal.embed(true).setColor("#00AE86"));
|
||||||
|
|
||||||
|
message.channel.send(
|
||||||
|
successEmbed("The appeal has been accepted.")
|
||||||
|
.setDescription(`${appeal.userTag} is unbanned`)
|
||||||
|
.addField("Original Message", `[link](${appeal.link()})`)
|
||||||
|
.setFooter(appeal.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
appeal.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AcceptCommand;
|
@ -0,0 +1,33 @@
|
|||||||
|
import { Command } from "discord-akairo";
|
||||||
|
import { Message } from "discord.js";
|
||||||
|
import { cancelString } from "../../config/Constants";
|
||||||
|
import { Appeal } from "../../entity/Appeal";
|
||||||
|
import { infoEmbed } from "../Util";
|
||||||
|
|
||||||
|
class AppealCommand extends Command {
|
||||||
|
constructor() {
|
||||||
|
super("appeal", {
|
||||||
|
category: "Review",
|
||||||
|
aliases: ["Appeal"],
|
||||||
|
description: "<appeal>",
|
||||||
|
clientPermissions: ["CREATE_INSTANT_INVITE", "BAN_MEMBERS"],
|
||||||
|
channel: "guild",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
id: "appeal",
|
||||||
|
type: "appeal",
|
||||||
|
prompt: {
|
||||||
|
start: () => infoEmbed("The appeal ID:").setFooter(cancelString),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(message: Message, args: { appeal: Appeal }) {
|
||||||
|
const { appeal } = args;
|
||||||
|
message.reply(appeal.embed(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppealCommand;
|
@ -0,0 +1,30 @@
|
|||||||
|
import { Category, Command } from "discord-akairo";
|
||||||
|
import { Message } from "discord.js";
|
||||||
|
import { infoEmbed } from "../Util";
|
||||||
|
|
||||||
|
class Help extends Command {
|
||||||
|
constructor() {
|
||||||
|
super("help", {
|
||||||
|
category: "Utility",
|
||||||
|
aliases: ["Help"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
exec(message: Message) {
|
||||||
|
const embed = infoEmbed("Help menu").setDescription(
|
||||||
|
"Here is a list of available commands."
|
||||||
|
);
|
||||||
|
|
||||||
|
this.handler.categories.forEach((c: Category<any, any>, k: string) => {
|
||||||
|
const value = c
|
||||||
|
.map((cmd: Command) => cmd.aliases[0] + " " + (cmd.description || ""))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
embed.addField(k, "```fix\n" + value + "```");
|
||||||
|
});
|
||||||
|
|
||||||
|
message.reply(embed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Help;
|
@ -0,0 +1,98 @@
|
|||||||
|
import { Command } from "discord-akairo";
|
||||||
|
import { Message } from "discord.js";
|
||||||
|
import { paginate, reactions } from "../../config/Constants";
|
||||||
|
import { Appeal } from "../../entity/Appeal";
|
||||||
|
import { Guild } from "../../entity/Guild";
|
||||||
|
import { infoEmbed } from "../Util";
|
||||||
|
|
||||||
|
class History extends Command {
|
||||||
|
constructor() {
|
||||||
|
super("history", {
|
||||||
|
category: "Review",
|
||||||
|
aliases: ["History"],
|
||||||
|
description: "<user> [page]",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
id: "user",
|
||||||
|
type: "string",
|
||||||
|
prompt: {
|
||||||
|
start: () =>
|
||||||
|
infoEmbed("The ID for the user:").setFooter(
|
||||||
|
'You can cancel this at any time by saying "cancel".'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pageNum",
|
||||||
|
type: "number",
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(
|
||||||
|
message: Message,
|
||||||
|
{ user, pageNum }: { user: string; pageNum: number }
|
||||||
|
) {
|
||||||
|
const userRe = /<@!?(\d+)>/.exec(user);
|
||||||
|
if (userRe) {
|
||||||
|
user = userRe[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = await Guild.findOneOrFail({
|
||||||
|
where: { guild: message.member.guild.id },
|
||||||
|
});
|
||||||
|
const appeals = await Appeal.allAppeals(user, guild.id);
|
||||||
|
const page = paginate(appeals, 10, pageNum);
|
||||||
|
|
||||||
|
if (appeals.length === 0) {
|
||||||
|
message.channel.send(infoEmbed(`The specified user has 0 appeals.`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = (pageNum - 1) * 10 + 1;
|
||||||
|
const footer = `Showing appeals ${start} to ${
|
||||||
|
start + page.length - 1
|
||||||
|
}. Total ${appeals.length} appeal(s).`;
|
||||||
|
|
||||||
|
const embed = this.createEmbed(page[0], start, footer);
|
||||||
|
const msg = await message.channel.send(embed);
|
||||||
|
|
||||||
|
if (appeals.length === 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
page.forEach((_, i) => msg.react(reactions[i]).catch(() => {}));
|
||||||
|
|
||||||
|
const collector = msg.createReactionCollector(
|
||||||
|
(reaction, user) => {
|
||||||
|
return (
|
||||||
|
!user.bot &&
|
||||||
|
reactions.slice(0, page.length).indexOf(reaction.emoji.name) != -1
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ time: 60000 }
|
||||||
|
);
|
||||||
|
collector.on("collect", (reaction, user) => {
|
||||||
|
const index = appeals.indexOf(
|
||||||
|
page[reactions.indexOf(reaction.emoji.name)]
|
||||||
|
);
|
||||||
|
msg.edit(this.createEmbed(appeals[index], index + 1, footer));
|
||||||
|
msg.reactions.resolve(reaction.emoji.name).users.remove(user);
|
||||||
|
});
|
||||||
|
collector.on("end", () => {
|
||||||
|
msg.reactions.removeAll().catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createEmbed(appeal: Appeal, index: number, footer: string) {
|
||||||
|
const embed = appeal.embed(false);
|
||||||
|
embed
|
||||||
|
.setTitle(`${embed.title} - (${index})`)
|
||||||
|
.setFooter(`${embed.footer.text}\n${footer}`);
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default History;
|
@ -0,0 +1,19 @@
|
|||||||
|
import { Command } from "discord-akairo";
|
||||||
|
import { Message } from "discord.js";
|
||||||
|
import { statusEmbed } from "../Util";
|
||||||
|
|
||||||
|
class Info extends Command {
|
||||||
|
constructor() {
|
||||||
|
super("info", {
|
||||||
|
category: "Utility",
|
||||||
|
aliases: ["Info"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(message: Message) {
|
||||||
|
const embed = await statusEmbed(message);
|
||||||
|
message.channel.send(embed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Info;
|
@ -0,0 +1,80 @@
|
|||||||
|
import { Command } from "discord-akairo";
|
||||||
|
import { Message, TextChannel } from "discord.js";
|
||||||
|
import { cancelString } from "../../config/Constants";
|
||||||
|
import { Appeal } from "../../entity/Appeal";
|
||||||
|
import { errorEmbed, infoEmbed, successEmbed } from "../Util";
|
||||||
|
|
||||||
|
class RejectCommand extends Command {
|
||||||
|
constructor() {
|
||||||
|
super("reject", {
|
||||||
|
category: "Review",
|
||||||
|
aliases: ["Reject"],
|
||||||
|
description: "<appeal> [decision]",
|
||||||
|
clientPermissions: ["CREATE_INSTANT_INVITE", "BAN_MEMBERS"],
|
||||||
|
channel: "guild",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
id: "appeal",
|
||||||
|
type: "appeal",
|
||||||
|
prompt: {
|
||||||
|
start: () => infoEmbed("The appeal ID:").setFooter(cancelString),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "reason",
|
||||||
|
type: "string",
|
||||||
|
prompt: {
|
||||||
|
start: () =>
|
||||||
|
infoEmbed("(Optional) Decision note for appeal:")
|
||||||
|
.setDescription("Enter `none` to provide no reason")
|
||||||
|
.setFooter(cancelString),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(message: Message, args: { appeal: Appeal; reason: string }) {
|
||||||
|
let { appeal, reason } = args;
|
||||||
|
if (reason === "none") reason = null;
|
||||||
|
|
||||||
|
if (["accepted", "rejected"].includes(appeal.status)) {
|
||||||
|
return message.channel.send(
|
||||||
|
errorEmbed("Could not accept")
|
||||||
|
.addField("Error", "Cannot reject appeal with decision already made.")
|
||||||
|
.addField("Status", appeal.status)
|
||||||
|
.setFooter(appeal.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
appeal.reject(message.author.id, reason);
|
||||||
|
|
||||||
|
const channel = await message.client.channels
|
||||||
|
.fetch(appeal.guild.channel)
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
if (!channel || !channel?.isText()) {
|
||||||
|
return errorEmbed("Could not update. Guild configuration invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const origMsg = await (channel as TextChannel).messages
|
||||||
|
.fetch(appeal.message)
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
if (!origMsg) {
|
||||||
|
return errorEmbed("Could not update original message");
|
||||||
|
}
|
||||||
|
|
||||||
|
message.channel.send(
|
||||||
|
successEmbed("The appeal has been rejected.")
|
||||||
|
.setDescription(`${appeal.userTag} will stay banned.`)
|
||||||
|
.addField("Original Message", `[link](${appeal.link()})`)
|
||||||
|
.setFooter(appeal.id)
|
||||||
|
);
|
||||||
|
origMsg.edit(appeal.embed(true).setColor("#B93758"));
|
||||||
|
|
||||||
|
appeal.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RejectCommand;
|
@ -0,0 +1,58 @@
|
|||||||
|
import { Command } from "discord-akairo";
|
||||||
|
import { Channel } from "discord.js";
|
||||||
|
import { Message } from "discord.js";
|
||||||
|
import { cancelString } from "../../config/Constants";
|
||||||
|
import { Guild } from "../../entity/Guild";
|
||||||
|
|
||||||
|
import { infoEmbed, successEmbed } from "../Util";
|
||||||
|
|
||||||
|
class SetChannel extends Command {
|
||||||
|
constructor() {
|
||||||
|
super("setChannel", {
|
||||||
|
category: "Server Settings",
|
||||||
|
aliases: ["SetChannel"],
|
||||||
|
description: "<channel>",
|
||||||
|
clientPermissions: ["CREATE_INSTANT_INVITE", "BAN_MEMBERS"],
|
||||||
|
channel: "guild",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
id: "newChannel",
|
||||||
|
type: "channel",
|
||||||
|
prompt: {
|
||||||
|
start: () =>
|
||||||
|
infoEmbed("The new channel for appeal posts:").setFooter(
|
||||||
|
cancelString
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(message: Message, args: { newChannel: Channel }) {
|
||||||
|
const { newChannel } = args;
|
||||||
|
const author = message.member;
|
||||||
|
if (author === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = await Guild.findOneOrFail({
|
||||||
|
where: { guild: author.guild.id },
|
||||||
|
});
|
||||||
|
guild.channel = newChannel.id;
|
||||||
|
guild.save();
|
||||||
|
|
||||||
|
message.reply(
|
||||||
|
successEmbed("Guild Settings Updated")
|
||||||
|
.addField("Name", guild.name, true)
|
||||||
|
.addField("Post Channel", `<#${guild.channel}>`, true)
|
||||||
|
.addField("\u200b", "\u200b", true)
|
||||||
|
.addField("Staff Role", `<@&${guild.staffRole}>`, true)
|
||||||
|
.addField("Restricted Role", `<@&${guild.jailedRole}>`, true)
|
||||||
|
.addField("\u200b", "\u200b", true)
|
||||||
|
.addField("Minimum time before re-appeal", guild.defaultExpiry)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SetChannel;
|
@ -0,0 +1,64 @@
|
|||||||
|
import { Command } from "discord-akairo";
|
||||||
|
import { Message } from "discord.js";
|
||||||
|
import { cancelString } from "../../config/Constants";
|
||||||
|
import { Guild } from "../../entity/Guild";
|
||||||
|
import { infoEmbed, successEmbed } from "../Util";
|
||||||
|
|
||||||
|
class SetDefaultExpiry extends Command {
|
||||||
|
constructor() {
|
||||||
|
super("setDefaultExpiry", {
|
||||||
|
category: "Server Settings",
|
||||||
|
aliases: ["SetDefaultExpiry"],
|
||||||
|
description: "<time>",
|
||||||
|
clientPermissions: ["CREATE_INSTANT_INVITE", "BAN_MEMBERS"],
|
||||||
|
channel: "guild",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
id: "newExpiry",
|
||||||
|
type: "timestring",
|
||||||
|
prompt: {
|
||||||
|
start: () =>
|
||||||
|
infoEmbed("The timestring:")
|
||||||
|
.setDescription(
|
||||||
|
"The minimum default time before users may appeal again after a rejection.\n" +
|
||||||
|
"For no expiry, specify `never`"
|
||||||
|
)
|
||||||
|
.addField(
|
||||||
|
"Valid Durations",
|
||||||
|
"```1d - days\n1w - weeks\n1m - months\n1y - years```"
|
||||||
|
)
|
||||||
|
.addField(
|
||||||
|
"Examples",
|
||||||
|
"```\n1m - 1 month\n1d - 1 day\n1m 3d - 1 month 3 days\n1y1m - 1 year 1 month```"
|
||||||
|
)
|
||||||
|
.setFooter(cancelString),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(message: Message, args: { newExpiry: string }) {
|
||||||
|
const { newExpiry } = args;
|
||||||
|
const author = message.member;
|
||||||
|
|
||||||
|
const guild = await Guild.findOneOrFail({
|
||||||
|
where: { guild: author.guild.id },
|
||||||
|
});
|
||||||
|
guild.defaultExpiry = newExpiry;
|
||||||
|
guild.save();
|
||||||
|
|
||||||
|
message.reply(
|
||||||
|
successEmbed("Guild Settings Updated")
|
||||||
|
.addField("Name", guild.name, true)
|
||||||
|
.addField("Post Channel", `<#${guild.channel}>`, true)
|
||||||
|
.addField("\u200b", "\u200b", true)
|
||||||
|
.addField("Staff Role", `<@&${guild.staffRole}>`, true)
|
||||||
|
.addField("Restricted Role", `<@&${guild.jailedRole}>`, true)
|
||||||
|
.addField("\u200b", "\u200b", true)
|
||||||
|
.addField("Minimum time before re-appeal", guild.defaultExpiry)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SetDefaultExpiry;
|
@ -0,0 +1,91 @@
|
|||||||
|
import { Command } from "discord-akairo";
|
||||||
|
import { TextChannel } from "discord.js";
|
||||||
|
import { Message } from "discord.js";
|
||||||
|
import { cancelString } from "../../config/Constants";
|
||||||
|
import { Appeal } from "../../entity/Appeal";
|
||||||
|
import { errorEmbed, infoEmbed, successEmbed } from "../Util";
|
||||||
|
|
||||||
|
class SetExpiry extends Command {
|
||||||
|
constructor() {
|
||||||
|
super("setExpiry", {
|
||||||
|
category: "Review",
|
||||||
|
aliases: ["SetExpiry"],
|
||||||
|
description: "<appeal> <date>",
|
||||||
|
clientPermissions: ["CREATE_INSTANT_INVITE", "BAN_MEMBERS"],
|
||||||
|
channel: "guild",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
id: "appeal",
|
||||||
|
type: "appeal",
|
||||||
|
prompt: {
|
||||||
|
start: () => infoEmbed("The appeal ID:").setFooter(cancelString),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "date",
|
||||||
|
type: "optionalDate",
|
||||||
|
prompt: {
|
||||||
|
start: () =>
|
||||||
|
infoEmbed("The date of expiry:")
|
||||||
|
.setDescription(
|
||||||
|
"Specify JS dateString,\nor specify `never`,\nor specify `now`.\n\nDate must be in the future."
|
||||||
|
)
|
||||||
|
.setFooter(cancelString),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(
|
||||||
|
message: Message,
|
||||||
|
args: { appeal: Appeal; date: Date | "never" | "now" }
|
||||||
|
) {
|
||||||
|
const { appeal, date } = args;
|
||||||
|
if (appeal.status !== "rejected") {
|
||||||
|
return message.reply(
|
||||||
|
errorEmbed("Invalid Appeal Status")
|
||||||
|
.setDescription("The specified appeal's status must be 'rejected'.")
|
||||||
|
.addField("Current Appeal Status", appeal.status)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date === "never") appeal.rejectionExpiry = null;
|
||||||
|
else if (date === "now") appeal.rejectionExpiry = new Date();
|
||||||
|
else appeal.rejectionExpiry = date;
|
||||||
|
|
||||||
|
if (date < new Date()) {
|
||||||
|
return message.reply(
|
||||||
|
errorEmbed("Invalid Date provided").addField(
|
||||||
|
"Parsed Date:",
|
||||||
|
date.toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
appeal.save();
|
||||||
|
message.reply(
|
||||||
|
successEmbed("Appeal expiry updated").setDescription(
|
||||||
|
`View original message: [link](${appeal.link()}).`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const channel = await message.client.channels
|
||||||
|
.fetch(appeal.guild.channel)
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
if (!channel || !channel?.isText()) {
|
||||||
|
return errorEmbed("Could not update. Guild configuration invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = await (channel as TextChannel).messages
|
||||||
|
.fetch(appeal.message)
|
||||||
|
.catch(() => null);
|
||||||
|
if (!msg) {
|
||||||
|
return errorEmbed("Could not update original message");
|
||||||
|
}
|
||||||
|
msg.edit(appeal.embed(true).setColor("#B93758"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SetExpiry;
|
@ -0,0 +1,60 @@
|
|||||||
|
import { Command } from "discord-akairo";
|
||||||
|
import { Message } from "discord.js";
|
||||||
|
import { cancelString } from "../../config/Constants";
|
||||||
|
import { Guild } from "../../entity/Guild";
|
||||||
|
|
||||||
|
import { errorEmbed, infoEmbed, successEmbed } from "../Util";
|
||||||
|
|
||||||
|
class SetName extends Command {
|
||||||
|
constructor() {
|
||||||
|
super("setName", {
|
||||||
|
category: "Server Settings",
|
||||||
|
aliases: ["SetName"],
|
||||||
|
description: "<name>",
|
||||||
|
clientPermissions: ["CREATE_INSTANT_INVITE", "BAN_MEMBERS"],
|
||||||
|
channel: "guild",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
id: "newName",
|
||||||
|
type: "string",
|
||||||
|
prompt: {
|
||||||
|
start: () =>
|
||||||
|
infoEmbed("The new name for the guild:").setFooter(cancelString),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(message: Message, { newName }: { newName: string }) {
|
||||||
|
const author = message.member;
|
||||||
|
if (author === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await Guild.count({ where: { name: newName } })) {
|
||||||
|
return message.reply(
|
||||||
|
errorEmbed("A guild has already registered with that name.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = await Guild.findOneOrFail({
|
||||||
|
where: { guild: author.guild.id },
|
||||||
|
});
|
||||||
|
guild.name = newName;
|
||||||
|
guild.save();
|
||||||
|
|
||||||
|
message.reply(
|
||||||
|
successEmbed("Guild Settings Updated")
|
||||||
|
.addField("Name", guild.name, true)
|
||||||
|
.addField("Post Channel", `<#${guild.channel}>`, true)
|
||||||
|
.addField("\u200b", "\u200b", true)
|
||||||
|
.addField("Staff Role", `<@&${guild.staffRole}>`, true)
|
||||||
|
.addField("Restricted Role", `<@&${guild.jailedRole}>`, true)
|
||||||
|
.addField("\u200b", "\u200b", true)
|
||||||
|
.addField("Minimum time before re-appeal", guild.defaultExpiry)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SetName;
|
@ -0,0 +1,58 @@
|
|||||||
|
import { Command } from "discord-akairo";
|
||||||
|
import { Role } from "discord.js";
|
||||||
|
import { Message } from "discord.js";
|
||||||
|
import { cancelString } from "../../config/Constants";
|
||||||
|
import { Guild } from "../../entity/Guild";
|
||||||
|
|
||||||
|
import { infoEmbed, successEmbed } from "../Util";
|
||||||
|
|
||||||
|
class SetRestrictedRole extends Command {
|
||||||
|
constructor() {
|
||||||
|
super("setRestrictedRole", {
|
||||||
|
category: "Server Settings",
|
||||||
|
aliases: ["SetRestrictedRole"],
|
||||||
|
description: "<role>",
|
||||||
|
clientPermissions: ["CREATE_INSTANT_INVITE", "BAN_MEMBERS"],
|
||||||
|
channel: "guild",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
id: "newRole",
|
||||||
|
type: "role",
|
||||||
|
prompt: {
|
||||||
|
start: () =>
|
||||||
|
infoEmbed("The new role for restricted access:").setFooter(
|
||||||
|
cancelString
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(message: Message, args: { newRole: Role }) {
|
||||||
|
const { newRole } = args;
|
||||||
|
const author = message.member;
|
||||||
|
if (author === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = await Guild.findOneOrFail({
|
||||||
|
where: { guild: author.guild.id },
|
||||||
|
});
|
||||||
|
guild.jailedRole = newRole.id;
|
||||||
|
guild.save();
|
||||||
|
|
||||||
|
message.reply(
|
||||||
|
successEmbed("Guild Settings Updated")
|
||||||
|
.addField("Name", guild.name, true)
|
||||||
|
.addField("Post Channel", `<#${guild.channel}>`, true)
|
||||||
|
.addField("\u200b", "\u200b", true)
|
||||||
|
.addField("Staff Role", `<@&${guild.staffRole}>`, true)
|
||||||
|
.addField("Restricted Role", `<@&${guild.jailedRole}>`, true)
|
||||||
|
.addField("\u200b", "\u200b", true)
|
||||||
|
.addField("Minimum time before re-appeal", guild.defaultExpiry)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SetRestrictedRole;
|
@ -0,0 +1,58 @@
|
|||||||
|
import { Command } from "discord-akairo";
|
||||||
|
import { Role } from "discord.js";
|
||||||
|
import { Message } from "discord.js";
|
||||||
|
import { Guild } from "../../entity/Guild";
|
||||||
|
|
||||||
|
import { infoEmbed, successEmbed } from "../Util";
|
||||||
|
|
||||||
|
const cancelString = 'You can cancel this at any time by saying "cancel".';
|
||||||
|
class SetStaffRole extends Command {
|
||||||
|
constructor() {
|
||||||
|
super("SetStaffRole", {
|
||||||
|
category: "Server Settings",
|
||||||
|
aliases: ["SetStaffRole"],
|
||||||
|
description: "<role>",
|
||||||
|
clientPermissions: ["CREATE_INSTANT_INVITE", "BAN_MEMBERS"],
|
||||||
|
channel: "guild",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
id: "newRole",
|
||||||
|
type: "role",
|
||||||
|
prompt: {
|
||||||
|
start: () =>
|
||||||
|
infoEmbed("The new role for staff users:").setFooter(
|
||||||
|
cancelString
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(message: Message, args: { newRole: Role }) {
|
||||||
|
const { newRole } = args;
|
||||||
|
const author = message.member;
|
||||||
|
if (author === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = await Guild.findOneOrFail({
|
||||||
|
where: { guild: author.guild.id },
|
||||||
|
});
|
||||||
|
guild.jailedRole = newRole.id;
|
||||||
|
guild.save();
|
||||||
|
|
||||||
|
message.reply(
|
||||||
|
successEmbed("Guild Settings Updated")
|
||||||
|
.addField("Name", guild.name, true)
|
||||||
|
.addField("Post Channel", `<#${guild.channel}>`, true)
|
||||||
|
.addField("\u200b", "\u200b", true)
|
||||||
|
.addField("Staff Role", `<@&${guild.staffRole}>`, true)
|
||||||
|
.addField("Restricted Role", `<@&${guild.jailedRole}>`, true)
|
||||||
|
.addField("\u200b", "\u200b", true)
|
||||||
|
.addField("Minimum time before re-appeal", guild.defaultExpiry)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SetStaffRole;
|
@ -0,0 +1,157 @@
|
|||||||
|
import { Command } from "discord-akairo";
|
||||||
|
import { Channel } from "discord.js";
|
||||||
|
import { Role } from "discord.js";
|
||||||
|
import { Message } from "discord.js";
|
||||||
|
import { cancelString } from "../../config/Constants";
|
||||||
|
import { Guild } from "../../entity/Guild";
|
||||||
|
|
||||||
|
import { errorEmbed, infoEmbed, successEmbed } from "../Util";
|
||||||
|
|
||||||
|
class Setup extends Command {
|
||||||
|
constructor() {
|
||||||
|
super("setup", {
|
||||||
|
category: "Utility",
|
||||||
|
aliases: ["Setup"],
|
||||||
|
clientPermissions: ["CREATE_INSTANT_INVITE", "BAN_MEMBERS"],
|
||||||
|
userPermissions: ["ADMINISTRATOR"],
|
||||||
|
channel: "guild",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
type: "string",
|
||||||
|
prompt: {
|
||||||
|
start: () =>
|
||||||
|
infoEmbed("Please provide the following information.")
|
||||||
|
.setDescription("1. Supply a name for this guild.")
|
||||||
|
.setFooter(cancelString),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "channel",
|
||||||
|
type: "textChannel",
|
||||||
|
prompt: {
|
||||||
|
start: () =>
|
||||||
|
successEmbed("Success")
|
||||||
|
.setDescription("2. Supply a channel for posting appeals.")
|
||||||
|
.setFooter(cancelString),
|
||||||
|
retry: errorEmbed("Error")
|
||||||
|
.setDescription(
|
||||||
|
"Please try again. Mention a channel or provide its ID."
|
||||||
|
)
|
||||||
|
.setFooter(cancelString),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "staffRole",
|
||||||
|
type: "role",
|
||||||
|
prompt: {
|
||||||
|
start: () =>
|
||||||
|
successEmbed("Success")
|
||||||
|
.setDescription(
|
||||||
|
"3. Enter the staff role ID or mention the staff role to access and use this bot"
|
||||||
|
)
|
||||||
|
.setFooter(cancelString),
|
||||||
|
retry: errorEmbed("Error")
|
||||||
|
.setDescription(
|
||||||
|
"Please try again. Mention a role or provide its ID."
|
||||||
|
)
|
||||||
|
.setFooter(cancelString),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "jailedRole",
|
||||||
|
type: "role",
|
||||||
|
prompt: {
|
||||||
|
start: () =>
|
||||||
|
successEmbed("Success")
|
||||||
|
.setDescription(
|
||||||
|
"3. Enter the role ID or mention the role for users who join for further questioning (i.e the muted role)"
|
||||||
|
)
|
||||||
|
.setFooter(cancelString),
|
||||||
|
retry: errorEmbed("Error")
|
||||||
|
.setDescription(
|
||||||
|
"Please try again. Mention a role or provide its ID."
|
||||||
|
)
|
||||||
|
.setFooter(cancelString),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "defaultExpiry",
|
||||||
|
type: "timestring",
|
||||||
|
prompt: {
|
||||||
|
start: () =>
|
||||||
|
successEmbed("Success")
|
||||||
|
.setDescription(
|
||||||
|
"4. Enter a time string for the minimum time before users can appeal after a rejection."
|
||||||
|
)
|
||||||
|
.addField(
|
||||||
|
"Valid Durations",
|
||||||
|
"```1d - days\n1w - weeks\n1m - months\n1y - years```"
|
||||||
|
)
|
||||||
|
.addField(
|
||||||
|
"Examples",
|
||||||
|
"```\n1m - 1 month\n1d - 1 day\n1m 3d - 1 month 3 days\n1y1m - 1 year 1 month```"
|
||||||
|
)
|
||||||
|
.setFooter(cancelString),
|
||||||
|
retry: errorEmbed("Error")
|
||||||
|
.setDescription("Please try again with a valid time string.")
|
||||||
|
.setFooter(cancelString),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(
|
||||||
|
message: Message,
|
||||||
|
args: {
|
||||||
|
name: string;
|
||||||
|
channel: Channel;
|
||||||
|
staffRole: Role;
|
||||||
|
jailedRole: Role;
|
||||||
|
defaultExpiry: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { name, channel, staffRole, jailedRole, defaultExpiry } = args;
|
||||||
|
const author = message.member;
|
||||||
|
if (author === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await Guild.count({ where: { guild: author.guild.id } })) {
|
||||||
|
return message.channel.send(errorEmbed("This guild is already setup."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await Guild.count({ where: { name: args.name } })) {
|
||||||
|
return message.channel.send(
|
||||||
|
errorEmbed("A guild has already registered with that name.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = new Guild(
|
||||||
|
name,
|
||||||
|
author.guild.id,
|
||||||
|
channel.id,
|
||||||
|
staffRole.id,
|
||||||
|
jailedRole.id,
|
||||||
|
defaultExpiry
|
||||||
|
);
|
||||||
|
guild.save();
|
||||||
|
|
||||||
|
message.channel.send(
|
||||||
|
successEmbed("Guild Setup Complete")
|
||||||
|
.setDescription(
|
||||||
|
"The guild has successfully been setup, and will listen for appeal requests."
|
||||||
|
)
|
||||||
|
.addField("Name", name, true)
|
||||||
|
.addField("Post Channel", `<#${guild.channel}>`, true)
|
||||||
|
.addField("\u200b", "\u200b", true)
|
||||||
|
.addField("Staff Role", `<@&${guild.staffRole}>`, true)
|
||||||
|
.addField("Restricted Role", `<@&${guild.jailedRole}>`, true)
|
||||||
|
.addField("\u200b", "\u200b", true)
|
||||||
|
.addField("Minimum time before re-appeal", defaultExpiry || "Never")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Setup;
|
@ -0,0 +1,28 @@
|
|||||||
|
import { Command, Inhibitor } from "discord-akairo";
|
||||||
|
import { Role } from "discord.js";
|
||||||
|
import { Message } from "discord.js";
|
||||||
|
import { Guild } from "../../entity/Guild";
|
||||||
|
|
||||||
|
class RegisteredGuild extends Inhibitor {
|
||||||
|
constructor() {
|
||||||
|
super("HasRole", {
|
||||||
|
reason: "permissions",
|
||||||
|
type: "post",
|
||||||
|
priority: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(message: Message, command: Command) {
|
||||||
|
const author = message.member
|
||||||
|
if (!author) return false
|
||||||
|
|
||||||
|
if (author.hasPermission("ADMINISTRATOR")) return false
|
||||||
|
if (command.id === "setup") return true
|
||||||
|
|
||||||
|
const guild = await Guild.findOneOrFail({ where: { guild: author.guild.id } })
|
||||||
|
|
||||||
|
return !author.roles.cache.some((role: Role) => role.id === guild.staffRole)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RegisteredGuild;
|
@ -0,0 +1,21 @@
|
|||||||
|
import { Command, Inhibitor } from "discord-akairo";
|
||||||
|
import { Message } from "discord.js";
|
||||||
|
|
||||||
|
class InGuild extends Inhibitor {
|
||||||
|
constructor() {
|
||||||
|
super("InGuild", {
|
||||||
|
reason: "no guild",
|
||||||
|
type: "post",
|
||||||
|
priority: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(message: Message, command: Command) {
|
||||||
|
// we handle no guild somewhere else
|
||||||
|
if (message.guild) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InGuild;
|
@ -0,0 +1,29 @@
|
|||||||
|
import { Command, Inhibitor } from "discord-akairo";
|
||||||
|
import { Message } from "discord.js";
|
||||||
|
import { Guild } from "../../entity/Guild";
|
||||||
|
|
||||||
|
class RegisteredGuild extends Inhibitor {
|
||||||
|
constructor() {
|
||||||
|
super("RegisteredGuild", {
|
||||||
|
reason: "unregistered",
|
||||||
|
type: "post",
|
||||||
|
priority: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(message: Message, command: Command) {
|
||||||
|
// we handle no guild somewhere else
|
||||||
|
if (!message.guild) return false;
|
||||||
|
|
||||||
|
if (command.id === "setup") return false;
|
||||||
|
if (command.id === "help") return false;
|
||||||
|
|
||||||
|
const author = message.member;
|
||||||
|
if (!author) return true;
|
||||||
|
|
||||||
|
const guild = await Guild.count({ where: { guild: author.guild.id } });
|
||||||
|
return guild == 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RegisteredGuild;
|
@ -0,0 +1,28 @@
|
|||||||
|
import { Command, Inhibitor } from "discord-akairo";
|
||||||
|
import { Message } from "discord.js";
|
||||||
|
import { Guild } from "../../entity/Guild";
|
||||||
|
|
||||||
|
class RegisteredGuild extends Inhibitor {
|
||||||
|
constructor() {
|
||||||
|
super("SetupWhileRegistered", {
|
||||||
|
reason: "already setup",
|
||||||
|
type: "post",
|
||||||
|
priority: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(message: Message, command: Command) {
|
||||||
|
// we handle no guild somewhere else
|
||||||
|
if (!message.guild) return false;
|
||||||
|
|
||||||
|
if (command.id !== "setup") return false;
|
||||||
|
|
||||||
|
const author = message.member;
|
||||||
|
if (!author) return true;
|
||||||
|
|
||||||
|
const guild = await Guild.count({ where: { guild: author.guild.id } });
|
||||||
|
return guild !== 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RegisteredGuild;
|
@ -0,0 +1,40 @@
|
|||||||
|
import { Command, Listener } from "discord-akairo";
|
||||||
|
import { Message } from "discord.js";
|
||||||
|
import Bot from "../Bot";
|
||||||
|
import { errorEmbed } from "../Util";
|
||||||
|
|
||||||
|
class CommandBlockedListener extends Listener {
|
||||||
|
constructor() {
|
||||||
|
super("commandBlocked", {
|
||||||
|
emitter: "commandHandler",
|
||||||
|
event: "commandBlocked",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
exec(message: Message, command: Command, reason: string) {
|
||||||
|
const prefix = Bot.config.bot.prefix;
|
||||||
|
if (reason === "unregistered")
|
||||||
|
return message.reply(
|
||||||
|
errorEmbed("You must setup the guild first.").setDescription(
|
||||||
|
`Try ${prefix}setup.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (reason === "permissions")
|
||||||
|
return message.reply(
|
||||||
|
errorEmbed("You do not have access to this command.")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (reason === "no guild")
|
||||||
|
return message.reply(errorEmbed("You cannot run commands in DMs."));
|
||||||
|
|
||||||
|
if (reason === "already setup")
|
||||||
|
return message.reply(
|
||||||
|
errorEmbed("This guild is already setup").setDescription(
|
||||||
|
"To update configuration, use other commands."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommandBlockedListener;
|
@ -0,0 +1,26 @@
|
|||||||
|
import {Listener} from "discord-akairo";
|
||||||
|
import {Message} from "discord.js";
|
||||||
|
import {errorEmbed, statusEmbed} from "../Util";
|
||||||
|
|
||||||
|
class MessageCreate extends Listener {
|
||||||
|
constructor() {
|
||||||
|
super("message", {
|
||||||
|
emitter: "client",
|
||||||
|
event: "message",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(message: Message) {
|
||||||
|
const author = message.member;
|
||||||
|
if (!author) {
|
||||||
|
return errorEmbed("This bot can only be used in guilds.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message.content.match(`^<@!?${message.client.user.id}>$`)) return;
|
||||||
|
|
||||||
|
const embed = await statusEmbed(message);
|
||||||
|
return message.channel.send(embed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MessageCreate;
|
@ -0,0 +1,23 @@
|
|||||||
|
import { Command, Listener } from "discord-akairo"
|
||||||
|
import { Message } from "discord.js"
|
||||||
|
import { errorEmbed } from "../Util"
|
||||||
|
|
||||||
|
class MissingPermissions extends Listener {
|
||||||
|
constructor() {
|
||||||
|
super('missingPermissions', {
|
||||||
|
emitter: 'commandHandler',
|
||||||
|
event: 'missingPermissions'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
exec(message: Message, command: Command, reason: string, missing: Array<string>) {
|
||||||
|
if (reason === "user") return message.reply(errorEmbed("Missing permissions."))
|
||||||
|
if (reason === "client") {
|
||||||
|
const embed = errorEmbed("The bot is missing required permissions")
|
||||||
|
.setDescription("Permisisons required: `" + missing.join(", ") + "`.")
|
||||||
|
return message.reply(embed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MissingPermissions
|
@ -0,0 +1,51 @@
|
|||||||
|
import { Listener } from "discord-akairo";
|
||||||
|
import { TextChannel } from "discord.js";
|
||||||
|
import { Appeal } from "../../entity/Appeal";
|
||||||
|
import { Guild } from "../../entity/Guild";
|
||||||
|
|
||||||
|
class NewAppeal extends Listener {
|
||||||
|
constructor() {
|
||||||
|
super("newAppealHandler", {
|
||||||
|
emitter: "appealHandler",
|
||||||
|
event: "newAppeal",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(
|
||||||
|
guild: Guild,
|
||||||
|
appeal: Appeal,
|
||||||
|
success: () => {},
|
||||||
|
fail: (msg: string) => {}
|
||||||
|
) {
|
||||||
|
const g = await this.client.guilds.fetch(guild.guild).catch(() => null);
|
||||||
|
if (!g) {
|
||||||
|
return fail("Could not submit appeal. Guild not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ban = await g.fetchBan(appeal.userID).catch(() => null);
|
||||||
|
if (!ban) {
|
||||||
|
return fail("Could not submit appeal. No ban found for user.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await this.client.channels
|
||||||
|
.fetch(guild.channel)
|
||||||
|
.catch(() => null);
|
||||||
|
if (!channel?.isText()) {
|
||||||
|
return fail("Could not submit appeal. Guild configuration invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
success();
|
||||||
|
|
||||||
|
const embed = appeal.embed(true)
|
||||||
|
const msg = await (channel as TextChannel).send(embed);
|
||||||
|
appeal.message = msg.id;
|
||||||
|
await appeal.save();
|
||||||
|
|
||||||
|
msg.edit(embed.setFooter("Appeal ID: " + appeal.id));
|
||||||
|
msg.react("✅");
|
||||||
|
msg.react("❔");
|
||||||
|
msg.react("❌");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NewAppeal;
|
@ -0,0 +1,215 @@
|
|||||||
|
import { Listener } from "discord-akairo";
|
||||||
|
import { Message } from "discord.js";
|
||||||
|
import { MessageReaction } from "discord.js";
|
||||||
|
import { User } from "discord.js";
|
||||||
|
import { Appeal } from "../../entity/Appeal";
|
||||||
|
import { decision, errorEmbed, infoEmbed, successEmbed } from "../Util";
|
||||||
|
|
||||||
|
class ReactionAdd extends Listener {
|
||||||
|
constructor() {
|
||||||
|
super("embedDecision", {
|
||||||
|
emitter: "client",
|
||||||
|
event: "messageReactionAdd",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(reaction: MessageReaction, user: User) {
|
||||||
|
if (reaction.message.partial) await reaction.message.fetch();
|
||||||
|
|
||||||
|
if (!reaction.message.guild) return;
|
||||||
|
if (user === this.client.user) return;
|
||||||
|
if (reaction.message.author !== this.client.user) return;
|
||||||
|
if (!"✅❔❌".includes(reaction.emoji.name)) return;
|
||||||
|
|
||||||
|
const embed = reaction.message.embeds[0];
|
||||||
|
if (!embed) return;
|
||||||
|
|
||||||
|
const footer = embed.footer.text;
|
||||||
|
if (!footer) return;
|
||||||
|
|
||||||
|
const id = /Appeal ID: ([A-z\d-]*)$/i.exec(footer);
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const appeal = await Appeal.findOne(id[1]);
|
||||||
|
if (!appeal) return;
|
||||||
|
|
||||||
|
reaction.message.reactions.resolve(reaction.emoji.name).users.remove(user);
|
||||||
|
|
||||||
|
switch (reaction.emoji.name) {
|
||||||
|
case "✅":
|
||||||
|
this.onAccept(reaction.message, appeal, user);
|
||||||
|
break;
|
||||||
|
case "❔":
|
||||||
|
reaction.message.channel.send("Not implemented yet :(");
|
||||||
|
break;
|
||||||
|
case "❌":
|
||||||
|
this.onReject(reaction.message, appeal, user);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onAccept(message: Message, appeal: Appeal, user: User) {
|
||||||
|
decision(
|
||||||
|
message,
|
||||||
|
user,
|
||||||
|
(m) => m.content === "Yes",
|
||||||
|
infoEmbed("Are you sure you would like to accept this appeal?")
|
||||||
|
.setDescription(
|
||||||
|
'Respond with "Yes" to continue.\n' +
|
||||||
|
"Sending any other message will cancel the process."
|
||||||
|
)
|
||||||
|
.setFooter(appeal.id),
|
||||||
|
|
||||||
|
() =>
|
||||||
|
this.decisionReason(
|
||||||
|
message,
|
||||||
|
appeal,
|
||||||
|
user,
|
||||||
|
"Provide Reason",
|
||||||
|
this.accept
|
||||||
|
),
|
||||||
|
|
||||||
|
() => this.onCancel(message, appeal)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onReject(message: Message, appeal: Appeal, user: User) {
|
||||||
|
decision(
|
||||||
|
message,
|
||||||
|
user,
|
||||||
|
(m) => m.content === "Yes",
|
||||||
|
infoEmbed("Are you sure you would like to reject this appeal?")
|
||||||
|
.setDescription(
|
||||||
|
'Respond with "Yes" to continue.\n' +
|
||||||
|
"Sending any other message will cancel the process."
|
||||||
|
)
|
||||||
|
.setFooter(appeal.id),
|
||||||
|
|
||||||
|
() =>
|
||||||
|
this.decisionReason(
|
||||||
|
message,
|
||||||
|
appeal,
|
||||||
|
user,
|
||||||
|
"Rejection Reason",
|
||||||
|
this.reject
|
||||||
|
),
|
||||||
|
|
||||||
|
() => this.onCancel(message, appeal)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
decisionReason(
|
||||||
|
message: Message,
|
||||||
|
appeal: Appeal,
|
||||||
|
user: User,
|
||||||
|
title: string,
|
||||||
|
final: (
|
||||||
|
message: Message,
|
||||||
|
appeal: Appeal,
|
||||||
|
user: User,
|
||||||
|
reason?: string
|
||||||
|
) => void
|
||||||
|
) {
|
||||||
|
decision(
|
||||||
|
message,
|
||||||
|
user,
|
||||||
|
(m) => m.content !== "cancel",
|
||||||
|
infoEmbed(title)
|
||||||
|
.setDescription(
|
||||||
|
"Enter `none` to provide no reason.\n" +
|
||||||
|
"Enter `cancel` to cancel.\n\n" +
|
||||||
|
"*Timeout in 120 seconds*"
|
||||||
|
)
|
||||||
|
.setFooter(appeal.id),
|
||||||
|
(m) => {
|
||||||
|
let content = m.content;
|
||||||
|
if (content === "none") content = null;
|
||||||
|
final(message, appeal, user, content);
|
||||||
|
},
|
||||||
|
() => this.onCancel(message, appeal),
|
||||||
|
120_000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async accept(message: Message, appeal: Appeal, user: User, reason?: string) {
|
||||||
|
const banned = await message.guild
|
||||||
|
.fetchBan(appeal.userID)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
if (!banned) {
|
||||||
|
message.channel
|
||||||
|
.send(infoEmbed("The user is not banned. Appeal status updated."))
|
||||||
|
.then((m) => setTimeout(() => m.delete(), 5000));
|
||||||
|
|
||||||
|
appeal.accept(user.id, reason);
|
||||||
|
message.reactions.removeAll();
|
||||||
|
|
||||||
|
const embed = appeal.embed(true).setColor("#00AE86");
|
||||||
|
message.edit(embed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let err: Error = await message.guild.members
|
||||||
|
.unban(appeal.userID)
|
||||||
|
.then(() => null)
|
||||||
|
.catch((e) => e);
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
message.channel
|
||||||
|
.send(
|
||||||
|
errorEmbed("An error occurred")
|
||||||
|
.setDescription(
|
||||||
|
"Could not unban user.\nThe appeal status was NOT updated."
|
||||||
|
)
|
||||||
|
.addField("Error", err.message)
|
||||||
|
.setFooter(appeal.id)
|
||||||
|
)
|
||||||
|
.then((m) => setTimeout(() => m.delete(), 5000));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
appeal.accept(user.id, reason);
|
||||||
|
|
||||||
|
message.channel
|
||||||
|
.send(
|
||||||
|
successEmbed("The appeal has been accepted.")
|
||||||
|
.setDescription(`${user.username}#${user.discriminator} is unbanned`)
|
||||||
|
.setThumbnail(user.displayAvatarURL())
|
||||||
|
.setFooter(appeal.id)
|
||||||
|
)
|
||||||
|
.then((m) => setTimeout(() => m.delete(), 5000));
|
||||||
|
|
||||||
|
message.reactions.removeAll();
|
||||||
|
const embed = appeal.embed(true).setColor("#00AE86");
|
||||||
|
message.edit(embed);
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(message: Message, appeal: Appeal, user: User, reason?: string) {
|
||||||
|
appeal.reject(user.id, reason);
|
||||||
|
|
||||||
|
message.channel
|
||||||
|
.send(
|
||||||
|
successEmbed("The appeal has been rejected.")
|
||||||
|
.setDescription(`${appeal.userID} will stay banned.`)
|
||||||
|
.setFooter(appeal.id)
|
||||||
|
.addField(
|
||||||
|
"Rejection expiry",
|
||||||
|
appeal.rejectionExpiry?.toLocaleDateString() || "Never"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then((m) => setTimeout(() => m.delete(), 5000));
|
||||||
|
|
||||||
|
message.reactions.removeAll();
|
||||||
|
const embed = appeal.embed(true).setColor("#B93758");
|
||||||
|
message.edit(embed);
|
||||||
|
}
|
||||||
|
|
||||||
|
onCancel(message: Message, appeal: Appeal) {
|
||||||
|
message.channel
|
||||||
|
.send(errorEmbed("Action cancelled.").setFooter(appeal.id))
|
||||||
|
.then((m) => setTimeout(() => m.delete(), 5000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReactionAdd;
|
@ -0,0 +1,16 @@
|
|||||||
|
import { Listener } from "discord-akairo"
|
||||||
|
|
||||||
|
class Ready extends Listener {
|
||||||
|
constructor() {
|
||||||
|
super("ready", {
|
||||||
|
emitter: "client",
|
||||||
|
event: "ready",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public exec() {
|
||||||
|
console.log("Connected to discord.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Ready
|
@ -0,0 +1,36 @@
|
|||||||
|
export class Config {
|
||||||
|
api: ApiConfig;
|
||||||
|
bot: BotConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiConfig {
|
||||||
|
cors_host: string[];
|
||||||
|
redirect_uri: string;
|
||||||
|
port: number;
|
||||||
|
client_secret: string;
|
||||||
|
client_id: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
cors_host: string[],
|
||||||
|
redirect_uri: string,
|
||||||
|
port: number,
|
||||||
|
client_secret: string,
|
||||||
|
client_id: string
|
||||||
|
) {
|
||||||
|
this.cors_host = cors_host;
|
||||||
|
this.redirect_uri = redirect_uri;
|
||||||
|
this.port = port;
|
||||||
|
this.client_secret = client_secret;
|
||||||
|
this.client_id = client_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BotConfig {
|
||||||
|
token: string;
|
||||||
|
prefix: string;
|
||||||
|
|
||||||
|
constructor(token: string, prefix: string) {
|
||||||
|
this.token = token;
|
||||||
|
this.prefix = prefix;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,64 @@
|
|||||||
|
import { Request, Response } from "express";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of API routes with examples
|
||||||
|
* @route GET /api
|
||||||
|
*/
|
||||||
|
export const getApi = (_: Request, res: Response) => {
|
||||||
|
res.json({
|
||||||
|
"POST /oauth": {
|
||||||
|
summary: "Create a request to generate an OAuth token URL from Discord.",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "The OAuth URL",
|
||||||
|
format: {
|
||||||
|
redirectURL: {
|
||||||
|
type: "string",
|
||||||
|
format: "url",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"GET /authorize": {
|
||||||
|
summary: "Callback from discord with auth token",
|
||||||
|
parameters: {
|
||||||
|
query: {
|
||||||
|
code: {
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successfully complete OAuth exchange",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
description: "An invalid code or state parameter was provided",
|
||||||
|
format: {
|
||||||
|
reason: {
|
||||||
|
type: "string",
|
||||||
|
format: "error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
500: {
|
||||||
|
description: "Something went wrong on the server",
|
||||||
|
format: {
|
||||||
|
reason: {
|
||||||
|
type: "string",
|
||||||
|
format: "error",
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"POST /submit": {
|
||||||
|
summary: "Post an unban request appeal",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
@ -0,0 +1,29 @@
|
|||||||
|
import { Request, Response } from "express";
|
||||||
|
import { Config } from "../config/Config";
|
||||||
|
import { DiscordToken } from "../entity/DiscordToken";
|
||||||
|
import { StateToken } from "../entity/StateToken";
|
||||||
|
import { fail, respond } from "./Util";
|
||||||
|
|
||||||
|
export const authorize = async (req: Request, res: Response) => {
|
||||||
|
const config: Config = req.app.get("config");
|
||||||
|
const { code, state } = req.body;
|
||||||
|
|
||||||
|
// enforce types
|
||||||
|
if (!(typeof code === "string" && typeof state === "string")) {
|
||||||
|
return fail(res, 500, "Invalid request body");
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateToken = await StateToken.findOne({ where: { token: state } });
|
||||||
|
if (!stateToken) {
|
||||||
|
return fail(res, 400, "Invalid state provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
let dToken: DiscordToken;
|
||||||
|
try {
|
||||||
|
dToken = await stateToken.verify(config, code.toString());
|
||||||
|
} catch ({ reason, status }) {
|
||||||
|
fail(res, status, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(res, { token: dToken.id });
|
||||||
|
};
|
@ -0,0 +1,46 @@
|
|||||||
|
import { Request, Response } from "express";
|
||||||
|
import { DiscordCache } from "../entity/DiscordCache";
|
||||||
|
import { DiscordToken } from "../entity/DiscordToken";
|
||||||
|
import { Guild } from "../entity/Guild";
|
||||||
|
import { fail, respond } from "./Util";
|
||||||
|
|
||||||
|
export const info = async (req: Request, res: Response) => {
|
||||||
|
const { token } = req.body;
|
||||||
|
|
||||||
|
// enforce types
|
||||||
|
if (!(typeof token === "string")) {
|
||||||
|
return fail(res, 500, "Invalid request body");
|
||||||
|
}
|
||||||
|
|
||||||
|
const discordToken = await DiscordToken.findOne({ where: { id: token } });
|
||||||
|
if (!discordToken) {
|
||||||
|
return fail(res, 400, "Invalid token provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache: DiscordCache;
|
||||||
|
try {
|
||||||
|
cache = await discordToken.queryData();
|
||||||
|
} catch ({ status, reason }) {
|
||||||
|
fail(res, status, reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let platforms: Guild[];
|
||||||
|
try {
|
||||||
|
platforms = await Guild.all();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Could not fetch guilds", err);
|
||||||
|
return fail(res, 500, "An internal error occurred.");
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(res, {
|
||||||
|
id: cache.userID,
|
||||||
|
username: cache.username,
|
||||||
|
discriminator: cache.discriminator,
|
||||||
|
avatar: cache.avatar,
|
||||||
|
platforms: platforms.map((g) => ({
|
||||||
|
name: g.name,
|
||||||
|
id: g.id,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
};
|
@ -0,0 +1,28 @@
|
|||||||
|
import base64url from "base64url";
|
||||||
|
import * as crypto from "crypto";
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { OAUTH_SCOPE } from "../config/Constants";
|
||||||
|
import { StateToken } from "../entity/StateToken";
|
||||||
|
import { respond } from "./Util";
|
||||||
|
|
||||||
|
export const genOAuth = async (req: Request, res: Response) => {
|
||||||
|
const { api } = req.app.get("config");
|
||||||
|
|
||||||
|
let token: string;
|
||||||
|
do {
|
||||||
|
token = base64url(crypto.randomBytes(18));
|
||||||
|
} while ((await StateToken.count({ where: { token } })) !== 0);
|
||||||
|
|
||||||
|
new StateToken(token).save();
|
||||||
|
|
||||||
|
let url =
|
||||||
|
"https://discord.com/api/oauth2/authorize" +
|
||||||
|
`?response_type=code` +
|
||||||
|
`&prompt=consent` +
|
||||||
|
`&client_id=${api.client_id}` +
|
||||||
|
`&scope=${encodeURIComponent(OAUTH_SCOPE)}` +
|
||||||
|
`&state=${token}` +
|
||||||
|
`&redirect_uri=${encodeURIComponent(api.redirect_uri)}`;
|
||||||
|
|
||||||
|
respond(res, { redirectURL: url });
|
||||||
|
};
|
@ -0,0 +1,51 @@
|
|||||||
|
import { Request, Response } from "express";
|
||||||
|
import { Appeal } from "../entity/Appeal";
|
||||||
|
import { DiscordCache } from "../entity/DiscordCache";
|
||||||
|
import { DiscordToken } from "../entity/DiscordToken";
|
||||||
|
import { fail, respond } from "./Util";
|
||||||
|
|
||||||
|
export const status = async (req: Request, res: Response) => {
|
||||||
|
const { token } = req.body;
|
||||||
|
|
||||||
|
// enforce types
|
||||||
|
if (!(typeof token === "string")) {
|
||||||
|
return fail(res, 500, "Invalid request body");
|
||||||
|
}
|
||||||
|
|
||||||
|
const discordToken = await DiscordToken.findOne({ where: { id: token } });
|
||||||
|
if (!discordToken) {
|
||||||
|
res.status(400);
|
||||||
|
res.json({ reason: "Invalid token provided", code: 400 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: check with bot if the user is banned.
|
||||||
|
let cache: DiscordCache;
|
||||||
|
try {
|
||||||
|
cache = await discordToken.queryData();
|
||||||
|
} catch ({ status, reason }) {
|
||||||
|
return fail(res, status, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
let appeals: Appeal[];
|
||||||
|
try {
|
||||||
|
appeals = await Appeal.allAppeals(cache.userID);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Could not fetch appeals", err);
|
||||||
|
return fail(res, 500, "An internal error occurred.");
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(
|
||||||
|
res,
|
||||||
|
appeals.map((appeal) => {
|
||||||
|
return {
|
||||||
|
...appeal,
|
||||||
|
message: undefined,
|
||||||
|
decisionBy: undefined,
|
||||||
|
userTag: undefined,
|
||||||
|
userAvatar: undefined,
|
||||||
|
guild: appeal.guild.name,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,82 @@
|
|||||||
|
import { Request, Response } from "express";
|
||||||
|
import { EventEmitter } from "typeorm/platform/PlatformTools";
|
||||||
|
import { Appeal } from "../entity/Appeal";
|
||||||
|
import { DiscordCache } from "../entity/DiscordCache";
|
||||||
|
import { DiscordToken } from "../entity/DiscordToken";
|
||||||
|
import { Guild } from "../entity/Guild";
|
||||||
|
import { fail, respond } from "./Util";
|
||||||
|
|
||||||
|
export const submit = async (req: Request, res: Response) => {
|
||||||
|
const {
|
||||||
|
token,
|
||||||
|
platform,
|
||||||
|
punishment_date,
|
||||||
|
ban_reason,
|
||||||
|
appeal_reason,
|
||||||
|
additional_info,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// enforce types
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
typeof token === "string" &&
|
||||||
|
typeof platform === "string" &&
|
||||||
|
typeof punishment_date === "string" &&
|
||||||
|
typeof ban_reason === "string" &&
|
||||||
|
typeof appeal_reason === "string" &&
|
||||||
|
typeof additional_info === "string"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return fail(res, 500, "Invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
const discordToken = await DiscordToken.findOne({ where: { id: token } });
|
||||||
|
if (!discordToken) {
|
||||||
|
return fail(res, 400, "Invalid token provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache: DiscordCache;
|
||||||
|
try {
|
||||||
|
cache = await discordToken.queryData();
|
||||||
|
} catch ({ status, reason }) {
|
||||||
|
return fail(res, status, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = await Guild.findOne({ where: { id: platform } });
|
||||||
|
if (!guild) {
|
||||||
|
return fail(res, 400, "Guild not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const appeals = await Appeal.activeAppeals(cache.userID, guild.id);
|
||||||
|
if (appeals.length != 0) {
|
||||||
|
return fail(
|
||||||
|
res,
|
||||||
|
400,
|
||||||
|
`You already have an active appeal for ${guild.name}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const appeal = new Appeal(
|
||||||
|
cache.userID,
|
||||||
|
`${cache.username}#${cache.discriminator}`,
|
||||||
|
cache.avatar,
|
||||||
|
guild,
|
||||||
|
punishment_date,
|
||||||
|
ban_reason,
|
||||||
|
appeal_reason,
|
||||||
|
additional_info
|
||||||
|
);
|
||||||
|
|
||||||
|
const msg = appeal.validationMessage();
|
||||||
|
if (msg !== null) {
|
||||||
|
return fail(res, 422, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emitter: EventEmitter = req.app.get("emitter");
|
||||||
|
|
||||||
|
await new Promise((res, rej) => {
|
||||||
|
emitter.emit("newAppeal", guild, appeal, res, rej);
|
||||||
|
})
|
||||||
|
.then(() => respond(res, {}))
|
||||||
|
.catch((msg) => fail(res, 400, msg));
|
||||||
|
};
|
@ -0,0 +1,17 @@
|
|||||||
|
import {Response} from "express";
|
||||||
|
|
||||||
|
export const fail = (res: Response, status: number, msg: string) => {
|
||||||
|
res.status(status);
|
||||||
|
res.json({
|
||||||
|
status,
|
||||||
|
reason: msg,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const respond = (res: Response, data: any) => {
|
||||||
|
res.status(200);
|
||||||
|
res.json({
|
||||||
|
status: 200,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
};
|
@ -0,0 +1,245 @@
|
|||||||
|
import {
|
||||||
|
BaseEntity,
|
||||||
|
Brackets,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from "typeorm";
|
||||||
|
import { infoEmbed } from "../bot/Util";
|
||||||
|
import { CDN_URL, getDate } from "../config/Constants";
|
||||||
|
import { Guild } from "./Guild";
|
||||||
|
|
||||||
|
@Entity({ name: "appeals" })
|
||||||
|
export class Appeal extends BaseEntity {
|
||||||
|
@PrimaryGeneratedColumn("uuid")
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
// user data
|
||||||
|
@Column()
|
||||||
|
userTag: string;
|
||||||
|
@Column({ nullable: true })
|
||||||
|
userAvatar?: string;
|
||||||
|
@Column()
|
||||||
|
userID: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Guild, (guild) => guild.id, {
|
||||||
|
eager: true,
|
||||||
|
})
|
||||||
|
@JoinColumn()
|
||||||
|
guild: Guild;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
punishmentDate: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
banReason: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
appealReason: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
additionalInfo: string;
|
||||||
|
|
||||||
|
// Processing -> in_questioning -> questioning_rejoined -> accepted OR rejected
|
||||||
|
// Processing -> rejected
|
||||||
|
// Processing -> accepted
|
||||||
|
@Column()
|
||||||
|
status:
|
||||||
|
| "processing"
|
||||||
|
| "in_questioning"
|
||||||
|
| "questioning_rejoined"
|
||||||
|
| "accepted"
|
||||||
|
| "rejected";
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
rejectionExpiry?: Date; // If the user is rejected, the member is unable to apply again until this date passes.
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
decisionReason?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
decisionBy?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
created: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
lastUpdate: Date;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
userID?: string,
|
||||||
|
userTag?: string,
|
||||||
|
userAvatar?: string,
|
||||||
|
guild?: Guild,
|
||||||
|
punishDate?: string,
|
||||||
|
banReason?: string,
|
||||||
|
appealReason?: string,
|
||||||
|
additionalInfo?: string
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
if (!(userID && userTag && punishDate && banReason && appealReason)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.userID = userID;
|
||||||
|
this.userTag = userTag;
|
||||||
|
this.userAvatar = userAvatar;
|
||||||
|
this.guild = guild;
|
||||||
|
this.punishmentDate = punishDate.trim();
|
||||||
|
this.banReason = banReason.trim();
|
||||||
|
this.appealReason = appealReason.trim();
|
||||||
|
this.additionalInfo = additionalInfo?.trim();
|
||||||
|
this.status = "processing";
|
||||||
|
}
|
||||||
|
|
||||||
|
accept(by: string, reason?: string) {
|
||||||
|
this.decisionBy = by;
|
||||||
|
this.status = "accepted";
|
||||||
|
this.decisionReason = reason;
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(by: string, reason?: string) {
|
||||||
|
this.decisionBy = by;
|
||||||
|
this.status = "rejected";
|
||||||
|
this.decisionReason = reason;
|
||||||
|
|
||||||
|
if (this.guild.defaultExpiry) {
|
||||||
|
this.rejectionExpiry = getDate(this.guild.defaultExpiry);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
validationMessage(): string {
|
||||||
|
if (this.punishmentDate === "") return "Punishment date required.";
|
||||||
|
if (this.banReason === "") return "Ban Reason Required.";
|
||||||
|
if (this.appealReason === "") return "Appeal Reason required.";
|
||||||
|
|
||||||
|
if (this.punishmentDate.length > 128)
|
||||||
|
return "Punishment date length must be < 128.";
|
||||||
|
if (this.banReason.length > 256)
|
||||||
|
return "Ban reason length must be < 256 characters.";
|
||||||
|
if (this.appealReason.length > 1000)
|
||||||
|
return "Appeal reason length must be < 1000.";
|
||||||
|
if (this.additionalInfo.length > 1000)
|
||||||
|
return "Additional info length must be < 1000.";
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
embed(full?: boolean) {
|
||||||
|
const embed = infoEmbed(`Appeal - ${this.status}`)
|
||||||
|
.setThumbnail(this.avatar())
|
||||||
|
.addField("\u200B", "\u200B")
|
||||||
|
.setFooter("Appeal ID: " + this.id);
|
||||||
|
|
||||||
|
if (this.lastUpdate) {
|
||||||
|
embed.addField("Last Updated", this.lastUpdate.toLocaleString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (full) {
|
||||||
|
embed
|
||||||
|
.addField("Punishment Date", this.punishmentDate)
|
||||||
|
.addField("Ban Reason", this.banReason)
|
||||||
|
.addField("Appeal Reason", this.appealReason)
|
||||||
|
.addField("Additional Info", this.additionalInfo || "*None provided*");
|
||||||
|
} else {
|
||||||
|
embed.addField("Full Message", `[Link](${this.link()})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["accepted", "rejected"].includes(this.status)) {
|
||||||
|
embed.fields[0] = {
|
||||||
|
name: "Decision By",
|
||||||
|
value: `<@${this.decisionBy}>`,
|
||||||
|
inline: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.decisionReason) {
|
||||||
|
let decision = this.decisionReason.substr(0, full ? 1000 : 400);
|
||||||
|
if (decision !== this.decisionReason) decision += "\n\n*Message trimmed*";
|
||||||
|
embed.addField("Decision Note", decision);
|
||||||
|
}
|
||||||
|
|
||||||
|
let desc = `User: ${this.userTag}
|
||||||
|
User ID: ${this.userID}
|
||||||
|
Status: ${this.status}\n`;
|
||||||
|
|
||||||
|
if (this.status === "rejected") {
|
||||||
|
desc += `User re-appeal date: ${
|
||||||
|
this.rejectionExpiry?.toLocaleString() || "Never"
|
||||||
|
}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
embed.setDescription(`${"```fix\n"}${desc}${"```"}`);
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
link() {
|
||||||
|
return `https://discord.com/channels/${this.guild.guild}/${this.guild.channel}/${this.message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
avatar() {
|
||||||
|
const discrim = this.userTag.split("#")[1];
|
||||||
|
if (!this.userAvatar) {
|
||||||
|
return `${CDN_URL}/embed/avatars/${Number(discrim) % 5}.png`;
|
||||||
|
}
|
||||||
|
return `${CDN_URL}/avatars/${this.userID}/${this.userAvatar}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async activeAppeals(userID: string, guild: string) {
|
||||||
|
return await Appeal.createQueryBuilder("appeals")
|
||||||
|
.where("appeals.userID = :userID", { userID })
|
||||||
|
.andWhere("appeals.guildId = :guildID", { guildID: guild })
|
||||||
|
.andWhere(
|
||||||
|
new Brackets((qb) =>
|
||||||
|
qb
|
||||||
|
.where("appeals.status NOT IN (:...statuses)", {
|
||||||
|
statuses: ["accepted", "rejected"],
|
||||||
|
})
|
||||||
|
.orWhere(
|
||||||
|
new Brackets((qb) =>
|
||||||
|
qb
|
||||||
|
.where(`appeals.status = "rejected"`)
|
||||||
|
.andWhere(
|
||||||
|
"appeals.rejectionExpiry IS NULL OR appeals.rejectionExpiry > datetime(:now)",
|
||||||
|
{ now: new Date().toISOString() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy("appeals.created")
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
static async allAppeals(userID: string, guild?: string) {
|
||||||
|
const query = Appeal.createQueryBuilder("appeals")
|
||||||
|
.where("appeals.userID = :userID", { userID })
|
||||||
|
.leftJoinAndSelect("appeals.guild", "guilds")
|
||||||
|
.orderBy("appeals.created", "DESC");
|
||||||
|
|
||||||
|
if (guild) query.andWhere("appeals.guildId = :guild", { guild });
|
||||||
|
|
||||||
|
return await query.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
static async appeal(id: string, guild?: string) {
|
||||||
|
const query = Appeal.createQueryBuilder("appeals")
|
||||||
|
.where("appeals.id = :id", { id })
|
||||||
|
.leftJoinAndSelect("appeals.guild", "guilds");
|
||||||
|
|
||||||
|
if (guild) query.andWhere("guilds.guild = :guild", { guild });
|
||||||
|
|
||||||
|
return await query.getOne();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
import { getManager } from "typeorm";
|
||||||
|
import { DiscordCache } from "./DiscordCache";
|
||||||
|
import { DiscordToken } from "./DiscordToken";
|
||||||
|
import { StateToken } from "./StateToken";
|
||||||
|
|
||||||
|
export const pruneDB = (interval: number) => {
|
||||||
|
const manager = getManager();
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const cleaning = [
|
||||||
|
{ type: StateToken, name: "state_tokens", col: "expiring" },
|
||||||
|
{ type: DiscordToken, name: "discord_tokens", col: "expiring" },
|
||||||
|
{ type: DiscordCache, name: "discord_cache", col: "expiring" }
|
||||||
|
]
|
||||||
|
|
||||||
|
cleaning.forEach(({ type, name, col }) => {
|
||||||
|
manager
|
||||||
|
.createQueryBuilder()
|
||||||
|
.delete()
|
||||||
|
.from(type, name)
|
||||||
|
.where(`${name}.${col} < datetime(:now)`, { now: new Date().toISOString() })
|
||||||
|
.execute()
|
||||||
|
.catch(err => {
|
||||||
|
console.error("Error while pruning db: " + err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, interval);
|
||||||
|
}
|
@ -0,0 +1,49 @@
|
|||||||
|
import {BaseEntity, Column, Entity, PrimaryColumn} from "typeorm";
|
||||||
|
import {DiscordToken} from "./DiscordToken";
|
||||||
|
|
||||||
|
@Entity({ name: "discord_cache" })
|
||||||
|
export class DiscordCache extends BaseEntity {
|
||||||
|
@PrimaryColumn({ unique: true })
|
||||||
|
authToken: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
userID: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
discriminator: string;
|
||||||
|
|
||||||
|
@Column({nullable: true })
|
||||||
|
avatar?: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
expiring: Date;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
token: string,
|
||||||
|
userID: string,
|
||||||
|
name: string,
|
||||||
|
discr: string,
|
||||||
|
avatar?: string
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.authToken = token;
|
||||||
|
this.userID = userID;
|
||||||
|
this.username = name;
|
||||||
|
this.discriminator = discr;
|
||||||
|
this.avatar = avatar;
|
||||||
|
this.expiring = new Date();
|
||||||
|
this.expiring.setHours(this.expiring.getHours() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
json() {
|
||||||
|
return {
|
||||||
|
id: this.userID,
|
||||||
|
username: this.username,
|
||||||
|
discriminator: this.discriminator,
|
||||||
|
avatar: this.avatar,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,66 @@
|
|||||||
|
import axios, { AxiosResponse } from "axios";
|
||||||
|
import { BaseEntity, Column, Entity, PrimaryColumn } from "typeorm";
|
||||||
|
import { API_URL } from "../config/Constants";
|
||||||
|
import { DiscordCache } from "./DiscordCache";
|
||||||
|
import { StateToken } from "./StateToken";
|
||||||
|
|
||||||
|
@Entity({ name: "discord_tokens" })
|
||||||
|
export class DiscordToken extends BaseEntity {
|
||||||
|
@PrimaryColumn("uuid")
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ unique: true })
|
||||||
|
authToken: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
expiring: Date;
|
||||||
|
|
||||||
|
constructor(id: string, token: string) {
|
||||||
|
super();
|
||||||
|
this.id = id;
|
||||||
|
this.authToken = token;
|
||||||
|
this.expiring = new Date();
|
||||||
|
this.expiring.setDate(this.expiring.getDate() + 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryData(): Promise<DiscordCache> {
|
||||||
|
const cache = await DiscordCache.findOne({
|
||||||
|
where: { authToken: this.authToken },
|
||||||
|
});
|
||||||
|
if (cache) {
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
let res: AxiosResponse<any>;
|
||||||
|
try {
|
||||||
|
res = await axios.get(`${API_URL}/users/@me`, {
|
||||||
|
headers: { Authorization: `Bearer ${this.authToken}` },
|
||||||
|
validateStatus: (status) => status < 500,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Unexpected discord response", this, err.response);
|
||||||
|
return Promise.reject({
|
||||||
|
reason: "An internal error has occurred.",
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status != 200) {
|
||||||
|
console.error("Unexpected result from discord:", this.authToken, res.data);
|
||||||
|
return Promise.reject({
|
||||||
|
reason: "Invalid authorization code. Please logout and try again.",
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let discordCache = new DiscordCache(
|
||||||
|
this.id,
|
||||||
|
res.data.id,
|
||||||
|
res.data.username,
|
||||||
|
res.data.discriminator,
|
||||||
|
res.data.avatar
|
||||||
|
);
|
||||||
|
discordCache.save();
|
||||||
|
return discordCache;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,41 @@
|
|||||||
|
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm"
|
||||||
|
|
||||||
|
|
||||||
|
@Entity({ name: 'guilds' })
|
||||||
|
export class Guild extends BaseEntity {
|
||||||
|
|
||||||
|
@PrimaryGeneratedColumn("uuid")
|
||||||
|
id: string
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
name: string
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
guild: string
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
channel: string
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
staffRole: string
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
jailedRole: string
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
defaultExpiry?: string
|
||||||
|
|
||||||
|
constructor(name: string, guild: string, channel: string, staffRole: string, jailedRole: string, defaultExpiry: string) {
|
||||||
|
super()
|
||||||
|
this.name = name
|
||||||
|
this.guild = guild
|
||||||
|
this.channel = channel
|
||||||
|
this.staffRole = staffRole
|
||||||
|
this.jailedRole = jailedRole
|
||||||
|
this.defaultExpiry = defaultExpiry
|
||||||
|
}
|
||||||
|
|
||||||
|
static async all(): Promise<Array<Guild>> {
|
||||||
|
return await this.find()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,77 @@
|
|||||||
|
import axios, {AxiosResponse} from "axios";
|
||||||
|
import {
|
||||||
|
BaseEntity,
|
||||||
|
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn
|
||||||
|
} from "typeorm";
|
||||||
|
import {URLSearchParams} from "url";
|
||||||
|
import {Config} from "../config/Config";
|
||||||
|
import {API_URL, OAUTH_SCOPE} from "../config/Constants";
|
||||||
|
import {DiscordToken} from "./DiscordToken";
|
||||||
|
|
||||||
|
@Entity({ name: "state_tokens" })
|
||||||
|
export class StateToken extends BaseEntity {
|
||||||
|
@PrimaryGeneratedColumn("uuid")
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ unique: true })
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
expiring: Date;
|
||||||
|
|
||||||
|
constructor(token: string) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
if (token === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.token = token;
|
||||||
|
this.expiring = new Date();
|
||||||
|
this.expiring.setMinutes(this.expiring.getMinutes() + 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
async verify({ api }: Config, code: string): Promise<DiscordToken> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append("client_id", api.client_id);
|
||||||
|
params.append("client_secret", api.client_secret);
|
||||||
|
params.append("grant_type", "authorization_code");
|
||||||
|
params.append("code", `${code}`);
|
||||||
|
params.append("redirect_uri", `${api.redirect_uri}`);
|
||||||
|
params.append("scope", OAUTH_SCOPE);
|
||||||
|
|
||||||
|
let r: AxiosResponse;
|
||||||
|
try {
|
||||||
|
r = await axios.post(`${API_URL}/oauth2/token`, params.toString(), {
|
||||||
|
validateStatus: (status) => status < 500,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Could not decode request data: ", this, err);
|
||||||
|
Promise.reject({
|
||||||
|
reason: "An internal error has occurred.",
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.status != 200) {
|
||||||
|
console.error("Bad request from discord", r);
|
||||||
|
Promise.reject({ reason: r.data["error_description"], status: 400 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authToken = r.data["access_token"];
|
||||||
|
|
||||||
|
let dToken = await DiscordToken.findOne({ where: { authToken } });
|
||||||
|
if (!dToken) {
|
||||||
|
dToken = new DiscordToken(this.token, authToken);
|
||||||
|
dToken.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.remove();
|
||||||
|
return dToken;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,65 @@
|
|||||||
|
import * as cors from "cors";
|
||||||
|
import * as dotenv from "dotenv";
|
||||||
|
import * as express from "express";
|
||||||
|
import { createConnection } from "typeorm";
|
||||||
|
import { EventEmitter } from "typeorm/platform/PlatformTools";
|
||||||
|
import Bot from "./bot/Bot";
|
||||||
|
import { ApiConfig, BotConfig, Config } from "./config/Config";
|
||||||
|
import { PORT } from "./config/Constants";
|
||||||
|
import { getApi } from "./controllers/Api";
|
||||||
|
import { authorize } from "./controllers/Authorize";
|
||||||
|
import { info } from "./controllers/Info";
|
||||||
|
import { genOAuth } from "./controllers/OAuth";
|
||||||
|
import { status } from "./controllers/Status";
|
||||||
|
import { submit } from "./controllers/Submit";
|
||||||
|
import { fail } from "./controllers/Util";
|
||||||
|
import { pruneDB } from "./entity/Cleaner";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
const config = new Config();
|
||||||
|
config.api = new ApiConfig(
|
||||||
|
process.env.CORS_HOST.split(","),
|
||||||
|
process.env.REDIRECT_URI,
|
||||||
|
Number(process.env.API_PORT),
|
||||||
|
process.env.API_CLIENT_SECRET,
|
||||||
|
process.env.API_CLIENT_ID
|
||||||
|
);
|
||||||
|
config.bot = new BotConfig(
|
||||||
|
process.env.BOT_TOKEN,
|
||||||
|
process.env.BOT_PREFIX || "con!"
|
||||||
|
);
|
||||||
|
|
||||||
|
const appealEmitter = new EventEmitter();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cors({ origin: config.api.cors_host }));
|
||||||
|
app.use(
|
||||||
|
(err: Error, _req: express.Request, res: express.Response, _next: any) => {
|
||||||
|
if (err instanceof SyntaxError) {
|
||||||
|
fail(res, 400, "Invalid JSON: " + err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(err.message, err instanceof SyntaxError);
|
||||||
|
fail(res, 500, "Something went wrong!");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.set("config", config);
|
||||||
|
app.set("emitter", appealEmitter);
|
||||||
|
|
||||||
|
app.get("/api", getApi);
|
||||||
|
app.get("/oauth", genOAuth);
|
||||||
|
app.post("/authorize", authorize);
|
||||||
|
app.post("/info", info);
|
||||||
|
app.post("/status", status);
|
||||||
|
app.post("/submit", submit);
|
||||||
|
|
||||||
|
createConnection()
|
||||||
|
.then(async () => {
|
||||||
|
app.listen(PORT, () => console.log("Server is listening on port " + PORT));
|
||||||
|
new Bot(config, appealEmitter);
|
||||||
|
pruneDB(60 * 1000);
|
||||||
|
})
|
||||||
|
.catch((err) => console.error(err));
|
@ -0,0 +1,175 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Contrition
|
||||||
|
version: 0.0.1
|
||||||
|
contact:
|
||||||
|
name: Project Maintainer
|
||||||
|
email: 32350@jisedu.or.id
|
||||||
|
paths:
|
||||||
|
/oauth:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- OAuth Endpoints
|
||||||
|
summary: Create a request to generate an OAuth Token
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Successfully retrieve OAuth URL
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OAuth'
|
||||||
|
/verify:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- OAuth Endpoints
|
||||||
|
summary: Callback with discord auth request
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Successfully complete OAuth exchange
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OAuth'
|
||||||
|
400:
|
||||||
|
$ref: '#/components/responses/MalformedBody'
|
||||||
|
500:
|
||||||
|
$ref: '#/components/responses/ServerError'
|
||||||
|
|
||||||
|
/submit:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Form Endpoints
|
||||||
|
summary: Post an unban request appeal
|
||||||
|
requestBody:
|
||||||
|
description: Form body
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
platform:
|
||||||
|
type: string
|
||||||
|
punishment_date:
|
||||||
|
type: string
|
||||||
|
ban_reason:
|
||||||
|
type: string
|
||||||
|
appeal_reason:
|
||||||
|
type: string
|
||||||
|
additional_info:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Successfully posted appeal request
|
||||||
|
400:
|
||||||
|
$ref: '#/components/responses/MalformedBody'
|
||||||
|
401:
|
||||||
|
$ref: '#/components/responses/InvalidToken'
|
||||||
|
422:
|
||||||
|
$ref: '#/components/responses/Unprocessable'
|
||||||
|
500:
|
||||||
|
$ref: '#/components/responses/ServerError'
|
||||||
|
|
||||||
|
components:
|
||||||
|
requestBodies:
|
||||||
|
AuthCredentials:
|
||||||
|
description: User credentials.
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
required:
|
||||||
|
- username
|
||||||
|
- password
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
description: The username of the user.
|
||||||
|
type: string
|
||||||
|
password:
|
||||||
|
description: The password of the user.
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
MalformedBody:
|
||||||
|
description: The information in your body is incomplete, or your JSON format is invalid.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
example:
|
||||||
|
reason: Invalid request body provided.
|
||||||
|
code: 400
|
||||||
|
InvalidToken:
|
||||||
|
description: The access token you provided is invalid or missing.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
example:
|
||||||
|
reason: The token provided is invalid.
|
||||||
|
code: 401
|
||||||
|
NotFound:
|
||||||
|
description: The requested resource was not found.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
example:
|
||||||
|
reason: The specified resource does not exist.
|
||||||
|
code: 404
|
||||||
|
Unprocessable:
|
||||||
|
description: The request was valid, however some of the parameters have an invalid value.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
example:
|
||||||
|
reason: Varies
|
||||||
|
code: 422
|
||||||
|
ServerError:
|
||||||
|
description: The server could not complete a request due to an unknown error. More information may be available in the 'reason' parameter.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
example:
|
||||||
|
reason: The internal server reached an unknown state.
|
||||||
|
code: 500
|
||||||
|
schemas:
|
||||||
|
OAuth:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
redirectUrl:
|
||||||
|
type: string
|
||||||
|
format: url
|
||||||
|
Verify:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
user:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
discriminator:
|
||||||
|
type: string
|
||||||
|
banned:
|
||||||
|
type: boolean
|
||||||
|
Error:
|
||||||
|
required:
|
||||||
|
- reason
|
||||||
|
- code
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
reason:
|
||||||
|
description: The cause of the error message.
|
||||||
|
type: string
|
||||||
|
code:
|
||||||
|
description: The HTTP error code value accompanying the error.
|
||||||
|
type: integer
|
||||||
|
|
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": [
|
||||||
|
"es5",
|
||||||
|
"es6"
|
||||||
|
],
|
||||||
|
"target": "es6",
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue