So much database stuff. An absurd amount of database stuff.main
parent
d09c503add
commit
88018a0f16
@ -1 +1,9 @@ |
||||
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 |
||||
classpath: lib/postgresql-42.5.0.jar |
||||
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.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 {commandBotRestart} from "./restart" |
||||
import {commandBotShutdown} from "./shutdown" |
||||
import {commandBotRebuild} from "./rebuild" |
||||
import {BotRestartCommand} from "./restart" |
||||
import {BotShutdownCommand} from "./shutdown" |
||||
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 = { |
||||
name: "bot", |
||||
type: ApplicationCommandType.ChatInput, |
||||
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 {SubcommandData} from "../types" |
||||
import {UsersTable} from "../../database/users.js" |
||||
|
||||
class CharacterCreateCommandData extends SubcommandData { |
||||
export class CharacterCreateCommand extends SubcommandData { |
||||
readonly definition: ApplicationCommandSubCommandData = { |
||||
name: "create", |
||||
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: [ |
||||
{ |
||||
name: "name", |
||||
type: ApplicationCommandOptionType.String, |
||||
description: "The character's name.", |
||||
description: "The character's name, 1-20 characters. You can change this later.", |
||||
required: false, |
||||
maxLength: 20, |
||||
minLength: 1, |
||||
}, |
||||
{ |
||||
name: "title", |
||||
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, |
||||
}, |
||||
], |
||||
} |
||||
private readonly _users: UsersTable |
||||
|
||||
constructor({users}: { users: UsersTable }) { |
||||
super() |
||||
this._users = users |
||||
} |
||||
|
||||
async execute(b: ChatInputCommandInteraction) { |
||||
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 {InMemoryDatabase} from "../database/inmemory/database.js" |
||||
|
||||
describe("command definitions", () => { |
||||
test("has no descriptions over 100 characters", () => { |
||||
expect(commandDefinitions) |
||||
const db = new InMemoryDatabase() |
||||
expect(new Commands({users: db.users, cleanUp: async () => undefined}).definitions) |
||||
.not |
||||
.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 { |
||||
return x.isChatInputCommand() |
||||
} |
||||
|
||||
export function isAutocomplete(x: BaseInteraction): x is AutocompleteInteraction { |
||||
return x.isAutocomplete() |
||||
} |
Loading…
Reference in new issue