0
0
Fork 0

Move everything for the server in to a server folder to make room for the site

This commit is contained in:
Oliver-Akins 2021-09-24 22:58:23 -06:00
parent 6a45152d8f
commit 33ca09808e
26 changed files with 8 additions and 6 deletions

16
server/src/constants.ts Normal file
View file

@ -0,0 +1,16 @@
export var BRACKET_DATA = {
msg: "",
quotes: [],
votes: {},
users: {},
};
export var CHANNEL_DATA = {
webhook: {
token: "",
id: "",
},
bracket: BRACKET_DATA,
};
export var DISCORD_API_URI: string = `https://discord.com/api/v9`;

View file

@ -0,0 +1,38 @@
import { CHANNEL_DATA, DISCORD_API_URI } from "@/constants";
import { Request, ResponseToolkit } from "@hapi/hapi";
import { config, db } from "@/main";
import boom from "@hapi/boom";
import axios from "axios";
export default {
method: `GET`, path: `/discord/auth/callback`,
async handler(request: Request, h: ResponseToolkit) {
let { code, guild_id: gID } = request.query;
// Assert the guild is allowed to be setup.
if (!config.guilds[gID]) {
throw boom.notFound(`Cannot save a webhook for a guild that doesn't have a config set up.`);
};
let data = new URLSearchParams();
data.set(`client_id`, config.discord.client_id);
data.set(`client_secret`, config.discord.secret);
data.set(`grant_type`, `authorization_code`);
data.set(`code`, code);
data.set(`redirect_uri`, config.discord.auth_redirect);
let r = await axios.post(`${DISCORD_API_URI}/oauth2/token`, data, {
headers: {
'Content-Type': `application/x-www-form-urlencoded`
}
});
let { id, token } = r.data.webhook;
db[gID] = JSON.parse(JSON.stringify(CHANNEL_DATA))
db[gID].webhook.token = token;
db[gID].webhook.id = id;
return r.data;
},
}

View file

@ -0,0 +1,11 @@
import { Request, ResponseToolkit } from "@hapi/hapi";
import { config } from "@/main";
export default {
method: `GET`, path: `/discord/auth`,
async handler(request: Request, h: ResponseToolkit) {
return h.redirect(
`https://discord.com/api/oauth2/authorize?client_id=${config.discord.client_id}&redirect_uri=${encodeURIComponent(config.discord.auth_redirect)}&response_type=code&scope=webhook.incoming`
);
},
}

View file

@ -0,0 +1,76 @@
import { selectQuote } from "@/utils/components/dropdowns/select_quote";
import { deleteVote } from "@/utils/components/buttons/delete_vote";
import { countVotes } from "@/utils/components/buttons/count_votes";
import { showUserVote } from "@/utils/components/buttons/my_vote";
import { Request, ResponseToolkit } from "@hapi/hapi";
import { config } from "@/main";
import boom from "@hapi/boom";
import nacl from "tweetnacl";
import { viewDB } from "@/utils/components/buttons/view_db";
async function handleButton(data: any): Promise<object> {
switch (data.data.custom_id) {
case "deleteVote":
return await deleteVote(data);
case "showCount":
return await countVotes(data);
case "viewDB":
return await viewDB(data);
case "showMyVote":
return await showUserVote(data);
};
return {
type: 4,
data: {
content: `Unknown button, how did you trigger this response? 0\_o`,
flags: 1 << 6,
}
};
};
export default {
method: `POST`, path: `/discord/webhook`,
options: { auth: false },
async handler(request: Request, h: ResponseToolkit) {
let sig = request.headers[`x-signature-ed25519`];
let timestamp = request.headers[`x-signature-timestamp`];
let body: any = request.payload;
// Verify the body against Discord's stuff
let verified = nacl.sign.detached.verify(
Buffer.from(timestamp + JSON.stringify(body)),
Buffer.from(sig, `hex`),
Buffer.from(config.discord.public_key, `hex`)
);
if (!verified) {
return boom.unauthorized(`invalid request signature`);
};
switch (body.type) {
case 1:
return { type: 1 };
case 3:
// Parse the data properly
if (body.data.component_type === 3) {
return await selectQuote(body)
} else if (body.data.component_type === 2) {
return await handleButton(body)
};
return {
type: 4,
data: {
content: `Unknown component type.`,
flags: 1 << 6,
},
};
default:
return boom.badRequest()
};
},
};

