So much database stuff. An absurd amount of database stuff.main
parent
d09c503add
commit
88018a0f16
@ -1 +1,9 @@ |
|||||||
DISCORD_TOKEN= |
DISCORD_TOKEN= |
||||||
|
BOT_OWNER_ID= |
||||||
|
PGHOST=free-tier11.gcp-us-east1.cockroachlabs.cloud |
||||||
|
PGPORT=26257 |
||||||
|
PGDATABASE=nomrpg-chesting |
||||||
|
PGUSER= |
||||||
|
PGPASSWORD= |
||||||
|
PGSSLMODE=verify-full |
||||||
|
PGOPTIONS=--cluster=deliciousreya-nom-rpg-2220 |
@ -1,7 +1,4 @@ |
|||||||
# PostgreSQL |
# PostgreSQL |
||||||
classpath: lib/postgresql-42.5.0.jar |
|
||||||
driver: org.postgresql.Driver |
driver: org.postgresql.Driver |
||||||
url: jdbc:postgresql://free-tier11.gcp-us-east1.cockroachlabs.cloud:26257/nomrpg-chesting?options=--cluster%3Ddeliciousreya-nom-rpg-2220&sslmode=verify-full |
|
||||||
username: reya |
|
||||||
changeLogFile: configuration.yml |
|
||||||
liquibase.hub.mode=off |
liquibase.hub.mode=off |
||||||
|
liquibase.databaseClass=liquibase.database.core.CockroachDatabase |
||||||
|
@ -0,0 +1,79 @@ |
|||||||
|
import {config} from "dotenv" |
||||||
|
import {Liquibase, LiquibaseLogLevels} from "liquibase" |
||||||
|
import {resolve} from "path" |
||||||
|
|
||||||
|
interface PostgresEnvVars { |
||||||
|
host?: string |
||||||
|
port?: string |
||||||
|
db?: string |
||||||
|
sslmode?: string |
||||||
|
options?: string |
||||||
|
user?: string |
||||||
|
pass?: string |
||||||
|
} |
||||||
|
|
||||||
|
async function migrate() { |
||||||
|
config() |
||||||
|
const vars: PostgresEnvVars = {} |
||||||
|
for (const variable of Object.keys(process.env)) { |
||||||
|
const value = process.env[variable] || "" |
||||||
|
if (variable.startsWith("PG")) { |
||||||
|
switch (variable) { |
||||||
|
case "PGHOST": |
||||||
|
vars.host = value |
||||||
|
break |
||||||
|
case "PGPORT": |
||||||
|
vars.port = value |
||||||
|
break |
||||||
|
case "PGDATABASE": |
||||||
|
vars.db = value |
||||||
|
break |
||||||
|
case "PGUSER": |
||||||
|
vars.user = value |
||||||
|
break |
||||||
|
case "PGPASSWORD": |
||||||
|
vars.pass = value |
||||||
|
break |
||||||
|
case "PGOPTIONS": |
||||||
|
vars.options = value |
||||||
|
break |
||||||
|
case "PGSSLMODE": |
||||||
|
vars.sslmode = value |
||||||
|
break |
||||||
|
default: |
||||||
|
console.log("Unknown PG* environment variable: " + variable) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const params = [] |
||||||
|
params.push(`ApplicationName=${encodeURIComponent("VoreRPG Migration")}`) |
||||||
|
if (vars.options) { |
||||||
|
params.push(`options=${encodeURIComponent(vars.options)}`) |
||||||
|
} |
||||||
|
if (vars.sslmode) { |
||||||
|
params.push(`sslmode=${encodeURIComponent(vars.sslmode)}`) |
||||||
|
} |
||||||
|
const liquibase = new Liquibase({ |
||||||
|
changeLogFile: "liquibase-changelog.yml", |
||||||
|
classpath: resolve("lib/postgresql-42.5.0.jar"), |
||||||
|
liquibase: resolve("node_modules/liquibase/dist/liquibase/liquibase"), |
||||||
|
liquibasePropertiesFile: resolve("liquibase.properties"), |
||||||
|
username: vars.user || process.env.USER || process.env.USERNAME || "postgres", |
||||||
|
password: vars.pass || "", |
||||||
|
logLevel: LiquibaseLogLevels.Debug, |
||||||
|
url: `jdbc:postgresql://` |
||||||
|
+ `${vars.host ? encodeURIComponent(vars.host) : ""}${vars.port ? `:${encodeURIComponent(vars.port)}` : ""}` |
||||||
|
+ `${vars.db ? `/${encodeURIComponent(vars.db)}` : ""}${params.length > 0 ? `?${params.join("&")}` : ""}`, |
||||||
|
}) |
||||||
|
try { |
||||||
|
await liquibase.update({}) |
||||||
|
} catch (err) { |
||||||
|
console.log("Liquibase failed") |
||||||
|
console.log(err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
migrate().catch((err) => { |
||||||
|
console.log("Main thread failed", err) |
||||||
|
}) |
@ -1,21 +1,31 @@ |
|||||||
import {BaseChatInputCommandData, CommandWithSubcommandsData} from "../types" |
import {BaseChatInputCommandData, CommandWithSubcommandsData, SubcommandData} from "../types" |
||||||
import {ApplicationCommandType} from "discord.js" |
import {ApplicationCommandType} from "discord.js" |
||||||
import {commandBotRestart} from "./restart" |
import {BotRestartCommand} from "./restart" |
||||||
import {commandBotShutdown} from "./shutdown" |
import {BotShutdownCommand} from "./shutdown" |
||||||
import {commandBotRebuild} from "./rebuild" |
import {BotRebuildCommand} from "./rebuild" |
||||||
|
import {UsersTable} from "../../database/users.js" |
||||||
|
|
||||||
|
export class BotCommand extends CommandWithSubcommandsData { |
||||||
|
readonly rebuild: BotRebuildCommand |
||||||
|
readonly restart: BotRestartCommand |
||||||
|
readonly shutdown: BotShutdownCommand |
||||||
|
readonly subcommands: SubcommandData[] |
||||||
|
|
||||||
|
constructor({users, cleanUp}: { users: UsersTable, cleanUp: () => Promise<void> }) { |
||||||
|
super() |
||||||
|
this.rebuild = new BotRebuildCommand({users, cleanUp}) |
||||||
|
this.restart = new BotRestartCommand({users, cleanUp}) |
||||||
|
this.shutdown = new BotShutdownCommand({users, cleanUp}) |
||||||
|
this.subcommands = [ |
||||||
|
this.rebuild, |
||||||
|
this.restart, |
||||||
|
this.shutdown, |
||||||
|
] |
||||||
|
} |
||||||
|
|
||||||
class BotCommandData extends CommandWithSubcommandsData { |
|
||||||
readonly baseDefinition: BaseChatInputCommandData = { |
readonly baseDefinition: BaseChatInputCommandData = { |
||||||
name: "bot", |
name: "bot", |
||||||
type: ApplicationCommandType.ChatInput, |
type: ApplicationCommandType.ChatInput, |
||||||
description: "Commands to manage the bot's status.", |
description: "Commands to manage the bot's status.", |
||||||
} |
} |
||||||
|
|
||||||
readonly subcommands = [ |
|
||||||
commandBotRebuild, |
|
||||||
commandBotRestart, |
|
||||||
commandBotShutdown, |
|
||||||
] |
|
||||||
} |
} |
||||||
|
|
||||||
export const commandBot = new BotCommandData() |
|
@ -1,31 +1,80 @@ |
|||||||
import {ApplicationCommandOptionType, ApplicationCommandSubCommandData, ChatInputCommandInteraction} from "discord.js" |
import {ApplicationCommandOptionType, ApplicationCommandSubCommandData, ChatInputCommandInteraction} from "discord.js" |
||||||
import {SubcommandData} from "../types" |
import {SubcommandData} from "../types" |
||||||
|
import {UsersTable} from "../../database/users.js" |
||||||
|
|
||||||
class CharacterCreateCommandData extends SubcommandData { |
export class CharacterCreateCommand extends SubcommandData { |
||||||
readonly definition: ApplicationCommandSubCommandData = { |
readonly definition: ApplicationCommandSubCommandData = { |
||||||
name: "create", |
name: "create", |
||||||
type: ApplicationCommandOptionType.Subcommand, |
type: ApplicationCommandOptionType.Subcommand, |
||||||
description: "Begins the process of creating a new character.", |
description: "Begins the process of creating a new character. Omit parameters for interactive process.", |
||||||
options: [ |
options: [ |
||||||
{ |
{ |
||||||
name: "name", |
name: "name", |
||||||
type: ApplicationCommandOptionType.String, |
type: ApplicationCommandOptionType.String, |
||||||
description: "The character's name.", |
description: "The character's name, 1-20 characters. You can change this later.", |
||||||
required: false, |
required: false, |
||||||
|
maxLength: 20, |
||||||
|
minLength: 1, |
||||||
}, |
}, |
||||||
{ |
{ |
||||||
name: "title", |
name: "title", |
||||||
type: ApplicationCommandOptionType.String, |
type: ApplicationCommandOptionType.String, |
||||||
description: "A title for your character, optionally starting or ending with an ellipsis (...).", |
description: "The character's title, with @@ where your character's name goes. @@ alone means no title.", |
||||||
|
required: false, |
||||||
|
maxLength: 30, |
||||||
|
minLength: 2, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "pronouns", |
||||||
|
type: ApplicationCommandOptionType.String, |
||||||
|
description: "Your character's pronouns. Don't worry, you can change this later.", |
||||||
|
required: false, |
||||||
|
autocomplete: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "difficulty", |
||||||
|
type: ApplicationCommandOptionType.String, |
||||||
|
description: "The difficulty of reformation. Don't worry, you can change this later.", |
||||||
|
required: false, |
||||||
|
autocomplete: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "preference", |
||||||
|
type: ApplicationCommandOptionType.String, |
||||||
|
description: "What role(s) your character is able to play in vore. Don't worry, you can change this later.", |
||||||
|
required: false, |
||||||
|
autocomplete: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "types", |
||||||
|
type: ApplicationCommandOptionType.String, |
||||||
|
description: "The type or types that describe your character. Don't worry, you can change this later.", |
||||||
|
required: false, |
||||||
|
autocomplete: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "stats", |
||||||
|
type: ApplicationCommandOptionType.String, |
||||||
|
description: "Slash(/)-delimited base stats: Confidence/Health/Stamina/Brawn/Durability/Intensity/Resilience/Speed", |
||||||
required: false, |
required: false, |
||||||
}, |
}, |
||||||
], |
], |
||||||
} |
} |
||||||
|
private readonly _users: UsersTable |
||||||
|
|
||||||
|
constructor({users}: { users: UsersTable }) { |
||||||
|
super() |
||||||
|
this._users = users |
||||||
|
} |
||||||
|
|
||||||
async execute(b: ChatInputCommandInteraction) { |
async execute(b: ChatInputCommandInteraction) { |
||||||
await b.deferReply({ephemeral: true}) |
await b.deferReply({ephemeral: true}) |
||||||
await b.reply("Okaaaay, I'll make you a character ❤\n\nRight after this nap...") |
// create database interfacing code, using this format:
|
||||||
|
// INSERT INTO character_creation () VALUES () ON CONFLICT DO UPDATE SET (excluded.field IS NULL ? field : excluded.field)
|
||||||
|
// then prompt the user to fill in the first null field, using a modal to input the text fields
|
||||||
|
await b.followUp({ |
||||||
|
ephemeral: true, |
||||||
|
content: "Zz... mn?", |
||||||
|
}) |
||||||
} |
} |
||||||
} |
} |
||||||
|
|
||||||
export const commandCharacterCreate = new CharacterCreateCommandData() |
|
@ -1,9 +1,11 @@ |
|||||||
import {commandDefinitions} from "./index" |
import {Commands} from "./index" |
||||||
import {describe, expect, test} from "@jest/globals" |
import {describe, expect, test} from "@jest/globals" |
||||||
|
import {InMemoryDatabase} from "../database/inmemory/database.js" |
||||||
|
|
||||||
describe("command definitions", () => { |
describe("command definitions", () => { |
||||||
test("has no descriptions over 100 characters", () => { |
test("has no descriptions over 100 characters", () => { |
||||||
expect(commandDefinitions) |
const db = new InMemoryDatabase() |
||||||
|
expect(new Commands({users: db.users, cleanUp: async () => undefined}).definitions) |
||||||
.not |
.not |
||||||
.toContain(expect.objectContaining({"description": expect.stringMatching(/.{101,}/)})) |
.toContain(expect.objectContaining({"description": expect.stringMatching(/.{101,}/)})) |
||||||
}) |
}) |
||||||
|
@ -0,0 +1,16 @@ |
|||||||
|
import {Client} from "pg" |
||||||
|
import {UsersTable, UsersTableImpl} from "./users.js" |
||||||
|
|
||||||
|
export interface Database { |
||||||
|
readonly users: UsersTable |
||||||
|
} |
||||||
|
|
||||||
|
export class DatabaseImpl implements Database { |
||||||
|
readonly users: UsersTableImpl |
||||||
|
private readonly _query: Client["query"] |
||||||
|
|
||||||
|
constructor(query: Client["query"]) { |
||||||
|
this._query = query |
||||||
|
this.users = new UsersTableImpl(this._query) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,10 @@ |
|||||||
|
import {Database} from "../database.js" |
||||||
|
import {InMemoryUsersTable} from "./users.js" |
||||||
|
|
||||||
|
export class InMemoryDatabase implements Database { |
||||||
|
readonly users: InMemoryUsersTable |
||||||
|
|
||||||
|
constructor() { |
||||||
|
this.users = new InMemoryUsersTable() |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,46 @@ |
|||||||
|
import {userSnowflakeToUuid, UsersTable} from "../users.js" |
||||||
|
import {Snowflake} from "discord-api-types/globals.js" |
||||||
|
|
||||||
|
interface InMemoryUserData { |
||||||
|
id: string, |
||||||
|
is_admin: boolean |
||||||
|
active_at: Date | null |
||||||
|
} |
||||||
|
|
||||||
|
export class InMemoryUsersTable implements UsersTable { |
||||||
|
private readonly _users: Record<string, InMemoryUserData> = {} |
||||||
|
|
||||||
|
async createBotOwnerAsAdmin(snowflake: Snowflake): Promise<void> { |
||||||
|
const uuid = userSnowflakeToUuid(snowflake) |
||||||
|
this._users[uuid] = { |
||||||
|
id: uuid, |
||||||
|
is_admin: true, |
||||||
|
active_at: null, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async getActiveSnowflakeIsAdmin(snowflake: Snowflake): Promise<boolean> { |
||||||
|
const uuid = userSnowflakeToUuid(snowflake) |
||||||
|
if (!this._users.hasOwnProperty(uuid)) { |
||||||
|
this._users[uuid] = { |
||||||
|
id: uuid, |
||||||
|
is_admin: false, |
||||||
|
active_at: new Date(), |
||||||
|
} |
||||||
|
} |
||||||
|
const user = this._users[uuid] |
||||||
|
return user.is_admin |
||||||
|
} |
||||||
|
|
||||||
|
async getUuidForActiveSnowflake(snowflake: Snowflake): Promise<string> { |
||||||
|
const uuid = userSnowflakeToUuid(snowflake) |
||||||
|
if (!this._users.hasOwnProperty(uuid)) { |
||||||
|
this._users[uuid] = { |
||||||
|
id: uuid, |
||||||
|
is_admin: false, |
||||||
|
active_at: new Date(), |
||||||
|
} |
||||||
|
} |
||||||
|
return uuid |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,110 @@ |
|||||||
|
import {Snowflake} from "discord-api-types/globals.js" |
||||||
|
import {fast1a52 as fnvFast1a52} from "fnv-plus" |
||||||
|
import {parse as uuidParse, v1 as uuidV1, validate as uuidValidate, version as uuidVersion} from "uuid" |
||||||
|
import {SnowflakeUtil} from "discord.js" |
||||||
|
import {Client} from "pg" |
||||||
|
|
||||||
|
export interface UsersTable { |
||||||
|
getUuidForActiveSnowflake(snowflake: Snowflake): Promise<string> |
||||||
|
|
||||||
|
getActiveSnowflakeIsAdmin(snowflake: Snowflake): Promise<boolean> |
||||||
|
|
||||||
|
createBotOwnerAsAdmin(snowflake: Snowflake): Promise<void> |
||||||
|
} |
||||||
|
|
||||||
|
export class UsersTableImpl implements UsersTable { |
||||||
|
private readonly _query: Client["query"] |
||||||
|
|
||||||
|
constructor(query: Client["query"]) { |
||||||
|
this._query = query |
||||||
|
} |
||||||
|
|
||||||
|
async getUuidForActiveSnowflake(snowflake: Snowflake): Promise<string> { |
||||||
|
const result = await this._query(`INSERT INTO users (id, is_admin, created_at, updated_at, active_at)
|
||||||
|
VALUES ($1, FALSE, now(), now(), now()) |
||||||
|
ON CONFLICT (id) DO UPDATE SET active_at = now() |
||||||
|
RETURNING id;`, [userSnowflakeToUuid(snowflake)])
|
||||||
|
return result.rows[0].id |
||||||
|
} |
||||||
|
|
||||||
|
async getActiveSnowflakeIsAdmin(snowflake: Snowflake): Promise<boolean> { |
||||||
|
const result = await this._query(`UPDATE users
|
||||||
|
SET active_at = NOW() |
||||||
|
WHERE id = $1 |
||||||
|
RETURNING is_admin;`, [userSnowflakeToUuid(snowflake)])
|
||||||
|
if (result.rowCount === 0) { |
||||||
|
return false |
||||||
|
} |
||||||
|
return result.rows[0].is_admin |
||||||
|
} |
||||||
|
|
||||||
|
async createBotOwnerAsAdmin(snowflake: Snowflake): Promise<void> { |
||||||
|
await this._query(`INSERT INTO users (id, is_admin, created_at, updated_at)
|
||||||
|
VALUES ($1, TRUE, now(), now()) |
||||||
|
ON CONFLICT (id) DO UPDATE SET is_admin = TRUE, |
||||||
|
updated_at = NOW() |
||||||
|
WHERE users.is_admin = FALSE;`, [userSnowflakeToUuid(snowflake)])
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function userUuidToSnowflake(uuid: string): Snowflake | null { |
||||||
|
if (!uuidValidate(uuid) || uuidVersion(uuid) !== 1) { |
||||||
|
return null |
||||||
|
} |
||||||
|
const bytes = uuidParse(uuid) |
||||||
|
const time_low = bytes[0] << 24 | bytes[1] << 16 | bytes[2] << 8 | bytes[3] |
||||||
|
const time_mid = bytes[4] << 8 | bytes[5] |
||||||
|
const time_high = (bytes[6] & 0x0F) << 8 | bytes[7] |
||||||
|
const timestamp = ((BigInt(time_high) << 48n) | (BigInt(time_mid) << 32n) | BigInt(time_low)) / 10000n |
||||||
|
const increment = BigInt((bytes[8] & 0x0F) << 8 | bytes[9]) |
||||||
|
const workerId = BigInt((bytes[14] & 0x03) << 3 | (bytes[15] & 0x07) >> 5) |
||||||
|
const processId = BigInt(bytes[15] & 0x1F) |
||||||
|
|
||||||
|
return SnowflakeUtil.generate({ |
||||||
|
timestamp, |
||||||
|
increment, |
||||||
|
workerId, |
||||||
|
processId, |
||||||
|
}).toString(10) |
||||||
|
} |
||||||
|
|
||||||
|
export function userSnowflakeToUuid(snowflake: Snowflake): string { |
||||||
|
const { |
||||||
|
timestamp, |
||||||
|
increment, |
||||||
|
workerId, |
||||||
|
processId, |
||||||
|
} = SnowflakeUtil.deconstruct(snowflake) |
||||||
|
|
||||||
|
// Grab 52 hash bits for filling the empty spaces in the UUID.
|
||||||
|
const hashCode = fnvFast1a52(snowflake) |
||||||
|
// We get 10000 possibilities for number of 100-nanosecond periods.
|
||||||
|
const nanoseconds = (hashCode % 10000) |
||||||
|
// The node ID is 48 bits, and we have 10 from the process and worker IDs.
|
||||||
|
// The first bit must be 1 to indicate a made up node name, but the remaining 37 are open.
|
||||||
|
// Grab 37 bits after removing the 10000 variants of the 100-nanosecond periods.
|
||||||
|
const nodeIdPadding = Math.floor(hashCode / 10000) & 0x1FFFFFFFFF |
||||||
|
const nodeId = new Uint8Array(6) |
||||||
|
// Set the highest bit, indicating a multicast address...
|
||||||
|
// ... indicating an invalid address, indicating a made up node name.
|
||||||
|
// Then insert the hash padding and worker/process IDs.
|
||||||
|
// It doesn't matter what order they're in, as long as it's consistent.
|
||||||
|
// In this case, we choose the padding to be most significant, then worker, then process.
|
||||||
|
nodeId[0] = 0x80 | ((nodeIdPadding >> 30) & 0x7F) |
||||||
|
nodeId[1] = (nodeIdPadding >> 22) & 0xFF |
||||||
|
nodeId[2] = (nodeIdPadding >> 14) & 0xFF |
||||||
|
nodeId[3] = (nodeIdPadding >> 6) & 0xFF |
||||||
|
nodeId[4] = ((nodeIdPadding & 0x3F) << 2) | Number((workerId >> 3n) & 0x03n) |
||||||
|
nodeId[5] = Number(((workerId & 0x07n) << 5n) | (processId & 0x1Fn)) |
||||||
|
return uuidV1({ |
||||||
|
rng: () => { |
||||||
|
throw Error("rng is not supposed to be invoked") |
||||||
|
}, |
||||||
|
// The snowflake increment is 12 bits.
|
||||||
|
// UUIDs use a 12 bit clock sequence number. Perfect fit!
|
||||||
|
clockseq: Number(increment & 0xFFFn), |
||||||
|
nsecs: nanoseconds, |
||||||
|
msecs: Number(timestamp & 0x3FFFFFFFFFFn), |
||||||
|
node: nodeId, |
||||||
|
}) |
||||||
|
} |
@ -1,5 +1,9 @@ |
|||||||
import {BaseInteraction, ChatInputCommandInteraction} from "discord.js" |
import {AutocompleteInteraction, BaseInteraction, ChatInputCommandInteraction} from "discord.js" |
||||||
|
|
||||||
export function isChatInputCommand(x: BaseInteraction): x is ChatInputCommandInteraction { |
export function isChatInputCommand(x: BaseInteraction): x is ChatInputCommandInteraction { |
||||||
return x.isChatInputCommand() |
return x.isChatInputCommand() |
||||||
} |
} |
||||||
|
|
||||||
|
export function isAutocomplete(x: BaseInteraction): x is AutocompleteInteraction { |
||||||
|
return x.isAutocomplete() |
||||||
|
} |
Loading…
Reference in new issue