Add join command and rely on the database for determining valid channels

main
Mari 3 years ago
parent 48b0502cd7
commit 6aaf55f0c9
  1. 7
      .gitignore
  2. 2
      .idea/codeStyles/codeStyleConfig.xml
  3. 14
      .idea/runConfigurations/loadWebhooks.xml
  4. 12
      .idea/runConfigurations/regenerate.xml
  5. 43
      .idea/watcherTasks.xml
  6. 90
      package-lock.json
  7. 11
      package.json
  8. 185
      prisma/schema.prisma
  9. 15
      src/BaseServer.ts
  10. 152
      src/DiscordWebhookHandler.ts
  11. 54
      src/GameServer.ts
  12. 7
      src/NodeErrorHelpers.ts
  13. 152
      src/SavedWebhook.ts
  14. 192
      src/SetupServer.ts
  15. 32
      src/app.ts
  16. 67
      src/commands/game/JoinCommand.ts
  17. 52
      src/commands/game/PullCommand.ts
  18. 26
      src/commands/permissions/ChannelPermissions.ts
  19. 95
      src/queries/ChannelManager.ts
  20. 9
      src/queries/Prisma.ts
  21. 133
      src/queries/UserManager.ts
  22. 44
      src/tools/LoadGenders.ts
  23. 54
      src/tools/LoadJsonWebhooks.ts
  24. 2
      tsconfig.json

7
.gitignore vendored

@ -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>

90
package-lock.json generated