View file

@ -0,0 +1,151 @@
import { Request, ResponseToolkit } from "@hapi/hapi";
import { loadHistory, saveHistory } from "@/utils/data";
import { getQuote } from "@/utils/quotes";
import { db, config } from "@/main";
import { BRACKET_DATA, DISCORD_API_URI } from "@/constants";
import axios from "axios";
import { deleteVoteButton } from "@/utils/components/buttons/delete_vote";
import { showUserVoteButton } from "@/utils/components/buttons/my_vote";
import { viewDBButton } from "@/utils/components/buttons/view_db";
import { countVotesButton } from "@/utils/components/buttons/count_votes";
export default {
method: `POST`, path: `/{guild_id}/bracket`,
async handler(request: Request, h: ResponseToolkit) {
let { guild_id: gID } = request.params;
let wh = db[gID].webhook;
function generateFieldTitle(index: number, quote: quote): string {
// Change the name based on if the quote won or not
if (quote.win_streak > 0) {
let name = `👑 Quote ${index + 1}:`;
// Add the win streak information if desired
if ((config.guilds[gID].show_win_streak ?? true)
&& quote.win_streak > 0) {
name += ` (Streak: ${quote.win_streak})`;
};
return name;
}
else {
return `Quote ${index + 1}:`;
};
};
// Create the very first quote bracket
let quotes: quote[];
if (!db[gID].bracket.msg) {
quotes = await getQuote(gID, config.guilds[gID].quote_max);
} else {
await request.server.inject({
method: `DELETE`,
url: `/${gID}/bracket/${config.guilds[gID].delete_mode}`,
auth: request.auth,
});
let pastBrackets = await loadHistory(gID);
pastBrackets.push(db[gID].bracket.quotes);
saveHistory(gID, pastBrackets);
// Calculate the winners from the previous bracket
let r = await request.server.inject({
url: `/${gID}/bracket/winners?finalize=true`,
auth: request.auth,
});
let data = JSON.parse(r.payload);
var winner_count = data.count;
// Check if we are getting rid of all winners
if (data.eliminate_all) {
quotes = [];
winner_count = 0;
} else {
quotes = data.winners;
};
// Get enough quotes to meet the maximum for the guild
let new_quotes = await getQuote(
gID,
config.guilds[gID].quote_max - quotes.length
);
quotes.push(...new_quotes);
};
// Setup the database for the new bracket
db[gID].bracket = JSON.parse(JSON.stringify(BRACKET_DATA));
db[gID].bracket.quotes = quotes;
let message = {
content: `New Quote Bracket!`,
embeds: [
{
description: `Note: If **more than ${Math.floor(config.guilds[gID].quote_max / 2)}** of the quotes tie, they will all be eliminated, otherwise, the ones that tie will move on to the next bracket.`,
fields: quotes.map((quote, i) => { return {
name: generateFieldTitle(i, quote),
value: quote.text,
}}),
}
],
components: [
{
type: 1,
components: [
{
type: 3,
custom_id: `quote`,
placeholder: `Choose Your Favourite Quote`,
options: quotes.map((_, i) => {
return {
label: `Quote ${i + 1}`,
value: i,
emoji: i < winner_count ? {
name: `👑`
} : null
}
}),
}
]
},
{
type: 1,
components: [
showUserVoteButton,
deleteVoteButton,
],
}
]
};
//---------------------------------
// Add the extra buttons as desired
let extra_buttons = config.guilds[gID].extra_buttons ?? [];
if (extra_buttons.length > 0) {
let actionRow: action_row = {
type: 1,
components: [],
};
if (extra_buttons.includes(`DB`)) {
actionRow.components.push(viewDBButton);
};
if (extra_buttons.includes(`voteCount`)) {
actionRow.components.push(countVotesButton);
};
message.components.push(actionRow);
};
let url = `${DISCORD_API_URI}/webhooks/${wh.id}/${wh.token}`;
let r = await axios.post(url, message, { params: { wait: true } });
db[gID].bracket.msg = r.data.id;
db[gID].bracket.channel = r.data.channel_id;
return h.response(r.data).code(r.status);
},
}

