parent
48b0502cd7
commit
6aaf55f0c9
@ -1,4 +1,9 @@ |
|||||||
/build/ |
/build/**/*.js |
||||||
|
/build/**/* |
||||||
|
!/build/prisma |
||||||
|
!/build/prisma/package.json |
||||||
/node_modules/ |
/node_modules/ |
||||||
.env |
.env |
||||||
/runtime/ |
/runtime/ |
||||||
|
/generated/prisma/* |
||||||
|
!/generated/prisma/package.json |
@ -1,5 +1,5 @@ |
|||||||
<component name="ProjectCodeStyleConfiguration"> |
<component name="ProjectCodeStyleConfiguration"> |
||||||
<state> |
<state> |
||||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" /> |
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Prisma" /> |
||||||
</state> |
</state> |
||||||
</component> |
</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"?> |
<?xml version="1.0" encoding="UTF-8"?> |
||||||
<project version="4"> |
<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> |
</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