feat: v1.0.0 contrition

master
ALI Hamza 2021-09-23 11:06:30 +07:00
parent f8375dba6e
commit 4c53043c4b
Signed by: hamza
GPG Key ID: 22473A32291F8CB6
49 changed files with 10896 additions and 14 deletions

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

10
.gitignore vendored

@ -0,0 +1,10 @@
.idea/
.vscode/
node_modules/
build/
dist/
temp/
.env
ormlogs.log
*.sqlite

57
A.md

@ -4,22 +4,50 @@
### Defining the Problem
veksen is a senior moderator of TPH (The Programmer's Hangout), an online community with over 55,000 members from all over the world. The community is on Discord, a very popular chatting platform where people can hang out. veksen, alongside with the rest of the TPH staff team have a rather clunky way of handling those who misbehave, manage to get themselves *banned* from the server, and want a second chance. Usually, they either have to create a new account and join the server in an attempt to get in touch with the staff team, or have to get a friend to help them out.
In September, veksen approached me and discussed the issue at hand, wanting an unban system with an online interface, so that it is accessible for those who can't join the community anymore. The online interface would authenticate you with the very open Discord API, be able to validate, that a user is in fact banned from the server, and allow users to fill out a form to appeal their punishment(s). This seemed like a fantastic project for me to complete for my Internal Assessment, as I would be able to solve a programming-related problem, the actual implementation isn't going to be completely non-trivial and simple to solve, and I would be helping out a community that has been so helpful and useful to not only myself, but thousands of others alike.
In order to get more details, I decided to hop in a call with veksen on September 11th, and get more information about how the system would work.
veksen is a senior moderator of TPH (The Programmer's Hangout), an online
community with over 55,000 members from all over the world. The community is on
Discord, a very popular chatting platform where people can hang out. veksen,
alongside with the rest of the TPH staff team have a rather clunky way of
handling those who misbehave, manage to get themselves *banned* from the
server, and want a second chance. Usually, they either have to create a new
account and join the server in an attempt to get in touch with the staff team,
or have to get a friend to help them out.
In September, veksen approached me and discussed the issue at hand, wanting an
unban system with an online interface, so that it is accessible for those who
can't join the community anymore. The online interface would authenticate you
with the very open Discord API, be able to validate, that a user is in fact
banned from the server, and allow users to fill out a form to appeal their
punishment(s). This seemed like a fantastic project for me to complete for my
Internal Assessment, as I would be able to solve a programming-related problem,
the actual implementation isn't going to be completely non-trivial and simple
to solve, and I would be helping out a community that has been so helpful and
useful to not only myself, but thousands of others alike.
In order to get more details, I decided to hop in a call with veksen on
September 11th, and get more information about how the system would work.
### Rationale for Proposed Solution
There is going to be three main parts to the system. Firstly, there is going to be a REST API that their current website will hook into. This API will have access to information about users who authorize themselves with Discord, so that those who are appealing can be identified when filling out the request. Next, their current website will hook up with the backend API. Finally, there will be a "bot" on the community itself. when a request is processed, will allow the users to rejoin with limited access, so that the staff team can further look into their case and ask more questions.
There is going to be three main parts to the system. Firstly, there is going to
be a REST API that their current website will hook into. This API will have
access to information about users who authorize themselves with Discord, so
that those who are appealing can be identified when filling out the request.
Next, their current website will hook up with the backend API. Finally, there
will be a "bot" on the community itself. when a request is processed, will
allow the users to rejoin with limited access, so that the staff team can
further look into their case and ask more questions.
I have decided to write the bot, as well as the backend REST API in TypeScript because
- The rest of the staff team at TPH have experience with it
- Easy to write and maintain, especially in comparison with JavaScript, which is purely dynamically typed
- Has great libraries available to create REST APIs, as well as Discord bots
I have decided to write the bot, as well as the backend REST API in TypeScript
because
The frontend will be written with React/Gatsby, as that's what the current website is using, and rewriting it all from scratch will be undesirable.
- The rest of the staff team at TPH have experience with it
- Easy to write and maintain, especially in comparison with JavaScript, which
is purely dynamically typed
- Has great libraries available to create REST APIs, as well as Discord bots
The frontend will be written with React/Gatsby, as that's what the current
website is using, and rewriting it all from scratch will be undesirable.
### The Success Criteria
@ -27,7 +55,8 @@ The frontend will be written with React/Gatsby, as that's what the current websi
1. Identifies whether or not users are banned on the server
1. Interfaces with the backend REST API to submit an appeal request
1. Uses an email client to inform users information about their appeal request
1. Connects with the Discord Bot (known as Contrition) to allow users limited access on TPH
1. Connects with the Discord Bot (known as Contrition) to allow users limited
access on TPH
1. Has a logging system to keep track of misuse and errors
1. Has a flexible configuration system to stay flexible if the TPH server setup is changed
1. Has a flexible configuration system to stay flexible if the TPH server setup
is changed

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

8001
package-lock.json generated

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,72 @@
export const PORT = process.env.PORT || 4000;
export const OAUTH_SCOPE = "identify guilds.join";
export const API_URL = "https://discord.com/api/v6";
export const CDN_URL = "https://cdn.discordapp.com/";
export const cancelString =
'You can cancel this at any time by saying "cancel".';
export const reactions = [
"1⃣",
"2⃣",
"3⃣",
"4⃣",
"5⃣",
"6⃣",
"7⃣",
"8⃣",
"9⃣",
"🔟",
];
export const paginate = <T>(
array: Array<T>,
size: number,
pageNum: number
): Array<T> => {
return array.slice((pageNum - 1) * size, pageNum * size);
};
export const parseDuration = (timeString: string): string => {
const secs = timeString.match(/(\d+)\s*s/);
const days = timeString.match(/(\d+)\s*d/);
const weeks = timeString.match(/(\d+)\s*w/);
const months = timeString.match(/(\d+)\s*m/);
const years = timeString.match(/(\d+)\s*y/);
let str = "";
if (secs) str += `${secs[1]}s `;
if (days) str += `${days[1]}d `;
if (weeks) str += `${weeks[1]}w `;
if (months) str += `${months[1]}m `;
if (years) str += `${years[1]}y `;
return str.trim();
};
export const getDate = (timeString: string): Date => {
const now = new Date();
const date = new Date(now);
if (timeString === "never") {
return now;
}
const secs = timeString.match(/(\d+)\s*s/);
const days = timeString.match(/(\d+)\s*d/);
const weeks = timeString.match(/(\d+)\s*w/);
const months = timeString.match(/(\d+)\s*m/);
const years = timeString.match(/(\d+)\s*y/);
if (secs) date.setSeconds(date.getSeconds() + parseInt(secs[1]));
if (days) date.setDate(date.getDate() + parseInt(days[1]));
if (weeks) date.setDate(date.getDate() + parseInt(weeks[1]) * 7);
if (months) date.setMonth(date.getMonth() + parseInt(months[1]));
if (years) date.setFullYear(date.getFullYear() + parseInt(years[1]));
if (now.getTime() === date.getTime()) return null;
return date;
};

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