View file

@ -0,0 +1,18 @@
import { DISCORD_API_URI } from "@/constants";
import { db } from "@/main";
import { Request, ResponseToolkit } from "@hapi/hapi";
import axios from "axios";
export default {
method: `DELETE`, path: `/{guild_id}/bracket/delete_message`,
async handler(request: Request, h: ResponseToolkit) {
let { guild_id: gID } = request.params;
let wh = db[gID].webhook;
let r = await axios.delete(
`${DISCORD_API_URI}/webhooks/${wh.id}/${wh.token}/messages/${db[gID].bracket.msg}`
);
return h.response(r.data).code(r.status);
},
}

View file

@ -0,0 +1,19 @@
import { Request, ResponseToolkit } from "@hapi/hapi";
import { DISCORD_API_URI } from "@/constants";
import { db } from "@/main";
import axios from "axios";
export default {
method: `DELETE`, path: `/{guild_id}/bracket/remove_components`,
async handler(request: Request, h: ResponseToolkit) {
let { guild_id: gID } = request.params;
let wh = db[gID].webhook;
let r = await axios.patch(
`${DISCORD_API_URI}/webhooks/${wh.id}/${wh.token}/messages/${db[gID].bracket.msg}`,
{ components: [] }
);
return h.response(r.data).code(r.status);
},
}

View file

@ -0,0 +1,95 @@
import { Request, ResponseToolkit } from "@hapi/hapi";
import { DISCORD_API_URI } from "@/constants";
import { config, db } from "@/main";
import axios from "axios";
export default {
method: `GET`, path: `/{guild_id}/bracket/isTied`,
async handler(request: Request, h: ResponseToolkit) {
let { guild_id: gID } = request.params;
let thread_behaviour = config.guilds[gID].tie_reminder;
let r = await request.server.inject({
url: `/${gID}/bracket/winners`,
auth: request.auth,
});
let data = JSON.parse(r.payload);
if (data.count >= 2) {
let bracket = db[gID].bracket;
// Construct the primary body of the message
let content = `The bracket currently has a tie between:\n> `;
content += data.winners
.map((q:quote) => q.text)
.join('\n~~------------------------------------~~\n> ');
// Alert users if all will be eliminated or not
if (data.eliminate_all) {
content += `\n\n**If the tie remains, all quotes will be eliminated**`;
} else {
content += `\n\n**All of these quotes will advance if the tie isn't broken.**`;
};
// Define the query params that are needed all the time
let params: execute_webhook_query_params = { wait: true };
// Check if the user is wanting to use a thread notification
if ((thread_behaviour !== "channel") && config.guilds[gID].bot_token) {
try {
await axios.get(
`${DISCORD_API_URI}/channels/${bracket.msg}`,
{
headers: {
Authorization: `Bot ${config.guilds[gID].bot_token}`
}
}
);
params.thread_id = bracket.msg;
} catch (err) {
try {
await axios.post(
`${DISCORD_API_URI}/channels/${bracket.channel}/messages/${bracket.msg}/threads`,
{
name: config.guilds[gID].thread_name ?? `Quote Bracket Discussion`,
auto_archive_duration: 1440,
},
{
headers: {
Authorization: `Bot ${config.guilds[gID].bot_token}`
}
}
).then(response => {
params.thread_id = bracket.msg
});
} catch (err) {};
};
};
// Add link if we know what channel the message was posted in
if (db[gID].bracket.channel) {
switch (thread_behaviour) {
case "channel":
case "thread":
content += `\n\n[Jump To Bracket](https://discord.com/channels/${gID}/${bracket.channel}/${bracket.msg})`;
break;
case "thread_no_jump_link":
if (!params.thread_id) {
content += `\n\n[Jump To Bracket](https://discord.com/channels/${gID}/${bracket.channel}/${bracket.msg})`;
};
break;
};
};
let wh = db[gID].webhook;
let r = await axios.post(
`${DISCORD_API_URI}/webhooks/${wh.id}/${wh.token}`,
{ content },
{ params }
);
return h.response(r.data).code(r.status);
};
return h.response({ result: `No Tie` }).code(200);
},
}

