parent
48b0502cd7
commit
6aaf55f0c9
@ -1,4 +1,9 @@ |
||||
/build/ |
||||
/build/**/*.js |
||||
/build/**/* |
||||
!/build/prisma |
||||
!/build/prisma/package.json |
||||
/node_modules/ |
||||
.env |
||||
/runtime/ |
||||
/runtime/ |
||||
/generated/prisma/* |
||||
!/generated/prisma/package.json |
@ -1,5 +1,5 @@ |
||||
<component name="ProjectCodeStyleConfiguration"> |
||||
<state> |
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" /> |
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Prisma" /> |
||||
</state> |
||||
</component> |
@ -0,0 +1,14 @@ |
||||
<component name="ProjectRunConfigurationManager"> |
||||
<configuration default="false" name="loadWebhooks" type="js.build_tools.npm"> |
||||
<package-json value="$PROJECT_DIR$/package.json" /> |
||||
<command value="run" /> |
||||
<scripts> |
||||
<script value="loadWebhooks" /> |
||||
</scripts> |
||||
<node-interpreter value="project" /> |
||||
<envs /> |
||||
<method v="2"> |
||||
<option name="RunConfigurationTask" enabled="true" run_configuration_name="build" run_configuration_type="js.build_tools.npm" /> |
||||
</method> |
||||
</configuration> |
||||
</component> |
@ -0,0 +1,12 @@ |
||||
<component name="ProjectRunConfigurationManager"> |
||||
<configuration default="false" name="regenerate" type="js.build_tools.npm" nameIsGenerated="true"> |
||||
<package-json value="$PROJECT_DIR$/package.json" /> |
||||
<command value="run" /> |
||||
<scripts> |
||||
<script value="regenerate" /> |
||||
</scripts> |
||||
<node-interpreter value="project" /> |
||||
<envs /> |
||||
<method v="2" /> |
||||
</configuration> |
||||
</component> |
@ -1,4 +1,45 @@ |
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<project version="4"> |
||||
<component name="ProjectTasksOptions" suppressed-tasks="Pug/Jade" /> |
||||
<component name="ProjectTasksOptions" suppressed-tasks="Pug/Jade"> |
||||
<TaskOptions isEnabled="true"> |
||||
<option name="arguments" value="format" /> |
||||
<option name="checkSyntaxErrors" value="true" /> |
||||
<option name="description" /> |
||||
<option name="exitCodeBehavior" value="ERROR" /> |
||||
<option name="fileExtension" value="prisma" /> |
||||
<option name="immediateSync" value="true" /> |
||||
<option name="name" value="Schema reformat" /> |
||||
<option name="output" value="$PROJECT_DIR$/prisma/schema.prisma" /> |
||||
<option name="outputFilters"> |
||||
<array /> |
||||
</option> |
||||
<option name="outputFromStdout" value="false" /> |
||||
<option name="program" value="$PROJECT_DIR$/node_modules/.bin/prisma" /> |
||||
<option name="runOnExternalChanges" value="false" /> |
||||
<option name="scopeName" value="Project Files" /> |
||||
<option name="trackOnlyRoot" value="false" /> |
||||
<option name="workingDir" value="$PROJECT_DIR$" /> |
||||
<envs /> |
||||
</TaskOptions> |
||||
<TaskOptions isEnabled="true"> |
||||
<option name="arguments" value="generate" /> |
||||
<option name="checkSyntaxErrors" value="true" /> |
||||
<option name="description" /> |
||||
<option name="exitCodeBehavior" value="ERROR" /> |
||||
<option name="fileExtension" value="prisma" /> |
||||
<option name="immediateSync" value="true" /> |
||||
<option name="name" value="Schema Regeneration" /> |
||||
<option name="output" value="$PROJECT_DIR$/node_modules/@prisma/client" /> |
||||
<option name="outputFilters"> |
||||
<array /> |
||||
</option> |
||||
<option name="outputFromStdout" value="false" /> |
||||
<option name="program" value="$PROJECT_DIR$/node_modules/.bin/prisma" /> |
||||
<option name="runOnExternalChanges" value="true" /> |
||||
<option name="scopeName" value="prisma" /> |
||||
<option name="trackOnlyRoot" value="false" /> |
||||
<option name="workingDir" value="$PROJECT_DIR$" /> |
||||
<envs /> |
||||
</TaskOptions> |
||||
</component> |
||||
</project> |
@ -0,0 +1,185 @@ |
||||
datasource db { |
||||
provider = "sqlite" |
||||
url = "file:../runtime/database/db.sqlite" |
||||
} |
||||
|
||||
generator client { |
||||
provider = "prisma-client-js" |
||||
} |
||||
|
||||
/// Discord channels known to the server. |
||||
model DiscordChannel { |
||||
/// The ID of the channel in Discord. |
||||
discordId String @id |
||||
/// The last known name of this channel. |
||||
name String |
||||
/// True if this channel should be used to broadcast public game events. |
||||
broadcastGame Boolean |
||||
/// True if this channel should be used to send logs. |
||||
sendLogs Boolean |
||||
/// True if this channel can accept game commands. |
||||
acceptGameCommands Boolean |
||||
/// True if this channel can accept admin commands. |
||||
acceptAdminCommands Boolean |
||||
/// The priority of this channel when slowing down to account for rate limits. Higher is more important. |
||||
priority Int |
||||
/// The guild in which this channel exists, if it's known. |
||||
guildId String? |
||||
/// The ID of the webhook used to post to this channel. Deleted if the webhook 404's. |
||||
webhookId String? |
||||
/// The webhook token used to post to this channel. Deleted if the webhook 404's. |
||||
token String? |
||||
} |
||||
|
||||
/// User genders. |
||||
model Gender { |
||||
/// The internal ID associated with this gender. |
||||
id String @id @default(cuid()) |
||||
/// The human-readable name of this gender. Unique among genders. |
||||
name String @unique |
||||
/// The users with this gender. |
||||
User User[] |
||||
} |
||||
|
||||
/// In-game user data structure. |
||||
model User { |
||||
/// The internal ID associated with this account. |
||||
/// It's separate from the Discord ID associated with this account. This supports a few things: |
||||
/// 1) We can move the game off of Discord, or add the ability to play it as a separate phone app or webapp, |
||||
/// without losing our database. |
||||
/// 2) If necessary, we can support having multiple Discord users associated with the same user account, to |
||||
/// support multi-account play. |
||||
/// 3) If necessary, we can support changing the Discord user associated with a user account, if for any reason |
||||
/// they are no longer using the old account and want to switch to a new account. |
||||
id String @id @default(cuid()) |
||||
/// The user's name, for the purposes of the game. This is completely separate from both their username and nickname |
||||
/// as Discord sees it, though it defaults to their nickname at time of joining. It does not have to be unique, and |
||||
/// can be changed at any time. |
||||
name String |
||||
/// The discord user associated with this account. |
||||
discordUser DiscordUser? |
||||
/// The user's gender, for the purposes of the game. This is purely cosmetic and can be changed at any time. |
||||
gender Gender @relation(fields: [genderId], references: [id]) |
||||
/// Relation field for Gender |
||||
genderId String |
||||
/// The number of units of currency this user is currently carrying. |
||||
currency Int @default(100) |
||||
/// The time and date at which this user joined. |
||||
joinedAt DateTime @default(now()) |
||||
/// The last time this user used a command. |
||||
lastActive DateTime @default(now()) |
||||
/// The last time this user retrieved their daily resources. |
||||
lastDaily DateTime? |
||||
/// List of units this user has ever seen/pulled. |
||||
units UserUnit[] |
||||
/// List of units currently in this user's party. |
||||
summonedUnits SummonedUnit[] |
||||
} |
||||
|
||||
/// Informmation about a Discord user. |
||||
model DiscordUser { |
||||
/// The Discord ID this record is for. A Discord snowflake. |
||||
discordId String @id |
||||
/// The last known username associated with this user. |
||||
username String |
||||
/// The last known discriminator associated with this user. |
||||
discriminator String |
||||
/// The User that this DiscordUser is associated with. |
||||
user User? @relation(fields: [userId], references: [id]) |
||||
/// Relation field for User |
||||
userId String? @unique |
||||
} |
||||
|
||||
/// Definitions of unit tiers. |
||||
model Tier { |
||||
/// The internal ID associated with this tier. |
||||
id String @id @default(cuid()) |
||||
/// The human-readable name of this tier. Unique among tiers. |
||||
name String @unique |
||||
/// The chance of pulling a unit of this tier. |
||||
pullWeight Int |
||||
/// The cost of /recalling a unit of this tier. |
||||
recallCost Int |
||||
/// The list of units with this tier. |
||||
units Unit[] |
||||
} |
||||
|
||||
/// An individual unit that can be summoned. |
||||
model Unit { |
||||
/// The combination of Name and Subtitle is unique among units, allowing for multiple versions of a unit. |
||||
/// The internal ID associated with this unit. |
||||
id String @id @default(cuid()) |
||||
/// The name of this unit. |
||||
name String |
||||
/// The subtitle of this unit. |
||||
subtitle String |
||||
/// The description of this unit. |
||||
description String |
||||
/// The tier of this unit. |
||||
tier Tier @relation(fields: [tierId], references: [id]) |
||||
/// Relation field for Tier |
||||
tierId String |
||||
/// The unit's base health when summoned for the first time. |
||||
baseHealth Int |
||||
/// The unit's base strength when summoned for the first time. |
||||
baseStrength Int |
||||
/// Information about the bonds with users this unit has been summoned by. |
||||
users UserUnit[] |
||||
/// Information about this unit's summoned forms. |
||||
summonedUnits SummonedUnit[] |
||||
|
||||
@@unique([name, subtitle]) |
||||
} |
||||
|
||||
/// Connection between Users and Units, indicating how and when users have pulled this unit. |
||||
model UserUnit { |
||||
/// The User that summoned this unit at some point. |
||||
user User @relation(fields: [userId], references: [id]) |
||||
/// Relation field for User |
||||
userId String |
||||
/// The Unit that was summoned by this User at some point. |
||||
unit Unit @relation(fields: [unitId], references: [id]) |
||||
/// Relation field for Unit |
||||
unitId String |
||||
/// The first time this user pulled this unit. |
||||
firstPulled DateTime @default(now()) |
||||
/// The number of times this unit has been /pulled by this user. |
||||
/// Greatly increases the user's bond with this unit. |
||||
/// Higher bond means higher stats on being resummoned with /pull or /recall. |
||||
timesPulled Int @default(1) |
||||
/// The number of times this unit has been digested in this user's party, either with /feed or in battle. |
||||
/// Slightly decreases the user's bond with this unit. |
||||
/// If the total bond reaches zero, this unit can no longer be resummoned with /recall until they appear in /pull. |
||||
timesDigested Int @default(0) |
||||
/// The number of times this unit has digested other units, either with /feed or in battle. |
||||
/// Does not influence bond, but may have an influence on other things (e.g., Talk lines). |
||||
timesDigesting Int @default(0) |
||||
/// The summoned form of this unit, if this unit is currently summoned. |
||||
/// Created on pulling or recalling, destroyed on digestion. |
||||
summonedUnit SummonedUnit? |
||||
|
||||
@@id([userId, unitId]) |
||||
} |
||||
|
||||
/// Instances of summoned units. |
||||
model SummonedUnit { |
||||
/// The User this unit was summoned by. |
||||
user User @relation(fields: [userId], references: [id]) |
||||
/// The Unit this summoned unit is an instance of. |
||||
unit Unit @relation(fields: [unitId], references: [id]) |
||||
/// The user-unit pair this SummonedUnit originates with. |
||||
userUnit UserUnit @relation(fields: [userId, unitId], references: [userId, unitId]) |
||||
/// Relation field for User and UserUnit |
||||
userId String |
||||
/// Relation field for Unit and UserUnit |
||||
unitId String |
||||
/// The unit's current health. If 0, the unit is unconscious and cannot participate in fights. |
||||
/// At -MaxHealth, the unit has been fully digested and this record will be deleted. |
||||
currentHealth Int |
||||
/// The unit's maximum health. |
||||
maxHealth Int |
||||
/// The unit's strength. |
||||
strength Int |
||||
|
||||
@@id([userId, unitId]) |
||||
} |
@ -1,152 +0,0 @@ |
||||
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} from "./FastifyHelpers.js"; |
||||
import {join, relative} from "path"; |
||||
import {RouteGenericInterface} from "fastify/types/route"; |
||||
import {checkAndClearXSRFCookie, generateXSRFCookie} from "./CookieHelpers.js"; |
||||
|
||||
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 interface OAuthRoute extends RouteGenericInterface { |
||||
Querystring: { |
||||
code: string | string[] | undefined, |
||||
state: string | string[] | undefined, |
||||
error: string | string[] | undefined, |
||||
error_description: string | string[] | undefined, |
||||
error_uri: string | string[] | undefined, |
||||
} |
||||
} |
||||
|
||||
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<OAuthRoute>, res: FastifyReply): Promise<void> { |
||||
const baseUrl = getBaseUrl(req) |
||||
const withoutParams = new URL(req.url) |
||||
withoutParams.search = "" |
||||
const code = getFirstValue(req.query.code) ?? null |
||||
const errorCode = getFirstValue(req.query.error) ?? null |
||||
const client = new AuthorizationCode(this.config) |
||||
if (code === null && errorCode === null) { |
||||
const state = generateXSRFCookie(res) |
||||
const authUrl = client.authorizeURL({ |
||||
scope: ["applications.commands", "webhook.incoming"], |
||||
redirect_uri: withoutParams.toString(), |
||||
state, |
||||
}) |
||||
res.code(200) |
||||
res.type("text/html") |
||||
res.send(pug.renderFile(join("static/pages", this.templateFilename), { |
||||
authUrl, |
||||
baseUrl, |
||||
isReset: this.webhook.isPresent |
||||
})) |
||||
return |
||||
} |
||||
const validXSRF = checkAndClearXSRFCookie(req, res) |
||||
if (errorCode !== null || code === null) { |
||||
const errorDescription = getFirstValue(req.query.error_description) ?? null |
||||
const errorUrl = getFirstValue(req.query.error_uri) |
||||
return renderError({ |
||||
baseUrl, |
||||
res, |
||||
code: 400, |
||||
error: errorDescription ?? errorCode ?? "There was no code or error present.", |
||||
context: "authorizing the application", |
||||
errorUrl, |
||||
buttonText: "Try Again", |
||||
buttonUrl: relative("/", new URL(req.url).pathname), |
||||
}) |
||||
} |
||||
if (!validXSRF) { |
||||
return renderError({ |
||||
baseUrl, |
||||
res, |
||||
code: 409, |
||||
error: "The state was incorrectly set - try restarting the authentication process.", |
||||
context: "processing the new access code", |
||||
buttonText: "Try Again", |
||||
buttonUrl: relative("/", new URL(req.url).pathname), |
||||
}) |
||||
} |
||||
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, |
||||
code: 400, |
||||
context: "exchanging the code you gave me for a token", |
||||
buttonText: "Try Again", |
||||
buttonUrl: relative("/", new URL(req.url).pathname), |
||||
}) |
||||
} |
||||
if (!token.token.webhook) { |
||||
return renderError({ |
||||
baseUrl, |
||||
res: res, |
||||
code: 400, |
||||
error: "The token did not contain a webhook.", |
||||
context: "processing the token I received", |
||||
buttonText: "Try Again", |
||||
buttonUrl: relative("/", new URL(req.url).pathname), |
||||
}) |
||||
} |
||||
const wasSet = this.webhook.isPresent |
||||
try { |
||||
await this.webhook.replaceHook(token.token.webhook) |
||||
} catch (e) { |
||||
return renderError({ |
||||
baseUrl, |
||||
res: res, |
||||
code: 500, |
||||
error: e, |
||||
context: `saving the new webhook${wasSet ? " and deleting the old one" : ""}`, |
||||
buttonText: "Try Again", |
||||
buttonUrl: relative("/", new URL(req.url).pathname), |
||||
}) |
||||
} |
||||
res.redirect(this.destinationFunc()) |
||||
} |
||||
} |
@ -1,7 +0,0 @@ |
||||
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" |
||||
} |
@ -1,152 +0,0 @@ |
||||
/** */ |
||||
import {APIWebhook} from "discord-api-types"; |
||||
import {FastifyLoggerInstance} from "fastify"; |
||||
import {mkdir as fsMkdir, readFile as fsReadFile, rm as fsRm, writeFile as fsWriteFile} 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 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) |
||||
} |
||||
|
||||
private _state: APIWebhook | null | undefined |
||||
|
||||
/** 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 |
||||
} |
||||
|
||||
/** 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 |
||||
} |
||||
|
||||
/** 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}) |
||||
} |
||||
} |
@ -1,192 +0,0 @@ |
||||
import {DiscordWebhookHandler, OAuthRoute} from "./DiscordWebhookHandler.js"; |
||||
import {getBaseUrl} from "./FastifyHelpers.js"; |
||||
import pug from "pug"; |
||||
import {renderError} from "./PugRenderer.js"; |
||||
import {GameServer} from "./GameServer.js"; |
||||
import {checkAndClearXSRFCookie, generateXSRFCookie, XSRFRoute} from "./CookieHelpers.js"; |
||||
import {SetupCommand, setupComponentInteractionHandler} from "./commands/SetupCommand.js"; |
||||
import {BaseServer, BaseServerDeps} from "./BaseServer.js"; |
||||
|
||||
export class SetupServer extends BaseServer { |
||||
readonly gameFactory: () => GameServer |
||||
|
||||
constructor(deps: BaseServerDeps & { gameFactory: () => GameServer }) { |
||||
super(deps) |
||||
this.gameFactory = deps.gameFactory |
||||
} |
||||
|
||||
async _initInternal(): Promise<void> { |
||||
this.slashcmd.registerCommand(new SetupCommand(this.slashcmd)) |
||||
this.slashcmd.on("componentInteraction", setupComponentInteractionHandler) |
||||
const gameHandler = new DiscordWebhookHandler({ |
||||
webhook: this.gameWebhook, |
||||
templateFilename: "setup/game.pug", |
||||
appId: this.appId, |
||||
secret: this.clientSecret, |
||||
destinationFunc: () => { |
||||
if (this.adminWebhook.isPresent) { |
||||
return "clear" |
||||
} else { |
||||
return "adminChannel" |
||||
} |
||||
}, |
||||
}) |
||||
const adminHandler = new DiscordWebhookHandler({ |
||||
webhook: this.adminWebhook, |
||||
templateFilename: "setup/admin.pug", |
||||
appId: this.appId, |
||||
secret: this.clientSecret, |
||||
destinationFunc: () => { |
||||
if (this.gameWebhook.isPresent) { |
||||
return "clear" |
||||
} else { |
||||
return "gameChannel" |
||||
} |
||||
}, |
||||
}) |
||||
this.server.get("/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("/setup/start", async (req, res) => { |
||||
res.redirect(".") |
||||
}) |
||||
this.server.get("/game", async (req, res) => { |
||||
res.redirect("setup") |
||||
}) |
||||
this.server.get("/game/started", async (req, res) => { |
||||
res.redirect("../setup") |
||||
}) |
||||
this.server.get("/game/stop", async (req, res) => { |
||||
res.redirect("../setup") |
||||
}) |
||||
this.server.get<XSRFRoute>("/game/start", async (req, res) => { |
||||
if (!checkAndClearXSRFCookie(req, res)) { |
||||
return renderError({ |
||||
baseUrl: getBaseUrl(req), |
||||
res, |
||||
code: 400, |
||||
error: "Token was incorrect or not set.", |
||||
context: "starting the game", |
||||
buttonText: "Return to Setup", |
||||
buttonUrl: "setup" |
||||
}) |
||||
} |
||||
if (!this.gameWebhook.isPresent || !this.adminWebhook.isPresent) { |
||||
return renderError({ |
||||
baseUrl: getBaseUrl(req), |
||||
res, |
||||
code: 409, |
||||
error: "You can't start the game while one of the channels is not set!", |
||||
context: "starting the game", |
||||
buttonText: "Finish Setup", |
||||
buttonUrl: "setup" |
||||
}) |
||||
} |
||||
res.code(200) |
||||
res.type("text/html") |
||||
res.send(pug.renderFile("static/pages/setup/start.pug", { |
||||
startedUrl: "game/start" |
||||
})) |
||||
setImmediate(async () => { |
||||
this.server.log.info("Shutting down the setup server and switching to the game server.") |
||||
try { |
||||
await this.server.close() |
||||
} catch (e) { |
||||
this.server.log.error(e, "Failed to shut down the setup server") |
||||
} |
||||
try { |
||||
await this.gameFactory().initialize() |
||||
} catch (e) { |
||||
this.server.log.error(e, "Failed to start up the game server") |
||||
} |
||||
this.server.log.info("Successfully switched from the setup server to the game server.") |
||||
}) |
||||
|
||||
}) |
||||
this.server.get<OAuthRoute>("/setup/gameChannel", async (req, res) => { |
||||
return await gameHandler.handleRequest(req, res) |
||||
}) |
||||
this.server.get<OAuthRoute>("/setup/adminChannel", async (req, res) => { |
||||
return await adminHandler.handleRequest(req, res) |
||||
}) |
||||
this.server.get<XSRFRoute>("/setup/clear", async (req, res) => { |
||||
if (!checkAndClearXSRFCookie(req, res)) { |
||||
return renderError({ |
||||
baseUrl: getBaseUrl(req), |
||||
res, |
||||
code: 400, |
||||
error: "Token was incorrect or not set.", |
||||
context: "clearing the channel setup", |
||||
buttonText: "Return to Setup", |
||||
buttonUrl: "setup" |
||||
}) |
||||
} |
||||
try { |
||||
await Promise.all([this.gameWebhook, this.adminWebhook] |
||||
.filter((item) => item.isPresent) |
||||
.map((item) => item.clearHook())) |
||||
} catch (e) { |
||||
return renderError({ |
||||
baseUrl: getBaseUrl(req), |
||||
res, |
||||
code: 500, |
||||
error: e, |
||||
context: "clearing and deleting the webhooks", |
||||
buttonUrl: "setup/clear", |
||||
buttonText: "Try Again" |
||||
}) |
||||
} |
||||
res.redirect(".") |
||||
}) |
||||
this.server.get("/setup/done", async (req, res) => { |
||||
if (!this.gameWebhook.isPresent) { |
||||
res.redirect("gameChannel") |
||||
} else if (!this.adminWebhook.isPresent) { |
||||
res.redirect("adminChannel") |
||||
} else { |
||||
const token = generateXSRFCookie(res) |
||||
res.code(200) |
||||
res.type("text/html") |
||||
res.send(pug.renderFile("static/pages/setup/done.pug", { |
||||
baseUrl: getBaseUrl(req), |
||||
gameModeUrl: `game/start?token=${token}`, |
||||
clearUrl: `setup/clear?token=${token}`, |
||||
gameSetupUrl: `setup/gameChannel`, |
||||
adminSetupUrl: `setup/adminChannel`, |
||||
shutdownUrl: `shutdown?token=${token}` |
||||
})) |
||||
} |
||||
}) |
||||
this.server.get<XSRFRoute>("/shutdown", async (req, res) => { |
||||
if (!checkAndClearXSRFCookie(req, res)) { |
||||
return renderError({ |
||||
baseUrl: getBaseUrl(req), |
||||
res, |
||||
code: 400, |
||||
error: "Token was incorrect or not set.", |
||||
context: "shutting the server down", |
||||
buttonText: "Return to Setup", |
||||
buttonUrl: "setup" |
||||
}) |
||||
} |
||||
res.code(200) |
||||
res.type("text/html") |
||||
res.send(pug.renderFile("static/pages/shutdown.pug")) |
||||
setImmediate(async () => { |
||||
this.server.log.info("Shutting down the setup server.") |
||||
try { |
||||
await this.server.close() |
||||
} catch (e) { |
||||
this.server.log.error(e, "Failed to shut down the setup server") |
||||
} |
||||
this.server.log.info("Shut down. Good night...") |
||||
}) |
||||
}) |
||||
} |
||||
} |
@ -0,0 +1,67 @@ |
||||
import {CommandContext, CommandOptionType, SlashCommand, SlashCreator} from "slash-create"; |
||||
import {ChannelManager} from "../../queries/ChannelManager.js"; |
||||
import {Snowflake} from "discord-api-types"; |
||||
import {checkGameCommandAndRun} from "../permissions/ChannelPermissions.js"; |
||||
import {UserManager} from "../../queries/UserManager.js"; |
||||
|
||||
export class JoinCommand extends SlashCommand { |
||||
readonly channelManager: ChannelManager |
||||
readonly userManager: UserManager |
||||
|
||||
constructor(creator: SlashCreator, {channelManager, userManager, gameGuildIds, genders}: { |
||||
channelManager: ChannelManager, |
||||
userManager: UserManager, |
||||
gameGuildIds: Snowflake[], |
||||
genders: { id: string, name: string }[], |
||||
}) { |
||||
super(creator, { |
||||
name: "join", |
||||
guildIDs: gameGuildIds, |
||||
description: "Allows a new player to join the game.", |
||||
options: [ |
||||
{ |
||||
name: "name", |
||||
description: "Your alias for the purposes of this game. Purely cosmetic. You can change it at any time.", |
||||
required: true, |
||||
type: CommandOptionType.STRING, |
||||
}, |
||||
{ |
||||
name: "gender", |
||||
description: "Your gender for the purposes of this game. Purely cosmetic. You can change it at any time.", |
||||
required: true, |
||||
type: CommandOptionType.STRING, |
||||
choices: genders.map((item) => ({ |
||||
name: item.name, |
||||
value: item.id, |
||||
})), |
||||
} |
||||
] |
||||
}); |
||||
|
||||
this.channelManager = channelManager |
||||
this.userManager = userManager |
||||
} |
||||
|
||||
async run(ctx: CommandContext): Promise<any> { |
||||
return checkGameCommandAndRun(ctx, this.channelManager, async () => { |
||||
const result = await this.userManager.registerOrReregisterUserFromDiscord({ |
||||
discordId: ctx.user.id, |
||||
username: ctx.user.username, |
||||
discriminator: ctx.user.discriminator, |
||||
name: ctx.options.name ?? "Anonymous", |
||||
genderId: ctx.options.gender ?? "x", |
||||
}) |
||||
if (result.created) { |
||||
return ctx.send({ |
||||
content: `You got it! Welcome aboard, ${result.user.name}! I have you down in my records as ${result.user.gender.name}. If you ever want to change your name or gender, just /join again!`, |
||||
ephemeral: true, |
||||
}) |
||||
} else { |
||||
return ctx.send({ |
||||
content: `Duly noted! I've updated your deets to have you down as ${result.user.name}, who is ${result.user.gender.name}. If you ever want to change your name or gender, just /join again!`, |
||||
ephemeral: true, |
||||
}) |
||||
} |
||||
}) |
||||
} |
||||
} |
@ -0,0 +1,26 @@ |
||||
import {CommandContext} from "slash-create"; |
||||
import {ChannelManager} from "../../queries/ChannelManager.js"; |
||||
|
||||
export async function checkGameCommandAndRun(ctx: CommandContext, channelManager: ChannelManager, handler: (ctx: CommandContext) => Promise<any>): Promise<any> { |
||||
const guildID = ctx.guildID |
||||
try { |
||||
if (await channelManager.canUseGameCommandsInChannel(ctx.channelID)) { |
||||
return handler(ctx) |
||||
} else if (guildID !== undefined && await channelManager.canUseGameCommandsInGuild(guildID)) { |
||||
return ctx.send({ |
||||
content: `Sorry, you can't do that in this channel.`, |
||||
ephemeral: true, |
||||
}) |
||||
} else { |
||||
return ctx.send({ |
||||
content: "Sorry, you can't do that in this guild.", |
||||
ephemeral: true, |
||||
}) |
||||
} |
||||
} catch (e) { |
||||
return ctx.send({ |
||||
content: `Uhhhhhh. Something went very wrong. If you see Reya, tell her I said ${e}.`, |
||||
ephemeral: true, |
||||
}) |
||||
} |
||||
} |
@ -0,0 +1,95 @@ |
||||
import {Snowflake} from "discord-api-types"; |
||||
import {PrismaClient} from "./Prisma.js"; |
||||
|
||||
export class ChannelManager { |
||||
readonly client: PrismaClient |
||||
|
||||
constructor(client: PrismaClient) { |
||||
this.client = client |
||||
} |
||||
|
||||
async getGameCommandGuildIds(): Promise<Snowflake[]> { |
||||
return (await this.client.discordChannel.findMany({ |
||||
where: { |
||||
acceptGameCommands: true, |
||||
guildId: { |
||||
not: null |
||||
} |
||||
}, |
||||
distinct: ["guildId"], |
||||
select: { |
||||
guildId: true, |
||||
}, |
||||
})).map((item) => item.guildId as string) |
||||
// We know that the guild ID is not null because of the where condition.
|
||||
} |
||||
|
||||
async canUseGameCommandsInChannel(channelId: Snowflake): Promise<boolean> { |
||||
return ((await this.client.discordChannel.findUnique({ |
||||
where: { |
||||
discordId: channelId, |
||||
}, |
||||
select: { |
||||
acceptGameCommands: true, |
||||
}, |
||||
rejectOnNotFound: false, |
||||
})) ?? {acceptGameCommands: false}).acceptGameCommands |
||||
} |
||||
|
||||
async canUseGameCommandsInGuild(guildId: Snowflake): Promise<boolean> { |
||||
return (await this.client.discordChannel.findFirst({ |
||||
where: { |
||||
guildId: guildId, |
||||
acceptGameCommands: true, |
||||
}, |
||||
select: { |
||||
discordId: true |
||||
} |
||||
})) !== null |
||||
} |
||||
|
||||
async getAdminCommandGuildIds(): Promise<Snowflake[]> { |
||||
return (await this.client.discordChannel.findMany({ |
||||
where: { |
||||
acceptAdminCommands: true, |
||||
guildId: { |
||||
not: null |
||||
} |
||||
}, |
||||
distinct: ["guildId"], |
||||
select: { |
||||
guildId: true, |
||||
}, |
||||
})).map((item) => item.guildId as string) |
||||
// We know that the guild ID is not null because of the where condition.
|
||||
} |
||||
|
||||
async canUseAdminCommandsInChannel(channelId: Snowflake): Promise<boolean> { |
||||
return ((await this.client.discordChannel.findUnique({ |
||||
where: { |
||||
discordId: channelId, |
||||
}, |
||||
select: { |
||||
acceptAdminCommands: true, |
||||
}, |
||||
rejectOnNotFound: false, |
||||
})) ?? {acceptAdminCommands: false} |
||||
).acceptAdminCommands |
||||
} |
||||
|
||||
async canUseAdminCommandsInGuild(guildId: Snowflake): Promise<boolean> { |
||||
return (await this.client.discordChannel.findFirst({ |
||||
where: { |
||||
guildId: guildId, |
||||
acceptAdminCommands: true, |
||||
}, |
||||
select: { |
||||
discordId: true, |
||||
} |
||||
})) !== null |
||||
} |
||||
|
||||
async isGameReady() { |
||||
return false; |
||||
} |
||||
} |
@ -0,0 +1,9 @@ |
||||
import pkg from "@prisma/client"; |
||||
|
||||
export const { |
||||
PrismaClient, |
||||
Prisma: PrismaNS, |
||||
prisma |
||||
} = pkg |
||||
export type PrismaClient = InstanceType<typeof PrismaClient>; |
||||
export type {Prisma, DiscordUser, User, DiscordChannel} from "@prisma/client"; |
@ -0,0 +1,133 @@ |
||||
import {DiscordUser, Prisma, PrismaClient} from "./Prisma.js"; |
||||
import {Snowflake} from "discord-api-types"; |
||||
import cuid from "cuid"; |
||||
|
||||
const userRegistrationSelect = { |
||||
id: true, |
||||
name: true, |
||||
discordUser: true, |
||||
gender: true, |
||||
joinedAt: true, |
||||
} as const |
||||
export type UserRegistrationData = Prisma.UserGetPayload<{ select: typeof userRegistrationSelect }> |
||||
|
||||
const genderListSelect = { |
||||
id: true, |
||||
name: true, |
||||
} as const |
||||
export type GenderListData = Prisma.GenderGetPayload<{ select: typeof genderListSelect }> |
||||
|
||||
export class UserManager { |
||||
readonly client: PrismaClient |
||||
|
||||
constructor(client: PrismaClient) { |
||||
this.client = client |
||||
} |
||||
|
||||
async registerOrUpdateDiscordUser({ |
||||
discordId, |
||||
username, |
||||
discriminator, |
||||
}: { discordId: Snowflake, username: string, discriminator: string }): Promise<DiscordUser> { |
||||
return (await this.client.discordUser.upsert({ |
||||
where: { |
||||
discordId, |
||||
}, |
||||
create: { |
||||
discordId, |
||||
username, |
||||
discriminator, |
||||
userId: null, |
||||
}, |
||||
update: { |
||||
username, |
||||
discriminator, |
||||
user: { |
||||
update: { |
||||
lastActive: new Date() |
||||
} |
||||
} |
||||
}, |
||||
include: { |
||||
user: true, |
||||
} |
||||
})) |
||||
} |
||||
|
||||
async registerOrReregisterUserFromDiscord({ |
||||
discordId, |
||||
username, |
||||
discriminator, |
||||
name, |
||||
genderId |
||||
}: { discordId: Snowflake, username: string, discriminator: string, name: string, genderId: string }): Promise<{ |
||||
user: UserRegistrationData, created: boolean |
||||
}> { |
||||
const userId = cuid() |
||||
const user = (await this.client.discordUser.upsert({ |
||||
where: { |
||||
discordId, |
||||
}, |
||||
update: { |
||||
username, |
||||
discriminator, |
||||
user: { |
||||
upsert: { |
||||
update: { |
||||
name, |
||||
gender: { |
||||
connect: { |
||||
id: genderId, |
||||
} |
||||
}, |
||||
lastActive: new Date() |
||||
}, |
||||
create: { |
||||
id: userId, |
||||
name, |
||||
gender: { |
||||
connect: { |
||||
id: genderId, |
||||
} |
||||
}, |
||||
} |
||||
} |
||||
} |
||||
}, |
||||
create: { |
||||
discordId, |
||||
username, |
||||
discriminator, |
||||
user: { |
||||
create: { |
||||
id: userId, |
||||
name, |
||||
gender: { |
||||
connect: { |
||||
id: genderId, |
||||
} |
||||
}, |
||||
} |
||||
} |
||||
}, |
||||
select: { |
||||
user: { |
||||
select: userRegistrationSelect |
||||
} |
||||
}, |
||||
})).user |
||||
if (user === null) { |
||||
throw Error("...Somehow, there wasn't a user to return?!") |
||||
} |
||||
return { |
||||
user, |
||||
created: user.id === userId, |
||||
} |
||||
} |
||||
|
||||
async getGenders(): Promise<GenderListData[]> { |
||||
return this.client.gender.findMany({ |
||||
select: genderListSelect |
||||
}) |
||||
} |
||||
} |
@ -0,0 +1,44 @@ |
||||
import {PrismaClient} from "../queries/Prisma.js"; |
||||
|
||||
async function main() { |
||||
const client = new PrismaClient() |
||||
await client.$connect() |
||||
await client.gender.upsert({ |
||||
where: { |
||||
id: "f" |
||||
}, |
||||
create: { |
||||
id: "f", |
||||
name: "Female" |
||||
}, |
||||
update: { |
||||
name: "Female" |
||||
}, |
||||
}) |
||||
await client.gender.upsert({ |
||||
where: { |
||||
id: "m" |
||||
}, |
||||
create: { |
||||
id: "m", |
||||
name: "Male" |
||||
}, |
||||
update: { |
||||
name: "Male" |
||||
}, |
||||
}) |
||||
await client.gender.upsert({ |
||||
where: { |
||||
id: "x" |
||||
}, |
||||
create: { |
||||
id: "x", |
||||
name: "Non-binary" |
||||
}, |
||||
update: { |
||||
name: "Non-binary" |
||||
}, |
||||
}) |
||||
} |
||||
|
||||
main() |
@ -0,0 +1,54 @@ |
||||
import {Prisma, PrismaClient} from "../queries/Prisma.js"; |
||||
import {APIWebhook} from "discord-api-types"; |
||||
import {readFile} from "fs/promises"; |
||||
|
||||
type DiscordChannelPermissions = Prisma.DiscordChannelGetPayload<{ |
||||
select: { |
||||
broadcastGame: true, |
||||
sendLogs: true, |
||||
acceptGameCommands: true, |
||||
acceptAdminCommands: true, |
||||
} |
||||
}> |
||||
|
||||
async function loadHookIntoDatabase(client: PrismaClient, hook: APIWebhook, permissions: DiscordChannelPermissions): Promise<void> { |
||||
await client.discordChannel.upsert({ |
||||
where: {discordId: hook.channel_id}, |
||||
update: { |
||||
webhookId: hook.id, |
||||
token: hook.token, |
||||
...permissions |
||||
}, |
||||
create: { |
||||
discordId: hook.channel_id, |
||||
guildId: hook.guild_id ?? null, |
||||
webhookId: hook.id, |
||||
token: hook.token, |
||||
name: "???", |
||||
priority: 0, |
||||
...permissions |
||||
} |
||||
}) |
||||
} |
||||
|
||||
async function main() { |
||||
const client = new PrismaClient() |
||||
await client.$connect() |
||||
|
||||
const gameHook: APIWebhook = JSON.parse(await readFile("runtime/webhooks/game.json", {encoding: "utf-8"})) |
||||
await loadHookIntoDatabase(client, gameHook, { |
||||
acceptAdminCommands: false, |
||||
acceptGameCommands: true, |
||||
sendLogs: false, |
||||
broadcastGame: true, |
||||
}) |
||||
const adminHook: APIWebhook = JSON.parse(await readFile("runtime/webhooks/admin.json", {encoding: "utf-8"})) |
||||
await loadHookIntoDatabase(client, adminHook, { |
||||
acceptAdminCommands: true, |
||||
acceptGameCommands: false, |
||||
sendLogs: true, |
||||
broadcastGame: false, |
||||
}) |
||||
} |
||||
|
||||
main() |
Loading…
Reference in new issue