commit
a77af2a579
@ -0,0 +1,5 @@ |
||||
DISCORD_BOT_TOKEN= |
||||
DISCORD_PUBLIC_KEY= |
||||
DISCORD_CLIENT_SECRET= |
||||
DISCORD_APP_ID= |
||||
HTTP_PORT=5244 |
@ -0,0 +1,4 @@ |
||||
/build/ |
||||
/node_modules/ |
||||
.env |
||||
/runtime/ |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,26 @@ |
||||
{ |
||||
"name": "vore-gacha", |
||||
"version": "0.0.1", |
||||
"dependencies": { |
||||
"axios": "^0.24.0", |
||||
"detritus-client": "^0.16.3", |
||||
"detritus-client-rest": "^0.10.5", |
||||
"discord-api-types": "^0.25.2", |
||||
"dotenv": "^10.0.0", |
||||
"fastify": "^3.24.1", |
||||
"pug": "^3.0.2", |
||||
"simple-oauth2": "^4.2.0", |
||||
"slash-create": "^4.4.0" |
||||
}, |
||||
"devDependencies": { |
||||
"@types/node": "^16.11.12", |
||||
"@types/pug": "^2.0.5", |
||||
"@types/simple-oauth2": "^4.1.1", |
||||
"typescript": "^4.5.3" |
||||
}, |
||||
"type": "module", |
||||
"scripts": { |
||||
"build": "tsc --build", |
||||
"start": "node build/app.js" |
||||
} |
||||
} |
@ -0,0 +1,88 @@ |
||||
import {SavedWebhook} from "./SavedWebhook.js"; |
||||
import {FastifyReply, FastifyRequest} from "fastify"; |
||||
import {AccessToken, AuthorizationCode, ModuleOptions} from "simple-oauth2"; |
||||
import pug from "pug"; |
||||
import {renderError} from "./PugRenderer.js"; |
||||
import {APIWebhook} from "discord-api-types"; |
||||
import {getBaseUrl, getFirstValue, RouteWithQuerystring} from "./FastifyHelpers.js"; |
||||
import { join } from "path"; |
||||
|
||||
interface DiscordWebhookToken extends AccessToken { |
||||
token: {webhook?: APIWebhook} |
||||
} |
||||
|
||||
const AuthConfig: ModuleOptions["auth"] = { |
||||
tokenHost: "https://discord.com", |
||||
authorizePath: "/api/oauth2/authorize", |
||||
tokenPath: "/api/oauth2/token", |
||||
revokePath: "/api/oauth2/token/revoke", |
||||
} |
||||
|
||||
export class DiscordWebhookHandler { |
||||
readonly webhook: SavedWebhook |
||||
readonly templateFilename: string |
||||
readonly config: ModuleOptions |
||||
readonly destinationFunc: () => string |
||||
constructor({webhook, templateFilename, appId, secret, destinationFunc}: {webhook: SavedWebhook, templateFilename: string, appId: string, secret: string, destinationFunc: () => string}) { |
||||
this.templateFilename = templateFilename |
||||
this.webhook = webhook |
||||
this.config = { |
||||
client: { |
||||
id: appId, |
||||
secret, |
||||
}, |
||||
auth: AuthConfig, |
||||
} |
||||
this.destinationFunc = destinationFunc |
||||
} |
||||
async handleRequest(req: FastifyRequest<RouteWithQuerystring>, res: FastifyReply): Promise<void> { |
||||
const baseUrl = getBaseUrl(req) |
||||
const code = getFirstValue(req.query["code"]) ?? null |
||||
const client = new AuthorizationCode(this.config) |
||||
if (code === null) { |
||||
const authUrl = client.authorizeURL({ |
||||
scope: ["applications.commands", "webhook.incoming"], |
||||
redirect_uri: req.url, |
||||
}) |
||||
res.code(200) |
||||
res.type("text/html") |
||||
res.send(pug.renderFile(join("static/pages", this.templateFilename), {authUrl, baseUrl, isReset: this.webhook.isPresent})) |
||||
return |
||||
} |
||||
let token: DiscordWebhookToken |
||||
try { |
||||
token = await client.getToken({ |
||||
code, |
||||
scope: ["applications.commands", "webhook.incoming"], |
||||
redirect_uri: getBaseUrl(req) + "setup/gameChannel" |
||||
}) as DiscordWebhookToken |
||||
} catch (e) { |
||||
return renderError({ |
||||
baseUrl, |
||||
res: res, |
||||
error: e, |
||||
context: "exchanging the code you gave me for a token" |
||||
}) |
||||
} |
||||
if (!token.token.webhook) { |
||||
return renderError({ |
||||
baseUrl, |
||||
res: res, |
||||
error: "token did not contain webhook", |
||||
context: "processing the token I received" |
||||
}) |
||||
} |
||||
const wasSet = this.webhook.isPresent |
||||
try { |
||||
await this.webhook.replaceHook(token.token.webhook) |
||||
} catch (e) { |
||||
return renderError({ |
||||
baseUrl, |
||||
res: res, |
||||
error: e, |
||||
context: `saving the new webhook${wasSet ? " and deleting the old one" : ""}` |
||||
}) |
||||
} |
||||
res.redirect(this.destinationFunc()) |
||||
} |
||||
} |
@ -0,0 +1,19 @@ |
||||
/** Constructs the base URL from the headers from the given request. */ |
||||
import {FastifyRequest} from "fastify"; |
||||
import {RouteGenericInterface} from "fastify/types/route"; |
||||
|
||||
export interface RouteWithQuerystring extends RouteGenericInterface { |
||||
Querystring: {[key: string]: string|string[]|undefined} |
||||
} |
||||
|
||||
export function getBaseUrl(request: FastifyRequest): string { |
||||
const hostHeader = request.hostname ?? "localhost" |
||||
const proto = getFirstValue(request.headers["x-forwarded-proto"]) ?? "http" |
||||
const path = getFirstValue(request.headers["x-base-path"]) ?? "/" |
||||
return `${proto}://${hostHeader}${path}` |
||||
} |
||||
|
||||
/** Translates a zero-to-many set of strings to one or zero strings. */ |
||||
export function getFirstValue(value: string|string[]|undefined): string|undefined { |
||||
return typeof value === "string" ? value : Array.isArray(value) ? value[0] : undefined |
||||
} |
@ -0,0 +1,100 @@ |
||||
import {SavedWebhook} from "./SavedWebhook.js"; |
||||
import fastify, {FastifyInstance} from "fastify"; |
||||
import {DiscordWebhookHandler} from "./DiscordWebhookHandler.js"; |
||||
import {getBaseUrl, RouteWithQuerystring} from "./FastifyHelpers.js"; |
||||
import pug from "pug"; |
||||
import {renderError} from "./PugRenderer.js"; |
||||
|
||||
export class GameServer { |
||||
readonly server: FastifyInstance |
||||
readonly gameWebhook: SavedWebhook |
||||
readonly adminWebhook: SavedWebhook |
||||
readonly appId: string |
||||
readonly secret: string |
||||
readonly port: number |
||||
constructor({appId, secret, port}: {appId: string, secret: string, port: number}) { |
||||
this.server = fastify({ |
||||
logger: true |
||||
}) |
||||
this.appId = appId |
||||
this.secret = secret |
||||
this.port = port |
||||
this.gameWebhook = new SavedWebhook("broadcasthook.json", {logger: this.server.log}) |
||||
this.adminWebhook = new SavedWebhook("logginghook.json", {logger: this.server.log}) |
||||
} |
||||
|
||||
async initialize(): Promise<void> { |
||||
const gameHandler = new DiscordWebhookHandler({ |
||||
webhook: this.gameWebhook, |
||||
templateFilename: "setupGame.pug", |
||||
appId: this.appId, |
||||
secret: this.secret, |
||||
destinationFunc: () => { |
||||
if (this.adminWebhook.isPresent) { |
||||
return "clear" |
||||
} else { |
||||
return "adminChannel" |
||||
} |
||||
}, |
||||
}) |
||||
const adminHandler = new DiscordWebhookHandler({ |
||||
webhook: this.adminWebhook, |
||||
templateFilename: "setupAdmin.pug", |
||||
appId: this.appId, |
||||
secret: this.secret, |
||||
destinationFunc: () => { |
||||
if (this.gameWebhook.isPresent) { |
||||
return "clear" |
||||
} else { |
||||
return "gameChannel" |
||||
} |
||||
}, |
||||
}) |
||||
this.server.get<RouteWithQuerystring>("/setup", async (req, res) => { |
||||
if (!this.gameWebhook.isPresent) { |
||||
res.redirect(`setup/gameChannel`) |
||||
} else if (!this.adminWebhook.isPresent) { |
||||
res.redirect(`setup/adminChannel`) |
||||
} else { |
||||
res.redirect(`setup/done`) |
||||
} |
||||
}) |
||||
this.server.get<RouteWithQuerystring>("/setup/gameChannel", async (req, res) => { |
||||
return await gameHandler.handleRequest(req, res) |
||||
}) |
||||
this.server.get<RouteWithQuerystring>("/setup/adminChannel", async (req, res) => { |
||||
return await adminHandler.handleRequest(req, res) |
||||
}) |
||||
this.server.get<RouteWithQuerystring>("/setup/clear", async (req, res) => { |
||||
try { |
||||
await Promise.all([this.gameWebhook, this.adminWebhook] |
||||
.filter((item) => item.isPresent) |
||||
.map((item) => item.clearHook())) |
||||
} catch (e) { |
||||
return renderError({ |
||||
baseUrl: getBaseUrl(req), |
||||
res, |
||||
error: e, |
||||
context: "clearing the broadcast webhook" |
||||
}) |
||||
} |
||||
res.redirect(".") |
||||
}) |
||||
this.server.get<RouteWithQuerystring>("/setup/done", async (req, res) => { |
||||
if (!this.gameWebhook.isPresent) { |
||||
res.redirect("gameChannel") |
||||
} else if (!this.adminWebhook.isPresent) { |
||||
res.redirect("adminChannel") |
||||
} else { |
||||
res.code(200) |
||||
res.type("text/html") |
||||
res.send(pug.renderFile("static/pages/setupDone.pug", { |
||||
baseUrl: getBaseUrl(req), |
||||
clearUrl: "setup/clear", |
||||
gameSetupUrl: "setup/gameChannel", |
||||
adminSetupUrl: "setup/adminChannel"})) |
||||
} |
||||
}) |
||||
await Promise.all([this.gameWebhook.load(), this.adminWebhook.load(), this.server.listen(this.port, "127.0.0.1")]) |
||||
} |
||||
} |
@ -0,0 +1,7 @@ |
||||
export function isNodeErr(e: unknown): e is NodeJS.ErrnoException { |
||||
return typeof e === "object" && e !== null && e.hasOwnProperty("code") |
||||
} |
||||
|
||||
export function isENOENT(e: unknown): boolean { |
||||
return isNodeErr(e) && e.code === "ENOENT" |
||||
} |
@ -0,0 +1,13 @@ |
||||
import {FastifyReply} from "fastify"; |
||||
import {renderFile} from "pug"; |
||||
|
||||
/** Renders the error page into the given reply. */ |
||||
export function renderError({baseUrl, res, error, context}: {baseUrl: string, res: FastifyReply, error: unknown, context: string}): void { |
||||
res.code(500) |
||||
res.type("text/html") |
||||
res.send(renderFile("static/pages/error.pug", { |
||||
baseUrl, |
||||
error, |
||||
context, |
||||
})) |
||||
} |
@ -0,0 +1,143 @@ |
||||
/** */ |
||||
import {APIWebhook} from "discord-api-types"; |
||||
import {FastifyLoggerInstance} from "fastify"; |
||||
import {readFile as fsReadFile, writeFile as fsWriteFile, mkdir as fsMkdir, rm as fsRm} from "fs/promises"; |
||||
import axios from "axios"; |
||||
import {join} from "path"; |
||||
import {isENOENT} from "./NodeErrorHelpers.js"; |
||||
|
||||
const WebhookPath = "runtime/webhooks" |
||||
|
||||
/** File-based storage for webhook instances. */ |
||||
export class SavedWebhook { |
||||
readonly filename: string |
||||
private _state: APIWebhook|null|undefined |
||||
private readonly logger: FastifyLoggerInstance|null |
||||
private readonly readFile: typeof fsReadFile |
||||
private readonly writeFile: typeof fsWriteFile |
||||
private readonly mkdir: typeof fsMkdir |
||||
private readonly rm: typeof fsRm |
||||
private readonly delRequest: typeof axios["delete"] |
||||
|
||||
/** Initializes a SavedWebhook pointing at the given location. */ |
||||
constructor(filename: string, {logger, state, readFile, writeFile, mkdir, rm, delRequest}: {logger?: FastifyLoggerInstance|null, state?: APIWebhook|null, readFile?: typeof fsReadFile, writeFile?: typeof fsWriteFile, mkdir?: typeof fsMkdir, rm?: typeof fsRm, delRequest?: typeof axios["delete"]}) { |
||||
this.filename = filename |
||||
this._state = state |
||||
this.logger = logger?.child({webhookFile: filename}) ?? null |
||||
this.readFile = readFile ?? fsReadFile |
||||
this.writeFile = writeFile ?? fsWriteFile |
||||
this.mkdir = mkdir ?? fsMkdir |
||||
this.rm = rm ?? fsRm |
||||
this.delRequest = delRequest ?? axios.delete.bind(axios) |
||||
} |
||||
|
||||
/** The path of the webhook, including WebhookPath. */ |
||||
get path(): string { |
||||
return join(WebhookPath, this.filename) |
||||
} |
||||
|
||||
/** Gets whether a webhook has been added to this SavedWebhook. */ |
||||
get isPresent(): boolean { |
||||
return this.state !== null |
||||
} |
||||
|
||||
/** Gets the current state of the webhook, either a webhook instance or null. */ |
||||
get state(): APIWebhook|null { |
||||
if (this._state === undefined) { |
||||
this.logger?.warn("SavedWebhook was not initialized before having its state checked") |
||||
return null |
||||
} |
||||
return this._state |
||||
} |
||||
|
||||
/** Loads the current state from the disk, including a null state if not present. */ |
||||
async load(): Promise<APIWebhook|null> { |
||||
if (this._state !== undefined) { |
||||
this.logger?.warn(`SavedWebhook was double-initialized`) |
||||
} |
||||
try { |
||||
const text = await this.readFile(this.path, {encoding: "utf-8"}) |
||||
const state: APIWebhook = JSON.parse(text) |
||||
this._state = state |
||||
return state |
||||
} catch (e) { |
||||
if (isENOENT(e)) { |
||||
this._state = null |
||||
return null |
||||
} |
||||
throw e |
||||
} |
||||
} |
||||
|
||||
/** Replaces the webhook with a new one. */ |
||||
async replaceHook(newHook: APIWebhook): Promise<void> { |
||||
if (this._state === undefined) { |
||||
this.logger?.warn("SavedWebhook was not initialized before being replaced") |
||||
return |
||||
} |
||||
if (this._state !== null) { |
||||
await this.clearHook() |
||||
} |
||||
this._state = newHook |
||||
await this.save() |
||||
} |
||||
|
||||
/** Removes the webhook entirely. */ |
||||
async clearHook(): Promise<void> { |
||||
if (this._state === undefined) { |
||||
this.logger?.warn("SavedWebhook was not initialized before being cleared") |
||||
return |
||||
} |
||||
if (this._state === null) { |
||||
this.logger?.warn("SavedWebhook was already cleared and cleared again") |
||||
return |
||||
} |
||||
await this.deleteHook() |
||||
this._state = null |
||||
await this.save() |
||||
} |
||||
|
||||
/** Saves the current state to disk, deleting the file if the state is null. */ |
||||
private async save(): Promise<void> { |
||||
if (this._state === undefined) { |
||||
this.logger?.warn("SavedWebhook was not initialized before being saved") |
||||
return |
||||
} |
||||
if (this._state === null) { |
||||
try { |
||||
await this.rm(this.path) |
||||
} catch (e) { |
||||
if (!isENOENT(e)) { |
||||
throw e |
||||
} |
||||
this.logger?.warn("SavedWebhook was re-deleted despite not existing to begin with.") |
||||
} |
||||
return |
||||
} |
||||
try { |
||||
const text = JSON.stringify(this._state) |
||||
await this.writeFile(this.path, text, {encoding: "utf-8"}) |
||||
} catch (e) { |
||||
if (isENOENT(e)) { |
||||
await this.mkdir(WebhookPath, {recursive: true}) |
||||
const text = JSON.stringify(this._state) |
||||
await this.writeFile(this.path, text, {encoding: "utf-8"}) |
||||
} |
||||
throw e |
||||
} |
||||
} |
||||
|
||||
/** Deletes the webhook from the server. */ |
||||
private async deleteHook(): Promise<void> { |
||||
if (this._state === undefined) { |
||||
this.logger?.warn("SavedWebhook was not initialized before being deleted") |
||||
return |
||||
} |
||||
if (this._state === null) { |
||||
this.logger?.warn("SavedWebhook was deleted despite being empty") |
||||
return |
||||
} |
||||
await this.delRequest(`https://discord.com/api/webhooks/${this._state.id}/${this._state.token}`, |
||||
{validateStatus: (s) => s === 204 || s === 404 }) |
||||
} |
||||
} |
@ -0,0 +1,21 @@ |
||||
import dotenv from "dotenv"; |
||||
import {GameServer} from "./GameServer.js"; |
||||
|
||||
async function main(): Promise<void> { |
||||
const {parsed, error} = dotenv.config() |
||||
if (error || !parsed) { |
||||
throw error ?? Error("No parsed data.") |
||||
} |
||||
const clientSecret = parsed["DISCORD_CLIENT_SECRET"] |
||||
const appId = parsed["DISCORD_APP_ID"] |
||||
const port = parseInt(parsed["HTTP_PORT"] ?? "5244") |
||||
const server = new GameServer({ |
||||
appId, port, secret: clientSecret |
||||
}) |
||||
return await server.initialize() |
||||
} |
||||
|
||||
main().catch((err) => { |
||||
console.log("Main crashed!") |
||||
console.log(err) |
||||
}) |
@ -0,0 +1,10 @@ |
||||
extends ../template/template |
||||
|
||||
block title |
||||
| Setup Error |
||||
block content |
||||
| Something went wrong while #{context}. What did you do? |
||||
br |
||||
| The error was: #{error} |
||||
block link |
||||
a(href="setup") Return to Setup Page |
@ -0,0 +1,19 @@ |
||||
extends ../template/template |
||||
|
||||
block title |
||||
| Admin Setup |
||||
block content |
||||
if isReset |
||||
b You've already set the admin channel up, but you can set it up again differently! |
||||
br |
||||
| Don't worry, nothing is overwritten until you finish the Discord authentication in the link. |
||||
br |
||||
br |
||||
| On the upcoming page, you'll need to choose the server you're going to control this game from (it can be, but |
||||
| doesn't have to be, the same server the game itself is in), as well as the channel in that server that will be |
||||
| used for logging informational messages and accepting administrative commands. This channel - or at least use of |
||||
| application commands within it - must be limited to access by game admins only. |
||||
br |
||||
| Click the link to choose an admin channel. |
||||
block link |
||||
a(href=authUrl) Connect Admin Channel |
@ -0,0 +1,12 @@ |
||||
extends ../template/template |
||||
|
||||
block title |
||||
| Setup Complete! |
||||
block content |
||||
| You're all done! Time to just play the game. |
||||
br |
||||
| If you'd like to reconfigure the game's channels, click one of the buttons below. |
||||
block link |
||||
a(href=gameSetupUrl) Reconfigure Game Channel |
||||
a(href=adminSetupUrl) Reconfigure Admin Channel |
||||
a(href=clearUrl) Clear All Channels |
@ -0,0 +1,18 @@ |
||||
extends ../template/template |
||||
|
||||
block title |
||||
| Game Setup |
||||
block content |
||||
if isReset |
||||
b You've already set the game channel up, but you can set it up again differently! |
||||
br |
||||
| Don't worry, nothing is overwritten until you finish the Discord authentication in the link. |
||||
br |
||||
br |
||||
| It's time to get started on rolling up those cuties! On the upcoming page, you'll need to choose the server you're |
||||
| going to add this game to, as well as the text channel that the game should broadcast to and limit its commands to |
||||
| being used within. |
||||
br |
||||
| Click the link to choose a game channel. |
||||
block link |
||||
a(href=authUrl) Connect Game Channel |
@ -0,0 +1,20 @@ |
||||
html |
||||
head |
||||
title |
||||
block title |
||||
base(href=baseUrl) |
||||
style. |
||||
body { display: flex; flex-flow: column; justify-content: center; align-items: center; min-height: 100vh; font-family: sans-serif; background-color: darkblue; color: aliceblue } |
||||
body * { max-width: 40em; } |
||||
p.content { text-align: center; } |
||||
p.link { display: flex; flex-flow: row; justify-content: center; align-items: center; } |
||||
p.link a { display: inline-block; border-radius: 5px; background-color: cadetblue; color: black; border: 2px solid darkslateblue; padding: 0.5em; box-shadow: 2px 4px 0 rgba(255,255,255,0.55); } |
||||
a:visited, a:active, a:link { color: inherit; text-decoration: none; } |
||||
p.link a:active { transform: translate(2px, 4px); box-shadow: 0 0 transparent; } |
||||
body |
||||
h1 |
||||
block title |
||||
p.content |
||||
block content |
||||
p.link |
||||
block link |
@ -0,0 +1,26 @@ |
||||
{ |
||||
"compilerOptions": { |
||||
"target": "es5", |
||||
"lib": [ |
||||
"dom", |
||||
"dom.iterable", |
||||
"esnext" |
||||
], |
||||
"allowJs": true, |
||||
"skipLibCheck": true, |
||||
"esModuleInterop": true, |
||||
"allowSyntheticDefaultImports": true, |
||||
"strict": true, |
||||
"strictNullChecks": true, |
||||
"forceConsistentCasingInFileNames": true, |
||||
"noFallthroughCasesInSwitch": true, |
||||
"module": "esnext", |
||||
"moduleResolution": "node", |
||||
"resolveJsonModule": true, |
||||
"isolatedModules": true, |
||||
"outDir": "build" |
||||
}, |
||||
"include": [ |
||||
"src" |
||||
] |
||||
} |
Loading…
Reference in new issue