diff --git a/.env.example b/.env.example index 8141df0..459923e 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,9 @@ -DISCORD_TOKEN= \ No newline at end of file +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 \ No newline at end of file diff --git a/configuration.yml b/liquibase-changelog.yml similarity index 100% rename from configuration.yml rename to liquibase-changelog.yml diff --git a/liquibase.properties b/liquibase.properties index 778987e..2febea5 100644 --- a/liquibase.properties +++ b/liquibase.properties @@ -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 diff --git a/migrations/0001-characters.sql b/migrations/0001-characters.sql index 864c723..40e9da5 100644 --- a/migrations/0001-characters.sql +++ b/migrations/0001-characters.sql @@ -3,9 +3,13 @@ --changeset reya:users_table runInTransaction:false CREATE TABLE IF NOT EXISTS users ( - id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), - discord_snowflake INT NOT NULL, - INDEX discord_users (discord_snowflake) + id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + active_at TIMESTAMPTZ NULL DEFAULT NULL, + FAMILY essentials (id, is_admin), + FAMILY timestamps (created_at, updated_at, active_at) ); --rollback DROP TABLE IF EXISTS users; @@ -41,14 +45,15 @@ VALUES (0, 'Basic', '', 0), (14, 'Bully', '', 14), (15, 'Mythic', '', 15), (16, 'Toy', '', 16), - (17, 'Cute', '', 17); + (17, 'Cute', '', 17) +ON CONFLICT DO NOTHING; --rollback DELETE FROM types WHERE id in (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17) --changeset reya:difficulties_table runInTransaction:false CREATE TABLE IF NOT EXISTS difficulties ( id INT NOT NULL PRIMARY KEY, - name STRING NOT NULL, + name STRING NOT NULL UNIQUE, description STRING NOT NULL, display_order INT NOT NULL, endo_only BOOLEAN NOT NULL DEFAULT false, @@ -83,17 +88,15 @@ VALUES (0, 'Endo Only', 'You can never be digested. You always come out sooner o false, true, 90, 50, 90, true), (6, 'Extremely Dangerous', 'If you get devoured and you don''t get out, you''ll lose just about everything. Be very, very careful...', 6, - false, true, 100, 100, 100, true), - (7, 'Impossible', - 'There is nothing to look forward to after digestion but an eternity on someone else''s thighs. When you''re digested, your life is over. There''s no coming back. Ever.', - 7, false, true, 100, 100, 100, false); + false, true, 100, 100, 100, true) +ON CONFLICT DO NOTHING; --rollback DELETE FROM difficulties WHERE id IN (0, 1, 2, 3, 4, 5, 6, 7); --changeset reya:preferences_table runInTransaction:false CREATE TABLE IF NOT EXISTS preferences ( id INT NOT NULL PRIMARY KEY, - name STRING NOT NULL, + name STRING NOT NULL UNIQUE, description STRING NOT NULL, display_order INT NOT NULL, can_use_vore BOOLEAN NOT NULL, @@ -106,7 +109,8 @@ INSERT INTO preferences (id, name, description, display_order, can_use_vore, can VALUES (0, 'Observer', 'You can neither eat nor be eaten.', 0, false, false), (1, 'Prey Only', 'You can only be eaten, not eat.', 1, false, true), (2, 'Pred Only', 'You can only eat, not be eaten.', 2, true, false), - (3, 'Switch', 'You can both eat and be eaten.', 3, true, true); + (3, 'Switch', 'You can both eat and be eaten.', 3, true, true) +ON CONFLICT DO NOTHING; --rollback DELETE FROM preferences WHERE id IN (0, 1, 2, 3); --changeset reya:genders_table runInTransaction:false @@ -114,6 +118,7 @@ CREATE TABLE IF NOT EXISTS genders ( id INT NOT NULL PRIMARY KEY, name STRING NOT NULL, + pronouns STRING NOT NULL UNIQUE, display_order INT NOT NULL, use_plural BOOLEAN NOT NULL, subjective STRING NOT NULL, @@ -125,33 +130,36 @@ CREATE TABLE IF NOT EXISTS genders --rollback DROP TABLE IF EXISTS genders; --changeset reya:genders_values runInTransaction:true -INSERT INTO genders (id, name, display_order, use_plural, subjective, adjective, possessive, reflexive, objective) -VALUES (0, 'Non-binary (name only)', 0, false, '@@', '@@''s', '@@''s', '@@''s self', '@@'), - (1, 'Female (she/her)', 1, false, 'she', 'her', 'hers', 'herself', 'her'), - (2, 'Non-binary (they/them)', 2, true, 'they', 'their', 'theirs', 'themself', 'them'), - (3, 'Male (he/him)', 3, false, 'he', 'his', 'his', 'himself', 'him'), - (4, 'Object (it/its)', 4, false, 'it', 'its', 'its', 'itself', 'it'), - (5, 'Herm (shi/hir)', 5, false, 'shi', 'hir', 'hirs', 'hirself', 'hir'), - (6, 'Non-binary (ae/aer)', 6, false, 'ae', 'aer', 'aers', 'aerself', 'aer'), - (7, 'Non-binary (fae/faer)', 7, false, 'fae', 'faer', 'faers', 'faerself', 'faer'), - (8, 'Non-binary (e/em)', 8, false, 'e', 'eir', 'eirs', 'emself', 'em'), - (9, 'Non-binary (ey/em)', 9, false, 'ey', 'eir', 'eirs', 'emself', 'em'), - (10, 'Non-binary (per/per)', 10, false, 'per', 'pers', 'pers', 'perself', 'per'), - (11, 'Non-binary (ve/ver)', 11, false, 've', 'vis', 'vis', 'verself', 'ver'), - (12, 'Non-binary (xe/xem)', 12, false, 'xe', 'xyr', 'xyrs', 'xemself', 'xem'), - (13, 'Non-binary (ze/hir)', 13, false, 'ze', 'hir', 'hirs', 'hirself', 'hir'), - (14, 'Non-binary (zie/hir)', 14, false, 'zie', 'hir', 'hirs', 'hirself', 'hir'), - (15, 'Non-binary (zie/zim)', 15, false, 'zie', 'zir', 'zis', 'zieself', 'zim'), - (16, 'Non-binary (sie/sie)', 16, false, 'sie', 'hir', 'hirs', 'hirself', 'sie'), - (17, 'Non-binary (te/ter)', 17, false, 'te', 'tem', 'ters', 'terself', 'ter'); +INSERT INTO genders (id, name, pronouns, display_order, use_plural, subjective, adjective, possessive, reflexive, + objective) +VALUES (0, 'Non-binary', 'name only', 0, false, '@@', '@@''s', '@@''s', '@@''s self', '@@'), + (1, 'Female', 'she/her', 1, false, 'she', 'her', 'hers', 'herself', 'her'), + (2, 'Non-binary', 'they/them', 2, true, 'they', 'their', 'theirs', 'themself', 'them'), + (3, 'Male', 'he/him', 3, false, 'he', 'his', 'his', 'himself', 'him'), + (4, 'Object', 'it/its', 4, false, 'it', 'its', 'its', 'itself', 'it'), + (5, 'Herm', 'shi/hir', 5, false, 'shi', 'hir', 'hirs', 'hirself', 'hir'), + (6, 'Non-binary', 'ae/aer', 6, false, 'ae', 'aer', 'aers', 'aerself', 'aer'), + (7, 'Non-binary', 'fae/faer', 7, false, 'fae', 'faer', 'faers', 'faerself', 'faer'), + (8, 'Non-binary', 'e/em', 8, false, 'e', 'eir', 'eirs', 'emself', 'em'), + (9, 'Non-binary', 'ey/em', 9, false, 'ey', 'eir', 'eirs', 'emself', 'em'), + (10, 'Non-binary', 'per/per', 10, false, 'per', 'pers', 'pers', 'perself', 'per'), + (11, 'Non-binary', 've/ver', 11, false, 've', 'vis', 'vis', 'verself', 'ver'), + (12, 'Non-binary', 'xe/xem', 12, false, 'xe', 'xyr', 'xyrs', 'xemself', 'xem'), + (13, 'Non-binary', 'ze/hir', 13, false, 'ze', 'hir', 'hirs', 'hirself', 'hir'), + (14, 'Non-binary', 'zie/hir', 14, false, 'zie', 'hir', 'hirs', 'hirself', 'hir'), + (15, 'Non-binary', 'zie/zim', 15, false, 'zie', 'zir', 'zis', 'zieself', 'zim'), + (16, 'Non-binary', 'sie/sie', 16, false, 'sie', 'hir', 'hirs', 'hirself', 'sie'), + (17, 'Non-binary', 'te/ter', 17, false, 'te', 'tem', 'ters', 'terself', 'ter') +ON CONFLICT DO NOTHING; --rollback DELETE FROM genders WHERE id IN (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17); --changeset reya:characters_table runInTransaction:false CREATE TABLE IF NOT EXISTS characters ( - id UUID NOT NULL PRIMARY KEY, + id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, name STRING NOT NULL, + discriminator INT NULL DEFAULT NULL, title STRING NOT NULL, profile STRING NOT NULL, gender_id INT NOT NULL REFERENCES genders (id) ON DELETE RESTRICT, @@ -159,51 +167,51 @@ CREATE TABLE IF NOT EXISTS characters preference_id INT NOT NULL REFERENCES preferences (id) ON DELETE RESTRICT, type1_id INT NOT NULL REFERENCES types (id) ON DELETE RESTRICT, type2_id INT NOT NULL REFERENCES types (id) ON DELETE RESTRICT, - experience INT NOT NULL DEFAULT 0, - money INT NOT NULL DEFAULT 0, + experience INT NOT NULL DEFAULT 0, + money INT NOT NULL DEFAULT 0, -- stats: base - base_confidence INT NOT NULL DEFAULT 70, - base_health INT NOT NULL DEFAULT 70, - base_stamina INT NOT NULL DEFAULT 70, - base_brawn INT NOT NULL DEFAULT 70, - base_durability INT NOT NULL DEFAULT 70, - base_intensity INT NOT NULL DEFAULT 70, - base_resilience INT NOT NULL DEFAULT 70, - base_speed INT NOT NULL DEFAULT 70, + base_confidence INT NOT NULL DEFAULT 70, + base_health INT NOT NULL DEFAULT 70, + base_stamina INT NOT NULL DEFAULT 70, + base_brawn INT NOT NULL DEFAULT 70, + base_durability INT NOT NULL DEFAULT 70, + base_intensity INT NOT NULL DEFAULT 70, + base_resilience INT NOT NULL DEFAULT 70, + base_speed INT NOT NULL DEFAULT 70, -- stats: banked talent points - min_confidence_talent INT NOT NULL DEFAULT 0, - min_health_talent INT NOT NULL DEFAULT 0, - min_stamina_talent INT NOT NULL DEFAULT 0, - min_brawn_talent INT NOT NULL DEFAULT 0, - min_durability_talent INT NOT NULL DEFAULT 0, - min_intensity_talent INT NOT NULL DEFAULT 0, - min_resilience_talent INT NOT NULL DEFAULT 0, - min_speed_talent INT NOT NULL DEFAULT 0, + min_confidence_talent INT NOT NULL DEFAULT 0, + min_health_talent INT NOT NULL DEFAULT 0, + min_stamina_talent INT NOT NULL DEFAULT 0, + min_brawn_talent INT NOT NULL DEFAULT 0, + min_durability_talent INT NOT NULL DEFAULT 0, + min_intensity_talent INT NOT NULL DEFAULT 0, + min_resilience_talent INT NOT NULL DEFAULT 0, + min_speed_talent INT NOT NULL DEFAULT 0, -- stats: current talent points - confidence_talent INT NOT NULL DEFAULT floor(random() * 32)::INT, --min_confidence_talent + floor(random() * (32 - min_confidence_talent)), - health_talent INT NOT NULL DEFAULT floor(random() * 32)::INT, --min_health_talent + floor(random() * (32 - min_health_talent)), - stamina_talent INT NOT NULL DEFAULT floor(random() * 32)::INT, --min_stamina_talent + floor(random() * (32 - min_stamina_talent)), - brawn_talent INT NOT NULL DEFAULT floor(random() * 32)::INT, --min_brawn_talent + floor(random() * (32 - min_brawn_talent)), - durability_talent INT NOT NULL DEFAULT floor(random() * 32)::INT, --min_durability_talent + floor(random() * (32 - min_durability_talent)), - intensity_talent INT NOT NULL DEFAULT floor(random() * 32)::INT, --min_intensity_talent + floor(random() * (32 - min_intensity_talent)), - resilience_talent INT NOT NULL DEFAULT floor(random() * 32)::INT, --min_resilience_talent + floor(random() * (32 - min_resilience_talent)), - speed_talent INT NOT NULL DEFAULT floor(random() * 32)::INT, --min_speed_talent + floor(random() * (32 - min_speed_talent)), + confidence_talent INT NOT NULL DEFAULT floor(random() * 32)::INT, --min_confidence_talent + floor(random() * (32 - min_confidence_talent)), + health_talent INT NOT NULL DEFAULT floor(random() * 32)::INT, --min_health_talent + floor(random() * (32 - min_health_talent)), + stamina_talent INT NOT NULL DEFAULT floor(random() * 32)::INT, --min_stamina_talent + floor(random() * (32 - min_stamina_talent)), + brawn_talent INT NOT NULL DEFAULT floor(random() * 32)::INT, --min_brawn_talent + floor(random() * (32 - min_brawn_talent)), + durability_talent INT NOT NULL DEFAULT floor(random() * 32)::INT, --min_durability_talent + floor(random() * (32 - min_durability_talent)), + intensity_talent INT NOT NULL DEFAULT floor(random() * 32)::INT, --min_intensity_talent + floor(random() * (32 - min_intensity_talent)), + resilience_talent INT NOT NULL DEFAULT floor(random() * 32)::INT, --min_resilience_talent + floor(random() * (32 - min_resilience_talent)), + speed_talent INT NOT NULL DEFAULT floor(random() * 32)::INT, --min_speed_talent + floor(random() * (32 - min_speed_talent)), -- stats: current proficiency points - confidence_proficiency INT NOT NULL DEFAULT 0, - health_proficiency INT NOT NULL DEFAULT 0, - stamina_proficiency INT NOT NULL DEFAULT 0, - brawn_proficiency INT NOT NULL DEFAULT 0, - durability_proficiency INT NOT NULL DEFAULT 0, - intensity_proficiency INT NOT NULL DEFAULT 0, - resilience_proficiency INT NOT NULL DEFAULT 0, - speed_proficiency INT NOT NULL DEFAULT 0, + confidence_proficiency INT NOT NULL DEFAULT 0, + health_proficiency INT NOT NULL DEFAULT 0, + stamina_proficiency INT NOT NULL DEFAULT 0, + brawn_proficiency INT NOT NULL DEFAULT 0, + durability_proficiency INT NOT NULL DEFAULT 0, + intensity_proficiency INT NOT NULL DEFAULT 0, + resilience_proficiency INT NOT NULL DEFAULT 0, + speed_proficiency INT NOT NULL DEFAULT 0, + UNIQUE (user_id, name, discriminator), FAMILY character_base (id, user_id, name, title, profile, gender_id, type1_id, type2_id, base_confidence, base_health, base_stamina, base_brawn, base_durability, base_intensity, base_resilience, base_speed), - FAMILY character_reformation_stats ( - min_confidence_talent, min_health_talent, min_stamina_talent, min_brawn_talent, + FAMILY character_reformation_stats (min_confidence_talent, min_health_talent, min_stamina_talent, min_brawn_talent, min_durability_talent, min_intensity_talent, min_resilience_talent, min_speed_talent, confidence_talent, health_talent, stamina_talent, brawn_talent, @@ -212,4 +220,40 @@ CREATE TABLE IF NOT EXISTS characters health_proficiency, stamina_proficiency, brawn_proficiency, durability_proficiency, intensity_proficiency, resilience_proficiency, speed_proficiency) ); ---rollback DROP TABLE IF EXISTS characters_table; \ No newline at end of file +--rollback DROP TABLE IF EXISTS characters; + +--changeset reya:character_creation_table runInTransaction:false +CREATE TABLE IF NOT EXISTS character_creation +( + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + character_id UUID NULL REFERENCES characters (id) ON DELETE CASCADE, + name STRING NULL, + title STRING NULL, + profile STRING NULL, + gender_id INT NULL REFERENCES genders (id) ON DELETE RESTRICT, + difficulty_id INT NULL REFERENCES difficulties (id) ON DELETE RESTRICT, + preference_id INT NULL REFERENCES preferences (id) ON DELETE RESTRICT, + type1_id INT NULL REFERENCES types (id) ON DELETE RESTRICT, + type2_id INT NULL REFERENCES types (id) ON DELETE RESTRICT, + base_confidence INT NULL DEFAULT 70, + base_health INT NULL DEFAULT 70, + base_stamina INT NULL DEFAULT 70, + base_brawn INT NULL DEFAULT 70, + base_durability INT NULL DEFAULT 70, + base_intensity INT NULL DEFAULT 70, + base_resilience INT NULL DEFAULT 70, + base_speed INT NULL DEFAULT 70, + + PRIMARY KEY (user_id, character_id) +); +--rollback DROP TABLE IF EXISTS character_creation; + +--changeset reya:userDefaultDifficultyPreferenceGender +ALTER TABLE users + ADD COLUMN default_gender_id INT NULL REFERENCES genders (id) ON DELETE RESTRICT DEFAULT NULL + CREATE IF NOT EXISTS FAMILY character_defaults, + ADD COLUMN default_difficulty_id INT NULL REFERENCES difficulties (id) ON DELETE RESTRICT DEFAULT NULL + FAMILY character_defaults, + ADD COLUMN default_preference_id INT NULL REFERENCES preferences (id) ON DELETE RESTRICT DEFAULT NULL + FAMILY character_defaults; +--rollback ALTER \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d899ee1..730e66a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,15 @@ "license": "ISC", "dependencies": { "@reduxjs/toolkit": "^1.8.5", + "@types/fnv-plus": "^1.3.0", + "@types/uuid": "^8.3.4", "discord-api-types": "^0.37.12", "discord.js": "^14.5.0", "dotenv": "^16.0.3", + "fnv-plus": "^1.3.1", "liquibase": "^4.4.0", - "ts-postgres": "^1.3.0" + "pg": "^8.8.0", + "uuid": "^9.0.0" }, "devDependencies": { "@babel/cli": "^7.19.3", @@ -2429,6 +2433,11 @@ "@babel/types": "^7.3.0" } }, + "node_modules/@types/fnv-plus": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@types/fnv-plus/-/fnv-plus-1.3.0.tgz", + "integrity": "sha512-ijls8MsO6Q9JUSd5w1v4y2ijM6S4D/nmOyI/FwcepvrZfym0wZhLdYGFD5TJID7tga0O3I7SmtK69RzpSJ1Fcw==" + }, "node_modules/@types/graceful-fs": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", @@ -2490,6 +2499,11 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "node_modules/@types/uuid": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", + "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==" + }, "node_modules/@types/ws": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", @@ -2833,6 +2847,14 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/buffer-writer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz", + "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==", + "engines": { + "node": ">=4" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -3352,6 +3374,11 @@ "node": ">=8" } }, + "node_modules/fnv-plus": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fnv-plus/-/fnv-plus-1.3.1.tgz", + "integrity": "sha512-Gz1EvfOneuFfk4yG458dJ3TLJ7gV19q3OM/vVvvHf7eT02Hm1DleB4edsia6ahbKgAYxO9gvyQ1ioWZR+a00Yw==" + }, "node_modules/fs-readdir-recursive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz", @@ -4701,6 +4728,11 @@ "node": ">=6" } }, + "node_modules/packet-reader": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz", + "integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -4764,26 +4796,61 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/pg": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.8.0.tgz", + "integrity": "sha512-UXYN0ziKj+AeNNP7VDMwrehpACThH7LUl/p8TDFpEUuSejCUIwGSfxpHsPvtM6/WXFy6SU4E5RG4IJV/TZAGjw==", + "dependencies": { + "buffer-writer": "2.0.0", + "packet-reader": "1.0.0", + "pg-connection-string": "^2.5.0", + "pg-pool": "^3.5.2", + "pg-protocol": "^1.5.0", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-connection-string": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz", + "integrity": "sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==" + }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "dev": true, "engines": { "node": ">=4.0.0" } }, + "node_modules/pg-pool": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.5.2.tgz", + "integrity": "sha512-His3Fh17Z4eg7oANLob6ZvH8xIVen3phEZh2QuyrIl4dQSDVEabNducv6ysROKpDNPSD+12tONZVWfSgMvDD9w==", + "peerDependencies": { + "pg": ">=8.0" + } + }, "node_modules/pg-protocol": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.5.0.tgz", - "integrity": "sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ==", - "dev": true + "integrity": "sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ==" }, "node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "dev": true, "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", @@ -4795,6 +4862,14 @@ "node": ">=4" } }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -4847,7 +4922,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "dev": true, "engines": { "node": ">=4" } @@ -4856,7 +4930,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -4865,7 +4938,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -4874,7 +4946,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "dev": true, "dependencies": { "xtend": "^4.0.0" }, @@ -5197,6 +5268,14 @@ "source-map": "^0.6.0" } }, + "node_modules/split2": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.1.0.tgz", + "integrity": "sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -5551,22 +5630,6 @@ } } }, - "node_modules/ts-postgres": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/ts-postgres/-/ts-postgres-1.3.0.tgz", - "integrity": "sha512-YQY6omZM9RiMeJpzyVn36ankicZnTbSTkHaq+NTkqrLHSRYigsNW9JsPwrZXxLt1es3mV+V6/VUj0eOVcYXq1g==", - "dependencies": { - "ts-typed-events": "^3.0.0" - }, - "engines": { - "node": ">=10.7.0" - } - }, - "node_modules/ts-typed-events": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ts-typed-events/-/ts-typed-events-3.0.0.tgz", - "integrity": "sha512-+2FZ0XPX+UPR7PO8ZQjuvnuDMYRhzrDaCRaNHaBG1xSL//0oPa3XMU5yxgDTzW67VzkE33fQpx1YxWBdkaF7Zw==" - }, "node_modules/tslib": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", @@ -5688,6 +5751,14 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "node_modules/uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -5792,7 +5863,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, "engines": { "node": ">=0.4" } @@ -7602,6 +7672,11 @@ "@babel/types": "^7.3.0" } }, + "@types/fnv-plus": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@types/fnv-plus/-/fnv-plus-1.3.0.tgz", + "integrity": "sha512-ijls8MsO6Q9JUSd5w1v4y2ijM6S4D/nmOyI/FwcepvrZfym0wZhLdYGFD5TJID7tga0O3I7SmtK69RzpSJ1Fcw==" + }, "@types/graceful-fs": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", @@ -7663,6 +7738,11 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "@types/uuid": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", + "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==" + }, "@types/ws": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", @@ -7924,6 +8004,11 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "buffer-writer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz", + "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==" + }, "busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -8303,6 +8388,11 @@ "path-exists": "^4.0.0" } }, + "fnv-plus": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fnv-plus/-/fnv-plus-1.3.1.tgz", + "integrity": "sha512-Gz1EvfOneuFfk4yG458dJ3TLJ7gV19q3OM/vVvvHf7eT02Hm1DleB4edsia6ahbKgAYxO9gvyQ1ioWZR+a00Yw==" + }, "fs-readdir-recursive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz", @@ -9307,6 +9397,11 @@ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "dev": true }, + "packet-reader": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz", + "integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==" + }, "parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -9348,23 +9443,45 @@ "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.0.0.tgz", "integrity": "sha512-YtCKvLUOvwtMGmrniQPdO7MwPjgkFBtFIrmfSbYmYuq3tKDV/mcfAhBth1+C3ru7uXIZasc/pHnb+YDYNkkj4A==" }, + "pg": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.8.0.tgz", + "integrity": "sha512-UXYN0ziKj+AeNNP7VDMwrehpACThH7LUl/p8TDFpEUuSejCUIwGSfxpHsPvtM6/WXFy6SU4E5RG4IJV/TZAGjw==", + "requires": { + "buffer-writer": "2.0.0", + "packet-reader": "1.0.0", + "pg-connection-string": "^2.5.0", + "pg-pool": "^3.5.2", + "pg-protocol": "^1.5.0", + "pg-types": "^2.1.0", + "pgpass": "1.x" + } + }, + "pg-connection-string": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz", + "integrity": "sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==" + }, "pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "dev": true + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==" + }, + "pg-pool": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.5.2.tgz", + "integrity": "sha512-His3Fh17Z4eg7oANLob6ZvH8xIVen3phEZh2QuyrIl4dQSDVEabNducv6ysROKpDNPSD+12tONZVWfSgMvDD9w==", + "requires": {} }, "pg-protocol": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.5.0.tgz", - "integrity": "sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ==", - "dev": true + "integrity": "sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ==" }, "pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "dev": true, "requires": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", @@ -9373,6 +9490,14 @@ "postgres-interval": "^1.1.0" } }, + "pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "requires": { + "split2": "^4.1.0" + } + }, "picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -9409,26 +9534,22 @@ "postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "dev": true + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==" }, "postgres-bytea": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", - "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", - "dev": true + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==" }, "postgres-date": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "dev": true + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==" }, "postgres-interval": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "dev": true, "requires": { "xtend": "^4.0.0" } @@ -9680,6 +9801,11 @@ "source-map": "^0.6.0" } }, + "split2": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.1.0.tgz", + "integrity": "sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==" + }, "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -9901,19 +10027,6 @@ "yn": "3.1.1" } }, - "ts-postgres": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/ts-postgres/-/ts-postgres-1.3.0.tgz", - "integrity": "sha512-YQY6omZM9RiMeJpzyVn36ankicZnTbSTkHaq+NTkqrLHSRYigsNW9JsPwrZXxLt1es3mV+V6/VUj0eOVcYXq1g==", - "requires": { - "ts-typed-events": "^3.0.0" - } - }, - "ts-typed-events": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ts-typed-events/-/ts-typed-events-3.0.0.tgz", - "integrity": "sha512-+2FZ0XPX+UPR7PO8ZQjuvnuDMYRhzrDaCRaNHaBG1xSL//0oPa3XMU5yxgDTzW67VzkE33fQpx1YxWBdkaF7Zw==" - }, "tslib": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", @@ -9988,6 +10101,11 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==" + }, "v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -10059,8 +10177,7 @@ "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" }, "y18n": { "version": "5.0.8", diff --git a/package.json b/package.json index c8f8ea8..b3b08ce 100644 --- a/package.json +++ b/package.json @@ -3,21 +3,26 @@ "version": "0.0.0", "description": "Vore RPG core", "private": true, - "main": "build/index.js", + "main": "build/bin/bot.js", "scripts": { - "build": "tsc", + "build": "tsc && babel src --out-dir=build --extensions=.ts", + "migrate": "node build/bin/migrate.js", + "start": "node build/bin/bot.js", "test": "jest" }, "author": "", "license": "ISC", - "type": "module", "dependencies": { "@reduxjs/toolkit": "^1.8.5", + "@types/fnv-plus": "^1.3.0", + "@types/uuid": "^8.3.4", "discord-api-types": "^0.37.12", "discord.js": "^14.5.0", "dotenv": "^16.0.3", + "fnv-plus": "^1.3.1", "liquibase": "^4.4.0", - "ts-postgres": "^1.3.0" + "pg": "^8.8.0", + "uuid": "^9.0.0" }, "devDependencies": { "@babel/cli": "^7.19.3", diff --git a/src/main.spec.ts b/src/bin/bot.spec.ts similarity index 100% rename from src/main.spec.ts rename to src/bin/bot.spec.ts diff --git a/src/main.ts b/src/bin/bot.ts similarity index 53% rename from src/main.ts rename to src/bin/bot.ts index eade6cf..89ff1e8 100644 --- a/src/main.ts +++ b/src/bin/bot.ts @@ -1,17 +1,37 @@ import {BaseInteraction, Client} from "discord.js" import {config} from "dotenv" -import {isChatInputCommand} from "./types/interactions" -import {commandDefinitions, executeCommand, storeCachedCommands} from "./commands/index" -import {checkIsRestart, reportFailed, reportReady, reportStarted} from "./ipc/restart" -import {defaultPresence} from "./defaultPresence" +import {isAutocomplete, isChatInputCommand} from "../types/interactions.js" +import {Commands} from "../commands/index.js" +import {checkIsRestart, reportFailed, reportReady, reportStarted} from "../ipc/restart.js" +import {defaultPresence} from "../defaultPresence.js" +import {Pool} from "pg" +import {Database, DatabaseImpl} from "../database/database.js" -async function main() { +async function bot() { await checkIsRestart() config() const c = new Client({ intents: [], }) + const p = new Pool({ + application_name: "VoreRPG Bot", + }) + + async function cleanUp() { + await p.end() + c.destroy() + } + + const db: Database = new DatabaseImpl(p.query.bind(p)) + const cmd = new Commands({users: db.users, cleanUp}) + c.on("ready", async () => { + try { + await db.users.createBotOwnerAsAdmin(process.env.BOT_OWNER_ID || "") + } catch (ex) { + await reportFailed(c, ex) + return + } const app = c.application if (!app) { c.destroy() @@ -19,10 +39,10 @@ async function main() { return } else { try { - storeCachedCommands(await app.commands.set(commandDefinitions)) + cmd.setCache(await app.commands.set(cmd.definitions)) const g = await c.guilds.fetch() for (const guild of g.values()) { - storeCachedCommands(await app.commands.set(commandDefinitions, guild.id)) + cmd.setCache(await app.commands.set(cmd.definitions, guild.id)) } } catch (ex) { c.destroy() @@ -64,28 +84,44 @@ async function main() { } if (isChatInputCommand(ev)) { try { - await executeCommand(ev) + await cmd.execute(ev) } catch (ex) { console.log("failed executing command", ev, ex) - if (!ev.replied) { - try { - await ev.reply({ - ephemeral: true, - content: "Uuguuu... I can't think straight... try again later, 'kay?", - }) - } catch (innerEx) { - console.log("failed sending error response", innerEx) - } + } + if (!ev.replied) { + console.log("never replied to command", ev) + try { + await ev.reply({ + ephemeral: true, + content: "Uuguuu... I can't think straight... try again later, 'kay?", + }) + } catch (ex) { + console.log("failed sending error reply", ex) + } + } + } else if (isAutocomplete(ev)) { + try { + await cmd.autocomplete(ev) + } catch (ex) { + console.log("failed autocompleting for command", ev, ex) + } + if (!ev.responded) { + console.log("never autocompleted for command", ev) + try { + await ev.respond([]) + } catch (ex) { + console.log("failed sending error response", ex) } } } else if (ev.isRepliable()) { try { + console.log("unknown command", ev) await ev.reply({ ephemeral: true, content: "Huuuuuuuh? ... I don't know what to do with that yet.", }) } catch (ex) { - console.log("failed sending unknown command response", ex) + console.log("failed sending unknown command reply", ex) } } else { console.log("got an interaction but can't reply to it") @@ -95,6 +131,6 @@ async function main() { await c.login(process.env.DISCORD_TOKEN || "") } -main().catch((ex) => { +bot().catch((ex) => { console.log("main thread failed", ex) }) \ No newline at end of file diff --git a/src/bin/migrate.ts b/src/bin/migrate.ts new file mode 100644 index 0000000..f3c0714 --- /dev/null +++ b/src/bin/migrate.ts @@ -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) +}) \ No newline at end of file diff --git a/src/commands/bot/index.ts b/src/commands/bot/index.ts index 5f69fc3..ba8e2c6 100644 --- a/src/commands/bot/index.ts +++ b/src/commands/bot/index.ts @@ -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 }) { + 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() \ No newline at end of file +} \ No newline at end of file diff --git a/src/commands/bot/rebuild.ts b/src/commands/bot/rebuild.ts index 4354a61..b207c1a 100644 --- a/src/commands/bot/rebuild.ts +++ b/src/commands/bot/rebuild.ts @@ -1,4 +1,4 @@ -import {adminId, SubcommandData} from "../types" +import {SubcommandData} from "../types" import { ActivityType, ApplicationCommandOptionType, @@ -9,8 +9,18 @@ import {wrappedRestart} from "../../ipc/restart" import {fork} from "child_process" import {defaultPresence} from "../../defaultPresence" import {resolve as resolvePath} from "path" +import {UsersTable} from "../../database/users.js" + +export class BotRebuildCommand extends SubcommandData { + private readonly _users: UsersTable + private readonly _cleanUp: () => Promise + + constructor({users, cleanUp}: { users: UsersTable, cleanUp(): Promise }) { + super() + this._users = users + this._cleanUp = cleanUp + } -class RebuildCommand extends SubcommandData { readonly definition: ApplicationCommandSubCommandData = { name: "rebuild", description: "Rebuilds and restarts the bot.", @@ -18,8 +28,7 @@ class RebuildCommand extends SubcommandData { } async execute(b: ChatInputCommandInteraction): Promise { - const user = b.user || b.member - if (!!user && user.id === adminId) { + if (await this._users.getActiveSnowflakeIsAdmin(b.user.id)) { await b.reply({ content: "I dunno... Let's check if this will work...", ephemeral: true, @@ -159,7 +168,7 @@ class RebuildCommand extends SubcommandData { ephemeral: true, content: "Phewwww... now I'll just... take a quick nap after all that hard work...", }) - await wrappedRestart(b) + await wrappedRestart(b, this._cleanUp) } else { await b.reply({ ephemeral: true, @@ -167,6 +176,4 @@ class RebuildCommand extends SubcommandData { }) } } -} - -export const commandBotRebuild = new RebuildCommand() \ No newline at end of file +} \ No newline at end of file diff --git a/src/commands/bot/restart.ts b/src/commands/bot/restart.ts index aadf0f8..35fa51b 100644 --- a/src/commands/bot/restart.ts +++ b/src/commands/bot/restart.ts @@ -1,4 +1,4 @@ -import {adminId, SubcommandData} from "../types" +import {SubcommandData} from "../types" import { ActivityType, ApplicationCommandOptionType, @@ -6,8 +6,18 @@ import { ChatInputCommandInteraction, } from "discord.js" import {wrappedRestart} from "../../ipc/restart" +import {UsersTable} from "../../database/users.js" + +export class BotRestartCommand extends SubcommandData { + private readonly _users: UsersTable + private readonly _cleanUp: () => Promise + + constructor({users, cleanUp}: { users: UsersTable, cleanUp(): Promise }) { + super() + this._users = users + this._cleanUp = cleanUp + } -class RestartCommand extends SubcommandData { readonly definition: ApplicationCommandSubCommandData = { name: "restart", type: ApplicationCommandOptionType.Subcommand, @@ -15,8 +25,7 @@ class RestartCommand extends SubcommandData { } async execute(b: ChatInputCommandInteraction): Promise { - const user = b.user || b.member - if (!!user && user.id === adminId) { + if (await this._users.getActiveSnowflakeIsAdmin(b.user.id)) { await b.reply({ ephemeral: true, content: "Yaaaawwn... Okay... Just a quick nap then...", @@ -32,7 +41,7 @@ class RestartCommand extends SubcommandData { }, ], }) - await wrappedRestart(b) + await wrappedRestart(b, this._cleanUp) } else { await b.reply({ ephemeral: true, @@ -40,6 +49,4 @@ class RestartCommand extends SubcommandData { }) } } -} - -export const commandBotRestart = new RestartCommand() \ No newline at end of file +} \ No newline at end of file diff --git a/src/commands/bot/shutdown.ts b/src/commands/bot/shutdown.ts index bfb20b4..f71a841 100644 --- a/src/commands/bot/shutdown.ts +++ b/src/commands/bot/shutdown.ts @@ -1,7 +1,17 @@ -import {adminId, SubcommandData} from "../types" +import {SubcommandData} from "../types" import {ApplicationCommandOptionType, ApplicationCommandSubCommandData, ChatInputCommandInteraction} from "discord.js" +import {UsersTable} from "../../database/users.js" + +export class BotShutdownCommand extends SubcommandData { + private readonly _users: UsersTable + private readonly _cleanUp: () => Promise + + constructor({users, cleanUp}: { users: UsersTable, cleanUp(): Promise }) { + super() + this._users = users + this._cleanUp = cleanUp + } -class ShutdownCommand extends SubcommandData { readonly definition: ApplicationCommandSubCommandData = { name: "shutdown", type: ApplicationCommandOptionType.Subcommand, @@ -9,18 +19,16 @@ class ShutdownCommand extends SubcommandData { } async execute(b: ChatInputCommandInteraction): Promise { - const user = b.user || b.member - if (!!user && user.id === adminId) { + if (await this._users.getActiveSnowflakeIsAdmin(b.user.id)) { await b.reply({ ephemeral: true, content: "Good night =w=", }) - const self = b.client.user - self.presence.set({ + b.client.user.presence.set({ status: "invisible", activities: [], }) - b.client.destroy() + await this._cleanUp() } else { await b.reply({ ephemeral: true, @@ -28,6 +36,4 @@ class ShutdownCommand extends SubcommandData { }) } } -} - -export const commandBotShutdown = new ShutdownCommand() \ No newline at end of file +} \ No newline at end of file diff --git a/src/commands/character/create.ts b/src/commands/character/create.ts index 8b1aef2..5d40312 100644 --- a/src/commands/character/create.ts +++ b/src/commands/character/create.ts @@ -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() \ No newline at end of file +} \ No newline at end of file diff --git a/src/commands/character/index.ts b/src/commands/character/index.ts index 3181a59..cf9ce33 100644 --- a/src/commands/character/index.ts +++ b/src/commands/character/index.ts @@ -1,21 +1,29 @@ import {ApplicationCommandOptionType, ApplicationCommandSubCommandData, ApplicationCommandType} from "discord.js" -import {commandCharacterCreate} from "./create" -import {BaseChatInputCommandData, CommandWithSubcommandsData} from "../types" +import {BaseChatInputCommandData, CommandWithSubcommandsData, SubcommandData} from "../types" +import {UsersTable} from "../../database/users.js" +import {CharacterCreateCommand} from "./create.js" + +export class CharacterCommand extends CommandWithSubcommandsData { + readonly create: CharacterCreateCommand + readonly subcommands: SubcommandData[] + private readonly _users: UsersTable + + constructor({users}: { users: UsersTable }) { + super() + this._users = users + this.create = new CharacterCreateCommand({users}) + this.subcommands = [ + this.create, + ] + } -class CharacterCommandData extends CommandWithSubcommandsData { readonly baseDefinition: BaseChatInputCommandData = { name: "character", type: ApplicationCommandType.ChatInput, description: "Commands to manage your characters.", } - - readonly subcommands = [ - commandCharacterCreate, - ] } -export const commandCharacter = new CharacterCommandData() - const otherSubcommands: ApplicationCommandSubCommandData[] = [ { name: "select", diff --git a/src/commands/index.spec.ts b/src/commands/index.spec.ts index 84bddd5..148702a 100644 --- a/src/commands/index.spec.ts +++ b/src/commands/index.spec.ts @@ -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,}/)})) }) diff --git a/src/commands/index.ts b/src/commands/index.ts index 4576bdc..438e7cb 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -3,42 +3,66 @@ import { ApplicationCommandDataResolvable, ApplicationCommandOptionType, ApplicationCommandType, + AutocompleteInteraction, ChatInputCommandInteraction, Collection, } from "discord.js" -import {commandCharacter} from "./character/index" -import {CommandData} from "./types" -import {commandBot} from "./bot/index" +import {CharacterCommand} from "./character/index" +import {autocompleteNotImplementedError, CommandData} from "./types" +import {BotCommand} from "./bot/index" +import {UsersTable} from "../database/users.js" -const commands: CommandData[] = [ - commandCharacter, - commandBot, -] +export const invalidCommandError = "No command by that name exists." -export const commandDefinitions: ApplicationCommandDataResolvable[] = commands.map((c) => c.definition) +export class Commands { + readonly all: CommandData[] + readonly character: CharacterCommand + readonly bot: BotCommand + readonly definitions: ApplicationCommandDataResolvable[] + private readonly _nameCache: Record -const _commandNameCache: Record = Object.fromEntries( - commands.map((c) => [c.definition.name, c]), -) + constructor({users, cleanUp}: { users: UsersTable, cleanUp: () => Promise }) { + this.character = new CharacterCommand({users}) + this.bot = new BotCommand({users, cleanUp}) + this.all = [ + this.character, + this.bot, + ] + this.definitions = this.all.map((c) => c.definition) + this._nameCache = Object.fromEntries( + this.all.map((c) => [c.definition.name, c]), + ) + } -export const invalidCommandError = "No command by that name exists." + setCache(data: Collection) { + for (const command of data.values()) { + if (this._nameCache.hasOwnProperty(command.name)) { + this._nameCache[command.name].setCached(command) + } else { + console.log("No such command when caching commands: " + command.name) + } + } + } -export function storeCachedCommands(data: Collection) { - for (const command of data.values()) { - if (_commandNameCache.hasOwnProperty(command.name)) { - _commandNameCache[command.name].setCached(command) - } else { - console.log("No such command when caching commands: " + command.name) + async execute(ev: ChatInputCommandInteraction) { + const name = ev.commandName + if (!this._nameCache.hasOwnProperty(name)) { + throw invalidCommandError } + await this._nameCache[name].execute(ev) } -} -export async function executeCommand(ev: ChatInputCommandInteraction) { - const name = ev.commandName - if (!_commandNameCache.hasOwnProperty(name)) { - throw invalidCommandError + async autocomplete(ev: AutocompleteInteraction) { + const name = ev.commandName + if (this._nameCache.hasOwnProperty(name)) { + const command = this._nameCache[name] + if (command.autocomplete) { + await command.autocomplete(ev) + return + } + } + throw autocompleteNotImplementedError } - await _commandNameCache[name].execute(ev) } const otherCommands: ApplicationCommandDataResolvable[] = [ diff --git a/src/commands/types.ts b/src/commands/types.ts index 1bc8698..f77f421 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -2,6 +2,7 @@ import { ApplicationCommand, ApplicationCommandSubCommandData, ApplicationCommandSubGroupData, + AutocompleteInteraction, ChatInputApplicationCommandData, ChatInputCommandInteraction, } from "discord.js" @@ -9,8 +10,7 @@ import { export const noSubcommandError = "No subcommand was provided, but one is required." export const invalidSubcommandGroupError = "The subcommand group provided does not exist." export const invalidSubcommandError = "The subcommand provided does not exist." - -export const adminId = "126936953789743104" +export const autocompleteNotImplementedError = "An autocomplete implementation was not provided." export abstract class CommandData { private _cachedGlobal: ApplicationCommand | null = null @@ -40,6 +40,8 @@ export abstract class CommandData { } abstract execute(b: ChatInputCommandInteraction): Promise + + autocomplete?(b: AutocompleteInteraction): Promise } export type BaseChatInputCommandData = Omit @@ -59,7 +61,7 @@ export abstract class CommandWithSubcommandsData extends CommandData { protected abstract get subcommands(): (SubcommandData | SubcommandGroupData)[] - async execute(b: ChatInputCommandInteraction) { + async execute(b: ChatInputCommandInteraction): Promise { const group = b.options.getSubcommandGroup() const subcommand = b.options.getSubcommand() if (group !== null) { @@ -71,6 +73,26 @@ export abstract class CommandWithSubcommandsData extends CommandData { } } + async autocomplete?(b: AutocompleteInteraction): Promise { + const group = b.options.getSubcommandGroup() + const subcommand = b.options.getSubcommand() + if (group !== null) { + const groupObject = this.resolveSubcommandGroup(group) + if (groupObject.autocomplete) { + await groupObject.autocomplete(b) + return + } + } + if (subcommand !== null) { + const subcommandObject = this.resolveSubcommand(subcommand) + if (subcommandObject.autocomplete) { + await subcommandObject.autocomplete(b) + return + } + } + throw autocompleteNotImplementedError + } + protected resolveSubcommandGroup(name: string): SubcommandGroupData { let cache = this._subcommandGroupsCache if (cache === null) { @@ -131,6 +153,18 @@ export abstract class SubcommandGroupData { } } + async autocomplete?(b: AutocompleteInteraction): Promise { + const subcommand = b.options.getSubcommand() + if (subcommand !== null) { + const subcommandObject = this.resolveSubcommand(subcommand) + if (subcommandObject.autocomplete) { + await subcommandObject.autocomplete(b) + return + } + } + throw autocompleteNotImplementedError + } + protected resolveSubcommand(name: string): SubcommandData { let cache = this._subcommandsCache if (cache === null) { @@ -151,4 +185,6 @@ export abstract class SubcommandData { abstract get definition(): ApplicationCommandSubCommandData abstract execute(b: ChatInputCommandInteraction): Promise + + autocomplete?(b: AutocompleteInteraction): Promise } \ No newline at end of file diff --git a/src/database/database.ts b/src/database/database.ts new file mode 100644 index 0000000..bebbe99 --- /dev/null +++ b/src/database/database.ts @@ -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) + } +} \ No newline at end of file diff --git a/src/database/inmemory/database.ts b/src/database/inmemory/database.ts new file mode 100644 index 0000000..e73a3b0 --- /dev/null +++ b/src/database/inmemory/database.ts @@ -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() + } +} \ No newline at end of file diff --git a/src/database/inmemory/users.ts b/src/database/inmemory/users.ts new file mode 100644 index 0000000..341b689 --- /dev/null +++ b/src/database/inmemory/users.ts @@ -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 = {} + + async createBotOwnerAsAdmin(snowflake: Snowflake): Promise { + const uuid = userSnowflakeToUuid(snowflake) + this._users[uuid] = { + id: uuid, + is_admin: true, + active_at: null, + } + } + + async getActiveSnowflakeIsAdmin(snowflake: Snowflake): Promise { + 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 { + const uuid = userSnowflakeToUuid(snowflake) + if (!this._users.hasOwnProperty(uuid)) { + this._users[uuid] = { + id: uuid, + is_admin: false, + active_at: new Date(), + } + } + return uuid + } +} \ No newline at end of file diff --git a/src/database/users.ts b/src/database/users.ts new file mode 100644 index 0000000..286280d --- /dev/null +++ b/src/database/users.ts @@ -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 + + getActiveSnowflakeIsAdmin(snowflake: Snowflake): Promise + + createBotOwnerAsAdmin(snowflake: Snowflake): Promise +} + +export class UsersTableImpl implements UsersTable { + private readonly _query: Client["query"] + + constructor(query: Client["query"]) { + this._query = query + } + + async getUuidForActiveSnowflake(snowflake: Snowflake): Promise { + 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 { + 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 { + 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, + }) +} \ No newline at end of file diff --git a/src/ipc/restart.ts b/src/ipc/restart.ts index 119b0ea..70ca1e0 100644 --- a/src/ipc/restart.ts +++ b/src/ipc/restart.ts @@ -83,7 +83,7 @@ export async function reportReady(c: Client): Promise { return true } -export async function reportFailed(c: Client, error: unknown): Promise { +export async function reportFailed(c: Client, error: unknown): Promise { console.log("failed to start", error) if (restartState) { try { @@ -105,7 +105,7 @@ export async function reportFailed(c: Client, error: unknown): Promise { process.send(startData) } -export async function wrappedRestart(b: ChatInputCommandInteraction) { +export async function wrappedRestart(b: ChatInputCommandInteraction, cleanup: () => Promise) { try { await doRestart(b.client, b.webhook.id, b.webhook.token, RestartReason.RestartCommand) } catch (ex) { @@ -126,7 +126,7 @@ export async function wrappedRestart(b: ChatInputCommandInteraction) { } } } finally { - b.client.destroy() + await cleanup() } } diff --git a/src/types/interactions.ts b/src/types/interactions.ts index c5882c0..4d7ae16 100644 --- a/src/types/interactions.ts +++ b/src/types/interactions.ts @@ -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() } \ No newline at end of file