View file

@ -0,0 +1,33 @@
import { Request, ResponseToolkit } from "@hapi/hapi";
import { DISCORD_API_URI } from "@/constants";
import { config, db } from "@/main";
import axios from "axios";
export default {
method: `GET`, path: `/{guild_id}/bracket`,
async handler(request: Request, h: ResponseToolkit) {
let { guild_id: gID } = request.params;
let { convert_ids } = request.query;
// See if we are adding the user's conversion table to the response
let users: {[index: string]: string} = {};
if (convert_ids?.toLowerCase() === `true` && config.guilds[gID].bot_token) {
for (var k in db[gID].bracket.users) {
let r = await axios.get(
`${DISCORD_API_URI}/users/${k}`,
{
headers: {
Authorization: `Bot ${config.guilds[gID].bot_token}`
}
}
);
users[k] = `${r.data.username}#${r.data.discriminator}`
};
};
return h.response({
db: db[gID].bracket,
user_id_mapping: users,
});
},
}

View file

@ -0,0 +1,50 @@
import { Request, ResponseToolkit, ServerRoute } from "@hapi/hapi";
import { config, db } from "@/main";
import Joi from "joi";
const route: ServerRoute = {
method: `GET`, path: `/{guild_id}/bracket/winners`,
options: {
validate: {
query: Joi.object({
finalize: Joi.boolean().optional().default(false),
}),
},
},
async handler(request: Request, h: ResponseToolkit) {
let gID = request.params.guild_id;
let data = db[gID].bracket;
let { finalize } = request.query;
let winners: quote[] = [];
let highest = -1;
// Run through all of the quotes to find the most voted for ones
for (var quote of data.quotes) {
// New maximum, remove all previous winners
if (quote.votes > highest) {
winners = [ quote ];
highest = quote.votes;
}
else if (quote.votes === highest) {
winners.push( quote );
};
// Reset the bracket data as needed
if (finalize) {
quote.win_streak++;
quote.votes = 0;
};
};
let count = winners.length;
return h.response({
winners,
count,
eliminate_all: count > Math.floor(config.guilds[gID].quote_max / 2),
}).code(200);
},
};
export default route;

91
server/src/main.ts Normal file
View file

@ -0,0 +1,91 @@
// Filepath alias resolution
import "module-alias/register";
// Begin personal code
import { ResponseToolkit, Server, Request } from "@hapi/hapi";
import basic from "@hapi/basic";
import path from "path";
import glob from "glob";
import toml from "toml";
import fs from "fs";
// load the config
if (!fs.existsSync(`config.toml`)) {
console.log(`Please fill out the config and then try starting the server again.`);
process.exit(1);
};
export const config: config = toml.parse(fs.readFileSync(`config.toml`, `utf-8`));
// Load the database
if (!fs.existsSync(`data/db.json`)) {
console.log(`Can't find database file, creating default`);
fs.writeFileSync(`data/db.json`, `{}`);
};
export var db: database = JSON.parse(fs.readFileSync(`data/db.json`, `utf-8`));
function saveDB() {
console.log(`Saving database`);
fs.writeFileSync(`data/db.json`, JSON.stringify(db));
process.exit(0);
};
process.on(`SIGINT`, saveDB);
process.on(`SIGTERM`, saveDB);
process.on(`uncaughtException`, saveDB);
async function init() {
const server = new Server({
port: config.server.port,
});
// Setup authentication
server.register(basic);
server.auth.strategy(`simple`, `basic`, {
async validate(request: Request, user: string, password: string, h: ResponseToolkit) {
// Are we attempting to authenticate, then use the auth password
if (request.path.startsWith(`/discord/auth`)) {
return {
isValid: config.discord.auth_password === password,
credentials: { user, password },
};
};
// Assume the user is the same as the guild ID
user = user || request.params.guild_id;
// Ensure the guild has a config
if (!config.guilds[user]) {
return { isValid: false, };
};
return {
isValid: config.guilds[user].password === password,
credentials: { user, password }
};
},
allowEmptyUsername: true,
});
server.auth.default(`simple`)
// Register all the routes
let files = glob.sync(
`endpoints/**/!(*.map)`,
{ cwd: __dirname, nodir: true}
);
for (var file of files) {
let route = (await import(path.join(__dirname, file))).default;
console.log(`Registering route: ${route.method} ${route.path}`);
server.route(route);
};
server.start().then(() => {
console.log(`Server listening on ${config.server.host}:${config.server.port}`);
});
};
init();