@ -8,9 +8,11 @@
"name": "vore-gacha", "name": "vore-gacha",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@prisma/client": "^3.6.0",
"axios": "^0.24.0", "axios": "^0.24.0",
"chance": "^1.1.8", "chance": "^1.1.8",
"crypto-random-string": "^4.0.0", "crypto-random-string": "^4.0.0",
"cuid": "^2.1.8",
"detritus-client": "^0.16.3", "detritus-client": "^0.16.3",
"detritus-client-rest": "^0.10.5", "detritus-client-rest": "^0.10.5",
"discord-api-types": "^0.25.2", "discord-api-types": "^0.25.2",
@ -31,6 +33,7 @@
"@types/relateurl": "^0.2.29", "@types/relateurl": "^0.2.29",
"@types/simple-oauth2": "^4.1.1", "@types/simple-oauth2": "^4.1.1",
"pino-pretty": "^7.3.0", "pino-pretty": "^7.3.0",
"prisma": "^3.6.0",
"typescript": "^4.5.3" "typescript": "^4.5.3"
} }
}, },
@ -118,6 +121,38 @@
"@hapi/hoek": "9.x.x" "@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": { "node_modules/@sideway/address": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.3.tgz", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.3.tgz",
@ -417,6 +452,11 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/dateformat": {
"version": "4.6.3", "version": "4.6.3",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", "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", "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-4.0.0.tgz",
"integrity": "sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q==" "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": { "node_modules/promise": {
"version": "7.3.1", "version": "7.3.1",
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
@ -1813,6 +1870,25 @@
"@hapi/hoek": "9.x.x" "@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": { "@sideway/address": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.3.tgz", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.3.tgz",
@ -2063,6 +2139,11 @@
"type-fest": "^1.0.1" "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": { "dateformat": {
"version": "4.6.3", "version": "4.6.3",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", "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", "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-4.0.0.tgz",
"integrity": "sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q==" "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": { "promise": {
"version": "7.3.1", "version": "7.3.1",
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",

@ -2,9 +2,11 @@
"name": "vore-gacha", "name": "vore-gacha",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@prisma/client": "^3.6.0",
"axios": "^0.24.0", "axios": "^0.24.0",
"chance": "^1.1.8", "chance": "^1.1.8",
"crypto-random-string": "^4.0.0", "crypto-random-string": "^4.0.0",
"cuid": "^2.1.8",
"detritus-client": "^0.16.3", "detritus-client": "^0.16.3",
"detritus-client-rest": "^0.10.5", "detritus-client-rest": "^0.10.5",
"discord-api-types": "^0.25.2", "discord-api-types": "^0.25.2",
@ -25,11 +27,18 @@
"@types/relateurl": "^0.2.29", "@types/relateurl": "^0.2.29",
"@types/simple-oauth2": "^4.1.1", "@types/simple-oauth2": "^4.1.1",
"pino-pretty": "^7.3.0", "pino-pretty": "^7.3.0",
"prisma": "^3.6.0",
"typescript": "^4.5.3" "typescript": "^4.5.3"
}, },
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "tsc --build", "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"
} }
} }

@ -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,8 +1,8 @@
import {SavedWebhook} from "./SavedWebhook.js";
import fastify, {FastifyInstance} from "fastify"; import fastify, {FastifyInstance} from "fastify";
import {SlashCreator, SlashCreatorOptions} from "slash-create"; import {SlashCreator, SlashCreatorOptions} from "slash-create";
import fastifyCookie from "fastify-cookie"; import fastifyCookie from "fastify-cookie";
import {FastifyServerButItWorksUnlikeTheRealOne} from "./FastifyHelpers.js"; import {FastifyServerButItWorksUnlikeTheRealOne} from "./FastifyHelpers.js";
import {ChannelManager} from "./queries/ChannelManager.js";
export interface BaseServerDeps { export interface BaseServerDeps {
appId: string appId: string
@ -12,19 +12,17 @@ export interface BaseServerDeps {
listenPort: number listenPort: number
listenAddress: string listenAddress: string
cookieSecret: string cookieSecret: string
gameWebhook: SavedWebhook channelManager: ChannelManager
adminWebhook: SavedWebhook
} }
export class BaseServer { export class BaseServer {
readonly server: FastifyInstance readonly server: FastifyInstance
readonly gameWebhook: SavedWebhook
readonly adminWebhook: SavedWebhook
readonly appId: string readonly appId: string
readonly clientSecret: string readonly clientSecret: string
readonly listenPort: number readonly listenPort: number
readonly listenAddress: string readonly listenAddress: string
readonly slashcmd: SlashCreator readonly slashcmd: SlashCreator
readonly channelManager: ChannelManager
constructor({ constructor({
cookieSecret, cookieSecret,
@ -32,8 +30,7 @@ export class BaseServer {
clientSecret, clientSecret,
listenPort, listenPort,
listenAddress, listenAddress,
gameWebhook, channelManager,
adminWebhook,
publicKey, publicKey,
botToken, botToken,
slashCreatorOptions = {} slashCreatorOptions = {}
@ -60,9 +57,7 @@ export class BaseServer {
this.clientSecret = clientSecret this.clientSecret = clientSecret
this.listenPort = listenPort this.listenPort = listenPort
this.listenAddress = listenAddress this.listenAddress = listenAddress
this.gameWebhook = gameWebhook this.channelManager = channelManager
this.adminWebhook = adminWebhook
} }
async _initInternal(): Promise<void> { async _initInternal(): Promise<void> {

@ -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,21 +1,33 @@
import {SetupServer} from "./SetupServer.js";
import {checkAndClearXSRFCookie, generateXSRFCookie, XSRFRoute} from "./CookieHelpers.js"; import {checkAndClearXSRFCookie, generateXSRFCookie, XSRFRoute} from "./CookieHelpers.js";
import {renderError} from "./PugRenderer.js"; import {renderError} from "./PugRenderer.js";
import {getBaseUrl} from "./FastifyHelpers.js"; import {getBaseUrl} from "./FastifyHelpers.js";
import pug from "pug"; import pug from "pug";
import {BaseServer, BaseServerDeps} from "./BaseServer.js"; import {BaseServer, BaseServerDeps} from "./BaseServer.js";
import {PullCommand} from "./commands/game/PullCommand.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 { export class GameServer extends BaseServer {
readonly setupFactory: () => SetupServer readonly userManager: UserManager
constructor(deps: BaseServerDeps & { setupFactory: () => SetupServer }) { constructor(deps: BaseServerDeps & { userManager: UserManager }) {
super(deps) super(deps)
this.setupFactory = deps.setupFactory this.userManager = deps.userManager
} }
async _initInternal(): Promise<void> { async _initInternal(): Promise<void> {
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) => { this.server.get("/game/started", async (req, res) => {
const token = generateXSRFCookie(res) const token = generateXSRFCookie(res)
res.code(200) res.code(200)
@ -48,37 +60,7 @@ export class GameServer extends BaseServer {
res.redirect("../game/started") res.redirect("../game/started")
}) })
this.server.get<XSRFRoute>("/game/stop", async (req, res) => { this.server.get<XSRFRoute>("/game/stop", async (req, res) => {
if (!checkAndClearXSRFCookie(req, res)) { res.redirect("../shutdown")
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.")
})
}) })
this.server.get<XSRFRoute>("/shutdown", async (req, res) => { this.server.get<XSRFRoute>("/shutdown", async (req, res) => {
if (!checkAndClearXSRFCookie(req, res)) { if (!checkAndClearXSRFCookie(req, res)) {

@ -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...")
})
})
}
}

@ -1,9 +1,10 @@
import dotenv from "dotenv"; import dotenv from "dotenv";
import {SetupServer} from "./SetupServer.js";
import {GameServer} from "./GameServer.js"; import {GameServer} from "./GameServer.js";
import cryptoRandomString from "crypto-random-string"; import cryptoRandomString from "crypto-random-string";
import {SavedWebhook} from "./SavedWebhook.js";
import pino from "pino" 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() const log = pino()
@ -19,37 +20,20 @@ async function main(): Promise<void> {
const listenPort = parseInt(parsed["HTTP_PORT"] ?? "5244") const listenPort = parseInt(parsed["HTTP_PORT"] ?? "5244")
const listenAddress = parsed["HTTP_ADDRESS"] ?? "127.0.0.1" const listenAddress = parsed["HTTP_ADDRESS"] ?? "127.0.0.1"
const cookieSecret = parsed["COOKIE_SECRET"] ?? cryptoRandomString({length: 32, type: "base64"}) const cookieSecret = parsed["COOKIE_SECRET"] ?? cryptoRandomString({length: 32, type: "base64"})
const gameWebhook = new SavedWebhook("game.json", {logger: log}) const client = new PrismaClient()
const adminWebhook = new SavedWebhook("admin.json", {logger: log}) await client.$connect()
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 deps = { const deps = {
appId, appId,
listenAddress, listenAddress,
listenPort, listenPort,
clientSecret, clientSecret,
cookieSecret, cookieSecret,
gameFactory: () => factory.game(), channelManager: new ChannelManager(client),
setupFactory: () => factory.setup(), userManager: new UserManager(client),
gameWebhook,
adminWebhook,
botToken, botToken,
publicKey, publicKey,
} }
factory.setup = () => new SetupServer(deps) await new GameServer(deps).initialize()
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()
}
} }
main().catch((err) => { main().catch((err) => {

@ -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,
})
}
})
}
}

@ -1,16 +1,21 @@
import {CommandContext, CommandOptionType, SlashCommand, SlashCreator} from "slash-create"; import {CommandContext, CommandOptionType, SlashCommand, SlashCreator} from "slash-create";
import {SavedWebhook} from "../../SavedWebhook.js";
import {Chance} from "chance"; 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() const rand = Chance()
export class PullCommand extends SlashCommand { 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, { super(creator, {
name: "pull", name: "pull",
guildIDs: gameWebhook.state?.guild_id, guildIDs: gameGuildIds,
description: "Pulls one or more new heroines from the ether.", description: "Pulls one or more new heroines from the ether.",
options: [ options: [
{ {
@ -23,34 +28,25 @@ export class PullCommand extends SlashCommand {
} }
] ]
}); });
this.gameWebhook = gameWebhook
this.channelManager = channelManager
} }
run(ctx: CommandContext): Promise<any> { async run(ctx: CommandContext): Promise<any> {
if (ctx.guildID !== this.gameWebhook.state?.guild_id) { return checkGameCommandAndRun(ctx, this.channelManager, async () => {
return ctx.send({ const count: number = typeof ctx.options.count === "number" && ctx.options.count >= 1 && ctx.options.count <= 10 ? Math.floor(ctx.options.count) : 1
content: "Sorry, you can't do that in this guild.", const results: string[] = []
ephemeral: true, for (let x = 0; x < count; x += 1) {
}) results.push(rand.weighted(["**Nicole**: D tier Predator Podcaster",
} "**Herja**: C tier Viking Warrior",
if (ctx.channelID !== this.gameWebhook.state?.channel_id) { "**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({ return ctx.send({
content: `Sorry, you can't do that here. You have to do it in <#${this.gameWebhook.state?.channel_id}>.`, content: `_${ctx.user.mention}_, you pulled...\n \\* ${results.join("\n \\* ")}`,
ephemeral: true, 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,
}) })
} }
} }

@ -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()

@ -14,7 +14,7 @@
"strictNullChecks": true, "strictNullChecks": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"module": "esnext", "module": "ESNext",
"moduleResolution": "node", "moduleResolution": "node",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,

Loading…
Cancel
Save