Initial checkin for gacha bot.

main
Mari 2 years ago
commit a77af2a579
  1. 5
      .env.example
  2. 4
      .gitignore
  3. 2242
      package-lock.json
  4. 26
      package.json
  5. 88
      src/DiscordWebhookHandler.ts
  6. 19
      src/FastifyHelpers.ts
  7. 100
      src/GameServer.ts
  8. 7
      src/NodeErrorHelpers.ts
  9. 13
      src/PugRenderer.ts
  10. 143
      src/SavedWebhook.ts
  11. 21
      src/app.ts
  12. 10
      static/pages/error.pug
  13. 19
      static/pages/setupAdmin.pug
  14. 12
      static/pages/setupDone.pug
  15. 18
      static/pages/setupGame.pug
  16. 20
      static/template/template.pug
  17. 26
      tsconfig.json

@ -0,0 +1,5 @@
DISCORD_BOT_TOKEN=
DISCORD_PUBLIC_KEY=
DISCORD_CLIENT_SECRET=
DISCORD_APP_ID=
HTTP_PORT=5244

4
.gitignore vendored

@ -0,0 +1,4 @@
/build/
/node_modules/
.env
/runtime/

2242
package-lock.json generated

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…
Cancel
Save