29
server/src/types/config.d.ts vendored Normal file
View file

@ -0,0 +1,29 @@
interface channel_config {
password: string;
api_base: string;
quote_max: number;
bot_token?: string;
delete_mode: "remove_components" | "delete_message";
params: { [index: string]: any };
thread_name?: string;
show_win_streak?: boolean;
extra_buttons?: string[];
tie_reminder: "channel" | "thread" | "thread_no_jump_link";
}
interface config {
discord: {
auth_password: string;
client_id: string;
secret: string;
public_key: string;
auth_redirect: string;
};
server: {
host: string;
port: number;
};
guilds: {
[index: string]: channel_config;
};
}

25
server/src/types/database.d.ts vendored Normal file
View file

@ -0,0 +1,25 @@
interface quote {
text: string;
votes: number;
win_streak: number;
}
interface bracket_data {
msg: string;
channel: string;
quotes: quote[];
users: { [index: string]: number };
}
interface database {
[index: string]: {
webhook: {
id: string;
token: string;
};
bracket: bracket_data;
}
}
type bracket_history = quote[];

View file

@ -0,0 +1,32 @@
interface emoji {
name: string;
id: string;
animated: boolean;
}
interface action_row {
type: 1,
components: any[];
}
interface button {
type: 2;
/**
* - 1 = Primary (Blurple)
* - 2 = Secondary (Gray)
* - 3 = Success (Green)
* - 4 = Danger (Red)
*/
style: 1 | 2 | 3 | 4;
custom_id: string;
label?: string;
emoji?: emoji;
disabled?: boolean;
}
interface link_button {
type: 2;
style: 5;
url: string;
}

4
server/src/types/misc.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
interface execute_webhook_query_params {
wait?: boolean;
thread_id?: string;
}

View file

@ -0,0 +1,26 @@
import { db } from "@/main";
export const countVotesButton: button = {
type: 2,
style: 2,
label: `See Count`,
custom_id: `showCount`,
};
export async function countVotes(data: any): Promise<object> {
let gID = data.guild_id;
let response = `Quote Votes:`;
for (var q of db[gID].bracket.quotes) {
response += `\n${q.votes} vote${q.votes !== 1 ? 's' : ''} for ${q.text}`;
}
return {
type: 4,
data: {
content: response,
flags: 1 << 6,
}
};
}

View file

@ -0,0 +1,40 @@
import { db } from "@/main";
export const deleteVoteButton = {
type: 2,
style: 4,
label: `Remove Vote`,
custom_id: `deleteVote`
};
export async function deleteVote(data: any): Promise<object> {
let userID = data.member.user.id;
let gID = data.guild_id;
// Assert the user has voted
if (db[gID].bracket.users[userID] == null) {
return {
type: 4,
data: {
content: `You haven't voted in the bracket, so you can't delete your vote.`,
flags: 1 << 6,
}
};
};
// Subtract user's vote from total
let vote = db[gID].bracket.users[userID];
--db[gID].bracket.quotes[vote].votes;
delete db[gID].bracket.users[userID];
return {
type: 4,
data: {
content: `Your vote has been deleted.`,
flags: 1 << 6,
}
};
}

View file

@ -0,0 +1,28 @@
import { db } from "@/main";
export const showUserVoteButton = {
type: 2,
style: 1,
label: `What Did I Vote For?`,
custom_id: `showMyVote`,
};
export async function showUserVote(data: any): Promise<object> {
let gID = data.guild_id;
let vote = db[gID].bracket.users[data.member.user.id];
let quote = db[gID].bracket.quotes[vote];
let content = `You currently haven't voted for a quote!`;
if (quote) {
content = `Your vote is for:\n> ${quote.text}`;
};
return {
type: 4,
data: {
content,
flags: 1 << 6,
}
};
}

