Move everything for the server in to a server folder to make room for the site
This commit is contained in:
parent
6a45152d8f
commit
33ca09808e
26 changed files with 8 additions and 6 deletions
66
server/config.template.toml
Normal file
66
server/config.template.toml
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
[discord]
|
||||
|
||||
# The password required in order to be allowed to authenticate with Discord
|
||||
auth_password = ""
|
||||
|
||||
# The OAuth application's client ID
|
||||
client_id = ""
|
||||
|
||||
# The application secret for Discord
|
||||
secret = ""
|
||||
|
||||
# The application's public key
|
||||
public_key = ""
|
||||
|
||||
# The authentication redirect for Discord to send back to, replace DOMAIN_HERE
|
||||
# with the domain pointing to your server. (this domain needs to support HTTPS,
|
||||
# as is imposed by Discord for the webhook URL)
|
||||
auth_redirect = "DOMAIN_HERE/discord/auth/callback"
|
||||
|
||||
[server]
|
||||
# The location the server is hosted at, this is only for logging purposes
|
||||
host = ""
|
||||
|
||||
# The port to run the server on
|
||||
port = 3000
|
||||
|
||||
[guilds]
|
||||
|
||||
# This is an example Guild object, replace GUILD_ID in the square brackets below
|
||||
# with the ID of the guild you want to service. This is a mandatory step, and if
|
||||
# the guild ID is wrong, the system will not allow you to setup the quote bracket
|
||||
# in that guild.
|
||||
#
|
||||
# This block of options can be repeated for however many guilds that you want,
|
||||
# if the server is running while you modify this file, you must restart the
|
||||
# server in order for changes to take effect.
|
||||
[guilds.GUILD_ID]
|
||||
|
||||
# The password for the guild in order to access the management endpoints
|
||||
password = ""
|
||||
|
||||
# How many quotes should be in each bracket
|
||||
quote_max = 5
|
||||
|
||||
# How will the previous bracket message be handled?
|
||||
# - "remove_components": Removes the dropdown and buttons from the message,
|
||||
# leaves the message content/embed as it was for the duration of the
|
||||
# bracket. Allowing context to remain for conversation.
|
||||
# - "delete_message": Completely deletes the message from Discord. These
|
||||
# brackets cannot be recovered and the message is gone forever.
|
||||
delete_mode = "remove_components"
|
||||
|
||||
# The URL that the GET request to get the quotes is made to.
|
||||
api_base = "https://twitch.center/customapi/quote/list"
|
||||
|
||||
# The querystring parameters that are sent following the api_base URL
|
||||
params = { token = "" }
|
||||
|
||||
# OPTIONAL: The bot token that can be used to create a thread when alerting of
|
||||
# a tie
|
||||
bot_token = ""
|
||||
|
||||
# Whether or not to include a hyperlink in the message content for when a tie
|
||||
# message is sent. This only affects the message when it is being sent in a
|
||||
# thread
|
||||
include_jump_link_for_threads = false
|
||||
1123
server/package-lock.json
generated
Normal file
1123
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
44
server/package.json
Normal file
44
server/package.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "quote-bracket",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "dist/main.js",
|
||||
"directories": {
|
||||
"doc": "docs"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build": "rm -r dist; tsc"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Oliver-Akins/Quote-Bracket.git"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/Oliver-Akins/Quote-Bracket/issues"
|
||||
},
|
||||
"homepage": "https://github.com/Oliver-Akins/Quote-Bracket#readme",
|
||||
"dependencies": {
|
||||
"@hapi/basic": "^6.0.0",
|
||||
"@hapi/boom": "^9.1.3",
|
||||
"@hapi/hapi": "^20.1.5",
|
||||
"@types/hapi__basic": "^5.1.2",
|
||||
"@types/hapi__hapi": "^20.0.9",
|
||||
"@types/node": "^16.3.1",
|
||||
"axios": "^0.21.1",
|
||||
"glob": "^7.1.7",
|
||||
"joi": "^17.4.2",
|
||||
"module-alias": "^2.2.2",
|
||||
"path": "^0.12.7",
|
||||
"toml": "^3.0.0",
|
||||
"tweetnacl": "^1.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/glob": "^7.1.4"
|
||||
},
|
||||
"_moduleAliases": {
|
||||
"@": "./dist"
|
||||
}
|
||||
}
|
||||
9
server/quote-bracket.service
Normal file
9
server/quote-bracket.service
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
[Unit]
|
||||
Description=The webhook ingest for voting on a community's favourite quote.
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# WorkingDirectory=
|
||||
# ExecStart=
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
16
server/src/constants.ts
Normal file
16
server/src/constants.ts
Normal 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`;
|
||||
38
server/src/endpoints/discord/auth/callback.ts
Normal file
38
server/src/endpoints/discord/auth/callback.ts
Normal 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;
|
||||
},
|
||||
}
|
||||
11
server/src/endpoints/discord/auth/redirect.ts
Normal file
11
server/src/endpoints/discord/auth/redirect.ts
Normal 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`
|
||||
);
|
||||
},
|
||||
}
|
||||
76
server/src/endpoints/discord/webhook.ts
Normal file
76
server/src/endpoints/discord/webhook.ts
Normal 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()
|
||||
};
|
||||
},
|
||||
};
|
||||
151
server/src/endpoints/management/create_bracket.ts
Normal file
151
server/src/endpoints/management/create_bracket.ts
Normal 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);
|
||||
},
|
||||
}
|
||||
18
server/src/endpoints/management/delete/delete_message.ts
Normal file
18
server/src/endpoints/management/delete/delete_message.ts
Normal 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);
|
||||
},
|
||||
}
|
||||
19
server/src/endpoints/management/delete/remove_components.ts
Normal file
19
server/src/endpoints/management/delete/remove_components.ts
Normal 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);
|
||||
},
|
||||
}
|
||||
95
server/src/endpoints/management/is_tied.ts
Normal file
95
server/src/endpoints/management/is_tied.ts
Normal 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);
|
||||
},
|
||||
}
|
||||
33
server/src/endpoints/management/view_bracket_data.ts
Normal file
33
server/src/endpoints/management/view_bracket_data.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
}
|
||||
50
server/src/endpoints/management/winners.ts
Normal file
50
server/src/endpoints/management/winners.ts
Normal 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
91
server/src/main.ts
Normal 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
29
server/src/types/config.d.ts
vendored
Normal 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
25
server/src/types/database.d.ts
vendored
Normal 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[];
|
||||
32
server/src/types/discord_components.ts
Normal file
32
server/src/types/discord_components.ts
Normal 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
4
server/src/types/misc.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
interface execute_webhook_query_params {
|
||||
wait?: boolean;
|
||||
thread_id?: string;
|
||||
}
|
||||
26
server/src/utils/components/buttons/count_votes.ts
Normal file
26
server/src/utils/components/buttons/count_votes.ts
Normal 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,
|
||||
}
|
||||
};
|
||||
}
|
||||
40
server/src/utils/components/buttons/delete_vote.ts
Normal file
40
server/src/utils/components/buttons/delete_vote.ts
Normal 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,
|
||||
}
|
||||
};
|
||||
}
|
||||
28
server/src/utils/components/buttons/my_vote.ts
Normal file
28
server/src/utils/components/buttons/my_vote.ts
Normal 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,
|
||||
}
|
||||
};
|
||||
}
|
||||
21
server/src/utils/components/buttons/view_db.ts
Normal file
21
server/src/utils/components/buttons/view_db.ts
Normal 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,
|
||||
}
|
||||
};
|
||||
}
|
||||
48
server/src/utils/components/dropdowns/select_quote.ts
Normal file
48
server/src/utils/components/dropdowns/select_quote.ts
Normal 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
50
server/src/utils/data.ts
Normal 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));
|
||||
};
|
||||
31
server/src/utils/quotes.ts
Normal file
31
server/src/utils/quotes.ts
Normal 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;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue