diff --git a/.gitignore b/.gitignore
index 09a1f77..a7f2571 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,9 @@
-/build/
+/build/**/*.js
+/build/**/*
+!/build/prisma
+!/build/prisma/package.json
/node_modules/
.env
-/runtime/
\ No newline at end of file
+/runtime/
+/generated/prisma/*
+!/generated/prisma/package.json
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
index a55e7a1..41156ed 100644
--- a/.idea/codeStyles/codeStyleConfig.xml
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -1,5 +1,5 @@
-
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/loadWebhooks.xml b/.idea/runConfigurations/loadWebhooks.xml
new file mode 100644
index 0000000..364837f
--- /dev/null
+++ b/.idea/runConfigurations/loadWebhooks.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/regenerate.xml b/.idea/runConfigurations/regenerate.xml
new file mode 100644
index 0000000..a84f708
--- /dev/null
+++ b/.idea/runConfigurations/regenerate.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/watcherTasks.xml b/.idea/watcherTasks.xml
index afc2f4e..624d8f1 100644
--- a/.idea/watcherTasks.xml
+++ b/.idea/watcherTasks.xml
@@ -1,4 +1,45 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 02f1694..9b4f2cd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,9 +8,11 @@
"name": "vore-gacha",
"version": "0.0.1",
"dependencies": {
+ "@prisma/client": "^3.6.0",
"axios": "^0.24.0",
"chance": "^1.1.8",
"crypto-random-string": "^4.0.0",
+ "cuid": "^2.1.8",
"detritus-client": "^0.16.3",
"detritus-client-rest": "^0.10.5",
"discord-api-types": "^0.25.2",
@@ -31,6 +33,7 @@
"@types/relateurl": "^0.2.29",
"@types/simple-oauth2": "^4.1.1",
"pino-pretty": "^7.3.0",
+ "prisma": "^3.6.0",
"typescript": "^4.5.3"
}
},
@@ -118,6 +121,38 @@
"@hapi/hoek": "9.x.x"
}
},
+ "node_modules/@prisma/client": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-3.6.0.tgz",
+ "integrity": "sha512-ycSGY9EZGROtje0iCNsgC5Zqi/ttX2sO7BNMYaLsUMiTlf3F69ZPH+08pRo0hrDfkZzyimXYqeXJlaoYDH1w7A==",
+ "hasInstallScript": true,
+ "dependencies": {
+ "@prisma/engines-version": "3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727"
+ },
+ "engines": {
+ "node": ">=12.6"
+ },
+ "peerDependencies": {
+ "prisma": "*"
+ },
+ "peerDependenciesMeta": {
+ "prisma": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@prisma/engines": {
+ "version": "3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727",
+ "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727.tgz",
+ "integrity": "sha512-dRClHS7DsTVchDKzeG72OaEyeDskCv91pnZ72Fftn0mp4BkUvX2LvWup65hCNzwwQm5IDd6A88APldKDnMiEMA==",
+ "devOptional": true,
+ "hasInstallScript": true
+ },
+ "node_modules/@prisma/engines-version": {
+ "version": "3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727",
+ "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727.tgz",
+ "integrity": "sha512-vtoO2ys6mSfc8ONTWdcYztKN3GBU1tcKBj0aXObyjzSuGwHFcM/pEA0xF+n1W4/0TAJgfoPX2khNEit6g0jtNA=="
+ },
"node_modules/@sideway/address": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.3.tgz",
@@ -417,6 +452,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/cuid": {
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/cuid/-/cuid-2.1.8.tgz",
+ "integrity": "sha512-xiEMER6E7TlTPnDxrM4eRiC6TRgjNX9xzEZ5U/Se2YJKr7Mq4pJn/2XEHjl3STcSh96GmkHPcBXLES8M29wyyg=="
+ },
"node_modules/dateformat": {
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
@@ -1180,6 +1220,23 @@
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-4.0.0.tgz",
"integrity": "sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q=="
},
+ "node_modules/prisma": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/prisma/-/prisma-3.6.0.tgz",
+ "integrity": "sha512-6SqgHS/5Rq6HtHjsWsTxlj+ySamGyCLBUQfotc2lStOjPv52IQuDVpp58GieNqc9VnfuFyHUvTZw7aQB+G2fvQ==",
+ "devOptional": true,
+ "hasInstallScript": true,
+ "dependencies": {
+ "@prisma/engines": "3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727"
+ },
+ "bin": {
+ "prisma": "build/index.js",
+ "prisma2": "build/index.js"
+ },
+ "engines": {
+ "node": ">=12.6"
+ }
+ },
"node_modules/promise": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
@@ -1813,6 +1870,25 @@
"@hapi/hoek": "9.x.x"
}
},
+ "@prisma/client": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-3.6.0.tgz",
+ "integrity": "sha512-ycSGY9EZGROtje0iCNsgC5Zqi/ttX2sO7BNMYaLsUMiTlf3F69ZPH+08pRo0hrDfkZzyimXYqeXJlaoYDH1w7A==",
+ "requires": {
+ "@prisma/engines-version": "3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727"
+ }
+ },
+ "@prisma/engines": {
+ "version": "3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727",
+ "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727.tgz",
+ "integrity": "sha512-dRClHS7DsTVchDKzeG72OaEyeDskCv91pnZ72Fftn0mp4BkUvX2LvWup65hCNzwwQm5IDd6A88APldKDnMiEMA==",
+ "devOptional": true
+ },
+ "@prisma/engines-version": {
+ "version": "3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727",
+ "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727.tgz",
+ "integrity": "sha512-vtoO2ys6mSfc8ONTWdcYztKN3GBU1tcKBj0aXObyjzSuGwHFcM/pEA0xF+n1W4/0TAJgfoPX2khNEit6g0jtNA=="
+ },
"@sideway/address": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.3.tgz",
@@ -2063,6 +2139,11 @@
"type-fest": "^1.0.1"
}
},
+ "cuid": {
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/cuid/-/cuid-2.1.8.tgz",
+ "integrity": "sha512-xiEMER6E7TlTPnDxrM4eRiC6TRgjNX9xzEZ5U/Se2YJKr7Mq4pJn/2XEHjl3STcSh96GmkHPcBXLES8M29wyyg=="
+ },
"dateformat": {
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
@@ -2656,6 +2737,15 @@
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-4.0.0.tgz",
"integrity": "sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q=="
},
+ "prisma": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/prisma/-/prisma-3.6.0.tgz",
+ "integrity": "sha512-6SqgHS/5Rq6HtHjsWsTxlj+ySamGyCLBUQfotc2lStOjPv52IQuDVpp58GieNqc9VnfuFyHUvTZw7aQB+G2fvQ==",
+ "devOptional": true,
+ "requires": {
+ "@prisma/engines": "3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727"
+ }
+ },
"promise": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
diff --git a/package.json b/package.json
index 8e99f6e..0306f0c 100644
--- a/package.json
+++ b/package.json
@@ -2,9 +2,11 @@
"name": "vore-gacha",
"version": "0.0.1",
"dependencies": {
+ "@prisma/client": "^3.6.0",
"axios": "^0.24.0",
"chance": "^1.1.8",
"crypto-random-string": "^4.0.0",
+ "cuid": "^2.1.8",
"detritus-client": "^0.16.3",
"detritus-client-rest": "^0.10.5",
"discord-api-types": "^0.25.2",
@@ -25,11 +27,18 @@
"@types/relateurl": "^0.2.29",
"@types/simple-oauth2": "^4.1.1",
"pino-pretty": "^7.3.0",
+ "prisma": "^3.6.0",
"typescript": "^4.5.3"
},
"type": "module",
"scripts": {
"build": "tsc --build",
- "start": "node build/app.js"
+ "clean": "rm -rf build generated",
+ "start": "node build/app.js",
+ "regenerate": "prisma generate",
+ "loadWebhooks": "node build/tools/LoadJsonWebhooks.js",
+ "loadGenders": "node build/tools/LoadGenders.js",
+ "pushDb": "prisma db push --skip-generate",
+ "fullRebuild": "npm run clean && npm run regenerate && npm run pushDb && npm run build && npm run loadWebhooks && npm run loadGenders"
}
}
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
new file mode 100644
index 0000000..e4b38bf
--- /dev/null
+++ b/prisma/schema.prisma
@@ -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])
+}
diff --git a/src/BaseServer.ts b/src/BaseServer.ts
index 130cf72..d7e7fb1 100644
--- a/src/BaseServer.ts
+++ b/src/BaseServer.ts
@@ -1,8 +1,8 @@
-import {SavedWebhook} from "./SavedWebhook.js";
import fastify, {FastifyInstance} from "fastify";
import {SlashCreator, SlashCreatorOptions} from "slash-create";
import fastifyCookie from "fastify-cookie";
import {FastifyServerButItWorksUnlikeTheRealOne} from "./FastifyHelpers.js";
+import {ChannelManager} from "./queries/ChannelManager.js";
export interface BaseServerDeps {
appId: string
@@ -12,19 +12,17 @@ export interface BaseServerDeps {
listenPort: number
listenAddress: string
cookieSecret: string
- gameWebhook: SavedWebhook
- adminWebhook: SavedWebhook
+ channelManager: ChannelManager
}
export class BaseServer {
readonly server: FastifyInstance
- readonly gameWebhook: SavedWebhook
- readonly adminWebhook: SavedWebhook
readonly appId: string
readonly clientSecret: string
readonly listenPort: number
readonly listenAddress: string
readonly slashcmd: SlashCreator
+ readonly channelManager: ChannelManager
constructor({
cookieSecret,
@@ -32,8 +30,7 @@ export class BaseServer {
clientSecret,
listenPort,
listenAddress,
- gameWebhook,
- adminWebhook,
+ channelManager,
publicKey,
botToken,
slashCreatorOptions = {}
@@ -60,9 +57,7 @@ export class BaseServer {
this.clientSecret = clientSecret
this.listenPort = listenPort
this.listenAddress = listenAddress
- this.gameWebhook = gameWebhook
- this.adminWebhook = adminWebhook
-
+ this.channelManager = channelManager
}
async _initInternal(): Promise {
diff --git a/src/DiscordWebhookHandler.ts b/src/DiscordWebhookHandler.ts
deleted file mode 100644
index ec804fc..0000000
--- a/src/DiscordWebhookHandler.ts
+++ /dev/null
@@ -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, res: FastifyReply): Promise {
- 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())
- }
-}
\ No newline at end of file
diff --git a/src/GameServer.ts b/src/GameServer.ts
index feccd17..7e7779b 100644
--- a/src/GameServer.ts
+++ b/src/GameServer.ts
@@ -1,21 +1,33 @@
-import {SetupServer} from "./SetupServer.js";
import {checkAndClearXSRFCookie, generateXSRFCookie, XSRFRoute} from "./CookieHelpers.js";
import {renderError} from "./PugRenderer.js";
import {getBaseUrl} from "./FastifyHelpers.js";
import pug from "pug";
import {BaseServer, BaseServerDeps} from "./BaseServer.js";
import {PullCommand} from "./commands/game/PullCommand.js";
+import {JoinCommand} from "./commands/game/JoinCommand.js";
+import {UserManager} from "./queries/UserManager.js";
export class GameServer extends BaseServer {
- readonly setupFactory: () => SetupServer
+ readonly userManager: UserManager
- constructor(deps: BaseServerDeps & { setupFactory: () => SetupServer }) {
+ constructor(deps: BaseServerDeps & { userManager: UserManager }) {
super(deps)
- this.setupFactory = deps.setupFactory
+ this.userManager = deps.userManager
}
async _initInternal(): Promise {
- this.slashcmd.registerCommand(new PullCommand(this.slashcmd, this.gameWebhook))
+ const gameGuildIds = await this.channelManager.getGameCommandGuildIds()
+ const genders = await this.userManager.getGenders()
+ this.slashcmd.registerCommand(new PullCommand(this.slashcmd, {
+ channelManager: this.channelManager,
+ gameGuildIds,
+ }))
+ this.slashcmd.registerCommand(new JoinCommand(this.slashcmd, {
+ channelManager: this.channelManager,
+ userManager: this.userManager,
+ gameGuildIds,
+ genders,
+ }))
this.server.get("/game/started", async (req, res) => {
const token = generateXSRFCookie(res)
res.code(200)
@@ -48,37 +60,7 @@ export class GameServer extends BaseServer {
res.redirect("../game/started")
})
this.server.get("/game/stop", async (req, res) => {
- if (!checkAndClearXSRFCookie(req, res)) {
- return renderError({
- baseUrl: getBaseUrl(req),
- res,
- code: 400,
- error: "Token was incorrect or not set.",
- context: "stopping the game",
- buttonText: "Return to Game",
- buttonUrl: "game/started"
- })
- }
- res.code(200)
- res.type("text/html")
- res.send(pug.renderFile("static/pages/game/stop.pug", {
- startedUrl: "setup"
- }))
- setImmediate(async () => {
- this.server.log.info("Shutting down the game server and switching to the setup server.")
- try {
- await this.server.close()
- } catch (e) {
- this.server.log.error(e, "Failed to shut down the game server")
- }
- try {
- await this.setupFactory().initialize()
- } catch (e) {
- this.server.log.error(e, "Failed to start up the setup server")
- }
- this.server.log.info("Successfully switched from the game server to the setup server.")
- })
-
+ res.redirect("../shutdown")
})
this.server.get("/shutdown", async (req, res) => {
if (!checkAndClearXSRFCookie(req, res)) {
diff --git a/src/NodeErrorHelpers.ts b/src/NodeErrorHelpers.ts
deleted file mode 100644
index 64d138b..0000000
--- a/src/NodeErrorHelpers.ts
+++ /dev/null
@@ -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"
-}
\ No newline at end of file
diff --git a/src/SavedWebhook.ts b/src/SavedWebhook.ts
deleted file mode 100644
index aa28bec..0000000
--- a/src/SavedWebhook.ts
+++ /dev/null
@@ -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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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})
- }
-}
\ No newline at end of file
diff --git a/src/SetupServer.ts b/src/SetupServer.ts
deleted file mode 100644
index ca1497e..0000000
--- a/src/SetupServer.ts
+++ /dev/null
@@ -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 {
- 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("/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("/setup/gameChannel", async (req, res) => {
- return await gameHandler.handleRequest(req, res)
- })
- this.server.get("/setup/adminChannel", async (req, res) => {
- return await adminHandler.handleRequest(req, res)
- })
- this.server.get("/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("/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...")
- })
- })
- }
-}
\ No newline at end of file
diff --git a/src/app.ts b/src/app.ts
index 5eb5cf4..87c08f2 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -1,9 +1,10 @@
import dotenv from "dotenv";
-import {SetupServer} from "./SetupServer.js";
import {GameServer} from "./GameServer.js";
import cryptoRandomString from "crypto-random-string";
-import {SavedWebhook} from "./SavedWebhook.js";
import pino from "pino"
+import {PrismaClient} from "./queries/Prisma.js";
+import {ChannelManager} from "./queries/ChannelManager.js";
+import {UserManager} from "./queries/UserManager.js";
const log = pino()
@@ -19,37 +20,20 @@ async function main(): Promise {
const listenPort = parseInt(parsed["HTTP_PORT"] ?? "5244")
const listenAddress = parsed["HTTP_ADDRESS"] ?? "127.0.0.1"
const cookieSecret = parsed["COOKIE_SECRET"] ?? cryptoRandomString({length: 32, type: "base64"})
- const gameWebhook = new SavedWebhook("game.json", {logger: log})
- const adminWebhook = new SavedWebhook("admin.json", {logger: log})
- const factory: { game: () => GameServer, setup: () => SetupServer } = {
- game(): never {
- throw Error("game factory not set up yet")
- },
- setup(): never {
- throw Error("setup factory not set up yet")
- },
- }
+ const client = new PrismaClient()
+ await client.$connect()
const deps = {
appId,
listenAddress,
listenPort,
clientSecret,
cookieSecret,
- gameFactory: () => factory.game(),
- setupFactory: () => factory.setup(),
- gameWebhook,
- adminWebhook,
+ channelManager: new ChannelManager(client),
+ userManager: new UserManager(client),
botToken,
publicKey,
}
- factory.setup = () => new SetupServer(deps)
- factory.game = () => new GameServer(deps)
- await Promise.all([gameWebhook.load(), adminWebhook.load()])
- if (gameWebhook.isPresent && adminWebhook.isPresent) {
- await factory.game().initialize()
- } else {
- await factory.setup().initialize()
- }
+ await new GameServer(deps).initialize()
}
main().catch((err) => {
diff --git a/src/commands/game/JoinCommand.ts b/src/commands/game/JoinCommand.ts
new file mode 100644
index 0000000..288c9e4
--- /dev/null
+++ b/src/commands/game/JoinCommand.ts
@@ -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 {
+ 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,
+ })
+ }
+ })
+ }
+}
\ No newline at end of file
diff --git a/src/commands/game/PullCommand.ts b/src/commands/game/PullCommand.ts
index a1fda32..109df3d 100644
--- a/src/commands/game/PullCommand.ts
+++ b/src/commands/game/PullCommand.ts
@@ -1,16 +1,21 @@
import {CommandContext, CommandOptionType, SlashCommand, SlashCreator} from "slash-create";
-import {SavedWebhook} from "../../SavedWebhook.js";
import {Chance} from "chance";
+import {ChannelManager} from "../../queries/ChannelManager.js";
+import {Snowflake} from "discord-api-types";
+import {checkGameCommandAndRun} from "../permissions/ChannelPermissions.js";
const rand = Chance()
export class PullCommand extends SlashCommand {
- readonly gameWebhook: SavedWebhook
+ readonly channelManager: ChannelManager
- constructor(creator: SlashCreator, gameWebhook: SavedWebhook) {
+ constructor(creator: SlashCreator, {channelManager, gameGuildIds}: {
+ channelManager: ChannelManager,
+ gameGuildIds: Snowflake[]
+ }) {
super(creator, {
name: "pull",
- guildIDs: gameWebhook.state?.guild_id,
+ guildIDs: gameGuildIds,
description: "Pulls one or more new heroines from the ether.",
options: [
{
@@ -23,34 +28,25 @@ export class PullCommand extends SlashCommand {
}
]
});
- this.gameWebhook = gameWebhook
+
+ this.channelManager = channelManager
}
- run(ctx: CommandContext): Promise {
- if (ctx.guildID !== this.gameWebhook.state?.guild_id) {
- return ctx.send({
- content: "Sorry, you can't do that in this guild.",
- ephemeral: true,
- })
- }
- if (ctx.channelID !== this.gameWebhook.state?.channel_id) {
+ async run(ctx: CommandContext): Promise {
+ return checkGameCommandAndRun(ctx, this.channelManager, async () => {
+ const count: number = typeof ctx.options.count === "number" && ctx.options.count >= 1 && ctx.options.count <= 10 ? Math.floor(ctx.options.count) : 1
+ const results: string[] = []
+ for (let x = 0; x < count; x += 1) {
+ results.push(rand.weighted(["**Nicole**: D tier Predator Podcaster",
+ "**Herja**: C tier Viking Warrior",
+ "**Sharla**: B tier Skark Girl",
+ "**Melpomene**: A tier Muse of Tragedy",
+ "**Lady Bootstrap**: S tier Time Traveler"], [20, 15, 10, 5, 1]))
+ }
return ctx.send({
- content: `Sorry, you can't do that here. You have to do it in <#${this.gameWebhook.state?.channel_id}>.`,
- ephemeral: true,
+ content: `_${ctx.user.mention}_, you pulled...\n \\* ${results.join("\n \\* ")}`,
+ ephemeral: false,
})
- }
- const count: number = ctx.options.count ?? 1
- const results: string[] = []
- for (let x = 0; x < count; x += 1) {
- results.push(rand.weighted(["**Nicole**: D tier Predator Podcaster",
- "**Herja**: C tier Viking Warrior",
- "**Sharla**: B tier Skark Girl",
- "**Melpomene**: A tier Muse of Tragedy",
- "**Lady Bootstrap**: S tier Time Traveler"], [20, 15, 10, 5, 1]))
- }
- return ctx.send({
- content: `_${ctx.user.mention}_, you pulled...\n \\* ${results.join("\n \\* ")}`,
- ephemeral: false,
})
}
}
\ No newline at end of file
diff --git a/src/commands/permissions/ChannelPermissions.ts b/src/commands/permissions/ChannelPermissions.ts
new file mode 100644
index 0000000..c326bbd
--- /dev/null
+++ b/src/commands/permissions/ChannelPermissions.ts
@@ -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): Promise {
+ 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,
+ })
+ }
+}
\ No newline at end of file
diff --git a/src/queries/ChannelManager.ts b/src/queries/ChannelManager.ts
new file mode 100644
index 0000000..6d46278
--- /dev/null
+++ b/src/queries/ChannelManager.ts
@@ -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 {
+ 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 {
+ return ((await this.client.discordChannel.findUnique({
+ where: {
+ discordId: channelId,
+ },
+ select: {
+ acceptGameCommands: true,
+ },
+ rejectOnNotFound: false,
+ })) ?? {acceptGameCommands: false}).acceptGameCommands
+ }
+
+ async canUseGameCommandsInGuild(guildId: Snowflake): Promise {
+ return (await this.client.discordChannel.findFirst({
+ where: {
+ guildId: guildId,
+ acceptGameCommands: true,
+ },
+ select: {
+ discordId: true
+ }
+ })) !== null
+ }
+
+ async getAdminCommandGuildIds(): Promise {
+ 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 {
+ return ((await this.client.discordChannel.findUnique({
+ where: {
+ discordId: channelId,
+ },
+ select: {
+ acceptAdminCommands: true,
+ },
+ rejectOnNotFound: false,
+ })) ?? {acceptAdminCommands: false}
+ ).acceptAdminCommands
+ }
+
+ async canUseAdminCommandsInGuild(guildId: Snowflake): Promise {
+ return (await this.client.discordChannel.findFirst({
+ where: {
+ guildId: guildId,
+ acceptAdminCommands: true,
+ },
+ select: {
+ discordId: true,
+ }
+ })) !== null
+ }
+
+ async isGameReady() {
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/src/queries/Prisma.ts b/src/queries/Prisma.ts
new file mode 100644
index 0000000..a30fa1b
--- /dev/null
+++ b/src/queries/Prisma.ts
@@ -0,0 +1,9 @@
+import pkg from "@prisma/client";
+
+export const {
+ PrismaClient,
+ Prisma: PrismaNS,
+ prisma
+} = pkg
+export type PrismaClient = InstanceType;
+export type {Prisma, DiscordUser, User, DiscordChannel} from "@prisma/client";
\ No newline at end of file
diff --git a/src/queries/UserManager.ts b/src/queries/UserManager.ts
new file mode 100644
index 0000000..6651aba
--- /dev/null
+++ b/src/queries/UserManager.ts
@@ -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 {
+ 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 {
+ return this.client.gender.findMany({
+ select: genderListSelect
+ })
+ }
+}
\ No newline at end of file
diff --git a/src/tools/LoadGenders.ts b/src/tools/LoadGenders.ts
new file mode 100644
index 0000000..abce500
--- /dev/null
+++ b/src/tools/LoadGenders.ts
@@ -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()
\ No newline at end of file
diff --git a/src/tools/LoadJsonWebhooks.ts b/src/tools/LoadJsonWebhooks.ts
new file mode 100644
index 0000000..a90d612
--- /dev/null
+++ b/src/tools/LoadJsonWebhooks.ts
@@ -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 {
+ 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()
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
index 011d0f7..3a50d26 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -14,7 +14,7 @@
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
- "module": "esnext",
+ "module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,