View file

@ -0,0 +1,21 @@
import { db } from "@/main";
export const viewDBButton = {
type: 2,
style: 1,
label: `See Database Object`,
custom_id: `viewDB`,
};
export async function viewDB(data: any): Promise<object> {
let gID = data.guild_id;
return {
type: 4,
data: {
content: `\`\`\`json\n${JSON.stringify(db[gID].bracket, null, ' ')}\`\`\``,
flags: 1 << 6,
}
};
}

View file

@ -0,0 +1,48 @@
import { db } from "@/main";
export async function selectQuote(data: any): Promise<object> {
let newVote = parseInt(data.data.values[0]);
let userID = data.member.user.id;
let gID = data.guild_id;
let oldVote = db[gID].bracket.users[userID];
// Assert votes are different
if (oldVote === newVote) {
return {
type: 4,
data: {
content: `You're already voting for that quote!`,
flags: 1 << 6,
}
};
};
++db[gID].bracket.quotes[newVote].votes;
db[gID].bracket.users[userID] = newVote;
// User changed their vote
if (oldVote != null) {
--db[gID].bracket.quotes[oldVote].votes;
return {
type: 4,
data: {
content: `Your vote has been changed from:\n> ${db[gID].bracket.quotes[oldVote].text}\nto:\n> ${db[gID].bracket.quotes[newVote].text}`,
flags: 1 << 6,
}
};
}
// User voted for the first time
else {
return {
type: 4,
data: {
content: `Your vote has been recorded for:\n> ${db[gID].bracket.quotes[newVote].text}`,
flags: 1 << 6,
}
};
};
};

50
server/src/utils/data.ts Normal file
View file

@ -0,0 +1,50 @@
import fs from "fs/promises";
/**
* Retrieves the historical data for each bracket that was ran for a channel.
*
* @param guild_id The guild ID that we are loading the history of brackets for
*/
export async function loadHistory(guild_id: string): Promise<bracket_history[]> {
try {
return JSON.parse(await fs.readFile(`data/history/${guild_id}.json`, `utf-8`))
} catch (err) {
return [];
};
};
/**
* Loads all of the quotes used by the system in previous quote brackets to
* prevent duplicates.
*
* @param guild_id The guild ID that we are loading quotes for
* @returns The quotes which have been used
*/
export async function loadUsedQuotes(guild_id: string): Promise<string[]> {
try {
return JSON.parse(await fs.readFile(`data/used_quotes/${guild_id}.json`, `utf-8`))
} catch (err) {
return [];
};
};
/**
* Saves the guild's quote bracket history.
*
* @param guild_id The ID of the guild which is being saved.
* @param data The data that we are saving to disk.
*/
export async function saveHistory(guild_id: string, data: any) {
fs.writeFile(`data/history/${guild_id}.json`, JSON.stringify(data));
};
/**
* Saves the data to the guild's Used Quotes file to prevent duplicate quotes from
* being used.
*
* @param guild_id The guild's ID
* @param data The data that we are saving
*/
export async function saveUsedQuotes(guild_id: string, data: any) {
fs.writeFile(`data/used_quotes/${guild_id}.json`, JSON.stringify(data));
};

View file

@ -0,0 +1,31 @@
import { loadUsedQuotes, saveUsedQuotes } from "./data";
import { config } from "../main";
import axios from "axios";
export async function getQuote(gID: string, count = 1) {
let r = await axios.get(
config.guilds[gID].api_base,
{ params: config.guilds[gID].params }
);
let quoteList = r.data.split(`\n`);
let history = await loadUsedQuotes(gID);
// Populate the quotes list
let quotes: quote[] = [];
do {
let quote: quote = {
text: quoteList[Math.floor(Math.random() * quoteList.length)],
votes: 0,
win_streak: 0,
};
if (!quotes.includes(quote) && !history.includes(quote.text)) {
quotes.push(quote);
history.push(quote.text);
};
} while (quotes.length < count);
await saveUsedQuotes(gID, history);
return quotes;
};