diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..46f5784 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,14 @@ +export var DB_DEFAULTS: database = { + bracket: { + msg: "", + quotes: [], + votes: {}, + users: {}, + }, + webhook: { + id: "", + token: "" + }, +}; + +export var DISCORD_API_URI: string = `https://discord.com/api/v8`; \ No newline at end of file diff --git a/src/endpoints/discord/auth/callback.ts b/src/endpoints/discord/auth/callback.ts new file mode 100644 index 0000000..b8aade3 --- /dev/null +++ b/src/endpoints/discord/auth/callback.ts @@ -0,0 +1,29 @@ +import { Request, ResponseToolkit } from "@hapi/hapi"; +import { config, db } from "@/main"; +import axios from "axios"; + +export default { + method: `GET`, path: `/discord/auth/callback`, + async handler(request: Request, h: ResponseToolkit) { + console.log(`Authentication finishing!`) + let code = request.query.code; + + 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(`https://discord.com/api/v8/oauth2/token`, data, { + headers: { + 'Content-Type': `application/x-www-form-urlencoded` + } + }); + + db.webhook.token = r.data.webhook.token; + db.webhook.id = r.data.webhook.id; + + return r.data; + }, +} \ No newline at end of file diff --git a/src/endpoints/discord/auth/redirect.ts b/src/endpoints/discord/auth/redirect.ts new file mode 100644 index 0000000..56d58d8 --- /dev/null +++ b/src/endpoints/discord/auth/redirect.ts @@ -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` + ); + }, +} \ No newline at end of file diff --git a/src/endpoints/discord/webhook.ts b/src/endpoints/discord/webhook.ts new file mode 100644 index 0000000..725f877 --- /dev/null +++ b/src/endpoints/discord/webhook.ts @@ -0,0 +1,75 @@ +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 { + 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`, + 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() + }; + }, +}; \ No newline at end of file diff --git a/src/endpoints/management/create_bracket.ts b/src/endpoints/management/create_bracket.ts new file mode 100644 index 0000000..a1d2d70 --- /dev/null +++ b/src/endpoints/management/create_bracket.ts @@ -0,0 +1,122 @@ +import { Request, ResponseToolkit } from "@hapi/hapi"; +import { DISCORD_API_URI } from "@/constants"; +import { getQuote } from "@/utils/quotes"; +import { config, db } from "@/main"; +import fs from "fs/promises"; +import axios from "axios"; + +export default { + method: `GET`, path: `/bracket/finish`, + async handler(request: Request, h: ResponseToolkit) { + + if (!db.bracket.msg) { + var quotes = await getQuote(config.discord.quote_max); + } else { + // Delete the old message from Discord while processing the new one + let wh = db.webhook; + await axios.delete(`${DISCORD_API_URI}/webhooks/${wh.id}/${wh.token}/messages/${db.bracket.msg}`); + + // Save the previous bracket to the history file + let pastBrackets = JSON.parse( + await fs.readFile(config.server.bracket_history, `utf-8`) + ); + pastBrackets.push({ + quotes: db.bracket.quotes, + votes: db.bracket.votes, + }); + await fs.writeFile(config.server.bracket_history, JSON.stringify(pastBrackets)); + + + // Calculate the winners from the previous bracket + let r = await request.server.inject(`/bracket/winners`); + var quotes: string[] = JSON.parse(r.payload).winners; + var winner_count = quotes.length; + + // Get any new quotes for the bracket + quotes.push(...(await getQuote(config.discord.quote_max - quotes.length))); + } + + // Setup the database for the new bracket + db.bracket.quotes = quotes; + db.bracket.votes = {}; + db.bracket.users = {}; + db.bracket.msg = ""; + + + let message = { + content: `New Quote Bracket!`, + embeds: [ + { + description: `Note: If **more than ${Math.floor(config.discord.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: `${i < winner_count ? '👑 ' : ''}Quote: ${i + 1}`, + value: quote, + }}), + } + ], + 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: [ + { + type: 2, + style: 1, + label: `What Did I Vote For?`, + custom_id: `showMyVote` + }, + { + type: 2, + style: 4, + label: `Remove Vote`, + custom_id: `deleteVote` + } + ] + } + ] + }; + + if (config.discord.dev_buttons) { + message.components.push({ + type: 1, + components: [ + { + type: 2, + style: 1, + label: `See Count`, + custom_id: `showCount`, + }, + { + type: 2, + style: 1, + label: `See Database Object`, + custom_id: `viewDB`, + } + ] + }); + }; + + let url = `${DISCORD_API_URI}/webhooks/${db.webhook.id}/${db.webhook.token}`; + let r = await axios.post(url, message, { params: { wait: true } }); + db.bracket.msg = r.data.id; + return { status: r.status } + }, +} \ No newline at end of file diff --git a/src/endpoints/management/get_winners.ts b/src/endpoints/management/get_winners.ts new file mode 100644 index 0000000..e176b0f --- /dev/null +++ b/src/endpoints/management/get_winners.ts @@ -0,0 +1,26 @@ +import { config, db } from "@/main" + +export default { + method: `GET`, path: `/bracket/winners`, + async handler() { + let winners: string[] = []; + let highest = -1; + + // Find the winners of the quote + for (var i in db.bracket.quotes) { + if (db.bracket.votes[i] > highest) { + winners = [ db.bracket.quotes[i] ]; + highest = db.bracket.votes[i]; + } else if (db.bracket.votes[i] === highest) { + winners.push(db.bracket.quotes[i]); + }; + }; + + // Ensure that the all elimination limit didn't get hit + if (winners.length > Math.floor(config.discord.quote_max / 2)) { + return { winners: [] }; + }; + + return { winners }; + }, +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index e69de29..f33ce79 100644 --- a/src/main.ts +++ b/src/main.ts @@ -0,0 +1,61 @@ +// Filepath alias resolution +import "module-alias/register"; + +// Begin personal code +import { DB_DEFAULTS } from "@/constants"; +import { Server } from "@hapi/hapi"; +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(config.server.db_file)) { + console.log(`Can't find database file, creating default`); + fs.writeFileSync(config.server.db_file, JSON.stringify(DB_DEFAULTS)); +}; +export var db: database = JSON.parse(fs.readFileSync(config.server.db_file, `utf-8`)); + + +process.on(`SIGINT`, () => { + console.log(`Saving database`); + fs.writeFileSync(config.server.db_file, JSON.stringify(db)); + process.exit(0); +}); + + +async function init() { + + const server = new Server({ + port: config.server.port, + debug: { + request: [ `error` ] + } + }); + + + // 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(); \ No newline at end of file diff --git a/src/types/config.d.ts b/src/types/config.d.ts new file mode 100644 index 0000000..c084c2f --- /dev/null +++ b/src/types/config.d.ts @@ -0,0 +1,21 @@ +interface config { + discord: { + quote_max: number; + client_id: string; + secret: string; + public_key: string; + auth_redirect: string; + dev_buttons: boolean; + }; + server: { + host: string; + port: number; + db_file: string; + quote_history: string; + bracket_history: string; + }; + quote: { + api_base: string; + token: string; + }; +} \ No newline at end of file diff --git a/src/types/database.d.ts b/src/types/database.d.ts new file mode 100644 index 0000000..3dd4f8f --- /dev/null +++ b/src/types/database.d.ts @@ -0,0 +1,12 @@ +interface database { + webhook: { + id: string; + token: string; + }; + bracket: { + msg: string; + quotes: string[]; + votes: { [index: number]: number }; + users: { [index: string]: number }; + }; +} \ No newline at end of file diff --git a/src/utils/components/buttons/count_votes.ts b/src/utils/components/buttons/count_votes.ts new file mode 100644 index 0000000..570290d --- /dev/null +++ b/src/utils/components/buttons/count_votes.ts @@ -0,0 +1,17 @@ +import { db } from "@/main"; + + +export async function countVotes(data: any): Promise { + + let response = `Quote Votes:`; + for (var i in db.bracket.quotes) { + response += `\n${db.bracket.votes[i] ?? 0} votes for \`${db.bracket.quotes[i]}\``; + }; + return { + type: 4, + data: { + content: response, + flags: 1 << 6, + } + }; +} \ No newline at end of file diff --git a/src/utils/components/buttons/delete_vote.ts b/src/utils/components/buttons/delete_vote.ts new file mode 100644 index 0000000..7e892ce --- /dev/null +++ b/src/utils/components/buttons/delete_vote.ts @@ -0,0 +1,30 @@ +import { db } from "@/main"; + + +export async function deleteVote(data: any): Promise { + let userID = data.member.user.id; + + if (!db.bracket.users[userID]) { + 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.bracket.users[userID]; + --db.bracket.votes[vote]; + + delete db.bracket.users[userID]; + + return { + type: 4, + data: { + content: `Your vote has been deleted.`, + flags: 1 << 6, + } + }; +} \ No newline at end of file diff --git a/src/utils/components/buttons/my_vote.ts b/src/utils/components/buttons/my_vote.ts new file mode 100644 index 0000000..b979299 --- /dev/null +++ b/src/utils/components/buttons/my_vote.ts @@ -0,0 +1,19 @@ +import { db } from "@/main"; + +export async function showUserVote(data: any): Promise { + let vote = db.bracket.users[data.member.user.id]; + let quote = db.bracket.quotes[vote]; + + let response = `You currently haven't voted for a quote!`; + if (quote) { + response = `Your vote is for:\n> ${quote}`; + }; + + return { + type: 4, + data: { + content: response, + flags: 1 << 6, + } + }; +} \ No newline at end of file diff --git a/src/utils/components/buttons/view_db.ts b/src/utils/components/buttons/view_db.ts new file mode 100644 index 0000000..c814ef6 --- /dev/null +++ b/src/utils/components/buttons/view_db.ts @@ -0,0 +1,12 @@ +import { db } from "@/main"; + + +export async function viewDB(data: any): Promise { + return { + type: 4, + data: { + content: `\`\`\`json\n${JSON.stringify(db.bracket, null, ' ')}\`\`\``, + flags: 1 << 6, + } + }; +} \ No newline at end of file diff --git a/src/utils/components/dropdowns/select_quote.ts b/src/utils/components/dropdowns/select_quote.ts new file mode 100644 index 0000000..9bd512f --- /dev/null +++ b/src/utils/components/dropdowns/select_quote.ts @@ -0,0 +1,40 @@ +import { db } from "@/main"; + +export async function selectQuote(data: any): Promise { + let vote = parseInt(data.data.values[0]); + let userID = data.member.user.id; + let oldVote = db.bracket.users[userID]; + + // Set quote to 0 if it hasn't been voted for yet + if (!db.bracket.votes[vote]) { + db.bracket.votes[vote] = 0; + }; + + ++db.bracket.votes[vote]; + db.bracket.users[userID] = vote; + + // User changed their vote + if (oldVote != null) { + + --db.bracket.votes[oldVote]; + + return { + type: 4, + data: { + content: `Your vote has been changed from:\n> ${db.bracket.quotes[oldVote]}\nto:\n> ${db.bracket.quotes[vote]}`, + flags: 1 << 6, + } + }; + } + + // User voted for the first time + else { + return { + type: 4, + data: { + content: `Your vote has been recorded for:\n> ${db.bracket.quotes[vote]}`, + flags: 1 << 6, + } + }; + }; +}; \ No newline at end of file diff --git a/src/utils/quotes.ts b/src/utils/quotes.ts new file mode 100644 index 0000000..33f01ba --- /dev/null +++ b/src/utils/quotes.ts @@ -0,0 +1,28 @@ +import { readFileSync, writeFileSync } from "fs"; +import { config } from "../main"; +import axios from "axios"; + +export async function getQuote(count = 1) { + let r = await axios.get( + config.quote.api_base, + { params: { token: config.quote.token } } + ); + let quoteList = r.data.split(`\n`); + let history: string[] = JSON.parse(readFileSync(config.server.quote_history, `utf-8`)); + + // Populate the quotes list + let quotes: string[] = []; + do { + let quote = quoteList[Math.floor(Math.random() * quoteList.length)]; + + if (!quotes.includes(quote) && !history.includes(quote)) { + quotes.push(quote); + }; + } while (quotes.length < count); + + history.push(...quotes) + + writeFileSync(config.server.quote_history, JSON.stringify(history)); + + return quotes; +}; \ No newline at end of file