diff --git a/.dockerignore b/.dockerignore index 3428c092..677ba532 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,11 +2,19 @@ node_modules npm-debug.log Dockerfile docker-compose.yml +docker-compose.app.yml .dockerignore .git .gitignore +.github .idea +.env +.env.* +!.env.example + data/backups logs docs +*.md +INFRA.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml index aa64d4d8..ab2e8324 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -22,7 +22,7 @@ updates: directory: / schedule: interval: daily - open-pull-requests-limit: 0 + open-pull-requests-limit: 5 labels: - dependencies - npm diff --git a/.github/workflows/provision-and-deploy.yml b/.github/workflows/provision-and-deploy.yml index c22445b5..9f491d19 100644 --- a/.github/workflows/provision-and-deploy.yml +++ b/.github/workflows/provision-and-deploy.yml @@ -302,6 +302,7 @@ jobs: printf 'SILENT=%s\n' "${BOT_SILENT:-false}" printf 'CONTAINER_NAME=%s\n' "${CONTAINER_NAME}" printf 'IMAGE_TAG=%s\n' "${IMAGE_TAG}" + printf 'GHCR_IMAGE=%s\n' "ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" } > "${RUNNER_TEMP}/bot.env" - name: Sync deploy artifacts to VPS @@ -325,8 +326,8 @@ jobs: GHCR_PULL_TOKEN: ${{ secrets.GHCR_PULL_TOKEN }} run: | if [ -n "${GHCR_PULL_TOKEN}" ]; then - ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" \ - "echo '${GHCR_PULL_TOKEN}' | docker login ghcr.io -u '${{ github.actor }}' --password-stdin" + printf '%s\n' "${GHCR_PULL_TOKEN}" | ssh -i ~/.ssh/bot_deploy_key deploy@"${BOT_HOST}" \ + "docker login ghcr.io -u '${{ github.actor }}' --password-stdin" fi - name: Deploy bot diff --git a/Dockerfile b/Dockerfile index cf30ee87..bab1577c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,4 +13,6 @@ FROM node:24-alpine AS runtime WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . +RUN chown -R node:node /app +USER node ENTRYPOINT ["npm", "start"] diff --git a/INFRA.md b/INFRA.md index 87a9d9f5..bc1af18e 100644 --- a/INFRA.md +++ b/INFRA.md @@ -128,9 +128,14 @@ and the droplet pulls it during deploy. Make the pull work one of two ways: - **Public package (simplest):** in the GHCR package settings, set the package visibility to public. No extra secret is needed. - **Private package:** create a classic PAT with the `read:packages` scope and - save it as the repository secret `GHCR_PULL_TOKEN`. The deploy job uses it to - `docker login ghcr.io` on the droplet. If the secret is empty, the login step - is skipped (so it is safe to leave unset for a public package). + save it as the repository secret `GHCR_PULL_TOKEN`. The deploy job pipes the + token to `docker login --password-stdin` on the droplet over SSH (the token is + never embedded in the remote command string). If the secret is empty, the login + step is skipped (so it is safe to leave unset for a public package). + +The deploy job also writes `GHCR_IMAGE=ghcr.io//` into the server +`.env` so `docker-compose.app.yml` can pull the correct registry path without +hardcoding it in the compose file. ## 5. Optional GitHub Variables @@ -147,6 +152,7 @@ default below. | `DO_DROPLET_NAME` | repo | `event-queue-bot` | | `DO_ENABLE_BACKUPS` | repo | `false` | | `DO_SWAP_SIZE` | repo | `1G` | +| `SSH_ALLOW_IPS` | repo | empty (SSH open to all) | | `APP_PATH` | env | branch-derived (see above) | | `BOT_TOP_GG_TOKEN` | env | empty | | `BOT_PATCH_NOTES_CHANNEL_ID` | env | empty | @@ -189,6 +195,16 @@ a re-provision. Firewall rules are reconciled by `scripts/ensure-firewall.sh` in both the `provision` and `deploy` jobs, so firewall changes apply even when provision is skipped. +Production containers run as the `node` user (see `Dockerfile`), with per-service +CPU/memory limits in `docker-compose.app.yml` suited to a 1 GB droplet running +both prod and dev bots. Patch notes and other stdin prompts are disabled in +production compose (`stdin_open` / `tty` are local-only in `docker-compose.yml`); +set `BOT_FORCE_SEND_PATCH_NOTES=true` on the environment when you want patch +notes sent without an interactive prompt. + +Local `.env` files are excluded from the Docker build context (`.dockerignore`) +so secrets are not baked into images. + Future pushes to `master` deploy to dev automatically; each run pauses at `gate` for `dev-gate` reviewer approval before `build-and-push`, `discover`, `provision`, and `deploy` proceed. Prod is reached only by merging @@ -242,6 +258,19 @@ Prod and dev share one droplet but no state: separate containers (`queue-bot` vs `queue-bot-nightly`), separate app dirs and `data/main.sqlite`, separate Discord applications. +## Single-instance requirement + +Each environment should run **one** bot container against **one** SQLite database. +SQLite write concurrency is poor with multiple writers on the same file. + +**Event sync** (`EventSyncLock`) uses a row in `event_sync_lock` so two processes +that accidentally share a database will not run `syncEventQueues` / +`reconcileRoomChannels` in parallel. Stale locks older than 10 minutes are cleared +at startup. + +**Scheduled occurrence jobs** (`node-schedule` in `event-jobs.registry`) remain +process-local — do not run multiple bot processes against the same DB. + ## Re-provisioning via the CLI When cloud-init changes (deploy script, sudoers, swap size, etc.), delete the @@ -264,6 +293,30 @@ including **`droplet:delete`** for teardown. Typical sequence: 4. Restore each database if needed (stop container, copy `main.sqlite` back, restart). +## SSH access and hardening + +SSH (port 22) is reachable from the public internet by default. The DigitalOcean +cloud firewall created by `scripts/ensure-firewall.sh` allows inbound TCP/22 from +`0.0.0.0/0` and `::/0` unless you restrict it. + +**Mitigations in this repo:** + +- **fail2ban** — installed on first boot via cloud-init with an `sshd` jail + (5 failures → 1 hour ban). Requires a **re-provision** to apply on an + existing droplet. +- **Optional IP allowlist** — set the repository variable `SSH_ALLOW_IPS` to a + comma-separated list of CIDRs (e.g. `203.0.113.10/32,198.51.100.0/24`). The + next deploy run updates the DO firewall to allow SSH only from those addresses. + Useful when your admin IP or a VPN egress range is stable. GitHub Actions + runners use varying IPs, so do not rely on this alone for CI unless you also + allow the ranges you need for deploy SSH. +- **Project-level controls** — consider a DigitalOcean project firewall, + Tailscale-only SSH, or disabling password auth (already off via cloud-init). + +Treat an open SSH port as a residual risk: keep the OS patched, rotate deploy +keys if compromised, and prefer restricting SSH at the network layer when +feasible. + ## Connect to the Droplet Get the droplet IPv4 from the latest workflow's `discover` job, `doctl compute diff --git a/README-dev.md b/README-dev.md index 637a0c72..4de62df7 100644 --- a/README-dev.md +++ b/README-dev.md @@ -159,6 +159,12 @@ npm run lint This project is designed to run without compiling thanks to `@swc-node/register/esm`. +### Dependencies + +**Dual schedulers:** Both `node-cron` and `node-schedule` are intentional. `node-cron` runs recurring cron expressions (queue `/schedule` jobs and DB maintenance in `db-scheduled-tasks.ts`). `node-schedule` runs one-shot `Date`-based jobs for event occurrence lifecycle (open, lock, cleanup, room pings/pulls in `event.utils.ts`). Consolidating would require reimplementing date-specific scheduling on top of cron or vice versa. + +**`drizzle-kit`** is a dev dependency only — migrations are generated at build time and applied at runtime by `drizzle-orm`'s migrator. + ## Migrating from the legacy project (pre June 2024) Open a terminal and navigate to the following directory in this project: `data/migrations/legacy-export`. diff --git a/data/migrations/0015_early_black_knight.sql b/data/migrations/0015_early_black_knight.sql new file mode 100644 index 00000000..99cb560c --- /dev/null +++ b/data/migrations/0015_early_black_knight.sql @@ -0,0 +1,38 @@ +DROP INDEX `event_occurrence_room_ping_occurrence_id_index`;--> statement-breakpoint +ALTER TABLE `event_occurrence_room_ping` ADD `guild_id` text REFERENCES guild(guild_id);--> statement-breakpoint +UPDATE `event_occurrence_room_ping` SET `guild_id` = (SELECT `guild_id` FROM `event_occurrence` WHERE `event_occurrence`.`id` = `event_occurrence_room_ping`.`occurrence_id`);--> statement-breakpoint +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_event_occurrence_room_ping` ( + `guild_id` text NOT NULL, + `occurrence_id` integer NOT NULL, + `event_queue_id` integer NOT NULL, + `handled_at` integer NOT NULL, + PRIMARY KEY(`occurrence_id`, `event_queue_id`), + FOREIGN KEY (`guild_id`) REFERENCES `guild`(`guild_id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`occurrence_id`) REFERENCES `event_occurrence`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`event_queue_id`) REFERENCES `event_queue`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `__new_event_occurrence_room_ping`(`guild_id`, `occurrence_id`, `event_queue_id`, `handled_at`) SELECT `guild_id`, `occurrence_id`, `event_queue_id`, `handled_at` FROM `event_occurrence_room_ping`;--> statement-breakpoint +DROP TABLE `event_occurrence_room_ping`;--> statement-breakpoint +ALTER TABLE `__new_event_occurrence_room_ping` RENAME TO `event_occurrence_room_ping`;--> statement-breakpoint +CREATE INDEX `event_occurrence_room_ping_guild_id_occurrence_id_index` ON `event_occurrence_room_ping` (`guild_id`,`occurrence_id`);--> statement-breakpoint +DROP INDEX `event_occurrence_room_pull_occurrence_id_index`;--> statement-breakpoint +ALTER TABLE `event_occurrence_room_pull` ADD `guild_id` text REFERENCES guild(guild_id);--> statement-breakpoint +UPDATE `event_occurrence_room_pull` SET `guild_id` = (SELECT `guild_id` FROM `event_occurrence` WHERE `event_occurrence`.`id` = `event_occurrence_room_pull`.`occurrence_id`);--> statement-breakpoint +CREATE TABLE `__new_event_occurrence_room_pull` ( + `guild_id` text NOT NULL, + `occurrence_id` integer NOT NULL, + `event_queue_id` integer NOT NULL, + `handled_at` integer NOT NULL, + PRIMARY KEY(`occurrence_id`, `event_queue_id`), + FOREIGN KEY (`guild_id`) REFERENCES `guild`(`guild_id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`occurrence_id`) REFERENCES `event_occurrence`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`event_queue_id`) REFERENCES `event_queue`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `__new_event_occurrence_room_pull`(`guild_id`, `occurrence_id`, `event_queue_id`, `handled_at`) SELECT `guild_id`, `occurrence_id`, `event_queue_id`, `handled_at` FROM `event_occurrence_room_pull`;--> statement-breakpoint +DROP TABLE `event_occurrence_room_pull`;--> statement-breakpoint +ALTER TABLE `__new_event_occurrence_room_pull` RENAME TO `event_occurrence_room_pull`;--> statement-breakpoint +CREATE INDEX `event_occurrence_room_pull_guild_id_occurrence_id_index` ON `event_occurrence_room_pull` (`guild_id`,`occurrence_id`);--> statement-breakpoint +PRAGMA foreign_keys=ON; diff --git a/data/migrations/0016_real_loki.sql b/data/migrations/0016_real_loki.sql new file mode 100644 index 00000000..f3637c01 --- /dev/null +++ b/data/migrations/0016_real_loki.sql @@ -0,0 +1,6 @@ +CREATE TABLE `event_sync_lock` ( + `guild_id` text NOT NULL, + `event_id` integer NOT NULL, + `locked_at` integer NOT NULL, + PRIMARY KEY(`guild_id`, `event_id`) +); diff --git a/data/migrations/meta/0015_snapshot.json b/data/migrations/meta/0015_snapshot.json new file mode 100644 index 00000000..e9f8beaa --- /dev/null +++ b/data/migrations/meta/0015_snapshot.json @@ -0,0 +1,3083 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f6bf3fe2-99bf-40f7-b662-156ac9d30fb5", + "prevId": "5688b198-1d01-4eb7-b878-e61c3cf888a7", + "tables": { + "admin": { + "name": "admin", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_role": { + "name": "is_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_guild_id_index": { + "name": "admin_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "admin_guild_id_subject_id_unique": { + "name": "admin_guild_id_subject_id_unique", + "columns": [ + "guild_id", + "subject_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "admin_guild_id_guild_guild_id_fk": { + "name": "admin_guild_id_guild_guild_id_fk", + "tableFrom": "admin", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "archived_member": { + "name": "archived_member", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_id": { + "name": "queue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position_time": { + "name": "position_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "join_time": { + "name": "join_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archived_time": { + "name": "archived_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "archived_member_queue_id_user_id_unique": { + "name": "archived_member_queue_id_user_id_unique", + "columns": [ + "queue_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "blacklisted": { + "name": "blacklisted", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_id": { + "name": "queue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_role": { + "name": "is_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "blacklisted_guild_id_index": { + "name": "blacklisted_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "blacklisted_queue_id_subject_id_unique": { + "name": "blacklisted_queue_id_subject_id_unique", + "columns": [ + "queue_id", + "subject_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "blacklisted_guild_id_guild_guild_id_fk": { + "name": "blacklisted_guild_id_guild_guild_id_fk", + "tableFrom": "blacklisted", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blacklisted_queue_id_queue_id_fk": { + "name": "blacklisted_queue_id_queue_id_fk", + "tableFrom": "blacklisted", + "tableTo": "queue", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "display": { + "name": "display", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_id": { + "name": "queue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_channel_id": { + "name": "display_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_message_id": { + "name": "last_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "display_guild_id_index": { + "name": "display_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "display_queue_id_display_channel_id_unique": { + "name": "display_queue_id_display_channel_id_unique", + "columns": [ + "queue_id", + "display_channel_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "display_guild_id_guild_guild_id_fk": { + "name": "display_guild_id_guild_guild_id_fk", + "tableFrom": "display", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "display_queue_id_queue_id_fk": { + "name": "display_queue_id_queue_id_fk", + "tableFrom": "display", + "tableTo": "queue", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_blacklisted": { + "name": "event_blacklisted", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_role": { + "name": "is_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "event_blacklisted_guild_id_index": { + "name": "event_blacklisted_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "event_blacklisted_event_id_index": { + "name": "event_blacklisted_event_id_index", + "columns": [ + "event_id" + ], + "isUnique": false + }, + "event_blacklisted_event_id_subject_id_unique": { + "name": "event_blacklisted_event_id_subject_id_unique", + "columns": [ + "event_id", + "subject_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_blacklisted_guild_id_guild_guild_id_fk": { + "name": "event_blacklisted_guild_id_guild_guild_id_fk", + "tableFrom": "event_blacklisted", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_blacklisted_event_id_event_id_fk": { + "name": "event_blacklisted_event_id_event_id_fk", + "tableFrom": "event_blacklisted", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_default": { + "name": "event_default", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_role": { + "name": "queue_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "autopull_toggle": { + "name": "autopull_toggle", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "badge_toggle": { + "name": "badge_toggle", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_update_type": { + "name": "display_update_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dm_on_pull_toggle": { + "name": "dm_on_pull_toggle", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "buttons_toggles": { + "name": "buttons_toggles", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "header": { + "name": "header", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inline_toggle": { + "name": "inline_toggle", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lock_toggle": { + "name": "lock_toggle", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "member_display_type": { + "name": "member_display_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_batch_size": { + "name": "pull_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_message": { + "name": "pull_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_message_display_type": { + "name": "pull_message_display_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_message_channel_id": { + "name": "pull_message_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rejoin_cooldown_period": { + "name": "rejoin_cooldown_period", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rejoin_grace_period": { + "name": "rejoin_grace_period", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "require_message_to_join": { + "name": "require_message_to_join", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_in_queue_id": { + "name": "role_in_queue_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_on_pull_id": { + "name": "role_on_pull_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_display_type": { + "name": "time_display_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voice_destination_channel_id": { + "name": "voice_destination_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voice_only_toggle": { + "name": "voice_only_toggle", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "event_default_guild_id_index": { + "name": "event_default_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "event_default_event_id_queue_role_unique": { + "name": "event_default_event_id_queue_role_unique", + "columns": [ + "event_id", + "queue_role" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_default_guild_id_guild_guild_id_fk": { + "name": "event_default_guild_id_guild_guild_id_fk", + "tableFrom": "event_default", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_default_event_id_event_id_fk": { + "name": "event_default_event_id_event_id_fk", + "tableFrom": "event_default", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_occurrence_room_ping": { + "name": "event_occurrence_room_ping", + "columns": { + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurrence_id": { + "name": "occurrence_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_queue_id": { + "name": "event_queue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "handled_at": { + "name": "handled_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "event_occurrence_room_ping_guild_id_occurrence_id_index": { + "name": "event_occurrence_room_ping_guild_id_occurrence_id_index", + "columns": [ + "guild_id", + "occurrence_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "event_occurrence_room_ping_guild_id_guild_guild_id_fk": { + "name": "event_occurrence_room_ping_guild_id_guild_guild_id_fk", + "tableFrom": "event_occurrence_room_ping", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_occurrence_room_ping_occurrence_id_event_occurrence_id_fk": { + "name": "event_occurrence_room_ping_occurrence_id_event_occurrence_id_fk", + "tableFrom": "event_occurrence_room_ping", + "tableTo": "event_occurrence", + "columnsFrom": [ + "occurrence_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_occurrence_room_ping_event_queue_id_event_queue_id_fk": { + "name": "event_occurrence_room_ping_event_queue_id_event_queue_id_fk", + "tableFrom": "event_occurrence_room_ping", + "tableTo": "event_queue", + "columnsFrom": [ + "event_queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "event_occurrence_room_ping_occurrence_id_event_queue_id_pk": { + "columns": [ + "occurrence_id", + "event_queue_id" + ], + "name": "event_occurrence_room_ping_occurrence_id_event_queue_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_occurrence_room_pull": { + "name": "event_occurrence_room_pull", + "columns": { + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurrence_id": { + "name": "occurrence_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_queue_id": { + "name": "event_queue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "handled_at": { + "name": "handled_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "event_occurrence_room_pull_guild_id_occurrence_id_index": { + "name": "event_occurrence_room_pull_guild_id_occurrence_id_index", + "columns": [ + "guild_id", + "occurrence_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "event_occurrence_room_pull_guild_id_guild_guild_id_fk": { + "name": "event_occurrence_room_pull_guild_id_guild_guild_id_fk", + "tableFrom": "event_occurrence_room_pull", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_occurrence_room_pull_occurrence_id_event_occurrence_id_fk": { + "name": "event_occurrence_room_pull_occurrence_id_event_occurrence_id_fk", + "tableFrom": "event_occurrence_room_pull", + "tableTo": "event_occurrence", + "columnsFrom": [ + "occurrence_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_occurrence_room_pull_event_queue_id_event_queue_id_fk": { + "name": "event_occurrence_room_pull_event_queue_id_event_queue_id_fk", + "tableFrom": "event_occurrence_room_pull", + "tableTo": "event_queue", + "columnsFrom": [ + "event_queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "event_occurrence_room_pull_occurrence_id_event_queue_id_pk": { + "columns": [ + "occurrence_id", + "event_queue_id" + ], + "name": "event_occurrence_room_pull_occurrence_id_event_queue_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_occurrence": { + "name": "event_occurrence", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open_handled_at": { + "name": "open_handled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lock_handled_at": { + "name": "lock_handled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "discord_event_id": { + "name": "discord_event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "event_occurrence_guild_id_index": { + "name": "event_occurrence_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "event_occurrence_event_id_index": { + "name": "event_occurrence_event_id_index", + "columns": [ + "event_id" + ], + "isUnique": false + }, + "event_occurrence_event_id_start_time_unique": { + "name": "event_occurrence_event_id_start_time_unique", + "columns": [ + "event_id", + "start_time" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_occurrence_guild_id_guild_guild_id_fk": { + "name": "event_occurrence_guild_id_guild_guild_id_fk", + "tableFrom": "event_occurrence", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_occurrence_event_id_event_id_fk": { + "name": "event_occurrence_event_id_event_id_fk", + "tableFrom": "event_occurrence", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_prioritized": { + "name": "event_prioritized", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_role": { + "name": "is_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "priority_order": { + "name": "priority_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 5 + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "event_prioritized_guild_id_index": { + "name": "event_prioritized_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "event_prioritized_event_id_index": { + "name": "event_prioritized_event_id_index", + "columns": [ + "event_id" + ], + "isUnique": false + }, + "event_prioritized_event_id_subject_id_unique": { + "name": "event_prioritized_event_id_subject_id_unique", + "columns": [ + "event_id", + "subject_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_prioritized_guild_id_guild_guild_id_fk": { + "name": "event_prioritized_guild_id_guild_guild_id_fk", + "tableFrom": "event_prioritized", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_prioritized_event_id_event_id_fk": { + "name": "event_prioritized_event_id_event_id_fk", + "tableFrom": "event_prioritized", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_queue": { + "name": "event_queue", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_id": { + "name": "queue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_role": { + "name": "queue_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_index": { + "name": "queue_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ping_channel_id": { + "name": "ping_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_created_role_id": { + "name": "auto_created_role_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "event_queue_guild_id_index": { + "name": "event_queue_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "event_queue_event_id_index": { + "name": "event_queue_event_id_index", + "columns": [ + "event_id" + ], + "isUnique": false + }, + "event_queue_event_id_queue_role_queue_index_unique": { + "name": "event_queue_event_id_queue_role_queue_index_unique", + "columns": [ + "event_id", + "queue_role", + "queue_index" + ], + "isUnique": true + }, + "event_queue_queue_id_unique": { + "name": "event_queue_queue_id_unique", + "columns": [ + "queue_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_queue_guild_id_guild_guild_id_fk": { + "name": "event_queue_guild_id_guild_guild_id_fk", + "tableFrom": "event_queue", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_queue_event_id_event_id_fk": { + "name": "event_queue_event_id_event_id_fk", + "tableFrom": "event_queue", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_queue_queue_id_queue_id_fk": { + "name": "event_queue_queue_id_queue_id_fk", + "tableFrom": "event_queue", + "tableTo": "queue", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_room_channel": { + "name": "event_room_channel", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_index": { + "name": "room_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "suffix": { + "name": "suffix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "event_room_channel_guild_id_index": { + "name": "event_room_channel_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "event_room_channel_event_id_index": { + "name": "event_room_channel_event_id_index", + "columns": [ + "event_id" + ], + "isUnique": false + }, + "event_room_channel_event_id_room_index_suffix_unique": { + "name": "event_room_channel_event_id_room_index_suffix_unique", + "columns": [ + "event_id", + "room_index", + "suffix" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_room_channel_guild_id_guild_guild_id_fk": { + "name": "event_room_channel_guild_id_guild_guild_id_fk", + "tableFrom": "event_room_channel", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_room_channel_event_id_event_id_fk": { + "name": "event_room_channel_event_id_event_id_fk", + "tableFrom": "event_room_channel", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_room_channel_template": { + "name": "event_room_channel_template", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "suffix": { + "name": "suffix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slowmode_seconds": { + "name": "slowmode_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "event_room_channel_template_guild_id_index": { + "name": "event_room_channel_template_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "event_room_channel_template_event_id_index": { + "name": "event_room_channel_template_event_id_index", + "columns": [ + "event_id" + ], + "isUnique": false + }, + "event_room_channel_template_event_id_suffix_unique": { + "name": "event_room_channel_template_event_id_suffix_unique", + "columns": [ + "event_id", + "suffix" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_room_channel_template_guild_id_guild_guild_id_fk": { + "name": "event_room_channel_template_guild_id_guild_guild_id_fk", + "tableFrom": "event_room_channel_template", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_room_channel_template_event_id_event_id_fk": { + "name": "event_room_channel_template_event_id_event_id_fk", + "tableFrom": "event_room_channel_template", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event": { + "name": "event", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_count": { + "name": "room_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_queues_channel_id": { + "name": "room_queues_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sub_queues_channel_id": { + "name": "sub_queues_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_length_ms": { + "name": "room_length_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_scheduling": { + "name": "room_scheduling", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'parallel'" + }, + "create_offset_ms": { + "name": "create_offset_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 86400000 + }, + "lock_offset_ms": { + "name": "lock_offset_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cleanup_offset_ms": { + "name": "cleanup_offset_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 3600000 + }, + "announcement_channel_id": { + "name": "announcement_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "announcement_message": { + "name": "announcement_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_ping_message": { + "name": "room_ping_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_rooms_per_user": { + "name": "max_rooms_per_user", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "max_subs_per_user": { + "name": "max_subs_per_user", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "parent_sub_mutually_exclusive": { + "name": "parent_sub_mutually_exclusive", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "room_category_id": { + "name": "room_category_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_in_room_queue": { + "name": "role_in_room_queue", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "role_on_room_pull": { + "name": "role_on_room_pull", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "role_in_sub_queue": { + "name": "role_in_sub_queue", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "role_on_sub_pull": { + "name": "role_on_sub_pull", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_pull_subs_at_room_start_toggle": { + "name": "auto_pull_subs_at_room_start_toggle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "shuffle_subs_before_auto_pull_toggle": { + "name": "shuffle_subs_before_auto_pull_toggle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sub_auto_pull_mode": { + "name": "sub_auto_pull_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'drain'" + }, + "create_discord_event": { + "name": "create_discord_event", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "discord_event_description": { + "name": "discord_event_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "winner_role_id": { + "name": "winner_role_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "event_guild_id_index": { + "name": "event_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "event_name_guild_id_unique": { + "name": "event_name_guild_id_unique", + "columns": [ + "name", + "guild_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_guild_id_guild_guild_id_fk": { + "name": "event_guild_id_guild_guild_id_fk", + "tableFrom": "event", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_whitelisted": { + "name": "event_whitelisted", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_role": { + "name": "is_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "event_whitelisted_guild_id_index": { + "name": "event_whitelisted_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "event_whitelisted_event_id_index": { + "name": "event_whitelisted_event_id_index", + "columns": [ + "event_id" + ], + "isUnique": false + }, + "event_whitelisted_event_id_subject_id_unique": { + "name": "event_whitelisted_event_id_subject_id_unique", + "columns": [ + "event_id", + "subject_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_whitelisted_guild_id_guild_guild_id_fk": { + "name": "event_whitelisted_guild_id_guild_guild_id_fk", + "tableFrom": "event_whitelisted", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_whitelisted_event_id_event_id_fk": { + "name": "event_whitelisted_event_id_event_id_fk", + "tableFrom": "event_whitelisted", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_winner": { + "name": "event_winner", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "declared_at": { + "name": "declared_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "event_winner_guild_id_index": { + "name": "event_winner_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "event_winner_event_id_index": { + "name": "event_winner_event_id_index", + "columns": [ + "event_id" + ], + "isUnique": false + }, + "event_winner_event_id_user_id_unique": { + "name": "event_winner_event_id_user_id_unique", + "columns": [ + "event_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_winner_guild_id_guild_guild_id_fk": { + "name": "event_winner_guild_id_guild_guild_id_fk", + "tableFrom": "event_winner", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_winner_event_id_event_id_fk": { + "name": "event_winner_event_id_event_id_fk", + "tableFrom": "event_winner", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guild_blacklisted": { + "name": "guild_blacklisted", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_role": { + "name": "is_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "guild_blacklisted_guild_id_index": { + "name": "guild_blacklisted_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "guild_blacklisted_guild_id_subject_id_unique": { + "name": "guild_blacklisted_guild_id_subject_id_unique", + "columns": [ + "guild_id", + "subject_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "guild_blacklisted_guild_id_guild_guild_id_fk": { + "name": "guild_blacklisted_guild_id_guild_guild_id_fk", + "tableFrom": "guild_blacklisted", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guild_prioritized": { + "name": "guild_prioritized", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_role": { + "name": "is_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "priority_order": { + "name": "priority_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 5 + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "guild_prioritized_guild_id_index": { + "name": "guild_prioritized_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "guild_prioritized_guild_id_subject_id_unique": { + "name": "guild_prioritized_guild_id_subject_id_unique", + "columns": [ + "guild_id", + "subject_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "guild_prioritized_guild_id_guild_guild_id_fk": { + "name": "guild_prioritized_guild_id_guild_guild_id_fk", + "tableFrom": "guild_prioritized", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guild": { + "name": "guild", + "columns": { + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "log_channel_id": { + "name": "log_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "log_scope": { + "name": "log_scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "joinTime": { + "name": "joinTime", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_updated_time": { + "name": "last_updated_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "messages_received": { + "name": "messages_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "commands_received": { + "name": "commands_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "buttons_received": { + "name": "buttons_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "queues_added": { + "name": "queues_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "voices_added": { + "name": "voices_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "displays_added": { + "name": "displays_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "members_added": { + "name": "members_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "schedules_added": { + "name": "schedules_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "whitelisted_added": { + "name": "whitelisted_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "blacklisted_added": { + "name": "blacklisted_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "prioritized_added": { + "name": "prioritized_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "admins_added": { + "name": "admins_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "archived_members_added": { + "name": "archived_members_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "events_added": { + "name": "events_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guild_whitelisted": { + "name": "guild_whitelisted", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_role": { + "name": "is_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "guild_whitelisted_guild_id_index": { + "name": "guild_whitelisted_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "guild_whitelisted_guild_id_subject_id_unique": { + "name": "guild_whitelisted_guild_id_subject_id_unique", + "columns": [ + "guild_id", + "subject_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "guild_whitelisted_guild_id_guild_guild_id_fk": { + "name": "guild_whitelisted_guild_id_guild_guild_id_fk", + "tableFrom": "guild_whitelisted", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_id": { + "name": "queue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position_time": { + "name": "position_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "join_time": { + "name": "join_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "priority_order": { + "name": "priority_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "member_guild_id_index": { + "name": "member_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "member_priority_order_index": { + "name": "member_priority_order_index", + "columns": [ + "priority_order" + ], + "isUnique": false + }, + "member_position_time_index": { + "name": "member_position_time_index", + "columns": [ + "position_time" + ], + "isUnique": false + }, + "member_queue_id_user_id_unique": { + "name": "member_queue_id_user_id_unique", + "columns": [ + "queue_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "member_guild_id_guild_guild_id_fk": { + "name": "member_guild_id_guild_guild_id_fk", + "tableFrom": "member", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_queue_id_queue_id_fk": { + "name": "member_queue_id_queue_id_fk", + "tableFrom": "member", + "tableTo": "queue", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "patch_note": { + "name": "patch_note", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "prioritized": { + "name": "prioritized", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_id": { + "name": "queue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_role": { + "name": "is_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "priority_order": { + "name": "priority_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 5 + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "prioritized_guild_id_index": { + "name": "prioritized_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "prioritized_queue_id_subject_id_unique": { + "name": "prioritized_queue_id_subject_id_unique", + "columns": [ + "queue_id", + "subject_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "prioritized_guild_id_guild_guild_id_fk": { + "name": "prioritized_guild_id_guild_guild_id_fk", + "tableFrom": "prioritized", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "prioritized_queue_id_queue_id_fk": { + "name": "prioritized_queue_id_queue_id_fk", + "tableFrom": "prioritized", + "tableTo": "queue", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "queue": { + "name": "queue", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "autopull_toggle": { + "name": "autopull_toggle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "badge_toggle": { + "name": "badge_toggle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Random'" + }, + "display_update_type": { + "name": "display_update_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'edit'" + }, + "dm_on_pull_toggle": { + "name": "dm_on_pull_toggle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "buttons_toggles": { + "name": "buttons_toggles", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'all'" + }, + "header": { + "name": "header", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inline_toggle": { + "name": "inline_toggle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "lock_toggle": { + "name": "lock_toggle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "member_display_type": { + "name": "member_display_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'mention'" + }, + "pull_batch_size": { + "name": "pull_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "pull_message": { + "name": "pull_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_message_display_type": { + "name": "pull_message_display_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'private'" + }, + "pull_message_channel_id": { + "name": "pull_message_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rejoin_cooldown_period": { + "name": "rejoin_cooldown_period", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "rejoin_grace_period": { + "name": "rejoin_grace_period", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "require_message_to_join": { + "name": "require_message_to_join", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "role_in_queue_id": { + "name": "role_in_queue_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_on_pull_id": { + "name": "role_on_pull_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_display_type": { + "name": "time_display_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'off'" + }, + "voice_destination_channel_id": { + "name": "voice_destination_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voice_only_toggle": { + "name": "voice_only_toggle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "queue_guild_id_index": { + "name": "queue_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "queue_name_guild_id_unique": { + "name": "queue_name_guild_id_unique", + "columns": [ + "name", + "guild_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "queue_guild_id_guild_guild_id_fk": { + "name": "queue_guild_id_guild_guild_id_fk", + "tableFrom": "queue", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule": { + "name": "schedule", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_id": { + "name": "queue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'america/chicago'" + }, + "message_channel_id": { + "name": "message_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schedule_guild_id_index": { + "name": "schedule_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "schedule_queue_id_command_cron_timezone_unique": { + "name": "schedule_queue_id_command_cron_timezone_unique", + "columns": [ + "queue_id", + "command", + "cron", + "timezone" + ], + "isUnique": true + } + }, + "foreignKeys": { + "schedule_guild_id_guild_guild_id_fk": { + "name": "schedule_guild_id_guild_guild_id_fk", + "tableFrom": "schedule", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedule_queue_id_queue_id_fk": { + "name": "schedule_queue_id_queue_id_fk", + "tableFrom": "schedule", + "tableTo": "queue", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "voice": { + "name": "voice", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_id": { + "name": "queue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_channel_id": { + "name": "source_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "join_sync_toggle": { + "name": "join_sync_toggle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "leave_sync_toggle": { + "name": "leave_sync_toggle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "voice_guild_id_index": { + "name": "voice_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "voice_queue_id_source_channel_id_unique": { + "name": "voice_queue_id_source_channel_id_unique", + "columns": [ + "queue_id", + "source_channel_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "voice_guild_id_guild_guild_id_fk": { + "name": "voice_guild_id_guild_guild_id_fk", + "tableFrom": "voice", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_queue_id_queue_id_fk": { + "name": "voice_queue_id_queue_id_fk", + "tableFrom": "voice", + "tableTo": "queue", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "whitelisted": { + "name": "whitelisted", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_id": { + "name": "queue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_role": { + "name": "is_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "whitelisted_guild_id_index": { + "name": "whitelisted_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "whitelisted_queue_id_subject_id_unique": { + "name": "whitelisted_queue_id_subject_id_unique", + "columns": [ + "queue_id", + "subject_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "whitelisted_guild_id_guild_guild_id_fk": { + "name": "whitelisted_guild_id_guild_guild_id_fk", + "tableFrom": "whitelisted", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "whitelisted_queue_id_queue_id_fk": { + "name": "whitelisted_queue_id_queue_id_fk", + "tableFrom": "whitelisted", + "tableTo": "queue", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/data/migrations/meta/0016_snapshot.json b/data/migrations/meta/0016_snapshot.json new file mode 100644 index 00000000..50db9f1d --- /dev/null +++ b/data/migrations/meta/0016_snapshot.json @@ -0,0 +1,3122 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6cad289e-7557-47f5-ba93-aaada038e01f", + "prevId": "f6bf3fe2-99bf-40f7-b662-156ac9d30fb5", + "tables": { + "admin": { + "name": "admin", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_role": { + "name": "is_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_guild_id_index": { + "name": "admin_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "admin_guild_id_subject_id_unique": { + "name": "admin_guild_id_subject_id_unique", + "columns": [ + "guild_id", + "subject_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "admin_guild_id_guild_guild_id_fk": { + "name": "admin_guild_id_guild_guild_id_fk", + "tableFrom": "admin", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "archived_member": { + "name": "archived_member", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_id": { + "name": "queue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position_time": { + "name": "position_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "join_time": { + "name": "join_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archived_time": { + "name": "archived_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "archived_member_queue_id_user_id_unique": { + "name": "archived_member_queue_id_user_id_unique", + "columns": [ + "queue_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "blacklisted": { + "name": "blacklisted", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_id": { + "name": "queue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_role": { + "name": "is_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "blacklisted_guild_id_index": { + "name": "blacklisted_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "blacklisted_queue_id_subject_id_unique": { + "name": "blacklisted_queue_id_subject_id_unique", + "columns": [ + "queue_id", + "subject_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "blacklisted_guild_id_guild_guild_id_fk": { + "name": "blacklisted_guild_id_guild_guild_id_fk", + "tableFrom": "blacklisted", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blacklisted_queue_id_queue_id_fk": { + "name": "blacklisted_queue_id_queue_id_fk", + "tableFrom": "blacklisted", + "tableTo": "queue", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "display": { + "name": "display", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_id": { + "name": "queue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_channel_id": { + "name": "display_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_message_id": { + "name": "last_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "display_guild_id_index": { + "name": "display_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "display_queue_id_display_channel_id_unique": { + "name": "display_queue_id_display_channel_id_unique", + "columns": [ + "queue_id", + "display_channel_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "display_guild_id_guild_guild_id_fk": { + "name": "display_guild_id_guild_guild_id_fk", + "tableFrom": "display", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "display_queue_id_queue_id_fk": { + "name": "display_queue_id_queue_id_fk", + "tableFrom": "display", + "tableTo": "queue", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_blacklisted": { + "name": "event_blacklisted", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_role": { + "name": "is_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "event_blacklisted_guild_id_index": { + "name": "event_blacklisted_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "event_blacklisted_event_id_index": { + "name": "event_blacklisted_event_id_index", + "columns": [ + "event_id" + ], + "isUnique": false + }, + "event_blacklisted_event_id_subject_id_unique": { + "name": "event_blacklisted_event_id_subject_id_unique", + "columns": [ + "event_id", + "subject_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_blacklisted_guild_id_guild_guild_id_fk": { + "name": "event_blacklisted_guild_id_guild_guild_id_fk", + "tableFrom": "event_blacklisted", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_blacklisted_event_id_event_id_fk": { + "name": "event_blacklisted_event_id_event_id_fk", + "tableFrom": "event_blacklisted", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_default": { + "name": "event_default", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_role": { + "name": "queue_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "autopull_toggle": { + "name": "autopull_toggle", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "badge_toggle": { + "name": "badge_toggle", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_update_type": { + "name": "display_update_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dm_on_pull_toggle": { + "name": "dm_on_pull_toggle", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "buttons_toggles": { + "name": "buttons_toggles", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "header": { + "name": "header", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inline_toggle": { + "name": "inline_toggle", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lock_toggle": { + "name": "lock_toggle", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "member_display_type": { + "name": "member_display_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_batch_size": { + "name": "pull_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_message": { + "name": "pull_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_message_display_type": { + "name": "pull_message_display_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_message_channel_id": { + "name": "pull_message_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rejoin_cooldown_period": { + "name": "rejoin_cooldown_period", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rejoin_grace_period": { + "name": "rejoin_grace_period", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "require_message_to_join": { + "name": "require_message_to_join", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_in_queue_id": { + "name": "role_in_queue_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_on_pull_id": { + "name": "role_on_pull_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_display_type": { + "name": "time_display_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voice_destination_channel_id": { + "name": "voice_destination_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voice_only_toggle": { + "name": "voice_only_toggle", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "event_default_guild_id_index": { + "name": "event_default_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "event_default_event_id_queue_role_unique": { + "name": "event_default_event_id_queue_role_unique", + "columns": [ + "event_id", + "queue_role" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_default_guild_id_guild_guild_id_fk": { + "name": "event_default_guild_id_guild_guild_id_fk", + "tableFrom": "event_default", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_default_event_id_event_id_fk": { + "name": "event_default_event_id_event_id_fk", + "tableFrom": "event_default", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_occurrence_room_ping": { + "name": "event_occurrence_room_ping", + "columns": { + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurrence_id": { + "name": "occurrence_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_queue_id": { + "name": "event_queue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "handled_at": { + "name": "handled_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "event_occurrence_room_ping_guild_id_occurrence_id_index": { + "name": "event_occurrence_room_ping_guild_id_occurrence_id_index", + "columns": [ + "guild_id", + "occurrence_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "event_occurrence_room_ping_guild_id_guild_guild_id_fk": { + "name": "event_occurrence_room_ping_guild_id_guild_guild_id_fk", + "tableFrom": "event_occurrence_room_ping", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_occurrence_room_ping_occurrence_id_event_occurrence_id_fk": { + "name": "event_occurrence_room_ping_occurrence_id_event_occurrence_id_fk", + "tableFrom": "event_occurrence_room_ping", + "tableTo": "event_occurrence", + "columnsFrom": [ + "occurrence_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_occurrence_room_ping_event_queue_id_event_queue_id_fk": { + "name": "event_occurrence_room_ping_event_queue_id_event_queue_id_fk", + "tableFrom": "event_occurrence_room_ping", + "tableTo": "event_queue", + "columnsFrom": [ + "event_queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "event_occurrence_room_ping_occurrence_id_event_queue_id_pk": { + "columns": [ + "occurrence_id", + "event_queue_id" + ], + "name": "event_occurrence_room_ping_occurrence_id_event_queue_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_occurrence_room_pull": { + "name": "event_occurrence_room_pull", + "columns": { + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurrence_id": { + "name": "occurrence_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_queue_id": { + "name": "event_queue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "handled_at": { + "name": "handled_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "event_occurrence_room_pull_guild_id_occurrence_id_index": { + "name": "event_occurrence_room_pull_guild_id_occurrence_id_index", + "columns": [ + "guild_id", + "occurrence_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "event_occurrence_room_pull_guild_id_guild_guild_id_fk": { + "name": "event_occurrence_room_pull_guild_id_guild_guild_id_fk", + "tableFrom": "event_occurrence_room_pull", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_occurrence_room_pull_occurrence_id_event_occurrence_id_fk": { + "name": "event_occurrence_room_pull_occurrence_id_event_occurrence_id_fk", + "tableFrom": "event_occurrence_room_pull", + "tableTo": "event_occurrence", + "columnsFrom": [ + "occurrence_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_occurrence_room_pull_event_queue_id_event_queue_id_fk": { + "name": "event_occurrence_room_pull_event_queue_id_event_queue_id_fk", + "tableFrom": "event_occurrence_room_pull", + "tableTo": "event_queue", + "columnsFrom": [ + "event_queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "event_occurrence_room_pull_occurrence_id_event_queue_id_pk": { + "columns": [ + "occurrence_id", + "event_queue_id" + ], + "name": "event_occurrence_room_pull_occurrence_id_event_queue_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_occurrence": { + "name": "event_occurrence", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open_handled_at": { + "name": "open_handled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lock_handled_at": { + "name": "lock_handled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "discord_event_id": { + "name": "discord_event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "event_occurrence_guild_id_index": { + "name": "event_occurrence_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "event_occurrence_event_id_index": { + "name": "event_occurrence_event_id_index", + "columns": [ + "event_id" + ], + "isUnique": false + }, + "event_occurrence_event_id_start_time_unique": { + "name": "event_occurrence_event_id_start_time_unique", + "columns": [ + "event_id", + "start_time" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_occurrence_guild_id_guild_guild_id_fk": { + "name": "event_occurrence_guild_id_guild_guild_id_fk", + "tableFrom": "event_occurrence", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_occurrence_event_id_event_id_fk": { + "name": "event_occurrence_event_id_event_id_fk", + "tableFrom": "event_occurrence", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_prioritized": { + "name": "event_prioritized", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_role": { + "name": "is_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "priority_order": { + "name": "priority_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 5 + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "event_prioritized_guild_id_index": { + "name": "event_prioritized_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "event_prioritized_event_id_index": { + "name": "event_prioritized_event_id_index", + "columns": [ + "event_id" + ], + "isUnique": false + }, + "event_prioritized_event_id_subject_id_unique": { + "name": "event_prioritized_event_id_subject_id_unique", + "columns": [ + "event_id", + "subject_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_prioritized_guild_id_guild_guild_id_fk": { + "name": "event_prioritized_guild_id_guild_guild_id_fk", + "tableFrom": "event_prioritized", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_prioritized_event_id_event_id_fk": { + "name": "event_prioritized_event_id_event_id_fk", + "tableFrom": "event_prioritized", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_queue": { + "name": "event_queue", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_id": { + "name": "queue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_role": { + "name": "queue_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_index": { + "name": "queue_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ping_channel_id": { + "name": "ping_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_created_role_id": { + "name": "auto_created_role_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "event_queue_guild_id_index": { + "name": "event_queue_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "event_queue_event_id_index": { + "name": "event_queue_event_id_index", + "columns": [ + "event_id" + ], + "isUnique": false + }, + "event_queue_event_id_queue_role_queue_index_unique": { + "name": "event_queue_event_id_queue_role_queue_index_unique", + "columns": [ + "event_id", + "queue_role", + "queue_index" + ], + "isUnique": true + }, + "event_queue_queue_id_unique": { + "name": "event_queue_queue_id_unique", + "columns": [ + "queue_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_queue_guild_id_guild_guild_id_fk": { + "name": "event_queue_guild_id_guild_guild_id_fk", + "tableFrom": "event_queue", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_queue_event_id_event_id_fk": { + "name": "event_queue_event_id_event_id_fk", + "tableFrom": "event_queue", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_queue_queue_id_queue_id_fk": { + "name": "event_queue_queue_id_queue_id_fk", + "tableFrom": "event_queue", + "tableTo": "queue", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_room_channel": { + "name": "event_room_channel", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_index": { + "name": "room_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "suffix": { + "name": "suffix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "event_room_channel_guild_id_index": { + "name": "event_room_channel_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "event_room_channel_event_id_index": { + "name": "event_room_channel_event_id_index", + "columns": [ + "event_id" + ], + "isUnique": false + }, + "event_room_channel_event_id_room_index_suffix_unique": { + "name": "event_room_channel_event_id_room_index_suffix_unique", + "columns": [ + "event_id", + "room_index", + "suffix" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_room_channel_guild_id_guild_guild_id_fk": { + "name": "event_room_channel_guild_id_guild_guild_id_fk", + "tableFrom": "event_room_channel", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_room_channel_event_id_event_id_fk": { + "name": "event_room_channel_event_id_event_id_fk", + "tableFrom": "event_room_channel", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_room_channel_template": { + "name": "event_room_channel_template", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "suffix": { + "name": "suffix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slowmode_seconds": { + "name": "slowmode_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "event_room_channel_template_guild_id_index": { + "name": "event_room_channel_template_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "event_room_channel_template_event_id_index": { + "name": "event_room_channel_template_event_id_index", + "columns": [ + "event_id" + ], + "isUnique": false + }, + "event_room_channel_template_event_id_suffix_unique": { + "name": "event_room_channel_template_event_id_suffix_unique", + "columns": [ + "event_id", + "suffix" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_room_channel_template_guild_id_guild_guild_id_fk": { + "name": "event_room_channel_template_guild_id_guild_guild_id_fk", + "tableFrom": "event_room_channel_template", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_room_channel_template_event_id_event_id_fk": { + "name": "event_room_channel_template_event_id_event_id_fk", + "tableFrom": "event_room_channel_template", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_sync_lock": { + "name": "event_sync_lock", + "columns": { + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_at": { + "name": "locked_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "event_sync_lock_guild_id_event_id_pk": { + "columns": [ + "guild_id", + "event_id" + ], + "name": "event_sync_lock_guild_id_event_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event": { + "name": "event", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_count": { + "name": "room_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_queues_channel_id": { + "name": "room_queues_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sub_queues_channel_id": { + "name": "sub_queues_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_length_ms": { + "name": "room_length_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_scheduling": { + "name": "room_scheduling", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'parallel'" + }, + "create_offset_ms": { + "name": "create_offset_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 86400000 + }, + "lock_offset_ms": { + "name": "lock_offset_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cleanup_offset_ms": { + "name": "cleanup_offset_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 3600000 + }, + "announcement_channel_id": { + "name": "announcement_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "announcement_message": { + "name": "announcement_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_ping_message": { + "name": "room_ping_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_rooms_per_user": { + "name": "max_rooms_per_user", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "max_subs_per_user": { + "name": "max_subs_per_user", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "parent_sub_mutually_exclusive": { + "name": "parent_sub_mutually_exclusive", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "room_category_id": { + "name": "room_category_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_in_room_queue": { + "name": "role_in_room_queue", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "role_on_room_pull": { + "name": "role_on_room_pull", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "role_in_sub_queue": { + "name": "role_in_sub_queue", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "role_on_sub_pull": { + "name": "role_on_sub_pull", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_pull_subs_at_room_start_toggle": { + "name": "auto_pull_subs_at_room_start_toggle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "shuffle_subs_before_auto_pull_toggle": { + "name": "shuffle_subs_before_auto_pull_toggle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sub_auto_pull_mode": { + "name": "sub_auto_pull_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'drain'" + }, + "create_discord_event": { + "name": "create_discord_event", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "discord_event_description": { + "name": "discord_event_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "winner_role_id": { + "name": "winner_role_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "event_guild_id_index": { + "name": "event_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "event_name_guild_id_unique": { + "name": "event_name_guild_id_unique", + "columns": [ + "name", + "guild_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_guild_id_guild_guild_id_fk": { + "name": "event_guild_id_guild_guild_id_fk", + "tableFrom": "event", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_whitelisted": { + "name": "event_whitelisted", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_role": { + "name": "is_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "event_whitelisted_guild_id_index": { + "name": "event_whitelisted_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "event_whitelisted_event_id_index": { + "name": "event_whitelisted_event_id_index", + "columns": [ + "event_id" + ], + "isUnique": false + }, + "event_whitelisted_event_id_subject_id_unique": { + "name": "event_whitelisted_event_id_subject_id_unique", + "columns": [ + "event_id", + "subject_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_whitelisted_guild_id_guild_guild_id_fk": { + "name": "event_whitelisted_guild_id_guild_guild_id_fk", + "tableFrom": "event_whitelisted", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_whitelisted_event_id_event_id_fk": { + "name": "event_whitelisted_event_id_event_id_fk", + "tableFrom": "event_whitelisted", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_winner": { + "name": "event_winner", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "declared_at": { + "name": "declared_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "event_winner_guild_id_index": { + "name": "event_winner_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "event_winner_event_id_index": { + "name": "event_winner_event_id_index", + "columns": [ + "event_id" + ], + "isUnique": false + }, + "event_winner_event_id_user_id_unique": { + "name": "event_winner_event_id_user_id_unique", + "columns": [ + "event_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_winner_guild_id_guild_guild_id_fk": { + "name": "event_winner_guild_id_guild_guild_id_fk", + "tableFrom": "event_winner", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_winner_event_id_event_id_fk": { + "name": "event_winner_event_id_event_id_fk", + "tableFrom": "event_winner", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guild_blacklisted": { + "name": "guild_blacklisted", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_role": { + "name": "is_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "guild_blacklisted_guild_id_index": { + "name": "guild_blacklisted_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "guild_blacklisted_guild_id_subject_id_unique": { + "name": "guild_blacklisted_guild_id_subject_id_unique", + "columns": [ + "guild_id", + "subject_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "guild_blacklisted_guild_id_guild_guild_id_fk": { + "name": "guild_blacklisted_guild_id_guild_guild_id_fk", + "tableFrom": "guild_blacklisted", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guild_prioritized": { + "name": "guild_prioritized", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_role": { + "name": "is_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "priority_order": { + "name": "priority_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 5 + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "guild_prioritized_guild_id_index": { + "name": "guild_prioritized_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "guild_prioritized_guild_id_subject_id_unique": { + "name": "guild_prioritized_guild_id_subject_id_unique", + "columns": [ + "guild_id", + "subject_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "guild_prioritized_guild_id_guild_guild_id_fk": { + "name": "guild_prioritized_guild_id_guild_guild_id_fk", + "tableFrom": "guild_prioritized", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guild": { + "name": "guild", + "columns": { + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "log_channel_id": { + "name": "log_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "log_scope": { + "name": "log_scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "joinTime": { + "name": "joinTime", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_updated_time": { + "name": "last_updated_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "messages_received": { + "name": "messages_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "commands_received": { + "name": "commands_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "buttons_received": { + "name": "buttons_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "queues_added": { + "name": "queues_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "voices_added": { + "name": "voices_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "displays_added": { + "name": "displays_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "members_added": { + "name": "members_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "schedules_added": { + "name": "schedules_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "whitelisted_added": { + "name": "whitelisted_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "blacklisted_added": { + "name": "blacklisted_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "prioritized_added": { + "name": "prioritized_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "admins_added": { + "name": "admins_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "archived_members_added": { + "name": "archived_members_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "events_added": { + "name": "events_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guild_whitelisted": { + "name": "guild_whitelisted", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_role": { + "name": "is_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "guild_whitelisted_guild_id_index": { + "name": "guild_whitelisted_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "guild_whitelisted_guild_id_subject_id_unique": { + "name": "guild_whitelisted_guild_id_subject_id_unique", + "columns": [ + "guild_id", + "subject_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "guild_whitelisted_guild_id_guild_guild_id_fk": { + "name": "guild_whitelisted_guild_id_guild_guild_id_fk", + "tableFrom": "guild_whitelisted", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_id": { + "name": "queue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position_time": { + "name": "position_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "join_time": { + "name": "join_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "priority_order": { + "name": "priority_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "member_guild_id_index": { + "name": "member_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "member_priority_order_index": { + "name": "member_priority_order_index", + "columns": [ + "priority_order" + ], + "isUnique": false + }, + "member_position_time_index": { + "name": "member_position_time_index", + "columns": [ + "position_time" + ], + "isUnique": false + }, + "member_queue_id_user_id_unique": { + "name": "member_queue_id_user_id_unique", + "columns": [ + "queue_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "member_guild_id_guild_guild_id_fk": { + "name": "member_guild_id_guild_guild_id_fk", + "tableFrom": "member", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_queue_id_queue_id_fk": { + "name": "member_queue_id_queue_id_fk", + "tableFrom": "member", + "tableTo": "queue", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "patch_note": { + "name": "patch_note", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "prioritized": { + "name": "prioritized", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_id": { + "name": "queue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_role": { + "name": "is_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "priority_order": { + "name": "priority_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 5 + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "prioritized_guild_id_index": { + "name": "prioritized_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "prioritized_queue_id_subject_id_unique": { + "name": "prioritized_queue_id_subject_id_unique", + "columns": [ + "queue_id", + "subject_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "prioritized_guild_id_guild_guild_id_fk": { + "name": "prioritized_guild_id_guild_guild_id_fk", + "tableFrom": "prioritized", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "prioritized_queue_id_queue_id_fk": { + "name": "prioritized_queue_id_queue_id_fk", + "tableFrom": "prioritized", + "tableTo": "queue", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "queue": { + "name": "queue", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "autopull_toggle": { + "name": "autopull_toggle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "badge_toggle": { + "name": "badge_toggle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Random'" + }, + "display_update_type": { + "name": "display_update_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'edit'" + }, + "dm_on_pull_toggle": { + "name": "dm_on_pull_toggle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "buttons_toggles": { + "name": "buttons_toggles", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'all'" + }, + "header": { + "name": "header", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inline_toggle": { + "name": "inline_toggle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "lock_toggle": { + "name": "lock_toggle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "member_display_type": { + "name": "member_display_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'mention'" + }, + "pull_batch_size": { + "name": "pull_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "pull_message": { + "name": "pull_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_message_display_type": { + "name": "pull_message_display_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'private'" + }, + "pull_message_channel_id": { + "name": "pull_message_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rejoin_cooldown_period": { + "name": "rejoin_cooldown_period", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "rejoin_grace_period": { + "name": "rejoin_grace_period", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "require_message_to_join": { + "name": "require_message_to_join", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "role_in_queue_id": { + "name": "role_in_queue_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_on_pull_id": { + "name": "role_on_pull_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_display_type": { + "name": "time_display_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'off'" + }, + "voice_destination_channel_id": { + "name": "voice_destination_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voice_only_toggle": { + "name": "voice_only_toggle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "queue_guild_id_index": { + "name": "queue_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "queue_name_guild_id_unique": { + "name": "queue_name_guild_id_unique", + "columns": [ + "name", + "guild_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "queue_guild_id_guild_guild_id_fk": { + "name": "queue_guild_id_guild_guild_id_fk", + "tableFrom": "queue", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule": { + "name": "schedule", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_id": { + "name": "queue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'america/chicago'" + }, + "message_channel_id": { + "name": "message_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "schedule_guild_id_index": { + "name": "schedule_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "schedule_queue_id_command_cron_timezone_unique": { + "name": "schedule_queue_id_command_cron_timezone_unique", + "columns": [ + "queue_id", + "command", + "cron", + "timezone" + ], + "isUnique": true + } + }, + "foreignKeys": { + "schedule_guild_id_guild_guild_id_fk": { + "name": "schedule_guild_id_guild_guild_id_fk", + "tableFrom": "schedule", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedule_queue_id_queue_id_fk": { + "name": "schedule_queue_id_queue_id_fk", + "tableFrom": "schedule", + "tableTo": "queue", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "voice": { + "name": "voice", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_id": { + "name": "queue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_channel_id": { + "name": "source_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "join_sync_toggle": { + "name": "join_sync_toggle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "leave_sync_toggle": { + "name": "leave_sync_toggle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "voice_guild_id_index": { + "name": "voice_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "voice_queue_id_source_channel_id_unique": { + "name": "voice_queue_id_source_channel_id_unique", + "columns": [ + "queue_id", + "source_channel_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "voice_guild_id_guild_guild_id_fk": { + "name": "voice_guild_id_guild_guild_id_fk", + "tableFrom": "voice", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "voice_queue_id_queue_id_fk": { + "name": "voice_queue_id_queue_id_fk", + "tableFrom": "voice", + "tableTo": "queue", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "whitelisted": { + "name": "whitelisted", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_id": { + "name": "queue_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_role": { + "name": "is_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "whitelisted_guild_id_index": { + "name": "whitelisted_guild_id_index", + "columns": [ + "guild_id" + ], + "isUnique": false + }, + "whitelisted_queue_id_subject_id_unique": { + "name": "whitelisted_queue_id_subject_id_unique", + "columns": [ + "queue_id", + "subject_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "whitelisted_guild_id_guild_guild_id_fk": { + "name": "whitelisted_guild_id_guild_guild_id_fk", + "tableFrom": "whitelisted", + "tableTo": "guild", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "guild_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "whitelisted_queue_id_queue_id_fk": { + "name": "whitelisted_queue_id_queue_id_fk", + "tableFrom": "whitelisted", + "tableTo": "queue", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/data/migrations/meta/_journal.json b/data/migrations/meta/_journal.json index 4d1c94d0..69504084 100644 --- a/data/migrations/meta/_journal.json +++ b/data/migrations/meta/_journal.json @@ -106,6 +106,20 @@ "when": 1780204452392, "tag": "0014_narrow_wind_dancer", "breakpoints": true + }, + { + "idx": 15, + "version": "6", + "when": 1782200971521, + "tag": "0015_early_black_knight", + "breakpoints": true + }, + { + "idx": 16, + "version": "6", + "when": 1782274698206, + "tag": "0016_real_loki", + "breakpoints": true } ] } \ No newline at end of file diff --git a/docker-compose.app.yml b/docker-compose.app.yml index 6d51fd40..65a220be 100644 --- a/docker-compose.app.yml +++ b/docker-compose.app.yml @@ -1,6 +1,6 @@ services: app: - image: ghcr.io/getboolean/event-queue-bot:${IMAGE_TAG:-master} + image: ${GHCR_IMAGE:-ghcr.io/getboolean/event-queue-bot}:${IMAGE_TAG:-master} container_name: ${CONTAINER_NAME:-queue-bot} restart: always env_file: .env @@ -10,8 +10,11 @@ services: - ./data/main.sqlite:/app/data/main.sqlite # Bind mount for database - ./data/backups:/app/data/backups # Bind mount for DB backups - ./.env:/app/.env # Bind mount for .env file - stdin_open: true # Allow stdin to be open - tty: true # Allocate a pseudo-TTY + deploy: + resources: + limits: + cpus: "0.45" + memory: 400M logging: driver: json-file options: diff --git a/eslint.config.js b/eslint.config.js index dca1043a..2adf2574 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -37,6 +37,7 @@ export default tseslint.config( // false`). The rest of `tseslint.configs.recommended` is left enabled. "@typescript-eslint/no-namespace": "off", "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/ban-ts-comment": "off", "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], // `no-undef` and `no-unused-vars` are turned off per typescript-eslint convention @@ -50,6 +51,9 @@ export default tseslint.config( "simple-import-sort/imports": "error", // Additional core rules (not in recommended) carried over from the legacy config + "no-case-declarations": "off", + "no-inner-declarations": "off", + "no-unused-expressions": "off", "no-empty-function": ["error", { allow: ["constructors"] }], "no-lonely-if": "error", "no-var": "error", diff --git a/infra/digitalocean/cloud-init.yml b/infra/digitalocean/cloud-init.yml index fdfc992b..c75d0a8c 100644 --- a/infra/digitalocean/cloud-init.yml +++ b/infra/digitalocean/cloud-init.yml @@ -94,6 +94,15 @@ write_files: content: | deploy ALL=(root) NOPASSWD: /usr/local/bin/deploy-event-queue-bot + - path: /etc/fail2ban/jail.local + owner: root:root + permissions: "0644" + content: | + [sshd] + enabled = true + maxretry = 5 + bantime = 1h + - path: /etc/ssh/ssh_host_ed25519_key owner: root:root permissions: "0600" @@ -108,7 +117,8 @@ write_files: runcmd: - systemctl restart ssh - apt-get update - - apt-get install -y ca-certificates curl git gnupg rsync + - apt-get install -y ca-certificates curl fail2ban git gnupg rsync + - systemctl enable --now fail2ban - install -m 0755 -d /etc/apt/keyrings - curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc - chmod a+r /etc/apt/keyrings/docker.asc diff --git a/package-lock.json b/package-lock.json index 3646b2b3..382d4a8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "csv-parser": "^3.0.0", "date-fns": "^4.1.0", "discord.js": "^14.14.1", - "drizzle-kit": "^0.31.10", "drizzle-orm": "^0.45.2", "lodash-es": "^4.17.21", "moment-timezone": "^0.6.2", @@ -27,6 +26,7 @@ "@types/lodash-es": "^4.17.12", "@types/node": "^24.12.4", "@types/node-schedule": "^2.1.8", + "drizzle-kit": "^0.31.10", "eslint": "^10.4.0", "eslint-plugin-simple-import-sort": "^13.0.0", "eslint-plugin-unused-imports": "^4.1.4", @@ -180,6 +180,7 @@ "version": "0.10.2", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, "license": "Apache-2.0" }, "node_modules/@emnapi/core": { @@ -218,6 +219,7 @@ "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, "license": "MIT", "dependencies": { "esbuild": "~0.18.20", @@ -231,6 +233,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -247,6 +250,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -263,6 +267,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -279,6 +284,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -295,6 +301,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -311,6 +318,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -327,6 +335,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -343,6 +352,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -359,6 +369,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -375,6 +386,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -391,6 +403,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -407,6 +420,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -423,6 +437,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -439,6 +454,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -455,6 +471,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -471,6 +488,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -487,6 +505,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -503,6 +522,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -519,6 +539,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -535,6 +556,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -551,6 +573,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -567,6 +590,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -580,6 +604,7 @@ "version": "0.18.20", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -618,6 +643,7 @@ "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, "license": "MIT", "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", @@ -631,6 +657,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -647,6 +674,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -663,6 +691,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -679,6 +708,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -695,6 +725,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -711,6 +742,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -727,6 +759,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -743,6 +776,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -759,6 +793,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -775,6 +810,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -791,6 +827,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -807,6 +844,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -823,6 +861,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -839,6 +878,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -855,6 +895,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -871,6 +912,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -887,6 +929,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -903,6 +946,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -919,6 +963,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -935,6 +980,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -951,6 +997,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -967,6 +1014,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -983,6 +1031,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -999,6 +1048,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1015,6 +1065,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1031,6 +1082,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3008,6 +3060,7 @@ "version": "0.31.10", "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "dev": true, "license": "MIT", "dependencies": { "@drizzle-team/brocli": "^0.10.2", @@ -3164,6 +3217,7 @@ "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -3557,6 +3611,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3571,6 +3626,7 @@ "version": "4.14.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -4533,6 +4589,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" @@ -4878,6 +4935,7 @@ "version": "4.22.3", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "dev": true, "license": "MIT", "dependencies": { "esbuild": "~0.28.0" @@ -4899,6 +4957,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4915,6 +4974,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4931,6 +4991,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4947,6 +5008,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4963,6 +5025,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4979,6 +5042,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4995,6 +5059,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5011,6 +5076,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5027,6 +5093,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5043,6 +5110,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5059,6 +5127,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5075,6 +5144,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5091,6 +5161,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5107,6 +5178,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5123,6 +5195,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5139,6 +5212,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5155,6 +5229,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5171,6 +5246,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5187,6 +5263,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5203,6 +5280,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5219,6 +5297,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5235,6 +5314,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5251,6 +5331,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5267,6 +5348,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5283,6 +5365,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5299,6 +5382,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5312,6 +5396,7 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index 3ca87d68..5e9d7c09 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "lint": "eslint src patch-notes drizzle.config.ts vitest.config.ts --fix", "lint:check": "eslint src patch-notes drizzle.config.ts vitest.config.ts", "typecheck": "tsc --noEmit", - "test": "vitest run --passWithNoTests" + "test": "vitest run" }, "type": "module", "devDependencies": { @@ -24,7 +24,8 @@ "globals": "^16.5.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.4", - "vitest": "^4.1.7" + "vitest": "^4.1.7", + "drizzle-kit": "^0.31.10" }, "dependencies": { "@swc-node/register": "^1.9.2", @@ -33,7 +34,6 @@ "csv-parser": "^3.0.0", "date-fns": "^4.1.0", "discord.js": "^14.14.1", - "drizzle-kit": "^0.31.10", "drizzle-orm": "^0.45.2", "lodash-es": "^4.17.21", "moment-timezone": "^0.6.2", diff --git a/scripts/ensure-firewall.sh b/scripts/ensure-firewall.sh index fb7e322c..85674e13 100644 --- a/scripts/ensure-firewall.sh +++ b/scripts/ensure-firewall.sh @@ -25,7 +25,20 @@ if [ -z "${droplet_id}" ]; then exit 0 fi -inbound_rules="protocol:tcp,ports:22,address:0.0.0.0/0,address:::/0" +if [ -n "${SSH_ALLOW_IPS:-}" ]; then + inbound_rules="protocol:tcp,ports:22" + IFS=',' read -ra ssh_allow_ips <<< "${SSH_ALLOW_IPS}" + for ip in "${ssh_allow_ips[@]}"; do + ip="${ip#"${ip%%[![:space:]]*}"}" + ip="${ip%"${ip##*[![:space:]]}"}" + [ -n "${ip}" ] || continue + inbound_rules="${inbound_rules},address:${ip}" + done + echo "Restricting SSH to SSH_ALLOW_IPS: ${SSH_ALLOW_IPS}" +else + inbound_rules="protocol:tcp,ports:22,address:0.0.0.0/0,address:::/0" + echo "SSH open to all addresses (set SSH_ALLOW_IPS to restrict)." +fi outbound_rules="protocol:icmp,address:0.0.0.0/0,address:::/0 protocol:tcp,ports:all,address:0.0.0.0/0,address:::/0 protocol:udp,ports:all,address:0.0.0.0/0,address:::/0" firewalls_json="$(doctl compute firewall list --output json)" diff --git a/src/client/client.ts b/src/client/client.ts index 994be378..358b11a6 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -4,6 +4,7 @@ import { checkForMigration } from "../db/legacy-migration/migrate.ts"; import { ClientListeners } from "../listeners/client.listeners.ts"; import { ClientUtils } from "../utils/client.utils.ts"; import { EventUtils } from "../utils/event.utils.ts"; +import { EventSyncLock } from "../utils/event-sync-lock.utils.ts"; import { ScheduleUtils } from "../utils/schedule.utils.ts"; export const CLIENT = new DiscordClient({ @@ -78,6 +79,8 @@ export namespace Client { ScheduleUtils.loadSchedules(); + EventSyncLock.cleanupStaleLocks(); + EventUtils.loadOccurrences(); console.timeEnd("READY"); diff --git a/src/commands/commands/events.command.ts b/src/commands/commands/events.command.ts index 70ddef29..d1ae580e 100644 --- a/src/commands/commands/events.command.ts +++ b/src/commands/commands/events.command.ts @@ -1,149 +1,39 @@ -import { channelMention, type Collection, EmbedBuilder, inlineCode, PermissionsBitField, roleMention, SlashCommandBuilder, time, TimestampStyles, userMention } from "discord.js"; -import { SQLiteColumn } from "drizzle-orm/sqlite-core"; -import { compact, findKey, isNil, omitBy } from "lodash-es"; +import { SlashCommandBuilder } from "discord.js"; -import { Queries } from "../../db/queries.ts"; -import { type DbEvent, EVENT_TABLE, QUEUE_TABLE } from "../../db/schema.ts"; -import { UserOption } from "../../options/base-option.ts"; -import { AnnouncementChannelOption } from "../../options/options/announcement-channel.option.ts"; -import { AnnouncementMessageOption } from "../../options/options/announcement-message.option.ts"; -import { AutoPullSubsAtRoomStartToggleOption } from "../../options/options/auto-pull-subs-at-room-start-toggle.option.ts"; -import { AutopullToggleOption } from "../../options/options/autopull-toggle.option.ts"; -import { BadgeToggleOption } from "../../options/options/badge-toggle.option.ts"; -import { ChannelSuffixOption } from "../../options/options/channel-suffix.option.ts"; -import { CleanupOffsetHoursOption } from "../../options/options/cleanup-offset-hours.option.ts"; -import { ColorOption } from "../../options/options/color.option.ts"; -import { CreateDiscordEventToggleOption } from "../../options/options/create-discord-event-toggle.option.ts"; -import { CreateOffsetHoursOption } from "../../options/options/create-offset-hours.option.ts"; -import { DayOption } from "../../options/options/day.option.ts"; -import { DiscordEventDescriptionOption } from "../../options/options/discord-event-description.option.ts"; -import { ButtonsToggleOption } from "../../options/options/display-buttons.option.ts"; -import { DisplayUpdateTypeOption } from "../../options/options/display-update-type.option.ts"; -import { DmOnPullToggleOption } from "../../options/options/dm-on-pull-toggle.option.ts"; -import { EventOption } from "../../options/options/event.option.ts"; -import { EventsOption } from "../../options/options/events.option.ts"; -import { HeaderOption } from "../../options/options/header.option.ts"; -import { InlineToggleOption } from "../../options/options/inline-toggle.option.ts"; -import { LockOffsetMinutesOption } from "../../options/options/lock-offset-minutes.option.ts"; -import { LockToggleOption } from "../../options/options/lock-toggle.option.ts"; -import { MaxRoomsPerUserOption } from "../../options/options/max-rooms-per-user.option.ts"; -import { MaxSubsPerUserOption } from "../../options/options/max-subs-per-user.option.ts"; -import { MemberDisplayTypeOption } from "../../options/options/member-display-type.option.ts"; -import { MonthOption } from "../../options/options/month.option.ts"; -import { NameOption } from "../../options/options/name.option.ts"; -import { ParentSubMutuallyExclusiveOption } from "../../options/options/parent-sub-mutually-exclusive.option.ts"; -import { PullBatchSizeOption } from "../../options/options/pull-batch-size.option.ts"; -import { PullMessageOption } from "../../options/options/pull-message.option.ts"; -import { PullMessageChannelOption } from "../../options/options/pull-message-channel.option.ts"; -import { PullMessageDisplayTypeOption } from "../../options/options/pull-message-display-type.option.ts"; -import { RejoinCooldownPeriodOption } from "../../options/options/rejoin-cooldown-period.option.ts"; -import { RejoinGracePeriodOption } from "../../options/options/rejoin-grace-period.option.ts"; -import { RequireMessageToJoinOption } from "../../options/options/require-message-to-join.option.ts"; -import { RoleInQueueOption } from "../../options/options/role-in-queue.option.ts"; -import { RoleInRoomQueueOption } from "../../options/options/role-in-room-queue.option.ts"; -import { RoleInSubQueueOption } from "../../options/options/role-in-sub-queue.option.ts"; -import { RoleOnPullOption } from "../../options/options/role-on-pull.option.ts"; -import { RoleOnRoomPullOption } from "../../options/options/role-on-room-pull.option.ts"; -import { RoleOnSubPullOption } from "../../options/options/role-on-sub-pull.option.ts"; -import { RoomCategoryOption } from "../../options/options/room-category.option.ts"; -import { RoomCountOption } from "../../options/options/room-count.option.ts"; -import { RoomLengthMinutesOption } from "../../options/options/room-length-minutes.option.ts"; -import { RoomPingMessageOption } from "../../options/options/room-ping-message.option.ts"; -import { RoomQueuesChannelOption } from "../../options/options/room-queues-channel.option.ts"; -import { RoomSchedulingOption } from "../../options/options/room-scheduling.option.ts"; -import { ShuffleSubsBeforeAutoPullToggleOption } from "../../options/options/shuffle-subs-before-auto-pull-toggle.option.ts"; -import { SizeOption } from "../../options/options/size.option.ts"; -import { SlowmodeOption } from "../../options/options/slowmode.option.ts"; -import { SlowmodeTimeOption } from "../../options/options/slowmode-time.option.ts"; -import { StartTimeOption } from "../../options/options/start-time.option.ts"; -import { SubAutoPullModeOption } from "../../options/options/sub-auto-pull-mode.option.ts"; -import { SubQueuesChannelOption } from "../../options/options/sub-queues-channel.option.ts"; -import { TimestampTypeOption } from "../../options/options/timestamp-type.option.ts"; -import { TimezoneOption } from "../../options/options/timezone.option.ts"; -import { VoiceDestinationChannelOption } from "../../options/options/voice-destination-channel.option.ts"; -import { VoiceOnlyToggleOption } from "../../options/options/voice-only-toggle.option.ts"; -import { WinnerRoleOption } from "../../options/options/winner-role.option.ts"; -import { YearOption } from "../../options/options/year.option.ts"; import { AdminCommand } from "../../types/command.types.ts"; -import { Color, EventQueueRole, type RoomScheduling, type SubAutoPullMode } from "../../types/db.types.ts"; -import type { SlashInteraction } from "../../types/interaction.types.ts"; -import { DateUtils } from "../../utils/date.utils.ts"; -import { CustomError, EventNotFoundWarning, WinnerRoleNotSetWarning } from "../../utils/error.utils.ts"; -import { EventUtils } from "../../utils/event.utils.ts"; -import { EventChannelUtils } from "../../utils/event-channel.utils.ts"; -import { EventSyncLock } from "../../utils/event-sync-lock.utils.ts"; -import { SelectMenuTransactor } from "../../utils/message-utils/select-menu-transactor.ts"; -import { toCollection } from "../../utils/misc.utils.ts"; -import { commandMention, describeTable, eventMention, queuesMention } from "../../utils/string.utils.ts"; -import { WinnerUtils } from "../../utils/winner.utils.ts"; - -const HOURS_TO_MS = 3_600_000n; -const MINUTES_TO_MS = 60_000n; - -function verifyMentionEveryonePermission(inter: SlashInteraction, message: string, channelId: string) { - if (/@(everyone|here)/.test(message) && !inter.member.permissionsIn(channelId).has(PermissionsBitField.Flags.MentionEveryone)) { - throw new CustomError({ - message: "Your announcement message contains @everyone or @here, but you lack the 'Mention Everyone' permission in the announcement channel", - }); - } -} - -const DISCORD_MESSAGE_LIMIT = 2000; - -function renderSyncReport(report: EventChannelUtils.SyncReport): string { - const lines: string[] = [`Synced room channels for **${report.eventName}**.`]; - - const namedBucket = (label: string, names: string[]) => { - if (names.length === 0) return; - lines.push(`• ${label}: ${names.map(inlineCode).join(", ")}`); - }; - - namedBucket("Created", report.created); - namedBucket("Adopted", report.adopted); - namedBucket("Untracked rows", report.untrackedRows); - namedBucket("Recreated missing", report.recreatedMissing); - - if (report.reorderApplied) { - lines.push(`• Reorder: ${report.trackedCount} tracked channel${report.trackedCount === 1 ? "" : "s"} reordered.`); - } - else { - lines.push("• Reorder: already in desired order (no changes)."); - } - - if (report.nonOwnedAtTop.length === 0) { - lines.push("• Non-owned channels at top of category: (none)"); - } - else { - const mentions = report.nonOwnedAtTop.map(c => channelMention(c.id)).join(", "); - lines.push(`• Non-owned channels at top of category (${report.nonOwnedAtTop.length}): ${mentions}`); - } - - return lines.join("\n"); -} +import { EventsChannelsHandlers } from "./events/channels.handlers.ts"; +import { EventsCrudHandlers } from "./events/crud.handlers.ts"; +import { EventsDefaultsHandlers } from "./events/defaults.handlers.ts"; +import { EventsHelpHandlers } from "./events/help.handlers.ts"; +import { EventsOptions } from "./events/options.ts"; +import { EventsScheduleHandlers } from "./events/schedule.handlers.ts"; +import { EventsSyncHandlers } from "./events/sync.handlers.ts"; +import { EventsWinnersHandlers } from "./events/winners.handlers.ts"; export class EventsCommand extends AdminCommand { static readonly ID = "events"; - deferResponse = false; - events_get = EventsCommand.events_get; - events_add = EventsCommand.events_add; - events_set = EventsCommand.events_set; - events_set_room_defaults = EventsCommand.events_set_room_defaults; - events_set_sub_defaults = EventsCommand.events_set_sub_defaults; - events_add_room_channel = EventsCommand.events_add_room_channel; - events_remove_room_channel = EventsCommand.events_remove_room_channel; - events_sync_room_channels = EventsCommand.events_sync_room_channels; - events_sync_queues = EventsCommand.events_sync_queues; - events_reset = EventsCommand.events_reset; - events_reset_room_defaults = EventsCommand.events_reset_room_defaults; - events_reset_sub_defaults = EventsCommand.events_reset_sub_defaults; - events_schedule = EventsCommand.events_schedule; - events_cancel = EventsCommand.events_cancel; - events_delete = EventsCommand.events_delete; - events_declare_winners = EventsCommand.events_declare_winners; - events_winners = EventsCommand.events_winners; - events_clear_winners = EventsCommand.events_clear_winners; - events_help = EventsCommand.events_help; + ephemeralSubcommands = new Set(["events_get", "events_help"]); + + events_get = EventsCrudHandlers.get; + events_add = EventsCrudHandlers.add; + events_set = EventsCrudHandlers.set; + events_set_room_defaults = EventsDefaultsHandlers.setRoomDefaults; + events_set_sub_defaults = EventsDefaultsHandlers.setSubDefaults; + events_add_room_channel = EventsChannelsHandlers.addRoomChannel; + events_remove_room_channel = EventsChannelsHandlers.removeRoomChannel; + events_sync_room_channels = EventsChannelsHandlers.syncRoomChannels; + events_sync_queues = EventsSyncHandlers.syncQueues; + events_reset = EventsDefaultsHandlers.reset; + events_reset_room_defaults = EventsDefaultsHandlers.resetRoomDefaults; + events_reset_sub_defaults = EventsDefaultsHandlers.resetSubDefaults; + events_schedule = EventsScheduleHandlers.schedule; + events_cancel = EventsScheduleHandlers.cancel; + events_delete = EventsCrudHandlers.deleteEvent; + events_declare_winners = EventsWinnersHandlers.declareWinners; + events_winners = EventsWinnersHandlers.winners; + events_clear_winners = EventsWinnersHandlers.clearWinners; + events_help = EventsHelpHandlers.help; data = new SlashCommandBuilder() .setName(EventsCommand.ID) @@ -152,126 +42,126 @@ export class EventsCommand extends AdminCommand { subcommand .setName("get") .setDescription("Show event details"); - Object.values(EventsCommand.GET_OPTIONS).forEach(option => option.addToCommand(subcommand)); + Object.values(EventsOptions.GET_OPTIONS).forEach(option => option.addToCommand(subcommand)); return subcommand; }) .addSubcommand(subcommand => { subcommand .setName("add") .setDescription("Create event with room + sub queues"); - Object.values(EventsCommand.ADD_OPTIONS).forEach(option => option.addToCommand(subcommand)); + Object.values(EventsOptions.ADD_OPTIONS).forEach(option => option.addToCommand(subcommand)); return subcommand; }) .addSubcommand(subcommand => { subcommand .setName("set") .setDescription("Update event properties"); - Object.values(EventsCommand.SET_OPTIONS).forEach(option => option.addToCommand(subcommand)); + Object.values(EventsOptions.SET_OPTIONS).forEach(option => option.addToCommand(subcommand)); return subcommand; }) .addSubcommand(subcommand => { subcommand .setName("set-room-defaults") .setDescription("Set room queue defaults"); - Object.values(EventsCommand.SET_ROOM_DEFAULTS_OPTIONS).forEach(option => option.addToCommand(subcommand)); + Object.values(EventsOptions.SET_ROOM_DEFAULTS_OPTIONS).forEach(option => option.addToCommand(subcommand)); return subcommand; }) .addSubcommand(subcommand => { subcommand .setName("set-sub-defaults") .setDescription("Set sub queue defaults"); - Object.values(EventsCommand.SET_SUB_DEFAULTS_OPTIONS).forEach(option => option.addToCommand(subcommand)); + Object.values(EventsOptions.SET_SUB_DEFAULTS_OPTIONS).forEach(option => option.addToCommand(subcommand)); return subcommand; }) .addSubcommand(subcommand => { subcommand .setName("add-room-channel") .setDescription("Add per-room channel template (e.g. room-code-{N})"); - Object.values(EventsCommand.ADD_ROOM_CHANNEL_OPTIONS).forEach(option => option.addToCommand(subcommand)); + Object.values(EventsOptions.ADD_ROOM_CHANNEL_OPTIONS).forEach(option => option.addToCommand(subcommand)); return subcommand; }) .addSubcommand(subcommand => { subcommand .setName("remove-room-channel") .setDescription("Remove per-room channel template"); - Object.values(EventsCommand.REMOVE_ROOM_CHANNEL_OPTIONS).forEach(option => option.addToCommand(subcommand)); + Object.values(EventsOptions.REMOVE_ROOM_CHANNEL_OPTIONS).forEach(option => option.addToCommand(subcommand)); return subcommand; }) .addSubcommand(subcommand => { subcommand .setName("sync-room-channels") .setDescription("Recreate missing room channels, fix perms + order"); - Object.values(EventsCommand.SYNC_ROOM_CHANNELS_OPTIONS).forEach(option => option.addToCommand(subcommand)); + Object.values(EventsOptions.SYNC_ROOM_CHANNELS_OPTIONS).forEach(option => option.addToCommand(subcommand)); return subcommand; }) .addSubcommand(subcommand => { subcommand .setName("sync-queues") .setDescription("Recreate missing queues, re-apply defaults, fix display order"); - Object.values(EventsCommand.SYNC_QUEUES_OPTIONS).forEach(option => option.addToCommand(subcommand)); + Object.values(EventsOptions.SYNC_QUEUES_OPTIONS).forEach(option => option.addToCommand(subcommand)); return subcommand; }) .addSubcommand(subcommand => { subcommand .setName("reset") .setDescription("Reset event properties"); - Object.values(EventsCommand.RESET_OPTIONS).forEach(option => option.addToCommand(subcommand)); + Object.values(EventsOptions.RESET_OPTIONS).forEach(option => option.addToCommand(subcommand)); return subcommand; }) .addSubcommand(subcommand => { subcommand .setName("reset-room-defaults") .setDescription("Reset room queue defaults"); - Object.values(EventsCommand.RESET_ROOM_DEFAULTS_OPTIONS).forEach(option => option.addToCommand(subcommand)); + Object.values(EventsOptions.RESET_ROOM_DEFAULTS_OPTIONS).forEach(option => option.addToCommand(subcommand)); return subcommand; }) .addSubcommand(subcommand => { subcommand .setName("reset-sub-defaults") .setDescription("Reset sub queue defaults"); - Object.values(EventsCommand.RESET_SUB_DEFAULTS_OPTIONS).forEach(option => option.addToCommand(subcommand)); + Object.values(EventsOptions.RESET_SUB_DEFAULTS_OPTIONS).forEach(option => option.addToCommand(subcommand)); return subcommand; }) .addSubcommand(subcommand => { subcommand .setName("schedule") .setDescription("Schedule an occurrence"); - Object.values(EventsCommand.SCHEDULE_OPTIONS).forEach(option => option.addToCommand(subcommand)); + Object.values(EventsOptions.SCHEDULE_OPTIONS).forEach(option => option.addToCommand(subcommand)); return subcommand; }) .addSubcommand(subcommand => { subcommand .setName("cancel") .setDescription("Cancel a pending occurrence"); - Object.values(EventsCommand.CANCEL_OPTIONS).forEach(option => option.addToCommand(subcommand)); + Object.values(EventsOptions.CANCEL_OPTIONS).forEach(option => option.addToCommand(subcommand)); return subcommand; }) .addSubcommand(subcommand => { subcommand .setName("delete") .setDescription("Delete event + its queues"); - Object.values(EventsCommand.DELETE_OPTIONS).forEach(option => option.addToCommand(subcommand)); + Object.values(EventsOptions.DELETE_OPTIONS).forEach(option => option.addToCommand(subcommand)); return subcommand; }) .addSubcommand(subcommand => { subcommand .setName("declare-winners") .setDescription("Grant the winner role to the event's winner(s)"); - Object.values(EventsCommand.DECLARE_WINNERS_OPTIONS).forEach(option => option.addToCommand(subcommand)); + Object.values(EventsOptions.DECLARE_WINNERS_OPTIONS).forEach(option => option.addToCommand(subcommand)); return subcommand; }) .addSubcommand(subcommand => { subcommand .setName("winners") .setDescription("List declared winners"); - Object.values(EventsCommand.WINNERS_OPTIONS).forEach(option => option.addToCommand(subcommand)); + Object.values(EventsOptions.WINNERS_OPTIONS).forEach(option => option.addToCommand(subcommand)); return subcommand; }) .addSubcommand(subcommand => { subcommand .setName("clear-winners") .setDescription("Revoke the winner role for the event"); - Object.values(EventsCommand.CLEAR_WINNERS_OPTIONS).forEach(option => option.addToCommand(subcommand)); + Object.values(EventsOptions.CLEAR_WINNERS_OPTIONS).forEach(option => option.addToCommand(subcommand)); return subcommand; }) .addSubcommand(subcommand => { @@ -280,946 +170,4 @@ export class EventsCommand extends AdminCommand { .setDescription("Event command help"); return subcommand; }); - - // ==================================================================== - // /events get - // ==================================================================== - - static readonly GET_OPTIONS = { - events: new EventsOption({ description: "Specific event(s)" }), - }; - - static async events_get(inter: SlashInteraction, events?: Collection) { - if (!inter.deferred) await inter.deferReply({ ephemeral: true }); - events = events ?? await EventsCommand.GET_OPTIONS.events.get(inter); - - if (!events || events.size === 0) { - events = inter.store.dbEvents(); - } - - if (events.size === 0) { - await inter.respond(`No events found. Create one with ${commandMention("events", "add")}.`); - return; - } - - const entries = events.map(event => { - const occurrences = Queries.selectManyOccurrences({ guildId: inter.guildId, eventId: event.id }); - const nextOcc = occurrences.sort((a, b) => Number(a.startTime) - Number(b.startTime))[0]; - const templates = Queries.selectManyRoomChannelTemplates({ guildId: inter.guildId, eventId: event.id }); - const templateSummary = templates.length - ? templates.map(t => `${t.suffix}${t.slowmodeSeconds ? ` (slowmode: ${t.slowmodeSeconds}s)` : ""}`).join(", ") - : null; - return { - ...event, - createOffsetMs: `${Number(event.createOffsetMs) / 3_600_000}h`, - lockOffsetMs: `${Number(event.lockOffsetMs) / 60_000}min`, - cleanupOffsetMs: `${Number(event.cleanupOffsetMs) / 3_600_000}h`, - roomLengthMs: event.roomLengthMs ? `${Number(event.roomLengthMs) / 60_000}min` : null, - nextOccurrence: nextOcc ? time(new Date(Number(nextOcc.startTime)), TimestampStyles.LongDateTime) : "None", - nextOccurrenceDiscordEventId: nextOcc?.discordEventId ?? null, - roomQueuesChannelId: channelMention(event.roomQueuesChannelId), - subQueuesChannelId: channelMention(event.subQueuesChannelId), - announcementChannelId: event.announcementChannelId ? channelMention(event.announcementChannelId) : null, - roomCategoryId: event.roomCategoryId ? channelMention(event.roomCategoryId) : null, - roomChannelTemplates: templateSummary, - winnerRoleId: event.winnerRoleId ? roleMention(event.winnerRoleId) : null, - }; - }); - - const descriptionMessage = describeTable({ - store: inter.store, - table: EVENT_TABLE, - tableLabel: "Events", - entryLabelProperty: "name", - entries: [...entries.values()], - hiddenProperties: ["name", "queueId"], - queueIdProperty: "guildId", - }); - - await inter.respond(descriptionMessage); - } - - // ==================================================================== - // /events add - // ==================================================================== - - static readonly ADD_OPTIONS = { - name: new NameOption({ required: true, description: "Event name" }), - roomCount: new RoomCountOption({ required: true, description: "Number of rooms" }), - roomQueuesChannel: new RoomQueuesChannelOption({ required: true, description: "Parent channel for room queues" }), - subQueuesChannel: new SubQueuesChannelOption({ required: true, description: "Parent channel for sub queues" }), - roomCategory: new RoomCategoryOption({ required: true, description: "Category for per-room channels" }), - roomScheduling: new RoomSchedulingOption({ description: "Room timing (parallel/sequential)" }), - roomLengthMinutes: new RoomLengthMinutesOption({ description: "Room length in minutes (sequential req)" }), - createOffsetHours: new CreateOffsetHoursOption({ description: "Hours before start to open" }), - lockOffsetMinutes: new LockOffsetMinutesOption({ description: "Minutes after start to lock (neg=before)" }), - cleanupOffsetHours: new CleanupOffsetHoursOption({ description: "Hours after rooms finish to cleanup" }), - announcementChannel: new AnnouncementChannelOption({ description: "Announcement channel" }), - announcementMessage: new AnnouncementMessageOption({ description: "Announcement template — placeholders: /events help" }), - roomPingMessage: new RoomPingMessageOption({ description: "Per-room ping template — placeholders: /events help" }), - maxRoomsPerUser: new MaxRoomsPerUserOption({ description: "Max rooms per user (0=unlimited)" }), - maxSubsPerUser: new MaxSubsPerUserOption({ description: "Max subs per user (0=unlimited)" }), - parentSubMutuallyExclusive: new ParentSubMutuallyExclusiveOption({ description: "Room + matching sub mutually exclusive" }), - roleInRoomQueue: new RoleInRoomQueueOption({ description: "Assign room role while in room queue" }), - roleOnRoomPull: new RoleOnRoomPullOption({ description: "Assign room role on room queue pull" }), - roleInSubQueue: new RoleInSubQueueOption({ description: "Assign room role while in sub queue" }), - roleOnSubPull: new RoleOnSubPullOption({ description: "Assign room role on sub queue pull" }), - autoPullSubsAtRoomStartToggle: new AutoPullSubsAtRoomStartToggleOption({ description: "Auto-pull subs at room start" }), - shuffleSubsBeforeAutoPullToggle: new ShuffleSubsBeforeAutoPullToggleOption({ description: "Shuffle subs before auto-pull" }), - subAutoPullMode: new SubAutoPullModeOption({ description: "Auto-pull mode" }), - createDiscordEvent: new CreateDiscordEventToggleOption({ description: "Create Discord scheduled event per occurrence" }), - discordEventDescription: new DiscordEventDescriptionOption({ description: "Discord event description — placeholders: /events help" }), - }; - - static async events_add(inter: SlashInteraction) { - await inter.deferReply(); - const roomLengthMinutes = EventsCommand.ADD_OPTIONS.roomLengthMinutes.get(inter); - const createOffsetHours = EventsCommand.ADD_OPTIONS.createOffsetHours.get(inter); - const lockOffsetMinutes = EventsCommand.ADD_OPTIONS.lockOffsetMinutes.get(inter); - const cleanupOffsetHours = EventsCommand.ADD_OPTIONS.cleanupOffsetHours.get(inter); - const announcementChannelId = EventsCommand.ADD_OPTIONS.announcementChannel.get(inter)?.id; - const announcementMessage = EventsCommand.ADD_OPTIONS.announcementMessage.get(inter); - - const newEvent = { - name: EventsCommand.ADD_OPTIONS.name.get(inter)?.substring(0, 240), - roomCount: BigInt(EventsCommand.ADD_OPTIONS.roomCount.get(inter)), - roomQueuesChannelId: EventsCommand.ADD_OPTIONS.roomQueuesChannel.get(inter)?.id, - subQueuesChannelId: EventsCommand.ADD_OPTIONS.subQueuesChannel.get(inter)?.id, - roomCategoryId: EventsCommand.ADD_OPTIONS.roomCategory.get(inter)?.id, - ...omitBy({ - roomScheduling: EventsCommand.ADD_OPTIONS.roomScheduling.get(inter) as RoomScheduling, - roomLengthMs: roomLengthMinutes ? BigInt(roomLengthMinutes) * MINUTES_TO_MS : undefined, - createOffsetMs: createOffsetHours != null ? BigInt(createOffsetHours) * HOURS_TO_MS : undefined, - lockOffsetMs: lockOffsetMinutes != null ? BigInt(lockOffsetMinutes) * MINUTES_TO_MS : undefined, - cleanupOffsetMs: cleanupOffsetHours != null ? BigInt(cleanupOffsetHours) * HOURS_TO_MS : undefined, - announcementChannelId, - announcementMessage, - roomPingMessage: EventsCommand.ADD_OPTIONS.roomPingMessage.get(inter), - maxRoomsPerUser: EventsCommand.ADD_OPTIONS.maxRoomsPerUser.get(inter), - maxSubsPerUser: EventsCommand.ADD_OPTIONS.maxSubsPerUser.get(inter), - parentSubMutuallyExclusive: EventsCommand.ADD_OPTIONS.parentSubMutuallyExclusive.get(inter), - roleInRoomQueue: EventsCommand.ADD_OPTIONS.roleInRoomQueue.get(inter), - roleOnRoomPull: EventsCommand.ADD_OPTIONS.roleOnRoomPull.get(inter), - roleInSubQueue: EventsCommand.ADD_OPTIONS.roleInSubQueue.get(inter), - roleOnSubPull: EventsCommand.ADD_OPTIONS.roleOnSubPull.get(inter), - autoPullSubsAtRoomStartToggle: EventsCommand.ADD_OPTIONS.autoPullSubsAtRoomStartToggle.get(inter), - shuffleSubsBeforeAutoPullToggle: EventsCommand.ADD_OPTIONS.shuffleSubsBeforeAutoPullToggle.get(inter), - subAutoPullMode: EventsCommand.ADD_OPTIONS.subAutoPullMode.get(inter) as SubAutoPullMode, - createDiscordEvent: EventsCommand.ADD_OPTIONS.createDiscordEvent.get(inter), - discordEventDescription: EventsCommand.ADD_OPTIONS.discordEventDescription.get(inter), - }, isNil), - }; - - if (announcementMessage && announcementChannelId) { - verifyMentionEveryonePermission(inter, announcementMessage, announcementChannelId); - } - - const event = await EventUtils.insertEvent(inter.store, newEvent); - - const eventQueues = Queries.selectManyEventQueues({ guildId: inter.guildId, eventId: event.id }); - const queues = eventQueues.map(eq => inter.store.dbQueues().get(eq.queueId)).filter(Boolean); - - await inter.respond( - `Created event ${eventMention(event)} with ${queues.length} queues: ${queuesMention(queues)}.`, - true, - ); - - await EventsCommand.events_get(inter, toCollection("id", [event])); - } - - // ==================================================================== - // /events set - // ==================================================================== - - static readonly SET_OPTIONS = { - event: new EventOption({ required: true, description: "Target event" }), - roomCount: new RoomCountOption({ description: "Number of rooms (grow only)" }), - roomScheduling: new RoomSchedulingOption({ description: "Room timing (parallel/sequential)" }), - roomLengthMinutes: new RoomLengthMinutesOption({ description: "Room length in minutes" }), - createOffsetHours: new CreateOffsetHoursOption({ description: "Hours before start to open" }), - lockOffsetMinutes: new LockOffsetMinutesOption({ description: "Minutes after start to lock" }), - cleanupOffsetHours: new CleanupOffsetHoursOption({ description: "Hours after rooms finish to cleanup" }), - announcementChannel: new AnnouncementChannelOption({ description: "Announcement channel" }), - announcementMessage: new AnnouncementMessageOption({ description: "Announcement template — placeholders: /events help" }), - roomPingMessage: new RoomPingMessageOption({ description: "Per-room ping template — placeholders: /events help" }), - maxRoomsPerUser: new MaxRoomsPerUserOption({ description: "Max rooms per user (0=unlimited)" }), - maxSubsPerUser: new MaxSubsPerUserOption({ description: "Max subs per user (0=unlimited)" }), - parentSubMutuallyExclusive: new ParentSubMutuallyExclusiveOption({ description: "Room + matching sub mutually exclusive" }), - roomCategory: new RoomCategoryOption({ description: "Category for per-room channels" }), - roleInRoomQueue: new RoleInRoomQueueOption({ description: "Assign room role while in room queue" }), - roleOnRoomPull: new RoleOnRoomPullOption({ description: "Assign room role on room queue pull" }), - roleInSubQueue: new RoleInSubQueueOption({ description: "Assign room role while in sub queue" }), - roleOnSubPull: new RoleOnSubPullOption({ description: "Assign room role on sub queue pull" }), - autoPullSubsAtRoomStartToggle: new AutoPullSubsAtRoomStartToggleOption({ description: "Auto-pull subs at room start" }), - shuffleSubsBeforeAutoPullToggle: new ShuffleSubsBeforeAutoPullToggleOption({ description: "Shuffle subs before auto-pull" }), - subAutoPullMode: new SubAutoPullModeOption({ description: "Auto-pull mode" }), - createDiscordEvent: new CreateDiscordEventToggleOption({ description: "Create Discord scheduled event per occurrence" }), - discordEventDescription: new DiscordEventDescriptionOption({ description: "Discord event description — placeholders: /events help" }), - winnerRole: new WinnerRoleOption({ description: "Role granted to declared winners" }), - }; - - static async events_set(inter: SlashInteraction) { - await inter.deferReply(); - const event = await EventsCommand.SET_OPTIONS.event.get(inter); - const newRoomCategoryId = EventsCommand.SET_OPTIONS.roomCategory.get(inter)?.id; - if (!newRoomCategoryId) { - EventUtils.assertHasRoomCategory(event); - } - const roomLengthMinutes = EventsCommand.SET_OPTIONS.roomLengthMinutes.get(inter); - const createOffsetHours = EventsCommand.SET_OPTIONS.createOffsetHours.get(inter); - const lockOffsetMinutes = EventsCommand.SET_OPTIONS.lockOffsetMinutes.get(inter); - const cleanupOffsetHours = EventsCommand.SET_OPTIONS.cleanupOffsetHours.get(inter); - const announcementChannelId = EventsCommand.SET_OPTIONS.announcementChannel.get(inter)?.id; - const announcementMessage = EventsCommand.SET_OPTIONS.announcementMessage.get(inter); - - const update = omitBy({ - roomCount: EventsCommand.SET_OPTIONS.roomCount.get(inter) ? BigInt(EventsCommand.SET_OPTIONS.roomCount.get(inter)) : undefined, - roomScheduling: EventsCommand.SET_OPTIONS.roomScheduling.get(inter) as RoomScheduling, - roomLengthMs: roomLengthMinutes ? BigInt(roomLengthMinutes) * MINUTES_TO_MS : undefined, - createOffsetMs: createOffsetHours != null ? BigInt(createOffsetHours) * HOURS_TO_MS : undefined, - lockOffsetMs: lockOffsetMinutes != null ? BigInt(lockOffsetMinutes) * MINUTES_TO_MS : undefined, - cleanupOffsetMs: cleanupOffsetHours != null ? BigInt(cleanupOffsetHours) * HOURS_TO_MS : undefined, - announcementChannelId, - announcementMessage, - roomPingMessage: EventsCommand.SET_OPTIONS.roomPingMessage.get(inter), - maxRoomsPerUser: EventsCommand.SET_OPTIONS.maxRoomsPerUser.get(inter), - maxSubsPerUser: EventsCommand.SET_OPTIONS.maxSubsPerUser.get(inter), - parentSubMutuallyExclusive: EventsCommand.SET_OPTIONS.parentSubMutuallyExclusive.get(inter), - roomCategoryId: EventsCommand.SET_OPTIONS.roomCategory.get(inter)?.id, - roleInRoomQueue: EventsCommand.SET_OPTIONS.roleInRoomQueue.get(inter), - roleOnRoomPull: EventsCommand.SET_OPTIONS.roleOnRoomPull.get(inter), - roleInSubQueue: EventsCommand.SET_OPTIONS.roleInSubQueue.get(inter), - roleOnSubPull: EventsCommand.SET_OPTIONS.roleOnSubPull.get(inter), - autoPullSubsAtRoomStartToggle: EventsCommand.SET_OPTIONS.autoPullSubsAtRoomStartToggle.get(inter), - shuffleSubsBeforeAutoPullToggle: EventsCommand.SET_OPTIONS.shuffleSubsBeforeAutoPullToggle.get(inter), - subAutoPullMode: EventsCommand.SET_OPTIONS.subAutoPullMode.get(inter) as SubAutoPullMode, - createDiscordEvent: EventsCommand.SET_OPTIONS.createDiscordEvent.get(inter), - discordEventDescription: EventsCommand.SET_OPTIONS.discordEventDescription.get(inter), - winnerRoleId: EventsCommand.SET_OPTIONS.winnerRole.get(inter)?.id, - }, isNil); - - const effectiveMessage = announcementMessage ?? event.announcementMessage; - const effectiveChannel = announcementChannelId ?? event.announcementChannelId; - if (effectiveMessage && effectiveChannel) { - verifyMentionEveryonePermission(inter, effectiveMessage, effectiveChannel); - } - - const updatedEvent = await EventUtils.updateEvent(inter.store, event, update); - - await inter.respond(`Updated ${eventMention(updatedEvent)}.`, true); - await EventsCommand.events_get(inter, toCollection("id", [updatedEvent])); - } - - // ==================================================================== - // /events set-room-defaults - // ==================================================================== - - static readonly SET_ROOM_DEFAULTS_OPTIONS = { - event: new EventOption({ required: true, description: "Target event" }), - autopullToggle: new AutopullToggleOption({ description: "Autopull toggle" }), - badgeToggle: new BadgeToggleOption({ description: "Badge toggle" }), - buttonsToggle: new ButtonsToggleOption({ description: "Buttons toggle" }), - color: new ColorOption({ description: "Queue color" }), - displayUpdateType: new DisplayUpdateTypeOption({ description: "Display update type" }), - dmOnPullToggle: new DmOnPullToggleOption({ description: "DM-on-pull toggle" }), - header: new HeaderOption({ description: "Display header" }), - inlineToggle: new InlineToggleOption({ description: "Inline toggle" }), - lockToggle: new LockToggleOption({ description: "Lock toggle" }), - memberDisplayType: new MemberDisplayTypeOption({ description: "Member display type" }), - pullBatchSize: new PullBatchSizeOption({ description: "Pull batch size" }), - pullMessage: new PullMessageOption({ description: "Pull message" }), - pullMessageDisplayType: new PullMessageDisplayTypeOption({ description: "Pull message display type" }), - pullMessageChannel: new PullMessageChannelOption({ description: "Pull message channel" }), - rejoinCooldownPeriod: new RejoinCooldownPeriodOption({ description: "Rejoin cooldown (s)" }), - rejoinGracePeriod: new RejoinGracePeriodOption({ description: "Rejoin grace (s)" }), - requireMessageToJoin: new RequireMessageToJoinOption({ description: "Require message to join" }), - roleInQueue: new RoleInQueueOption({ description: "In-queue role" }), - roleOnPull: new RoleOnPullOption({ description: "On-pull role" }), - size: new SizeOption({ description: "Size limit" }), - timestampType: new TimestampTypeOption({ description: "Timestamp format" }), - voiceOnlyToggle: new VoiceOnlyToggleOption({ description: "Voice-only toggle" }), - voiceDestinationChannel: new VoiceDestinationChannelOption({ description: "Voice destination channel" }), - }; - - static async events_set_room_defaults(inter: SlashInteraction) { - await EventsCommand.setDefaults(inter, EventQueueRole.Room, EventsCommand.SET_ROOM_DEFAULTS_OPTIONS); - } - - // ==================================================================== - // /events set-sub-defaults - // ==================================================================== - - static readonly SET_SUB_DEFAULTS_OPTIONS = { - event: new EventOption({ required: true, description: "Target event" }), - autopullToggle: new AutopullToggleOption({ description: "Autopull toggle" }), - badgeToggle: new BadgeToggleOption({ description: "Badge toggle" }), - buttonsToggle: new ButtonsToggleOption({ description: "Buttons toggle" }), - color: new ColorOption({ description: "Queue color" }), - displayUpdateType: new DisplayUpdateTypeOption({ description: "Display update type" }), - dmOnPullToggle: new DmOnPullToggleOption({ description: "DM-on-pull toggle" }), - header: new HeaderOption({ description: "Display header" }), - inlineToggle: new InlineToggleOption({ description: "Inline toggle" }), - lockToggle: new LockToggleOption({ description: "Lock toggle" }), - memberDisplayType: new MemberDisplayTypeOption({ description: "Member display type" }), - pullBatchSize: new PullBatchSizeOption({ description: "Pull batch size" }), - pullMessage: new PullMessageOption({ description: "Pull message" }), - pullMessageDisplayType: new PullMessageDisplayTypeOption({ description: "Pull message display type" }), - pullMessageChannel: new PullMessageChannelOption({ description: "Pull message channel" }), - rejoinCooldownPeriod: new RejoinCooldownPeriodOption({ description: "Rejoin cooldown (s)" }), - rejoinGracePeriod: new RejoinGracePeriodOption({ description: "Rejoin grace (s)" }), - requireMessageToJoin: new RequireMessageToJoinOption({ description: "Require message to join" }), - roleInQueue: new RoleInQueueOption({ description: "In-queue role" }), - roleOnPull: new RoleOnPullOption({ description: "On-pull role" }), - size: new SizeOption({ description: "Size limit" }), - timestampType: new TimestampTypeOption({ description: "Timestamp format" }), - voiceOnlyToggle: new VoiceOnlyToggleOption({ description: "Voice-only toggle" }), - voiceDestinationChannel: new VoiceDestinationChannelOption({ description: "Voice destination channel" }), - }; - - static async events_set_sub_defaults(inter: SlashInteraction) { - await EventsCommand.setDefaults(inter, EventQueueRole.Sub, EventsCommand.SET_SUB_DEFAULTS_OPTIONS); - } - - private static async setDefaults(inter: SlashInteraction, role: EventQueueRole, options: typeof EventsCommand.SET_ROOM_DEFAULTS_OPTIONS) { - await inter.deferReply(); - const event = await options.event.get(inter); - EventUtils.assertHasRoomCategory(event); - - const update = omitBy({ - autopullToggle: options.autopullToggle.get(inter), - badgeToggle: options.badgeToggle.get(inter), - buttonsToggle: options.buttonsToggle.get(inter), - color: options.color.get(inter), - displayUpdateType: options.displayUpdateType.get(inter), - dmOnPullToggle: options.dmOnPullToggle.get(inter), - header: options.header.get(inter), - inlineToggle: options.inlineToggle.get(inter), - lockToggle: options.lockToggle.get(inter), - memberDisplayType: options.memberDisplayType.get(inter), - pullBatchSize: options.pullBatchSize.get(inter), - pullMessage: options.pullMessage.get(inter), - pullMessageDisplayType: options.pullMessageDisplayType.get(inter), - pullMessageChannelId: options.pullMessageChannel.get(inter)?.id, - rejoinCooldownPeriod: options.rejoinCooldownPeriod.get(inter), - rejoinGracePeriod: options.rejoinGracePeriod.get(inter), - requireMessageToJoin: options.requireMessageToJoin.get(inter), - roleInQueueId: options.roleInQueue.get(inter)?.id, - roleOnPullId: options.roleOnPull.get(inter)?.id, - size: options.size.get(inter), - timestampType: options.timestampType.get(inter), - voiceOnlyToggle: options.voiceOnlyToggle.get(inter), - voiceDestinationChannelId: options.voiceDestinationChannel.get(inter)?.id, - }, isNil); - - await EventUtils.setRoleDefaults(inter.store, event, role, update); - - await inter.respond(`Updated ${role} queue defaults for ${eventMention(event)}.`, true); - } - - // ==================================================================== - // /events add-room-channel - // ==================================================================== - - static readonly ADD_ROOM_CHANNEL_OPTIONS = { - event: new EventOption({ required: true, description: "Target event" }), - suffix: new ChannelSuffixOption({ required: true, description: "Suffix (e.g. \"code\" → room-code-{N})" }), - slowmode: new SlowmodeOption({ description: "Slowmode value (0=none)" }), - slowmodeTime: new SlowmodeTimeOption({ description: "Slowmode unit" }), - }; - - static async events_add_room_channel(inter: SlashInteraction) { - await inter.deferReply(); - const event = await EventsCommand.ADD_ROOM_CHANNEL_OPTIONS.event.get(inter); - EventUtils.assertHasRoomCategory(event); - const suffix = EventsCommand.ADD_ROOM_CHANNEL_OPTIONS.suffix.get(inter); - const slowmode = EventsCommand.ADD_ROOM_CHANNEL_OPTIONS.slowmode.get(inter); - const slowmodeTime = EventsCommand.ADD_ROOM_CHANNEL_OPTIONS.slowmodeTime.get(inter); - const slowmodeSeconds = EventChannelUtils.toSlowmodeSeconds(slowmode, slowmodeTime); - - const cleanSuffix = suffix.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); - if (!cleanSuffix) { - throw new CustomError({ - message: "Suffix must contain at least one alphanumeric character.", - }); - } - - inter.store.insertRoomChannelTemplate({ - guildId: inter.guildId, - eventId: event.id, - suffix: cleanSuffix, - slowmodeSeconds: slowmodeSeconds > 0 ? BigInt(slowmodeSeconds) : null, - }); - - const report = await EventChannelUtils.reconcileRoomChannels(inter.store, event); - - const adoptedSuffix = report.adopted.length > 0 - ? ` Adopted ${report.adopted.length} existing channel${report.adopted.length === 1 ? "" : "s"}.` - : ""; - await inter.respond( - `Added ${inlineCode(`room-${cleanSuffix}-{N}`)} channel template to ${eventMention(event)}${slowmodeSeconds > 0 ? ` (slowmode: ${slowmodeSeconds}s)` : ""}.${adoptedSuffix}`, - true, - ); - } - - // ==================================================================== - // /events remove-room-channel - // ==================================================================== - - static readonly REMOVE_ROOM_CHANNEL_OPTIONS = { - event: new EventOption({ required: true, description: "Target event" }), - suffix: new ChannelSuffixOption({ required: true, description: "Suffix to remove (autocompletes)" }), - }; - - static async events_remove_room_channel(inter: SlashInteraction) { - await inter.deferReply(); - const event = await EventsCommand.REMOVE_ROOM_CHANNEL_OPTIONS.event.get(inter); - EventUtils.assertHasRoomCategory(event); - const suffix = EventsCommand.REMOVE_ROOM_CHANNEL_OPTIONS.suffix.get(inter); - - const templates = Queries.selectManyRoomChannelTemplates({ guildId: inter.guildId, eventId: event.id }); - const tmpl = templates.find(t => t.suffix === suffix); - if (!tmpl) { - throw new CustomError({ - message: `No channel template with suffix ${inlineCode(suffix)} found for ${eventMention(event)}.`, - }); - } - - EventChannelUtils.untrackChannelsForSuffix(inter.store, event, suffix); - inter.store.deleteRoomChannelTemplate({ eventId: event.id, suffix }); - - await inter.respond( - `Removed ${inlineCode(`room-${suffix}-{N}`)} channel template from ${eventMention(event)}. Existing channels are left in place — re-adding the template will adopt them.`, - true, - ); - } - - // ==================================================================== - // /events sync-room-channels - // ==================================================================== - - static readonly SYNC_ROOM_CHANNELS_OPTIONS = { - event: new EventOption({ description: "Event to sync (omit = all)" }), - }; - - static async events_sync_room_channels(inter: SlashInteraction) { - await inter.deferReply(); - const event = await EventsCommand.SYNC_ROOM_CHANNELS_OPTIONS.event.get(inter).catch((e: unknown) => { - if (e instanceof EventNotFoundWarning) return undefined; - throw e; - }); - - if (event) { - EventUtils.assertHasRoomCategory(event); - const report = await EventChannelUtils.reconcileRoomChannels(inter.store, event); - await inter.respond(renderSyncReport(report), true); - return; - } - - const allEvents = [...inter.store.dbEvents().values()]; - const targeted = allEvents.filter(e => !!e.roomCategoryId); - - if (targeted.length === 0) { - await inter.respond(`No events have a ${inlineCode("room_category")} configured. Set one with ${commandMention("events", "set")}.`); - return; - } - - const { reports, skipped } = await EventChannelUtils.reconcileAllGuildEvents(inter.store); - - const skippedSuffix = skipped.length > 0 - ? `, skipped ${skipped.length} event(s) already in progress: ${skipped.map(e => inlineCode(e.name)).join(", ")}` - : ""; - const header = `Synced room channels for ${reports.length} event(s)${skippedSuffix}:`; - const blocks = reports.map(renderSyncReport); - const combined = `${header}\n\n${blocks.join("\n\n")}`; - - if (combined.length <= DISCORD_MESSAGE_LIMIT) { - await inter.respond(combined, true); - } - else { - const embed = new EmbedBuilder().setTitle(header); - for (const report of reports) { - embed.addFields({ - name: report.eventName, - value: renderSyncReport(report).split("\n").slice(1).join("\n") || "(no changes)", - }); - } - await inter.respond({ embeds: [embed] }, true); - } - } - - // ==================================================================== - // /events sync-queues - // ==================================================================== - - static readonly SYNC_QUEUES_OPTIONS = { - event: new EventOption({ description: "Event to sync (omit = all)" }), - }; - - static async events_sync_queues(inter: SlashInteraction) { - await inter.deferReply(); - const event = await EventsCommand.SYNC_QUEUES_OPTIONS.event.get(inter).catch((e: unknown) => { - if (e instanceof EventNotFoundWarning) return undefined; - throw e; - }); - - if (event) { - EventUtils.assertHasRoomCategory(event); - const result = await EventUtils.syncEventQueues(inter.store, event); - await inter.respond( - `Synced queues for ${eventMention(event)}: recreated ${result.recreatedCount} queue(s), ` + - `re-applied defaults to ${result.reappliedRoomCount} room + ${result.reappliedSubCount} sub queue(s), ` + - `re-posted ${result.reshownCount} display(s).`, - true, - ); - return; - } - - const allEvents = [...inter.store.dbEvents().values()]; - const targeted = allEvents.filter(e => !!e.roomCategoryId); - - if (targeted.length === 0) { - await inter.respond(`No events have a ${inlineCode("room_category")} configured. Set one with ${commandMention("events", "set")}.`); - return; - } - - let recreatedTotal = 0; - let reappliedRoomTotal = 0; - let reappliedSubTotal = 0; - let reshownTotal = 0; - const skipped: DbEvent[] = []; - for (const ev of targeted) { - const result = await EventSyncLock.tryWithLock(inter.store.guild.id, ev.id, () => - EventUtils.syncEventQueues(inter.store, ev) - ); - if (result === "skipped") { - skipped.push(ev); - continue; - } - recreatedTotal += result.recreatedCount; - reappliedRoomTotal += result.reappliedRoomCount; - reappliedSubTotal += result.reappliedSubCount; - reshownTotal += result.reshownCount; - } - - const syncedCount = targeted.length - skipped.length; - const skippedSuffix = skipped.length > 0 - ? `, skipped ${skipped.length} event(s) already in progress: ${skipped.map(e => inlineCode(e.name)).join(", ")}` - : ""; - await inter.respond( - `Synced queues for ${syncedCount} event(s): recreated ${recreatedTotal} queue(s), ` + - `re-applied defaults to ${reappliedRoomTotal} room + ${reappliedSubTotal} sub queue(s), ` + - `re-posted ${reshownTotal} display(s)${skippedSuffix}.`, - true, - ); - } - - // ==================================================================== - // /events reset - // ==================================================================== - - static readonly RESET_OPTIONS = { - event: new EventOption({ required: true, description: "Target event" }), - }; - - static async events_reset(inter: SlashInteraction) { - await inter.deferReply(); - const event = await EventsCommand.RESET_OPTIONS.event.get(inter); - EventUtils.assertHasRoomCategory(event); - - const ANNOUNCEMENT_PAIR_VALUE = "__announcement_pair__"; - const selectMenuOptions = [ - { name: CreateOffsetHoursOption.ID, value: EVENT_TABLE.createOffsetMs.name }, - { name: LockOffsetMinutesOption.ID, value: EVENT_TABLE.lockOffsetMs.name }, - { name: CleanupOffsetHoursOption.ID, value: EVENT_TABLE.cleanupOffsetMs.name }, - { name: RoomSchedulingOption.ID, value: EVENT_TABLE.roomScheduling.name }, - { name: RoomLengthMinutesOption.ID, value: EVENT_TABLE.roomLengthMs.name }, - { name: `${AnnouncementChannelOption.ID} + ${AnnouncementMessageOption.ID}`, value: ANNOUNCEMENT_PAIR_VALUE }, - { name: RoomPingMessageOption.ID, value: EVENT_TABLE.roomPingMessage.name }, - { name: RoleInRoomQueueOption.ID, value: EVENT_TABLE.roleInRoomQueue.name }, - { name: RoleOnRoomPullOption.ID, value: EVENT_TABLE.roleOnRoomPull.name }, - { name: RoleInSubQueueOption.ID, value: EVENT_TABLE.roleInSubQueue.name }, - { name: RoleOnSubPullOption.ID, value: EVENT_TABLE.roleOnSubPull.name }, - { name: AutoPullSubsAtRoomStartToggleOption.ID, value: EVENT_TABLE.autoPullSubsAtRoomStartToggle.name }, - { name: ShuffleSubsBeforeAutoPullToggleOption.ID, value: EVENT_TABLE.shuffleSubsBeforeAutoPullToggle.name }, - { name: SubAutoPullModeOption.ID, value: EVENT_TABLE.subAutoPullMode.name }, - { name: CreateDiscordEventToggleOption.ID, value: EVENT_TABLE.createDiscordEvent.name }, - { name: DiscordEventDescriptionOption.ID, value: EVENT_TABLE.discordEventDescription.name }, - { name: `${WinnerRoleOption.ID} (does not revoke already-granted roles)`, value: EVENT_TABLE.winnerRoleId.name }, - ]; - const selectMenuTransactor = new SelectMenuTransactor(inter); - const propertiesToReset = await selectMenuTransactor.sendAndReceive("Event properties to reset", selectMenuOptions) ?? []; - if (propertiesToReset.length === 0) return; - - const update: Partial = {}; - const resetLabels: string[] = []; - for (const property of propertiesToReset) { - if (property === ANNOUNCEMENT_PAIR_VALUE) { - update.announcementChannelId = null; - update.announcementMessage = null; - resetLabels.push(AnnouncementChannelOption.ID, AnnouncementMessageOption.ID); - continue; - } - const columnKey = findKey(EVENT_TABLE, (column: SQLiteColumn) => column.name === property); - if (!columnKey) continue; - (update as any)[columnKey] = (EVENT_TABLE as any)[columnKey]?.default ?? null; - resetLabels.push(property); - } - - await EventUtils.updateEvent(inter.store, event, update); - - const propertiesStr = resetLabels.map(inlineCode).join(", "); - const haveWord = resetLabels.length === 1 ? "has" : "have"; - await selectMenuTransactor.updateWithResult( - "Reset event properties", - `${propertiesStr} ${haveWord} been reset for ${eventMention(event)}.`, - ); - - await EventsCommand.events_get(inter, toCollection("id", [event])); - } - - // ==================================================================== - // /events reset-room-defaults - // ==================================================================== - - static readonly RESET_ROOM_DEFAULTS_OPTIONS = { - event: new EventOption({ required: true, description: "Target event" }), - }; - - static async events_reset_room_defaults(inter: SlashInteraction) { - await EventsCommand.resetDefaults(inter, EventQueueRole.Room, EventsCommand.RESET_ROOM_DEFAULTS_OPTIONS); - } - - // ==================================================================== - // /events reset-sub-defaults - // ==================================================================== - - static readonly RESET_SUB_DEFAULTS_OPTIONS = { - event: new EventOption({ required: true, description: "Target event" }), - }; - - static async events_reset_sub_defaults(inter: SlashInteraction) { - await EventsCommand.resetDefaults(inter, EventQueueRole.Sub, EventsCommand.RESET_SUB_DEFAULTS_OPTIONS); - } - - private static async resetDefaults( - inter: SlashInteraction, - role: EventQueueRole, - options: typeof EventsCommand.RESET_ROOM_DEFAULTS_OPTIONS, - ) { - await inter.deferReply(); - const event = await options.event.get(inter); - EventUtils.assertHasRoomCategory(event); - - const selectMenuOptions = [ - { name: AutopullToggleOption.ID, value: QUEUE_TABLE.autopullToggle.name }, - { name: BadgeToggleOption.ID, value: QUEUE_TABLE.badgeToggle.name }, - { name: ButtonsToggleOption.ID, value: QUEUE_TABLE.buttonsToggle.name }, - { name: ColorOption.ID, value: QUEUE_TABLE.color.name }, - { name: DisplayUpdateTypeOption.ID, value: QUEUE_TABLE.displayUpdateType.name }, - { name: DmOnPullToggleOption.ID, value: QUEUE_TABLE.dmOnPullToggle.name }, - { name: HeaderOption.ID, value: QUEUE_TABLE.header.name }, - { name: InlineToggleOption.ID, value: QUEUE_TABLE.inlineToggle.name }, - { name: LockToggleOption.ID, value: QUEUE_TABLE.lockToggle.name }, - { name: MemberDisplayTypeOption.ID, value: QUEUE_TABLE.memberDisplayType.name }, - { name: PullBatchSizeOption.ID, value: QUEUE_TABLE.pullBatchSize.name }, - { name: PullMessageOption.ID, value: QUEUE_TABLE.pullMessage.name }, - { name: PullMessageDisplayTypeOption.ID, value: QUEUE_TABLE.pullMessageDisplayType.name }, - { name: PullMessageChannelOption.ID, value: QUEUE_TABLE.pullMessageChannelId.name }, - { name: RejoinCooldownPeriodOption.ID, value: QUEUE_TABLE.rejoinCooldownPeriod.name }, - { name: RejoinGracePeriodOption.ID, value: QUEUE_TABLE.rejoinGracePeriod.name }, - { name: RequireMessageToJoinOption.ID, value: QUEUE_TABLE.requireMessageToJoin.name }, - { name: RoleInQueueOption.ID, value: QUEUE_TABLE.roleInQueueId.name }, - { name: RoleOnPullOption.ID, value: QUEUE_TABLE.roleOnPullId.name }, - { name: SizeOption.ID, value: QUEUE_TABLE.size.name }, - { name: TimestampTypeOption.ID, value: QUEUE_TABLE.timestampType.name }, - { name: VoiceOnlyToggleOption.ID, value: QUEUE_TABLE.voiceOnlyToggle.name }, - { name: VoiceDestinationChannelOption.ID, value: QUEUE_TABLE.voiceDestinationChannelId.name }, - ]; - const selectMenuTransactor = new SelectMenuTransactor(inter); - const propertiesToReset = await selectMenuTransactor.sendAndReceive( - `${role} queue defaults to reset`, - selectMenuOptions, - ) ?? []; - if (propertiesToReset.length === 0) return; - - await EventUtils.resetRoleDefaults(inter.store, event, role, propertiesToReset); - - const propertiesStr = propertiesToReset.map(inlineCode).join(", "); - const haveWord = propertiesToReset.length === 1 ? "has" : "have"; - await selectMenuTransactor.updateWithResult( - `Reset ${role} queue defaults`, - `${propertiesStr} ${haveWord} been reset for ${role} queues of ${eventMention(event)}.`, - ); - - await EventsCommand.events_get(inter, toCollection("id", [event])); - } - - // ==================================================================== - // /events schedule - // ==================================================================== - - static readonly SCHEDULE_OPTIONS = { - event: new EventOption({ required: true, description: "Target event" }), - year: new YearOption({ required: true, description: "Start year" }), - month: new MonthOption({ required: true, description: "Start month" }), - day: new DayOption({ required: true, description: "Start day" }), - startTime: new StartTimeOption({ required: true, description: "Start time (12-hour, e.g. 9 AM, 9:30 PM)" }), - timezone: new TimezoneOption({ required: false, description: "IANA timezone", defaultValue: process.env.DEFAULT_SCHEDULE_TIMEZONE }), - }; - - static async events_schedule(inter: SlashInteraction) { - await inter.deferReply(); - - const event = await EventsCommand.SCHEDULE_OPTIONS.event.get(inter); - EventUtils.assertHasRoomCategory(event); - - const yearStr = await EventsCommand.SCHEDULE_OPTIONS.year.get(inter); - const monthStr = await EventsCommand.SCHEDULE_OPTIONS.month.get(inter); - const dayStr = await EventsCommand.SCHEDULE_OPTIONS.day.get(inter); - const startTime = await EventsCommand.SCHEDULE_OPTIONS.startTime.get(inter); - const timezoneRaw = await EventsCommand.SCHEDULE_OPTIONS.timezone.get(inter); - - const parsed = DateUtils.parseScheduledStart({ - yearStr, - monthStr, - dayStr, - startTime, - timezone: timezoneRaw || process.env.DEFAULT_SCHEDULE_TIMEZONE || "UTC", - }); - - const startTimeMs = BigInt(parsed.valueOf()); - const occurrence = await EventUtils.scheduleOccurrence( - inter.store, - event, - startTimeMs, - timezoneRaw || undefined, - ); - - const startDate = new Date(Number(occurrence.startTime)); - const embed = new EmbedBuilder() - .setTitle(`Scheduled ${event.name}`) - .setColor(Color.Green) - .setDescription( - `Occurrence scheduled for ${time(startDate, TimestampStyles.LongDateTime)} (${time(startDate, TimestampStyles.RelativeTime)}).\n\n` + - `**Event:** ${eventMention(event)}\n` + - `**Opens:** ${time(new Date(Number(occurrence.startTime) - Number(event.createOffsetMs)), TimestampStyles.RelativeTime)}\n` + - `**Locks rooms:** ${time(new Date(Number(occurrence.startTime) + Number(event.lockOffsetMs)), TimestampStyles.RelativeTime)}\n` + - `**Cleans up:** ${time(new Date(EventUtils.getRoomsFinishMs(event, Number(occurrence.startTime)) + Number(event.cleanupOffsetMs)), TimestampStyles.RelativeTime)}`, - ); - - await inter.respond({ embeds: [embed] }); - } - - // ==================================================================== - // /events cancel - // ==================================================================== - - static readonly CANCEL_OPTIONS = { - event: new EventOption({ required: true, description: "Target event" }), - }; - - static async events_cancel(inter: SlashInteraction) { - await inter.deferReply(); - const event = await EventsCommand.CANCEL_OPTIONS.event.get(inter); - EventUtils.assertHasRoomCategory(event); - const occurrences = Queries.selectManyOccurrences({ guildId: inter.guildId, eventId: event.id }); - - if (occurrences.length === 0) { - await inter.respond(`No pending occurrences for ${eventMention(event)}.`); - return; - } - - const selectMenuOptions = occurrences - .sort((a, b) => Number(a.startTime) - Number(b.startTime)) - .map(occ => ({ - name: `${time(new Date(Number(occ.startTime)), TimestampStyles.LongDateTime)}`, - value: occ.id.toString(), - })); - - const selectMenuTransactor = new SelectMenuTransactor(inter); - const result = await selectMenuTransactor.sendAndReceive("Select occurrence to cancel", selectMenuOptions); - if (!result || result.length === 0) return; - - for (const idStr of result) { - const occ = occurrences.find(o => o.id === BigInt(idStr)); - if (occ) { - await EventUtils.cancelOccurrence(inter.store, occ); - } - } - - await inter.respond( - `Cancelled ${result.length} occurrence(s) of ${eventMention(event)}. (NOTE: queues remain in their current state.)`, - true, - ); - } - - // ==================================================================== - // /events delete - // ==================================================================== - - static readonly DELETE_OPTIONS = { - event: new EventOption({ required: true, description: "Target event" }), - }; - - static async events_delete(inter: SlashInteraction) { - await inter.deferReply(); - const event = await EventsCommand.DELETE_OPTIONS.event.get(inter); - EventUtils.assertHasRoomCategory(event); - - const confirmed = await inter.promptConfirmOrCancel( - `Are you sure you want to delete the ${eventMention(event)} event? This will also delete all associated queues and displays.`, - ); - if (!confirmed) { - await inter.respond("Cancelled delete."); - return; - } - - await EventUtils.deleteEvent(inter.store, event); - - await inter.respond(`Deleted the ${eventMention(event)} event and all its queues.`, true); - } - - // ==================================================================== - // /events declare-winners - // ==================================================================== - - static readonly DECLARE_WINNERS_OPTIONS = { - event: new EventOption({ required: true, description: "Target event" }), - winner1: new UserOption({ required: true, id: "winner_1", description: "A winner" }), - winner2: new UserOption({ id: "winner_2", description: "A winner" }), - winner3: new UserOption({ id: "winner_3", description: "A winner" }), - winner4: new UserOption({ id: "winner_4", description: "A winner" }), - winner5: new UserOption({ id: "winner_5", description: "A winner" }), - }; - - static async events_declare_winners(inter: SlashInteraction) { - await inter.deferReply(); - const event = await EventsCommand.DECLARE_WINNERS_OPTIONS.event.get(inter); - if (!event.winnerRoleId) { - throw new WinnerRoleNotSetWarning(); - } - - const userIds = new Set(compact([ - EventsCommand.DECLARE_WINNERS_OPTIONS.winner1.get(inter), - EventsCommand.DECLARE_WINNERS_OPTIONS.winner2.get(inter), - EventsCommand.DECLARE_WINNERS_OPTIONS.winner3.get(inter), - EventsCommand.DECLARE_WINNERS_OPTIONS.winner4.get(inter), - EventsCommand.DECLARE_WINNERS_OPTIONS.winner5.get(inter), - ]).map(user => user.id)); - - const added = await WinnerUtils.declareWinners(inter.store, event, userIds); - - if (added.length === 0) { - await inter.respond(`No new winners added to ${eventMention(event)} — all selected users are already winners.`, true); - return; - } - - const mentions = added.map(userMention).join(", "); - await inter.respond( - `Granted ${roleMention(event.winnerRoleId)} to ${mentions} as winner(s) of ${eventMention(event)}.`, - true, - ); - } - - // ==================================================================== - // /events winners - // ==================================================================== - - static readonly WINNERS_OPTIONS = { - event: new EventOption({ required: true, description: "Target event" }), - }; - - static async events_winners(inter: SlashInteraction) { - await inter.deferReply(); - const event = await EventsCommand.WINNERS_OPTIONS.event.get(inter); - - const rows = Queries.selectManyEventWinners({ guildId: inter.guildId, eventId: event.id }); - const roleLine = `Winner role: ${event.winnerRoleId ? roleMention(event.winnerRoleId) : "not set"}`; - - if (rows.length === 0) { - await inter.respond(`No winners declared yet for ${eventMention(event)}.\n${roleLine}`); - return; - } - - const winnerList = rows.map(r => userMention(r.userId)).join(", "); - - const embed = new EmbedBuilder() - .setTitle(`Winners — ${event.name}`) - .setColor(Color.Gold) - .setDescription(`${winnerList}\n\n${roleLine}`); - - await inter.respond({ embeds: [embed] }); - } - - // ==================================================================== - // /events clear-winners - // ==================================================================== - - static readonly CLEAR_WINNERS_OPTIONS = { - event: new EventOption({ required: true, description: "Target event" }), - }; - - static async events_clear_winners(inter: SlashInteraction) { - await inter.deferReply(); - const event = await EventsCommand.CLEAR_WINNERS_OPTIONS.event.get(inter); - - const removals = await WinnerUtils.clearEventWinners(inter.store, event); - - await inter.respond( - `Cleared winners for ${eventMention(event)}. Revoked the role from ${removals.length} member(s).`, - true, - ); - } - - // ==================================================================== - // /events help - // ==================================================================== - - static async events_help(inter: SlashInteraction) { - await inter.deferReply({ ephemeral: true }); - const embeds = [new EmbedBuilder() - .setTitle("Events") - .setColor(Color.Indigo) - .setDescription( - "Events let you create recurring event templates with auto-managed room and sub queues.\n\n" + - "**Quick start:**\n" + - `1. ${commandMention("events", "add")} — create an event with N rooms (${inlineCode("room_category")} is required; one private \`room-{N}\` channel and a \`{event} Room {N}\` role are auto-created per room)\n` + - `2. ${commandMention("events", "set-room-defaults")} — configure room queue defaults (size, etc.)\n` + - `3. ${commandMention("events", "schedule")} — schedule an occurrence (\`event\`, \`year\`, \`month\`, \`day\`, \`start_time\` (12-hour, e.g. \`9:30 PM\`), optional \`timezone\`)\n\n` + - "**Lifecycle per occurrence:**\n" + - "- **T − create_offset** (default 24h before): queues unlock, displays refresh, announcement posts\n" + - "- **T + lock_offset** (default 0): room queues lock (sub queues stay open)\n" + - "- **Per-room ping**: at each room's start time a ping posts in the room's channel\n" + - "- **rooms_finish + cleanup_offset** (default 1h after rooms finish): all members cleared, all queues locked\n" + - "- A native Discord scheduled event is created per occurrence when `create_discord_event` is on (default).\n\n" + - "**Missed actions** (bot was down): run automatically on next startup.\n\n" + - "**Signup policies** (set via `/events add` or `/events set`):\n" + - "- `max_rooms_per_user` — cap on room queues a single user may sit in at once (`0` = unlimited)\n" + - "- `max_subs_per_user` — cap on sub-room queues (`0` = unlimited)\n" + - "- `parent_sub_mutually_exclusive` — when `true` (default), a user can't sit in both a room and its matching sub. Joining the room silently removes them from the sub; joining the sub while already in the room is blocked.\n\n" + - "**Room role assignment** — four booleans on the event template control which queues the auto-created `{event} Room {N}` role gets wired into:\n" + - "- `role_in_room_queue` (default `false`) — assign the role while a user is in the room queue\n" + - "- `role_on_room_pull` (default `false`) — assign the role when a user is pulled from the room queue\n" + - "- `role_in_sub_queue` (default `false`) — assign the role while a user is in the sub queue\n" + - "- `role_on_sub_pull` (default `false`) — assign the role when a user is pulled from the sub queue\n\n" + - "**Declaring winners** — crown the event's winner(s) with one shared role that auto-revokes when the event's next occurrence opens:\n" + - `- Configure the role once via \`winner_role\` on ${commandMention("events", "set")}.\n` + - `- ${commandMention("events", "declare-winners")} (\`winner_1\`..\`winner_5\`) grants it — additive, ties allowed; call again for >5 winners.\n` + - `- ${commandMention("events", "winners")} lists current winners; ${commandMention("events", "clear-winners")} revokes early.\n` + - "- With multiple occurrences scheduled, the **earliest** one to open revokes the role.\n\n" + - "**Auto-pull subs at room start:**\n" + - "- `auto_pull_subs_room_start_toggle` (default `false`) — at each room's start, lock paired sub and pull subs into the room. Forces room lock at exact `start_time` (ignores `lock_offset`).\n" + - "- `shuffle_subs_before_pull_toggle` (default `false`) — shuffle the sub queue before the pull.\n" + - "- `sub_auto_pull_mode` (default `drain`) — `drain`: standard `/pull` side effects fire. `promote`: move into room queue (bypasses room lock), no sub-side pull effects.\n\n" + - "**Extra per-room channels:**\n" + - `- ${commandMention("events", "add-room-channel")} adds an extra per-room channel like \`room-code-{N}\`, with optional slowmode.\n` + - `- ${commandMention("events", "remove-room-channel")} removes one of those templates and its channels.\n` + - `- ${commandMention("events", "sync-room-channels")} recreates any channels you accidentally deleted, re-applies permissions, and restores channel order.\n` + - `- ${commandMention("events", "sync-queues")} recreates any deleted queues, re-applies the room/sub defaults to every queue, and re-posts displays in queue-index order.\n\n` + - "**Announcement placeholders:** `{event_name}`, `{start_time}`, `{start_time_relative}`, `{room_queues_channel}`, `{sub_queues_channel}`\n" + - "**Ping placeholders:** `{room_role}`, `{room_name}`, `{room_index}`, `{room_queues_channel}`, `{ping_channel}`, `{start_time}`, `{start_time_relative}`", - )]; - - await inter.respond({ embeds }); - } } diff --git a/src/commands/commands/events/channels.handlers.ts b/src/commands/commands/events/channels.handlers.ts new file mode 100644 index 00000000..065b89aa --- /dev/null +++ b/src/commands/commands/events/channels.handlers.ts @@ -0,0 +1,112 @@ +import { EmbedBuilder, inlineCode } from "discord.js"; + +import { Queries } from "../../../db/queries.ts"; +import type { SlashInteraction } from "../../../types/interaction.types.ts"; +import { CustomError, EventNotFoundWarning } from "../../../utils/error.utils.ts"; +import { EventUtils } from "../../../utils/event.utils.ts"; +import { EventChannelUtils } from "../../../utils/event-channel.utils.ts"; +import { commandMention, eventMention } from "../../../utils/string.utils.ts"; +import { EventsOptions } from "./options.ts"; +import { DISCORD_MESSAGE_LIMIT, renderSyncReport } from "./shared.ts"; + +export namespace EventsChannelsHandlers { + export async function addRoomChannel(inter: SlashInteraction) { + const event = await EventsOptions.ADD_ROOM_CHANNEL_OPTIONS.event.get(inter); + EventUtils.assertHasRoomCategoryForChannelSync(event); + const suffix = EventsOptions.ADD_ROOM_CHANNEL_OPTIONS.suffix.get(inter); + const slowmode = EventsOptions.ADD_ROOM_CHANNEL_OPTIONS.slowmode.get(inter); + const slowmodeTime = EventsOptions.ADD_ROOM_CHANNEL_OPTIONS.slowmodeTime.get(inter); + const slowmodeSeconds = EventChannelUtils.toSlowmodeSeconds(slowmode, slowmodeTime); + + const cleanSuffix = suffix.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); + if (!cleanSuffix) { + throw new CustomError({ + message: "Suffix must contain at least one alphanumeric character.", + }); + } + + inter.store.insertRoomChannelTemplate({ + guildId: inter.guildId, + eventId: event.id, + suffix: cleanSuffix, + slowmodeSeconds: slowmodeSeconds > 0 ? BigInt(slowmodeSeconds) : null, + }); + + const report = await EventChannelUtils.reconcileRoomChannels(inter.store, event); + + const adoptedSuffix = report.adopted.length > 0 + ? ` Adopted ${report.adopted.length} existing channel${report.adopted.length === 1 ? "" : "s"}.` + : ""; + await inter.respond( + `Added ${inlineCode(`room-${cleanSuffix}-{N}`)} channel template to ${eventMention(event)}${slowmodeSeconds > 0 ? ` (slowmode: ${slowmodeSeconds}s)` : ""}.${adoptedSuffix}`, + true, + ); + } + + export async function removeRoomChannel(inter: SlashInteraction) { + const event = await EventsOptions.REMOVE_ROOM_CHANNEL_OPTIONS.event.get(inter); + EventUtils.assertHasRoomCategoryForChannelSync(event); + const suffix = EventsOptions.REMOVE_ROOM_CHANNEL_OPTIONS.suffix.get(inter); + + const templates = Queries.selectManyRoomChannelTemplates({ guildId: inter.guildId, eventId: event.id }); + const tmpl = templates.find(t => t.suffix === suffix); + if (!tmpl) { + throw new CustomError({ + message: `No channel template with suffix ${inlineCode(suffix)} found for ${eventMention(event)}.`, + }); + } + + EventChannelUtils.untrackChannelsForSuffix(inter.store, event, suffix); + inter.store.deleteRoomChannelTemplate({ eventId: event.id, suffix }); + + await inter.respond( + `Removed ${inlineCode(`room-${suffix}-{N}`)} channel template from ${eventMention(event)}. Existing channels are left in place — re-adding the template will adopt them.`, + true, + ); + } + + export async function syncRoomChannels(inter: SlashInteraction) { + const event = await EventsOptions.SYNC_ROOM_CHANNELS_OPTIONS.event.get(inter).catch((e: unknown) => { + if (e instanceof EventNotFoundWarning) return undefined; + throw e; + }); + + if (event) { + EventUtils.assertHasRoomCategoryForChannelSync(event); + const report = await EventChannelUtils.reconcileRoomChannels(inter.store, event); + await inter.respond(renderSyncReport(report), true); + return; + } + + const allEvents = [...inter.store.dbEvents().values()]; + const targeted = allEvents.filter(e => !!e.roomCategoryId); + + if (targeted.length === 0) { + await inter.respond(`No events have a ${inlineCode("room_category")} configured. Set one with ${commandMention("events", "set")}.`); + return; + } + + const { reports, skipped } = await EventChannelUtils.reconcileAllGuildEvents(inter.store); + + const skippedSuffix = skipped.length > 0 + ? `, skipped ${skipped.length} event(s) already in progress: ${skipped.map(e => inlineCode(e.name)).join(", ")}` + : ""; + const header = `Synced room channels for ${reports.length} event(s)${skippedSuffix}:`; + const blocks = reports.map(renderSyncReport); + const combined = `${header}\n\n${blocks.join("\n\n")}`; + + if (combined.length <= DISCORD_MESSAGE_LIMIT) { + await inter.respond(combined, true); + } + else { + const embed = new EmbedBuilder().setTitle(header); + for (const report of reports) { + embed.addFields({ + name: report.eventName, + value: renderSyncReport(report).split("\n").slice(1).join("\n") || "(no changes)", + }); + } + await inter.respond({ embeds: [embed] }, true); + } + } +} diff --git a/src/commands/commands/events/crud.handlers.ts b/src/commands/commands/events/crud.handlers.ts new file mode 100644 index 00000000..667a1cc1 --- /dev/null +++ b/src/commands/commands/events/crud.handlers.ts @@ -0,0 +1,176 @@ +import { channelMention, type Collection, roleMention, time, TimestampStyles } from "discord.js"; +import { isNil, omitBy } from "lodash-es"; + +import { Queries } from "../../../db/queries.ts"; +import { type DbEvent, EVENT_TABLE } from "../../../db/schema.ts"; +import { type RoomScheduling, type SubAutoPullMode } from "../../../types/db.types.ts"; +import type { SlashInteraction } from "../../../types/interaction.types.ts"; +import { EventUtils } from "../../../utils/event.utils.ts"; +import { toCollection } from "../../../utils/misc.utils.ts"; +import { commandMention, describeTable, eventMention, queuesMention } from "../../../utils/string.utils.ts"; +import { EventsOptions } from "./options.ts"; +import { buildEventOffsetFields, verifyMentionEveryonePermission } from "./shared.ts"; + +export namespace EventsCrudHandlers { + export async function get(inter: SlashInteraction, events?: Collection) { + events = events ?? await EventsOptions.GET_OPTIONS.events.get(inter); + + if (!events || events.size === 0) { + events = inter.store.dbEvents(); + } + + if (events.size === 0) { + await inter.respond(`No events found. Create one with ${commandMention("events", "add")}.`); + return; + } + + const entries = events.map(event => { + const occurrences = Queries.selectManyOccurrences({ guildId: inter.guildId, eventId: event.id }); + const nextOcc = occurrences.sort((a, b) => Number(a.startTime) - Number(b.startTime))[0]; + const templates = Queries.selectManyRoomChannelTemplates({ guildId: inter.guildId, eventId: event.id }); + const templateSummary = templates.length + ? templates.map(t => `${t.suffix}${t.slowmodeSeconds ? ` (slowmode: ${t.slowmodeSeconds}s)` : ""}`).join(", ") + : null; + return { + ...event, + createOffsetMs: `${Number(event.createOffsetMs) / 3_600_000}h`, + lockOffsetMs: `${Number(event.lockOffsetMs) / 60_000}min`, + cleanupOffsetMs: `${Number(event.cleanupOffsetMs) / 3_600_000}h`, + roomLengthMs: event.roomLengthMs ? `${Number(event.roomLengthMs) / 60_000}min` : null, + nextOccurrence: nextOcc ? time(new Date(Number(nextOcc.startTime)), TimestampStyles.LongDateTime) : "None", + nextOccurrenceDiscordEventId: nextOcc?.discordEventId ?? null, + roomQueuesChannelId: channelMention(event.roomQueuesChannelId), + subQueuesChannelId: channelMention(event.subQueuesChannelId), + announcementChannelId: event.announcementChannelId ? channelMention(event.announcementChannelId) : null, + roomCategoryId: event.roomCategoryId ? channelMention(event.roomCategoryId) : null, + roomChannelTemplates: templateSummary, + winnerRoleId: event.winnerRoleId ? roleMention(event.winnerRoleId) : null, + }; + }); + + const descriptionMessage = describeTable({ + store: inter.store, + table: EVENT_TABLE, + tableLabel: "Events", + entryLabelProperty: "name", + entries: [...entries.values()], + hiddenProperties: ["name", "queueId"], + queueIdProperty: "id", + }); + + await inter.respond(descriptionMessage); + } + + export async function add(inter: SlashInteraction) { + const roomLengthMinutes = EventsOptions.ADD_OPTIONS.roomLengthMinutes.get(inter); + const createOffsetHours = EventsOptions.ADD_OPTIONS.createOffsetHours.get(inter); + const lockOffsetMinutes = EventsOptions.ADD_OPTIONS.lockOffsetMinutes.get(inter); + const cleanupOffsetHours = EventsOptions.ADD_OPTIONS.cleanupOffsetHours.get(inter); + const announcementChannelId = EventsOptions.ADD_OPTIONS.announcementChannel.get(inter)?.id; + const announcementMessage = EventsOptions.ADD_OPTIONS.announcementMessage.get(inter); + + const newEvent = { + name: EventsOptions.ADD_OPTIONS.name.get(inter)?.substring(0, 240), + roomCount: BigInt(EventsOptions.ADD_OPTIONS.roomCount.get(inter)), + roomQueuesChannelId: EventsOptions.ADD_OPTIONS.roomQueuesChannel.get(inter)?.id, + subQueuesChannelId: EventsOptions.ADD_OPTIONS.subQueuesChannel.get(inter)?.id, + roomCategoryId: EventsOptions.ADD_OPTIONS.roomCategory.get(inter)?.id, + ...omitBy({ + roomScheduling: EventsOptions.ADD_OPTIONS.roomScheduling.get(inter) as RoomScheduling, + ...buildEventOffsetFields({ roomLengthMinutes, createOffsetHours, lockOffsetMinutes, cleanupOffsetHours }), + announcementChannelId, + announcementMessage, + roomPingMessage: EventsOptions.ADD_OPTIONS.roomPingMessage.get(inter), + maxRoomsPerUser: EventsOptions.ADD_OPTIONS.maxRoomsPerUser.get(inter), + maxSubsPerUser: EventsOptions.ADD_OPTIONS.maxSubsPerUser.get(inter), + parentSubMutuallyExclusive: EventsOptions.ADD_OPTIONS.parentSubMutuallyExclusive.get(inter), + roleInRoomQueue: EventsOptions.ADD_OPTIONS.roleInRoomQueue.get(inter), + roleOnRoomPull: EventsOptions.ADD_OPTIONS.roleOnRoomPull.get(inter), + roleInSubQueue: EventsOptions.ADD_OPTIONS.roleInSubQueue.get(inter), + roleOnSubPull: EventsOptions.ADD_OPTIONS.roleOnSubPull.get(inter), + autoPullSubsAtRoomStartToggle: EventsOptions.ADD_OPTIONS.autoPullSubsAtRoomStartToggle.get(inter), + shuffleSubsBeforeAutoPullToggle: EventsOptions.ADD_OPTIONS.shuffleSubsBeforeAutoPullToggle.get(inter), + subAutoPullMode: EventsOptions.ADD_OPTIONS.subAutoPullMode.get(inter) as SubAutoPullMode, + createDiscordEvent: EventsOptions.ADD_OPTIONS.createDiscordEvent.get(inter), + discordEventDescription: EventsOptions.ADD_OPTIONS.discordEventDescription.get(inter), + }, isNil), + }; + + if (announcementMessage && announcementChannelId) { + verifyMentionEveryonePermission(inter, announcementMessage, announcementChannelId); + } + + const event = await EventUtils.insertEvent(inter.store, newEvent); + + const eventQueues = Queries.selectManyEventQueues({ guildId: inter.guildId, eventId: event.id }); + const queues = eventQueues.map(eq => inter.store.dbQueues().get(eq.queueId)).filter(Boolean); + + await inter.respond( + `Created event ${eventMention(event)} with ${queues.length} queues: ${queuesMention(queues)}.`, + true, + ); + + await get(inter, toCollection("id", [event])); + } + + export async function set(inter: SlashInteraction) { + const event = await EventsOptions.SET_OPTIONS.event.get(inter); + const roomLengthMinutes = EventsOptions.SET_OPTIONS.roomLengthMinutes.get(inter); + const createOffsetHours = EventsOptions.SET_OPTIONS.createOffsetHours.get(inter); + const lockOffsetMinutes = EventsOptions.SET_OPTIONS.lockOffsetMinutes.get(inter); + const cleanupOffsetHours = EventsOptions.SET_OPTIONS.cleanupOffsetHours.get(inter); + const announcementChannelId = EventsOptions.SET_OPTIONS.announcementChannel.get(inter)?.id; + const announcementMessage = EventsOptions.SET_OPTIONS.announcementMessage.get(inter); + + const update = omitBy({ + roomCount: EventsOptions.SET_OPTIONS.roomCount.get(inter) ? BigInt(EventsOptions.SET_OPTIONS.roomCount.get(inter)) : undefined, + roomScheduling: EventsOptions.SET_OPTIONS.roomScheduling.get(inter) as RoomScheduling, + ...buildEventOffsetFields({ roomLengthMinutes, createOffsetHours, lockOffsetMinutes, cleanupOffsetHours }), + announcementChannelId, + announcementMessage, + roomPingMessage: EventsOptions.SET_OPTIONS.roomPingMessage.get(inter), + maxRoomsPerUser: EventsOptions.SET_OPTIONS.maxRoomsPerUser.get(inter), + maxSubsPerUser: EventsOptions.SET_OPTIONS.maxSubsPerUser.get(inter), + parentSubMutuallyExclusive: EventsOptions.SET_OPTIONS.parentSubMutuallyExclusive.get(inter), + roomCategoryId: EventsOptions.SET_OPTIONS.roomCategory.get(inter)?.id, + roleInRoomQueue: EventsOptions.SET_OPTIONS.roleInRoomQueue.get(inter), + roleOnRoomPull: EventsOptions.SET_OPTIONS.roleOnRoomPull.get(inter), + roleInSubQueue: EventsOptions.SET_OPTIONS.roleInSubQueue.get(inter), + roleOnSubPull: EventsOptions.SET_OPTIONS.roleOnSubPull.get(inter), + autoPullSubsAtRoomStartToggle: EventsOptions.SET_OPTIONS.autoPullSubsAtRoomStartToggle.get(inter), + shuffleSubsBeforeAutoPullToggle: EventsOptions.SET_OPTIONS.shuffleSubsBeforeAutoPullToggle.get(inter), + subAutoPullMode: EventsOptions.SET_OPTIONS.subAutoPullMode.get(inter) as SubAutoPullMode, + createDiscordEvent: EventsOptions.SET_OPTIONS.createDiscordEvent.get(inter), + discordEventDescription: EventsOptions.SET_OPTIONS.discordEventDescription.get(inter), + winnerRoleId: EventsOptions.SET_OPTIONS.winnerRole.get(inter)?.id, + }, isNil); + + const effectiveMessage = announcementMessage ?? event.announcementMessage; + const effectiveChannel = announcementChannelId ?? event.announcementChannelId; + if (effectiveMessage && effectiveChannel) { + verifyMentionEveryonePermission(inter, effectiveMessage, effectiveChannel); + } + + const updatedEvent = await EventUtils.updateEvent(inter.store, event, update); + + await inter.respond(`Updated ${eventMention(updatedEvent)}.`, true); + await get(inter, toCollection("id", [updatedEvent])); + } + + export async function deleteEvent(inter: SlashInteraction) { + await inter.deferReply(); + const event = await EventsOptions.DELETE_OPTIONS.event.get(inter); + + const confirmed = await inter.promptConfirmOrCancel( + `Are you sure you want to delete the ${eventMention(event)} event? This will also delete all associated queues and displays.`, + ); + if (!confirmed) { + await inter.respond("Cancelled delete."); + return; + } + + await EventUtils.deleteEvent(inter.store, event); + + await inter.respond(`Deleted the ${eventMention(event)} event and all its queues.`, true); + } +} diff --git a/src/commands/commands/events/defaults.handlers.ts b/src/commands/commands/events/defaults.handlers.ts new file mode 100644 index 00000000..d0d330e7 --- /dev/null +++ b/src/commands/commands/events/defaults.handlers.ts @@ -0,0 +1,214 @@ +import { inlineCode } from "discord.js"; +import { SQLiteColumn } from "drizzle-orm/sqlite-core"; +import { findKey, isNil, omitBy } from "lodash-es"; + +import { type DbEvent, EVENT_TABLE, QUEUE_TABLE } from "../../../db/schema.ts"; +import { AnnouncementChannelOption } from "../../../options/options/announcement-channel.option.ts"; +import { AnnouncementMessageOption } from "../../../options/options/announcement-message.option.ts"; +import { AutoPullSubsAtRoomStartToggleOption } from "../../../options/options/auto-pull-subs-at-room-start-toggle.option.ts"; +import { AutopullToggleOption } from "../../../options/options/autopull-toggle.option.ts"; +import { BadgeToggleOption } from "../../../options/options/badge-toggle.option.ts"; +import { CleanupOffsetHoursOption } from "../../../options/options/cleanup-offset-hours.option.ts"; +import { ColorOption } from "../../../options/options/color.option.ts"; +import { CreateDiscordEventToggleOption } from "../../../options/options/create-discord-event-toggle.option.ts"; +import { CreateOffsetHoursOption } from "../../../options/options/create-offset-hours.option.ts"; +import { DiscordEventDescriptionOption } from "../../../options/options/discord-event-description.option.ts"; +import { ButtonsToggleOption } from "../../../options/options/display-buttons.option.ts"; +import { DisplayUpdateTypeOption } from "../../../options/options/display-update-type.option.ts"; +import { DmOnPullToggleOption } from "../../../options/options/dm-on-pull-toggle.option.ts"; +import { HeaderOption } from "../../../options/options/header.option.ts"; +import { InlineToggleOption } from "../../../options/options/inline-toggle.option.ts"; +import { LockOffsetMinutesOption } from "../../../options/options/lock-offset-minutes.option.ts"; +import { LockToggleOption } from "../../../options/options/lock-toggle.option.ts"; +import { MemberDisplayTypeOption } from "../../../options/options/member-display-type.option.ts"; +import { PullBatchSizeOption } from "../../../options/options/pull-batch-size.option.ts"; +import { PullMessageOption } from "../../../options/options/pull-message.option.ts"; +import { PullMessageChannelOption } from "../../../options/options/pull-message-channel.option.ts"; +import { PullMessageDisplayTypeOption } from "../../../options/options/pull-message-display-type.option.ts"; +import { RejoinCooldownPeriodOption } from "../../../options/options/rejoin-cooldown-period.option.ts"; +import { RejoinGracePeriodOption } from "../../../options/options/rejoin-grace-period.option.ts"; +import { RequireMessageToJoinOption } from "../../../options/options/require-message-to-join.option.ts"; +import { RoleInQueueOption } from "../../../options/options/role-in-queue.option.ts"; +import { RoleInRoomQueueOption } from "../../../options/options/role-in-room-queue.option.ts"; +import { RoleInSubQueueOption } from "../../../options/options/role-in-sub-queue.option.ts"; +import { RoleOnPullOption } from "../../../options/options/role-on-pull.option.ts"; +import { RoleOnRoomPullOption } from "../../../options/options/role-on-room-pull.option.ts"; +import { RoleOnSubPullOption } from "../../../options/options/role-on-sub-pull.option.ts"; +import { RoomLengthMinutesOption } from "../../../options/options/room-length-minutes.option.ts"; +import { RoomPingMessageOption } from "../../../options/options/room-ping-message.option.ts"; +import { RoomSchedulingOption } from "../../../options/options/room-scheduling.option.ts"; +import { ShuffleSubsBeforeAutoPullToggleOption } from "../../../options/options/shuffle-subs-before-auto-pull-toggle.option.ts"; +import { SizeOption } from "../../../options/options/size.option.ts"; +import { SubAutoPullModeOption } from "../../../options/options/sub-auto-pull-mode.option.ts"; +import { TimestampTypeOption } from "../../../options/options/timestamp-type.option.ts"; +import { VoiceDestinationChannelOption } from "../../../options/options/voice-destination-channel.option.ts"; +import { VoiceOnlyToggleOption } from "../../../options/options/voice-only-toggle.option.ts"; +import { WinnerRoleOption } from "../../../options/options/winner-role.option.ts"; +import { EventQueueRole } from "../../../types/db.types.ts"; +import type { SlashInteraction } from "../../../types/interaction.types.ts"; +import { EventUtils } from "../../../utils/event.utils.ts"; +import { SelectMenuTransactor } from "../../../utils/message-utils/select-menu-transactor.ts"; +import { toCollection } from "../../../utils/misc.utils.ts"; +import { eventMention } from "../../../utils/string.utils.ts"; +import { EventsCrudHandlers } from "./crud.handlers.ts"; +import { EventsOptions } from "./options.ts"; + +export namespace EventsDefaultsHandlers { + export async function setRoomDefaults(inter: SlashInteraction) { + await setDefaults(inter, EventQueueRole.Room, EventsOptions.SET_ROOM_DEFAULTS_OPTIONS); + } + + export async function setSubDefaults(inter: SlashInteraction) { + await setDefaults(inter, EventQueueRole.Sub, EventsOptions.SET_SUB_DEFAULTS_OPTIONS); + } + + async function setDefaults(inter: SlashInteraction, role: EventQueueRole, options: typeof EventsOptions.SET_QUEUE_DEFAULTS_OPTIONS) { + await inter.deferReply(); + const event = await options.event.get(inter); + + const update = omitBy({ + autopullToggle: options.autopullToggle.get(inter), + badgeToggle: options.badgeToggle.get(inter), + buttonsToggle: options.buttonsToggle.get(inter), + color: options.color.get(inter), + displayUpdateType: options.displayUpdateType.get(inter), + dmOnPullToggle: options.dmOnPullToggle.get(inter), + header: options.header.get(inter), + inlineToggle: options.inlineToggle.get(inter), + lockToggle: options.lockToggle.get(inter), + memberDisplayType: options.memberDisplayType.get(inter), + pullBatchSize: options.pullBatchSize.get(inter), + pullMessage: options.pullMessage.get(inter), + pullMessageDisplayType: options.pullMessageDisplayType.get(inter), + pullMessageChannelId: options.pullMessageChannel.get(inter)?.id, + rejoinCooldownPeriod: options.rejoinCooldownPeriod.get(inter), + rejoinGracePeriod: options.rejoinGracePeriod.get(inter), + requireMessageToJoin: options.requireMessageToJoin.get(inter), + roleInQueueId: options.roleInQueue.get(inter)?.id, + roleOnPullId: options.roleOnPull.get(inter)?.id, + size: options.size.get(inter), + timestampType: options.timestampType.get(inter), + voiceOnlyToggle: options.voiceOnlyToggle.get(inter), + voiceDestinationChannelId: options.voiceDestinationChannel.get(inter)?.id, + }, isNil); + + await EventUtils.setRoleDefaults(inter.store, event, role, update); + + await inter.respond(`Updated ${role} queue defaults for ${eventMention(event)}.`, true); + } + + export async function reset(inter: SlashInteraction) { + await inter.deferReply(); + const event = await EventsOptions.RESET_OPTIONS.event.get(inter); + + const ANNOUNCEMENT_PAIR_VALUE = "__announcement_pair__"; + const selectMenuOptions = [ + { name: CreateOffsetHoursOption.ID, value: EVENT_TABLE.createOffsetMs.name }, + { name: LockOffsetMinutesOption.ID, value: EVENT_TABLE.lockOffsetMs.name }, + { name: CleanupOffsetHoursOption.ID, value: EVENT_TABLE.cleanupOffsetMs.name }, + { name: RoomSchedulingOption.ID, value: EVENT_TABLE.roomScheduling.name }, + { name: RoomLengthMinutesOption.ID, value: EVENT_TABLE.roomLengthMs.name }, + { name: `${AnnouncementChannelOption.ID} + ${AnnouncementMessageOption.ID}`, value: ANNOUNCEMENT_PAIR_VALUE }, + { name: RoomPingMessageOption.ID, value: EVENT_TABLE.roomPingMessage.name }, + { name: RoleInRoomQueueOption.ID, value: EVENT_TABLE.roleInRoomQueue.name }, + { name: RoleOnRoomPullOption.ID, value: EVENT_TABLE.roleOnRoomPull.name }, + { name: RoleInSubQueueOption.ID, value: EVENT_TABLE.roleInSubQueue.name }, + { name: RoleOnSubPullOption.ID, value: EVENT_TABLE.roleOnSubPull.name }, + { name: AutoPullSubsAtRoomStartToggleOption.ID, value: EVENT_TABLE.autoPullSubsAtRoomStartToggle.name }, + { name: ShuffleSubsBeforeAutoPullToggleOption.ID, value: EVENT_TABLE.shuffleSubsBeforeAutoPullToggle.name }, + { name: SubAutoPullModeOption.ID, value: EVENT_TABLE.subAutoPullMode.name }, + { name: CreateDiscordEventToggleOption.ID, value: EVENT_TABLE.createDiscordEvent.name }, + { name: DiscordEventDescriptionOption.ID, value: EVENT_TABLE.discordEventDescription.name }, + { name: `${WinnerRoleOption.ID} (does not revoke already-granted roles)`, value: EVENT_TABLE.winnerRoleId.name }, + ]; + const selectMenuTransactor = new SelectMenuTransactor(inter); + const propertiesToReset = await selectMenuTransactor.sendAndReceive("Event properties to reset", selectMenuOptions) ?? []; + if (propertiesToReset.length === 0) return; + + const update: Partial = {}; + const resetLabels: string[] = []; + for (const property of propertiesToReset) { + if (property === ANNOUNCEMENT_PAIR_VALUE) { + update.announcementChannelId = null; + update.announcementMessage = null; + resetLabels.push(AnnouncementChannelOption.ID, AnnouncementMessageOption.ID); + continue; + } + const columnKey = findKey(EVENT_TABLE, (column: SQLiteColumn) => column.name === property); + if (!columnKey) continue; + (update as any)[columnKey] = (EVENT_TABLE as any)[columnKey]?.default ?? null; + resetLabels.push(property); + } + + await EventUtils.updateEvent(inter.store, event, update); + + const propertiesStr = resetLabels.map(inlineCode).join(", "); + const haveWord = resetLabels.length === 1 ? "has" : "have"; + await selectMenuTransactor.updateWithResult( + "Reset event properties", + `${propertiesStr} ${haveWord} been reset for ${eventMention(event)}.`, + ); + + await EventsCrudHandlers.get(inter, toCollection("id", [event])); + } + + export async function resetRoomDefaults(inter: SlashInteraction) { + await resetDefaults(inter, EventQueueRole.Room, EventsOptions.RESET_ROOM_DEFAULTS_OPTIONS); + } + + export async function resetSubDefaults(inter: SlashInteraction) { + await resetDefaults(inter, EventQueueRole.Sub, EventsOptions.RESET_SUB_DEFAULTS_OPTIONS); + } + + async function resetDefaults( + inter: SlashInteraction, + role: EventQueueRole, + options: typeof EventsOptions.RESET_ROOM_DEFAULTS_OPTIONS, + ) { + await inter.deferReply(); + const event = await options.event.get(inter); + + const selectMenuOptions = [ + { name: AutopullToggleOption.ID, value: QUEUE_TABLE.autopullToggle.name }, + { name: BadgeToggleOption.ID, value: QUEUE_TABLE.badgeToggle.name }, + { name: ButtonsToggleOption.ID, value: QUEUE_TABLE.buttonsToggle.name }, + { name: ColorOption.ID, value: QUEUE_TABLE.color.name }, + { name: DisplayUpdateTypeOption.ID, value: QUEUE_TABLE.displayUpdateType.name }, + { name: DmOnPullToggleOption.ID, value: QUEUE_TABLE.dmOnPullToggle.name }, + { name: HeaderOption.ID, value: QUEUE_TABLE.header.name }, + { name: InlineToggleOption.ID, value: QUEUE_TABLE.inlineToggle.name }, + { name: LockToggleOption.ID, value: QUEUE_TABLE.lockToggle.name }, + { name: MemberDisplayTypeOption.ID, value: QUEUE_TABLE.memberDisplayType.name }, + { name: PullBatchSizeOption.ID, value: QUEUE_TABLE.pullBatchSize.name }, + { name: PullMessageOption.ID, value: QUEUE_TABLE.pullMessage.name }, + { name: PullMessageDisplayTypeOption.ID, value: QUEUE_TABLE.pullMessageDisplayType.name }, + { name: PullMessageChannelOption.ID, value: QUEUE_TABLE.pullMessageChannelId.name }, + { name: RejoinCooldownPeriodOption.ID, value: QUEUE_TABLE.rejoinCooldownPeriod.name }, + { name: RejoinGracePeriodOption.ID, value: QUEUE_TABLE.rejoinGracePeriod.name }, + { name: RequireMessageToJoinOption.ID, value: QUEUE_TABLE.requireMessageToJoin.name }, + { name: RoleInQueueOption.ID, value: QUEUE_TABLE.roleInQueueId.name }, + { name: RoleOnPullOption.ID, value: QUEUE_TABLE.roleOnPullId.name }, + { name: SizeOption.ID, value: QUEUE_TABLE.size.name }, + { name: TimestampTypeOption.ID, value: QUEUE_TABLE.timestampType.name }, + { name: VoiceOnlyToggleOption.ID, value: QUEUE_TABLE.voiceOnlyToggle.name }, + { name: VoiceDestinationChannelOption.ID, value: QUEUE_TABLE.voiceDestinationChannelId.name }, + ]; + const selectMenuTransactor = new SelectMenuTransactor(inter); + const propertiesToReset = await selectMenuTransactor.sendAndReceive( + `${role} queue defaults to reset`, + selectMenuOptions, + ) ?? []; + if (propertiesToReset.length === 0) return; + + await EventUtils.resetRoleDefaults(inter.store, event, role, propertiesToReset); + + const propertiesStr = propertiesToReset.map(inlineCode).join(", "); + const haveWord = propertiesToReset.length === 1 ? "has" : "have"; + await selectMenuTransactor.updateWithResult( + `Reset ${role} queue defaults`, + `${propertiesStr} ${haveWord} been reset for ${role} queues of ${eventMention(event)}.`, + ); + + await EventsCrudHandlers.get(inter, toCollection("id", [event])); + } +} diff --git a/src/commands/commands/events/help.handlers.ts b/src/commands/commands/events/help.handlers.ts new file mode 100644 index 00000000..af151563 --- /dev/null +++ b/src/commands/commands/events/help.handlers.ts @@ -0,0 +1,54 @@ +import { EmbedBuilder, inlineCode } from "discord.js"; + +import { Color } from "../../../types/db.types.ts"; +import type { SlashInteraction } from "../../../types/interaction.types.ts"; +import { commandMention } from "../../../utils/string.utils.ts"; + +export namespace EventsHelpHandlers { + export async function help(inter: SlashInteraction) { + const embeds = [new EmbedBuilder() + .setTitle("Events") + .setColor(Color.Indigo) + .setDescription( + "Events let you create recurring event templates with auto-managed room and sub queues.\n\n" + + "**Quick start:**\n" + + `1. ${commandMention("events", "add")} — create an event with N rooms (${inlineCode("room_category")} is required; one private \`room-{N}\` channel and a \`{event} Room {N}\` role are auto-created per room)\n` + + `2. ${commandMention("events", "set-room-defaults")} — configure room queue defaults (size, etc.)\n` + + `3. ${commandMention("events", "schedule")} — schedule an occurrence (\`event\`, \`year\`, \`month\`, \`day\`, \`start_time\` (12-hour, e.g. \`9:30 PM\`), optional \`timezone\`)\n\n` + + "**Lifecycle per occurrence:**\n" + + "- **T − create_offset** (default 24h before): queues unlock, displays refresh, announcement posts\n" + + "- **T + lock_offset** (default 0): room queues lock (sub queues stay open)\n" + + "- **Per-room ping**: at each room's start time a ping posts in the room's channel\n" + + "- **rooms_finish + cleanup_offset** (default 1h after rooms finish): all members cleared, all queues locked\n" + + "- A native Discord scheduled event is created per occurrence when `create_discord_event` is on (default).\n\n" + + "**Missed actions** (bot was down): run automatically on next startup.\n\n" + + "**Signup policies** (set via `/events add` or `/events set`):\n" + + "- `max_rooms_per_user` — cap on room queues a single user may sit in at once (`0` = unlimited)\n" + + "- `max_subs_per_user` — cap on sub-room queues (`0` = unlimited)\n" + + "- `parent_sub_mutually_exclusive` — when `true` (default), a user can't sit in both a room and its matching sub. Joining the room silently removes them from the sub; joining the sub while already in the room is blocked.\n\n" + + "**Room role assignment** — four booleans on the event template control which queues the auto-created `{event} Room {N}` role gets wired into:\n" + + "- `role_in_room_queue` (default `false`) — assign the role while a user is in the room queue\n" + + "- `role_on_room_pull` (default `false`) — assign the role when a user is pulled from the room queue\n" + + "- `role_in_sub_queue` (default `false`) — assign the role while a user is in the sub queue\n" + + "- `role_on_sub_pull` (default `false`) — assign the role when a user is pulled from the sub queue\n\n" + + "**Declaring winners** — crown the event's winner(s) with one shared role that auto-revokes when the event's next occurrence opens:\n" + + `- Configure the role once via \`winner_role\` on ${commandMention("events", "set")}.\n` + + `- ${commandMention("events", "declare-winners")} (\`winner_1\`..\`winner_5\`) grants it — additive, ties allowed; call again for >5 winners.\n` + + `- ${commandMention("events", "winners")} lists current winners; ${commandMention("events", "clear-winners")} revokes early.\n` + + "- With multiple occurrences scheduled, the **earliest** one to open revokes the role.\n\n" + + "**Auto-pull subs at room start:**\n" + + "- `auto_pull_subs_room_start_toggle` (default `false`) — at each room's start, lock paired sub and pull subs into the room. Forces room lock at exact `start_time` (ignores `lock_offset`).\n" + + "- `shuffle_subs_before_pull_toggle` (default `false`) — shuffle the sub queue before the pull.\n" + + "- `sub_auto_pull_mode` (default `drain`) — `drain`: standard `/pull` side effects fire. `promote`: move into room queue (bypasses room lock), no sub-side pull effects.\n\n" + + "**Extra per-room channels:**\n" + + `- ${commandMention("events", "add-room-channel")} adds an extra per-room channel like \`room-code-{N}\`, with optional slowmode.\n` + + `- ${commandMention("events", "remove-room-channel")} removes one of those templates and its channels.\n` + + `- ${commandMention("events", "sync-room-channels")} recreates any channels you accidentally deleted, re-applies permissions, and restores channel order.\n` + + `- ${commandMention("events", "sync-queues")} recreates any deleted queues, re-applies the room/sub defaults to every queue, and re-posts displays in queue-index order.\n\n` + + "**Announcement placeholders:** `{event_name}`, `{start_time}`, `{start_time_relative}`, `{room_queues_channel}`, `{sub_queues_channel}`\n" + + "**Ping placeholders:** `{room_role}`, `{room_name}`, `{room_index}`, `{room_queues_channel}`, `{ping_channel}`, `{start_time}`, `{start_time_relative}`", + )]; + + await inter.respond({ embeds }); + } +} diff --git a/src/commands/commands/events/options.ts b/src/commands/commands/events/options.ts new file mode 100644 index 00000000..cca7a649 --- /dev/null +++ b/src/commands/commands/events/options.ts @@ -0,0 +1,218 @@ +import { UserOption } from "../../../options/base-option.ts"; +import { AnnouncementChannelOption } from "../../../options/options/announcement-channel.option.ts"; +import { AnnouncementMessageOption } from "../../../options/options/announcement-message.option.ts"; +import { AutoPullSubsAtRoomStartToggleOption } from "../../../options/options/auto-pull-subs-at-room-start-toggle.option.ts"; +import { AutopullToggleOption } from "../../../options/options/autopull-toggle.option.ts"; +import { BadgeToggleOption } from "../../../options/options/badge-toggle.option.ts"; +import { ChannelSuffixOption } from "../../../options/options/channel-suffix.option.ts"; +import { CleanupOffsetHoursOption } from "../../../options/options/cleanup-offset-hours.option.ts"; +import { ColorOption } from "../../../options/options/color.option.ts"; +import { CreateDiscordEventToggleOption } from "../../../options/options/create-discord-event-toggle.option.ts"; +import { CreateOffsetHoursOption } from "../../../options/options/create-offset-hours.option.ts"; +import { DayOption } from "../../../options/options/day.option.ts"; +import { DiscordEventDescriptionOption } from "../../../options/options/discord-event-description.option.ts"; +import { ButtonsToggleOption } from "../../../options/options/display-buttons.option.ts"; +import { DisplayUpdateTypeOption } from "../../../options/options/display-update-type.option.ts"; +import { DmOnPullToggleOption } from "../../../options/options/dm-on-pull-toggle.option.ts"; +import { EventOption } from "../../../options/options/event.option.ts"; +import { EventsOption } from "../../../options/options/events.option.ts"; +import { HeaderOption } from "../../../options/options/header.option.ts"; +import { InlineToggleOption } from "../../../options/options/inline-toggle.option.ts"; +import { LockOffsetMinutesOption } from "../../../options/options/lock-offset-minutes.option.ts"; +import { LockToggleOption } from "../../../options/options/lock-toggle.option.ts"; +import { MaxRoomsPerUserOption } from "../../../options/options/max-rooms-per-user.option.ts"; +import { MaxSubsPerUserOption } from "../../../options/options/max-subs-per-user.option.ts"; +import { MemberDisplayTypeOption } from "../../../options/options/member-display-type.option.ts"; +import { MonthOption } from "../../../options/options/month.option.ts"; +import { NameOption } from "../../../options/options/name.option.ts"; +import { ParentSubMutuallyExclusiveOption } from "../../../options/options/parent-sub-mutually-exclusive.option.ts"; +import { PullBatchSizeOption } from "../../../options/options/pull-batch-size.option.ts"; +import { PullMessageOption } from "../../../options/options/pull-message.option.ts"; +import { PullMessageChannelOption } from "../../../options/options/pull-message-channel.option.ts"; +import { PullMessageDisplayTypeOption } from "../../../options/options/pull-message-display-type.option.ts"; +import { RejoinCooldownPeriodOption } from "../../../options/options/rejoin-cooldown-period.option.ts"; +import { RejoinGracePeriodOption } from "../../../options/options/rejoin-grace-period.option.ts"; +import { RequireMessageToJoinOption } from "../../../options/options/require-message-to-join.option.ts"; +import { RoleInQueueOption } from "../../../options/options/role-in-queue.option.ts"; +import { RoleInRoomQueueOption } from "../../../options/options/role-in-room-queue.option.ts"; +import { RoleInSubQueueOption } from "../../../options/options/role-in-sub-queue.option.ts"; +import { RoleOnPullOption } from "../../../options/options/role-on-pull.option.ts"; +import { RoleOnRoomPullOption } from "../../../options/options/role-on-room-pull.option.ts"; +import { RoleOnSubPullOption } from "../../../options/options/role-on-sub-pull.option.ts"; +import { RoomCategoryOption } from "../../../options/options/room-category.option.ts"; +import { RoomCountOption } from "../../../options/options/room-count.option.ts"; +import { RoomLengthMinutesOption } from "../../../options/options/room-length-minutes.option.ts"; +import { RoomPingMessageOption } from "../../../options/options/room-ping-message.option.ts"; +import { RoomQueuesChannelOption } from "../../../options/options/room-queues-channel.option.ts"; +import { RoomSchedulingOption } from "../../../options/options/room-scheduling.option.ts"; +import { ShuffleSubsBeforeAutoPullToggleOption } from "../../../options/options/shuffle-subs-before-auto-pull-toggle.option.ts"; +import { SizeOption } from "../../../options/options/size.option.ts"; +import { SlowmodeOption } from "../../../options/options/slowmode.option.ts"; +import { SlowmodeTimeOption } from "../../../options/options/slowmode-time.option.ts"; +import { StartTimeOption } from "../../../options/options/start-time.option.ts"; +import { SubAutoPullModeOption } from "../../../options/options/sub-auto-pull-mode.option.ts"; +import { SubQueuesChannelOption } from "../../../options/options/sub-queues-channel.option.ts"; +import { TimestampTypeOption } from "../../../options/options/timestamp-type.option.ts"; +import { TimezoneOption } from "../../../options/options/timezone.option.ts"; +import { VoiceDestinationChannelOption } from "../../../options/options/voice-destination-channel.option.ts"; +import { VoiceOnlyToggleOption } from "../../../options/options/voice-only-toggle.option.ts"; +import { WinnerRoleOption } from "../../../options/options/winner-role.option.ts"; +import { YearOption } from "../../../options/options/year.option.ts"; + +export namespace EventsOptions { + export const GET_OPTIONS = { + events: new EventsOption({ description: "Specific event(s)" }), + }; + + export const ADD_OPTIONS = { + name: new NameOption({ required: true, description: "Event name" }), + roomCount: new RoomCountOption({ required: true, description: "Number of rooms" }), + roomQueuesChannel: new RoomQueuesChannelOption({ required: true, description: "Parent channel for room queues" }), + subQueuesChannel: new SubQueuesChannelOption({ required: true, description: "Parent channel for sub queues" }), + roomCategory: new RoomCategoryOption({ required: true, description: "Category for per-room channels" }), + roomScheduling: new RoomSchedulingOption({ description: "Room timing (parallel/sequential)" }), + roomLengthMinutes: new RoomLengthMinutesOption({ description: "Room length in minutes (sequential req)" }), + createOffsetHours: new CreateOffsetHoursOption({ description: "Hours before start to open" }), + lockOffsetMinutes: new LockOffsetMinutesOption({ description: "Minutes after start to lock (neg=before)" }), + cleanupOffsetHours: new CleanupOffsetHoursOption({ description: "Hours after rooms finish to cleanup" }), + announcementChannel: new AnnouncementChannelOption({ description: "Announcement channel" }), + announcementMessage: new AnnouncementMessageOption({ description: "Announcement template — placeholders: /events help" }), + roomPingMessage: new RoomPingMessageOption({ description: "Per-room ping template — placeholders: /events help" }), + maxRoomsPerUser: new MaxRoomsPerUserOption({ description: "Max rooms per user (0=unlimited)" }), + maxSubsPerUser: new MaxSubsPerUserOption({ description: "Max subs per user (0=unlimited)" }), + parentSubMutuallyExclusive: new ParentSubMutuallyExclusiveOption({ description: "Room + matching sub mutually exclusive" }), + roleInRoomQueue: new RoleInRoomQueueOption({ description: "Assign room role while in room queue" }), + roleOnRoomPull: new RoleOnRoomPullOption({ description: "Assign room role on room queue pull" }), + roleInSubQueue: new RoleInSubQueueOption({ description: "Assign room role while in sub queue" }), + roleOnSubPull: new RoleOnSubPullOption({ description: "Assign room role on sub queue pull" }), + autoPullSubsAtRoomStartToggle: new AutoPullSubsAtRoomStartToggleOption({ description: "Auto-pull subs at room start" }), + shuffleSubsBeforeAutoPullToggle: new ShuffleSubsBeforeAutoPullToggleOption({ description: "Shuffle subs before auto-pull" }), + subAutoPullMode: new SubAutoPullModeOption({ description: "Auto-pull mode" }), + createDiscordEvent: new CreateDiscordEventToggleOption({ description: "Create Discord scheduled event per occurrence" }), + discordEventDescription: new DiscordEventDescriptionOption({ description: "Discord event description — placeholders: /events help" }), + }; + + export const SET_OPTIONS = { + event: new EventOption({ required: true, description: "Target event" }), + roomCount: new RoomCountOption({ description: "Number of rooms (grow only)" }), + roomScheduling: new RoomSchedulingOption({ description: "Room timing (parallel/sequential)" }), + roomLengthMinutes: new RoomLengthMinutesOption({ description: "Room length in minutes" }), + createOffsetHours: new CreateOffsetHoursOption({ description: "Hours before start to open" }), + lockOffsetMinutes: new LockOffsetMinutesOption({ description: "Minutes after start to lock" }), + cleanupOffsetHours: new CleanupOffsetHoursOption({ description: "Hours after rooms finish to cleanup" }), + announcementChannel: new AnnouncementChannelOption({ description: "Announcement channel" }), + announcementMessage: new AnnouncementMessageOption({ description: "Announcement template — placeholders: /events help" }), + roomPingMessage: new RoomPingMessageOption({ description: "Per-room ping template — placeholders: /events help" }), + maxRoomsPerUser: new MaxRoomsPerUserOption({ description: "Max rooms per user (0=unlimited)" }), + maxSubsPerUser: new MaxSubsPerUserOption({ description: "Max subs per user (0=unlimited)" }), + parentSubMutuallyExclusive: new ParentSubMutuallyExclusiveOption({ description: "Room + matching sub mutually exclusive" }), + roomCategory: new RoomCategoryOption({ description: "Category for per-room channels" }), + roleInRoomQueue: new RoleInRoomQueueOption({ description: "Assign room role while in room queue" }), + roleOnRoomPull: new RoleOnRoomPullOption({ description: "Assign room role on room queue pull" }), + roleInSubQueue: new RoleInSubQueueOption({ description: "Assign room role while in sub queue" }), + roleOnSubPull: new RoleOnSubPullOption({ description: "Assign room role on sub queue pull" }), + autoPullSubsAtRoomStartToggle: new AutoPullSubsAtRoomStartToggleOption({ description: "Auto-pull subs at room start" }), + shuffleSubsBeforeAutoPullToggle: new ShuffleSubsBeforeAutoPullToggleOption({ description: "Shuffle subs before auto-pull" }), + subAutoPullMode: new SubAutoPullModeOption({ description: "Auto-pull mode" }), + createDiscordEvent: new CreateDiscordEventToggleOption({ description: "Create Discord scheduled event per occurrence" }), + discordEventDescription: new DiscordEventDescriptionOption({ description: "Discord event description — placeholders: /events help" }), + winnerRole: new WinnerRoleOption({ description: "Role granted to declared winners" }), + }; + + export const SET_QUEUE_DEFAULTS_OPTIONS = { + event: new EventOption({ required: true, description: "Target event" }), + autopullToggle: new AutopullToggleOption({ description: "Autopull toggle" }), + badgeToggle: new BadgeToggleOption({ description: "Badge toggle" }), + buttonsToggle: new ButtonsToggleOption({ description: "Buttons toggle" }), + color: new ColorOption({ description: "Queue color" }), + displayUpdateType: new DisplayUpdateTypeOption({ description: "Display update type" }), + dmOnPullToggle: new DmOnPullToggleOption({ description: "DM-on-pull toggle" }), + header: new HeaderOption({ description: "Display header" }), + inlineToggle: new InlineToggleOption({ description: "Inline toggle" }), + lockToggle: new LockToggleOption({ description: "Lock toggle" }), + memberDisplayType: new MemberDisplayTypeOption({ description: "Member display type" }), + pullBatchSize: new PullBatchSizeOption({ description: "Pull batch size" }), + pullMessage: new PullMessageOption({ description: "Pull message" }), + pullMessageDisplayType: new PullMessageDisplayTypeOption({ description: "Pull message display type" }), + pullMessageChannel: new PullMessageChannelOption({ description: "Pull message channel" }), + rejoinCooldownPeriod: new RejoinCooldownPeriodOption({ description: "Rejoin cooldown (s)" }), + rejoinGracePeriod: new RejoinGracePeriodOption({ description: "Rejoin grace (s)" }), + requireMessageToJoin: new RequireMessageToJoinOption({ description: "Require message to join" }), + roleInQueue: new RoleInQueueOption({ description: "In-queue role" }), + roleOnPull: new RoleOnPullOption({ description: "On-pull role" }), + size: new SizeOption({ description: "Size limit" }), + timestampType: new TimestampTypeOption({ description: "Timestamp format" }), + voiceOnlyToggle: new VoiceOnlyToggleOption({ description: "Voice-only toggle" }), + voiceDestinationChannel: new VoiceDestinationChannelOption({ description: "Voice destination channel" }), + }; + + export const SET_ROOM_DEFAULTS_OPTIONS = SET_QUEUE_DEFAULTS_OPTIONS; + + export const SET_SUB_DEFAULTS_OPTIONS = SET_QUEUE_DEFAULTS_OPTIONS; + + export const ADD_ROOM_CHANNEL_OPTIONS = { + event: new EventOption({ required: true, description: "Target event" }), + suffix: new ChannelSuffixOption({ required: true, description: "Suffix (e.g. \"code\" → room-code-{N})" }), + slowmode: new SlowmodeOption({ description: "Slowmode value (0=none)" }), + slowmodeTime: new SlowmodeTimeOption({ description: "Slowmode unit" }), + }; + + export const REMOVE_ROOM_CHANNEL_OPTIONS = { + event: new EventOption({ required: true, description: "Target event" }), + suffix: new ChannelSuffixOption({ required: true, description: "Suffix to remove (autocompletes)" }), + }; + + export const SYNC_ROOM_CHANNELS_OPTIONS = { + event: new EventOption({ description: "Event to sync (omit = all)" }), + }; + + export const SYNC_QUEUES_OPTIONS = { + event: new EventOption({ description: "Event to sync (omit = all)" }), + }; + + export const RESET_OPTIONS = { + event: new EventOption({ required: true, description: "Target event" }), + }; + + export const RESET_ROOM_DEFAULTS_OPTIONS = { + event: new EventOption({ required: true, description: "Target event" }), + }; + + export const RESET_SUB_DEFAULTS_OPTIONS = { + event: new EventOption({ required: true, description: "Target event" }), + }; + + export const SCHEDULE_OPTIONS = { + event: new EventOption({ required: true, description: "Target event" }), + year: new YearOption({ required: true, description: "Start year" }), + month: new MonthOption({ required: true, description: "Start month" }), + day: new DayOption({ required: true, description: "Start day" }), + startTime: new StartTimeOption({ required: true, description: "Start time (12-hour, e.g. 9 AM, 9:30 PM)" }), + timezone: new TimezoneOption({ required: false, description: "IANA timezone", defaultValue: process.env.DEFAULT_SCHEDULE_TIMEZONE }), + }; + + export const CANCEL_OPTIONS = { + event: new EventOption({ required: true, description: "Target event" }), + }; + + export const DELETE_OPTIONS = { + event: new EventOption({ required: true, description: "Target event" }), + }; + + export const DECLARE_WINNERS_OPTIONS = { + event: new EventOption({ required: true, description: "Target event" }), + winner1: new UserOption({ required: true, id: "winner_1", description: "A winner" }), + winner2: new UserOption({ id: "winner_2", description: "A winner" }), + winner3: new UserOption({ id: "winner_3", description: "A winner" }), + winner4: new UserOption({ id: "winner_4", description: "A winner" }), + winner5: new UserOption({ id: "winner_5", description: "A winner" }), + }; + + export const WINNERS_OPTIONS = { + event: new EventOption({ required: true, description: "Target event" }), + }; + + export const CLEAR_WINNERS_OPTIONS = { + event: new EventOption({ required: true, description: "Target event" }), + }; +} diff --git a/src/commands/commands/events/schedule.handlers.ts b/src/commands/commands/events/schedule.handlers.ts new file mode 100644 index 00000000..f3ebf25f --- /dev/null +++ b/src/commands/commands/events/schedule.handlers.ts @@ -0,0 +1,88 @@ +import { EmbedBuilder, time, TimestampStyles } from "discord.js"; + +import { Queries } from "../../../db/queries.ts"; +import { Color } from "../../../types/db.types.ts"; +import type { SlashInteraction } from "../../../types/interaction.types.ts"; +import { DateUtils } from "../../../utils/date.utils.ts"; +import { EventUtils } from "../../../utils/event.utils.ts"; +import { SelectMenuTransactor } from "../../../utils/message-utils/select-menu-transactor.ts"; +import { eventMention } from "../../../utils/string.utils.ts"; +import { EventsOptions } from "./options.ts"; + +export namespace EventsScheduleHandlers { + export async function schedule(inter: SlashInteraction) { + await inter.deferReply(); + + const event = await EventsOptions.SCHEDULE_OPTIONS.event.get(inter); + + const yearStr = await EventsOptions.SCHEDULE_OPTIONS.year.get(inter); + const monthStr = await EventsOptions.SCHEDULE_OPTIONS.month.get(inter); + const dayStr = await EventsOptions.SCHEDULE_OPTIONS.day.get(inter); + const startTime = await EventsOptions.SCHEDULE_OPTIONS.startTime.get(inter); + const timezoneRaw = await EventsOptions.SCHEDULE_OPTIONS.timezone.get(inter); + + const parsed = DateUtils.parseScheduledStart({ + yearStr, + monthStr, + dayStr, + startTime, + timezone: timezoneRaw || process.env.DEFAULT_SCHEDULE_TIMEZONE || "UTC", + }); + + const startTimeMs = BigInt(parsed.valueOf()); + const occurrence = await EventUtils.scheduleOccurrence( + inter.store, + event, + startTimeMs, + timezoneRaw || undefined, + ); + + const startDate = new Date(Number(occurrence.startTime)); + const embed = new EmbedBuilder() + .setTitle(`Scheduled ${event.name}`) + .setColor(Color.Green) + .setDescription( + `Occurrence scheduled for ${time(startDate, TimestampStyles.LongDateTime)} (${time(startDate, TimestampStyles.RelativeTime)}).\n\n` + + `**Event:** ${eventMention(event)}\n` + + `**Opens:** ${time(new Date(Number(occurrence.startTime) - Number(event.createOffsetMs)), TimestampStyles.RelativeTime)}\n` + + `**Locks rooms:** ${time(new Date(Number(occurrence.startTime) + Number(event.lockOffsetMs)), TimestampStyles.RelativeTime)}\n` + + `**Cleans up:** ${time(new Date(EventUtils.getRoomsFinishMs(event, Number(occurrence.startTime)) + Number(event.cleanupOffsetMs)), TimestampStyles.RelativeTime)}`, + ); + + await inter.respond({ embeds: [embed] }); + } + + export async function cancel(inter: SlashInteraction) { + await inter.deferReply(); + const event = await EventsOptions.CANCEL_OPTIONS.event.get(inter); + const occurrences = Queries.selectManyOccurrences({ guildId: inter.guildId, eventId: event.id }); + + if (occurrences.length === 0) { + await inter.respond(`No pending occurrences for ${eventMention(event)}.`); + return; + } + + const selectMenuOptions = occurrences + .sort((a, b) => Number(a.startTime) - Number(b.startTime)) + .map(occ => ({ + name: `${time(new Date(Number(occ.startTime)), TimestampStyles.LongDateTime)}`, + value: occ.id.toString(), + })); + + const selectMenuTransactor = new SelectMenuTransactor(inter); + const result = await selectMenuTransactor.sendAndReceive("Select occurrence to cancel", selectMenuOptions); + if (!result || result.length === 0) return; + + for (const idStr of result) { + const occ = occurrences.find(o => o.id === BigInt(idStr)); + if (occ) { + await EventUtils.cancelOccurrence(inter.store, occ); + } + } + + await inter.respond( + `Cancelled ${result.length} occurrence(s) of ${eventMention(event)}. (NOTE: queues remain in their current state.)`, + true, + ); + } +} diff --git a/src/commands/commands/events/shared.ts b/src/commands/commands/events/shared.ts new file mode 100644 index 00000000..d726f4cd --- /dev/null +++ b/src/commands/commands/events/shared.ts @@ -0,0 +1,68 @@ +import { channelMention, inlineCode, PermissionsBitField } from "discord.js"; +import { isNil, omitBy } from "lodash-es"; + +import type { SlashInteraction } from "../../../types/interaction.types.ts"; +import { CustomError } from "../../../utils/error.utils.ts"; +import { EventChannelUtils } from "../../../utils/event-channel.utils.ts"; + +export const HOURS_TO_MS = 3_600_000n; +export const MINUTES_TO_MS = 60_000n; + +export function buildEventOffsetFields(opts: { + roomLengthMinutes?: number | null + createOffsetHours?: number | null + lockOffsetMinutes?: number | null + cleanupOffsetHours?: number | null +}) { + return omitBy({ + roomLengthMs: opts.roomLengthMinutes ? BigInt(opts.roomLengthMinutes) * MINUTES_TO_MS : undefined, + createOffsetMs: opts.createOffsetHours != null ? BigInt(opts.createOffsetHours) * HOURS_TO_MS : undefined, + lockOffsetMs: opts.lockOffsetMinutes != null ? BigInt(opts.lockOffsetMinutes) * MINUTES_TO_MS : undefined, + cleanupOffsetMs: opts.cleanupOffsetHours != null ? BigInt(opts.cleanupOffsetHours) * HOURS_TO_MS : undefined, + }, isNil); +} + +export function verifyMentionEveryonePermission(inter: SlashInteraction, message: string, channelId: string) { + if (/@(everyone|here)/.test(message) && !inter.member.permissionsIn(channelId).has(PermissionsBitField.Flags.MentionEveryone)) { + throw new CustomError({ + message: "Your announcement message contains @everyone or @here, but you lack the 'Mention Everyone' permission in the announcement channel", + }); + } +} + +export const DISCORD_MESSAGE_LIMIT = 2000; + +export function renderSyncReport(report: EventChannelUtils.SyncReport): string { + const lines: string[] = [`Synced room channels for **${report.eventName}**.`]; + + const namedBucket = (label: string, names: string[]) => { + if (names.length === 0) return; + lines.push(`• ${label}: ${names.map(inlineCode).join(", ")}`); + }; + + namedBucket("Created", report.created); + namedBucket("Adopted", report.adopted); + namedBucket("Untracked rows", report.untrackedRows); + namedBucket("Recreated missing", report.recreatedMissing); + + if (report.reorderApplied) { + lines.push(`• Reorder: ${report.trackedCount} tracked channel${report.trackedCount === 1 ? "" : "s"} reordered.`); + } + else { + lines.push("• Reorder: already in desired order (no changes)."); + } + + if (report.nonOwnedAtTop.length === 0) { + lines.push("• Non-owned channels at top of category: (none)"); + } + else { + const mentions = report.nonOwnedAtTop.map(c => channelMention(c.id)).join(", "); + lines.push(`• Non-owned channels at top of category (${report.nonOwnedAtTop.length}): ${mentions}`); + } + + if (report.errors.length > 0) { + lines.push(`• Errors: ${report.errors.map(inlineCode).join(", ")}`); + } + + return lines.join("\n"); +} diff --git a/src/commands/commands/events/sync.handlers.ts b/src/commands/commands/events/sync.handlers.ts new file mode 100644 index 00000000..b2577167 --- /dev/null +++ b/src/commands/commands/events/sync.handlers.ts @@ -0,0 +1,67 @@ +import { inlineCode } from "discord.js"; + +import { type DbEvent } from "../../../db/schema.ts"; +import type { SlashInteraction } from "../../../types/interaction.types.ts"; +import { EventNotFoundWarning } from "../../../utils/error.utils.ts"; +import { EventUtils } from "../../../utils/event.utils.ts"; +import { EventSyncLock } from "../../../utils/event-sync-lock.utils.ts"; +import { commandMention, eventMention } from "../../../utils/string.utils.ts"; +import { EventsOptions } from "./options.ts"; + +export namespace EventsSyncHandlers { + export async function syncQueues(inter: SlashInteraction) { + const event = await EventsOptions.SYNC_QUEUES_OPTIONS.event.get(inter).catch((e: unknown) => { + if (e instanceof EventNotFoundWarning) return undefined; + throw e; + }); + + if (event) { + const result = await EventUtils.syncEventQueues(inter.store, event); + await inter.respond( + `Synced queues for ${eventMention(event)}: recreated ${result.recreatedCount} queue(s), ` + + `re-applied defaults to ${result.reappliedRoomCount} room + ${result.reappliedSubCount} sub queue(s), ` + + `re-posted ${result.reshownCount} display(s).`, + true, + ); + return; + } + + const allEvents = [...inter.store.dbEvents().values()]; + const targeted = allEvents.filter(e => !!e.roomCategoryId); + + if (targeted.length === 0) { + await inter.respond(`No events have a ${inlineCode("room_category")} configured. Set one with ${commandMention("events", "set")}.`); + return; + } + + let recreatedTotal = 0; + let reappliedRoomTotal = 0; + let reappliedSubTotal = 0; + let reshownTotal = 0; + const skipped: DbEvent[] = []; + for (const ev of targeted) { + const result = await EventSyncLock.tryWithLock(inter.store.guild.id, ev.id, () => + EventUtils.syncEventQueues(inter.store, ev) + ); + if (result === "skipped") { + skipped.push(ev); + continue; + } + recreatedTotal += result.recreatedCount; + reappliedRoomTotal += result.reappliedRoomCount; + reappliedSubTotal += result.reappliedSubCount; + reshownTotal += result.reshownCount; + } + + const syncedCount = targeted.length - skipped.length; + const skippedSuffix = skipped.length > 0 + ? `, skipped ${skipped.length} event(s) already in progress: ${skipped.map(e => inlineCode(e.name)).join(", ")}` + : ""; + await inter.respond( + `Synced queues for ${syncedCount} event(s): recreated ${recreatedTotal} queue(s), ` + + `re-applied defaults to ${reappliedRoomTotal} room + ${reappliedSubTotal} sub queue(s), ` + + `re-posted ${reshownTotal} display(s)${skippedSuffix}.`, + true, + ); + } +} diff --git a/src/commands/commands/events/winners.handlers.ts b/src/commands/commands/events/winners.handlers.ts new file mode 100644 index 00000000..81253a58 --- /dev/null +++ b/src/commands/commands/events/winners.handlers.ts @@ -0,0 +1,72 @@ +import { EmbedBuilder, roleMention, userMention } from "discord.js"; +import { compact } from "lodash-es"; + +import { Queries } from "../../../db/queries.ts"; +import { Color } from "../../../types/db.types.ts"; +import type { SlashInteraction } from "../../../types/interaction.types.ts"; +import { WinnerRoleNotSetWarning } from "../../../utils/error.utils.ts"; +import { eventMention } from "../../../utils/string.utils.ts"; +import { WinnerUtils } from "../../../utils/winner.utils.ts"; +import { EventsOptions } from "./options.ts"; + +export namespace EventsWinnersHandlers { + export async function declareWinners(inter: SlashInteraction) { + const event = await EventsOptions.DECLARE_WINNERS_OPTIONS.event.get(inter); + if (!event.winnerRoleId) { + throw new WinnerRoleNotSetWarning(); + } + + const userIds = new Set(compact([ + EventsOptions.DECLARE_WINNERS_OPTIONS.winner1.get(inter), + EventsOptions.DECLARE_WINNERS_OPTIONS.winner2.get(inter), + EventsOptions.DECLARE_WINNERS_OPTIONS.winner3.get(inter), + EventsOptions.DECLARE_WINNERS_OPTIONS.winner4.get(inter), + EventsOptions.DECLARE_WINNERS_OPTIONS.winner5.get(inter), + ]).map(user => user.id)); + + const added = await WinnerUtils.declareWinners(inter.store, event, userIds); + + if (added.length === 0) { + await inter.respond(`No new winners added to ${eventMention(event)} — all selected users are already winners.`, true); + return; + } + + const mentions = added.map(userMention).join(", "); + await inter.respond( + `Granted ${roleMention(event.winnerRoleId)} to ${mentions} as winner(s) of ${eventMention(event)}.`, + true, + ); + } + + export async function winners(inter: SlashInteraction) { + const event = await EventsOptions.WINNERS_OPTIONS.event.get(inter); + + const rows = Queries.selectManyEventWinners({ guildId: inter.guildId, eventId: event.id }); + const roleLine = `Winner role: ${event.winnerRoleId ? roleMention(event.winnerRoleId) : "not set"}`; + + if (rows.length === 0) { + await inter.respond(`No winners declared yet for ${eventMention(event)}.\n${roleLine}`); + return; + } + + const winnerList = rows.map(r => userMention(r.userId)).join(", "); + + const embed = new EmbedBuilder() + .setTitle(`Winners — ${event.name}`) + .setColor(Color.Gold) + .setDescription(`${winnerList}\n\n${roleLine}`); + + await inter.respond({ embeds: [embed] }); + } + + export async function clearWinners(inter: SlashInteraction) { + const event = await EventsOptions.CLEAR_WINNERS_OPTIONS.event.get(inter); + + const removals = await WinnerUtils.clearEventWinners(inter.store, event); + + await inter.respond( + `Cleared winners for ${eventMention(event)}. Revoked the role from ${removals.length} member(s).`, + true, + ); + } +} diff --git a/src/commands/commands/prioritize.command.ts b/src/commands/commands/prioritize.command.ts index 74c5f799..8228113b 100644 --- a/src/commands/commands/prioritize.command.ts +++ b/src/commands/commands/prioritize.command.ts @@ -167,18 +167,18 @@ export class PrioritizeCommand extends AdminCommand { if (scope === ListScope.Queue) { const queues = await PrioritizeCommand.ADD_OPTIONS.queues.get(inter); if (!queues || queues.size === 0) throw new CustomError({ message: "Queue scope requires the `queues` option." }); - const { updatedQueueIds, insertedPrioritized } = PriorityUtils.insertQueuePrioritized(inter.store, queues, mentionables, priorityOrder, reason); + const { updatedQueueIds, insertedPrioritized } = await PriorityUtils.insertQueuePrioritized(inter.store, queues, mentionables, priorityOrder, reason); const updatedQueues = updatedQueueIds.map(id => inter.store.dbQueues().get(id)); await inter.respond(`Prioritized ${mentionablesMention(insertedPrioritized)} in the ${queuesMention(updatedQueues)} queue${updatedQueues.length > 1 ? "s" : ""}.`, true); } else if (scope === ListScope.Event) { const events = await PrioritizeCommand.ADD_OPTIONS.events.get(inter); if (!events || events.size === 0) throw new CustomError({ message: "Event scope requires the `events` option." }); - const { insertedPrioritized } = PriorityUtils.insertEventPrioritized(inter.store, events, mentionables, priorityOrder, reason); + const { insertedPrioritized } = await PriorityUtils.insertEventPrioritized(inter.store, events, mentionables, priorityOrder, reason); await inter.respond(`Prioritized ${mentionablesMention(insertedPrioritized)} in the ${[...events.values()].map(eventMention).join(", ")} event${events.size > 1 ? "s" : ""}.`, true); } else { - const { insertedPrioritized } = PriorityUtils.insertGuildPrioritized(inter.store, mentionables, priorityOrder, reason); + const { insertedPrioritized } = await PriorityUtils.insertGuildPrioritized(inter.store, mentionables, priorityOrder, reason); await inter.respond(`Prioritized ${mentionablesMention(insertedPrioritized)} in all queues in this server.`, true); } } @@ -205,15 +205,15 @@ export class PrioritizeCommand extends AdminCommand { const update = { ...(priorityOrder !== undefined ? { priorityOrder } : {}), ...(reason !== undefined && reason !== null ? { reason } : {}) }; if (selection.scope === ListScope.Queue) { - const { updatedPrioritized } = PriorityUtils.updatePrioritized(inter.store, [...selection.entries.keys()], update); + const { updatedPrioritized } = await PriorityUtils.updatePrioritized(inter.store, [...selection.entries.keys()], update); await inter.respond(`Updated priority of ${updatedPrioritized.map(mentionableMention).join(", ")}.`, true); } else if (selection.scope === ListScope.Event) { - const { updatedPrioritized } = PriorityUtils.updateEventPrioritized(inter.store, [...selection.entries.keys()], update); + const { updatedPrioritized } = await PriorityUtils.updateEventPrioritized(inter.store, [...selection.entries.keys()], update); await inter.respond(`Updated priority of ${updatedPrioritized.map(mentionableMention).join(", ")} (event scope).`, true); } else { - const { updatedPrioritized } = PriorityUtils.updateGuildPrioritized(inter.store, [...selection.entries.keys()], update); + const { updatedPrioritized } = await PriorityUtils.updateGuildPrioritized(inter.store, [...selection.entries.keys()], update); await inter.respond(`Updated priority of ${updatedPrioritized.map(mentionableMention).join(", ")} (global scope).`, true); } } @@ -238,7 +238,7 @@ export class PrioritizeCommand extends AdminCommand { } if (selection.scope === ListScope.Queue) { - const { deletedPrioritized } = PriorityUtils.deletePrioritized(inter.store, [...selection.entries.keys()]); + const { deletedPrioritized } = await PriorityUtils.deletePrioritized(inter.store, [...selection.entries.keys()]); const lines = deletedPrioritized.map(row => { const queue = inter.store.dbQueues().get(row.queueId); return `- ${mentionableMention(row)} in ${queue ? queueMention(queue) : "unknown queue"}`; @@ -246,7 +246,7 @@ export class PrioritizeCommand extends AdminCommand { await inter.respond(`Un-prioritized:\n${lines.join("\n")}`, true); } else if (selection.scope === ListScope.Event) { - const { deletedPrioritized } = PriorityUtils.deleteEventPrioritized(inter.store, [...selection.entries.keys()]); + const { deletedPrioritized } = await PriorityUtils.deleteEventPrioritized(inter.store, [...selection.entries.keys()]); const lines = deletedPrioritized.map(row => { const event = inter.store.dbEvents().get(row.eventId); return `- ${mentionableMention(row)} from ${event ? eventMention(event) : "unknown event"}`; @@ -254,7 +254,7 @@ export class PrioritizeCommand extends AdminCommand { await inter.respond(`Un-prioritized (event):\n${lines.join("\n")}`, true); } else { - const { deletedPrioritized } = PriorityUtils.deleteGuildPrioritized(inter.store, [...selection.entries.keys()]); + const { deletedPrioritized } = await PriorityUtils.deleteGuildPrioritized(inter.store, [...selection.entries.keys()]); const lines = deletedPrioritized.map(row => `- ${mentionableMention(row)} (global)`); await inter.respond(`Un-prioritized (global):\n${lines.join("\n")}`, true); } diff --git a/src/commands/commands/whitelist.command.ts b/src/commands/commands/whitelist.command.ts index 45bfc8f5..c40be7c1 100644 --- a/src/commands/commands/whitelist.command.ts +++ b/src/commands/commands/whitelist.command.ts @@ -145,18 +145,18 @@ export class WhitelistCommand extends AdminCommand { if (scope === ListScope.Queue) { const queues = await WhitelistCommand.ADD_OPTIONS.queues.get(inter); if (!queues || queues.size === 0) throw new CustomError({ message: "Queue scope requires the `queues` option." }); - const { updatedQueueIds, insertedWhitelisted } = WhitelistUtils.insertQueueWhitelisted(inter.store, queues, mentionables, reason); + const { updatedQueueIds, insertedWhitelisted } = await WhitelistUtils.insertQueueWhitelisted(inter.store, queues, mentionables, reason); const updatedQueues = updatedQueueIds.map(id => inter.store.dbQueues().get(id)); await inter.respond(`Whitelisted ${mentionablesMention(insertedWhitelisted)} in the ${queuesMention(updatedQueues)} queue${updatedQueues.length > 1 ? "s" : ""}.`, true); } else if (scope === ListScope.Event) { const events = await WhitelistCommand.ADD_OPTIONS.events.get(inter); if (!events || events.size === 0) throw new CustomError({ message: "Event scope requires the `events` option." }); - const { insertedWhitelisted } = WhitelistUtils.insertEventWhitelisted(inter.store, events, mentionables, reason); + const { insertedWhitelisted } = await WhitelistUtils.insertEventWhitelisted(inter.store, events, mentionables, reason); await inter.respond(`Whitelisted ${mentionablesMention(insertedWhitelisted)} in the ${[...events.values()].map(eventMention).join(", ")} event${events.size > 1 ? "s" : ""}.`, true); } else { - const { insertedWhitelisted } = WhitelistUtils.insertGuildWhitelisted(inter.store, mentionables, reason); + const { insertedWhitelisted } = await WhitelistUtils.insertGuildWhitelisted(inter.store, mentionables, reason); await inter.respond(`Whitelisted ${mentionablesMention(insertedWhitelisted)} in all queues in this server.`, true); } } diff --git a/src/db/db.ts b/src/db/db.ts index adf932e9..1f07bbd5 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -10,8 +10,12 @@ export const DB_FILEPATH = process.env.VITEST ? ":memory:" : "data/main.sqlite"; export const DB_BACKUP_DIRECTORY = "data/backups"; export const MIGRATIONS_FOLDER = "data/migrations"; +let sqlite: Database.Database | undefined; + function connect() { - const conn = drizzle(Database(DB_FILEPATH).defaultSafeIntegers(), { schema }); + const client = Database(DB_FILEPATH).defaultSafeIntegers(); + sqlite = client; + const conn = drizzle(client, { schema }); migrate(conn, { migrationsFolder: MIGRATIONS_FOLDER }); return conn; } @@ -19,7 +23,12 @@ function connect() { export let db = connect(); export namespace Db { + /** Rare: tests/dev only — closes and reopens the SQLite handle. */ export function reload() { + if (sqlite) { + sqlite.close(); + sqlite = undefined; + } db = connect(); } diff --git a/src/db/queries.ts b/src/db/queries.ts index ef803b9d..8a621c51 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -1,5 +1,5 @@ import type { Snowflake } from "discord.js"; -import { and, eq, sql } from "drizzle-orm"; +import { and, eq, inArray, lt, sql } from "drizzle-orm"; import { db } from "./db.ts"; import { @@ -16,6 +16,7 @@ import { EVENT_QUEUE_TABLE, EVENT_ROOM_CHANNEL_TABLE, EVENT_ROOM_CHANNEL_TEMPLATE_TABLE, + EVENT_SYNC_LOCK_TABLE, EVENT_TABLE, EVENT_WHITELISTED_TABLE, EVENT_WINNER_TABLE, @@ -71,6 +72,18 @@ export namespace Queries { return selectManyQueuesByGuildId.all(by); } + export function selectManyQueuesByIds(by: { guildId: Snowflake, ids: bigint[] }) { + if (by.ids.length === 0) return []; + return db + .select() + .from(QUEUE_TABLE) + .where(and( + eq(QUEUE_TABLE.guildId, by.guildId), + inArray(QUEUE_TABLE.id, by.ids), + )) + .all(); + } + export function selectAllQueues() { return db .select() @@ -294,7 +307,7 @@ export namespace Queries { { guildId: Snowflake, subjectId: Snowflake } | { guildId: Snowflake } ) { - if ("eventId" in by) { + if ("eventId" in by && by.eventId !== undefined) { return selectManyEventBlacklistedByGuildIdAndEventId.all(by); } else if ("subjectId" in by) { @@ -312,7 +325,7 @@ export namespace Queries { { guildId: Snowflake, subjectId: Snowflake } | { guildId: Snowflake } ) { - if ("eventId" in by) { + if ("eventId" in by && by.eventId !== undefined) { return selectManyEventWhitelistedByGuildIdAndEventId.all(by); } else if ("subjectId" in by) { @@ -330,7 +343,7 @@ export namespace Queries { { guildId: Snowflake, subjectId: Snowflake } | { guildId: Snowflake } ) { - if ("eventId" in by) { + if ("eventId" in by && by.eventId !== undefined) { return selectManyEventPrioritizedByGuildIdAndEventId.all(by); } else if ("subjectId" in by) { @@ -446,19 +459,29 @@ export namespace Queries { return selectManyEventsByGuildId.all(by); } + export function selectManyEventsByIds(by: { guildId: Snowflake, ids: bigint[] }) { + if (by.ids.length === 0) return []; + return db + .select() + .from(EVENT_TABLE) + .where(and( + eq(EVENT_TABLE.guildId, by.guildId), + inArray(EVENT_TABLE.id, by.ids), + )) + .all(); + } + // Event Occurrences - export function selectOccurrence(by: { id: bigint }) { - return selectOccurrenceById.get(by); + export function selectOccurrence(by: { guildId: Snowflake, id: bigint }) { + return selectOccurrenceByGuildIdAndId.get(by); } export function selectManyOccurrences(by: { guildId: Snowflake, eventId?: bigint }) { - if ("eventId" in by) { + if (by.eventId !== undefined) { return selectManyOccurrencesByGuildIdAndEventId.all(by); } - else { - return selectManyOccurrencesByGuildId.all(by); - } + return selectManyOccurrencesByGuildId.all(by); } export function selectAllOccurrences() { @@ -470,14 +493,75 @@ export namespace Queries { // Event Occurrence Room Pings - export function selectOccurrenceRoomPings(by: { occurrenceId: bigint }) { - return selectOccurrenceRoomPingsByOccurrenceId.all(by); + export function selectOccurrenceRoomPings(by: { guildId: Snowflake, occurrenceId: bigint }) { + return selectOccurrenceRoomPingsByGuildIdAndOccurrenceId.all(by); + } + + export function selectManyOccurrenceRoomPingsByOccurrenceIds(by: { + guildId: Snowflake, + occurrenceIds: bigint[], + }) { + if (by.occurrenceIds.length === 0) return []; + return db + .select() + .from(EVENT_OCCURRENCE_ROOM_PING_TABLE) + .where(and( + eq(EVENT_OCCURRENCE_ROOM_PING_TABLE.guildId, by.guildId), + inArray(EVENT_OCCURRENCE_ROOM_PING_TABLE.occurrenceId, by.occurrenceIds), + )) + .all(); } // Event Occurrence Room Pulls - export function selectOccurrenceRoomPulls(by: { occurrenceId: bigint }) { - return selectOccurrenceRoomPullsByOccurrenceId.all(by); + export function selectOccurrenceRoomPulls(by: { guildId: Snowflake, occurrenceId: bigint }) { + return selectOccurrenceRoomPullsByGuildIdAndOccurrenceId.all(by); + } + + export function selectManyOccurrenceRoomPullsByOccurrenceIds(by: { + guildId: Snowflake, + occurrenceIds: bigint[], + }) { + if (by.occurrenceIds.length === 0) return []; + return db + .select() + .from(EVENT_OCCURRENCE_ROOM_PULL_TABLE) + .where(and( + eq(EVENT_OCCURRENCE_ROOM_PULL_TABLE.guildId, by.guildId), + inArray(EVENT_OCCURRENCE_ROOM_PULL_TABLE.occurrenceId, by.occurrenceIds), + )) + .all(); + } + + // Event sync locks (cross-process coordination for event sync) + + export function tryAcquireEventSyncLock(by: { guildId: Snowflake, eventId: bigint }): boolean { + const row = db + .insert(EVENT_SYNC_LOCK_TABLE) + .values({ + guildId: by.guildId, + eventId: by.eventId, + lockedAt: BigInt(Date.now()), + }) + .onConflictDoNothing() + .returning() + .get(); + return row !== undefined; + } + + export function releaseEventSyncLock(by: { guildId: Snowflake, eventId: bigint }) { + db.delete(EVENT_SYNC_LOCK_TABLE) + .where(and( + eq(EVENT_SYNC_LOCK_TABLE.guildId, by.guildId), + eq(EVENT_SYNC_LOCK_TABLE.eventId, by.eventId), + )) + .run(); + } + + export function deleteStaleEventSyncLocks(olderThanMs: bigint) { + db.delete(EVENT_SYNC_LOCK_TABLE) + .where(lt(EVENT_SYNC_LOCK_TABLE.lockedAt, olderThanMs)) + .run(); } // Event Queues @@ -1171,12 +1255,13 @@ export namespace Queries { // Event Occurrences - const selectOccurrenceById = db + const selectOccurrenceByGuildIdAndId = db .select() .from(EVENT_OCCURRENCE_TABLE) - .where( + .where(and( + eq(EVENT_OCCURRENCE_TABLE.guildId, sql.placeholder("guildId")), eq(EVENT_OCCURRENCE_TABLE.id, sql.placeholder("id")) - ) + )) .prepare(); const selectManyOccurrencesByGuildId = db @@ -1198,22 +1283,24 @@ export namespace Queries { // Event Occurrence Room Pings - const selectOccurrenceRoomPingsByOccurrenceId = db + const selectOccurrenceRoomPingsByGuildIdAndOccurrenceId = db .select() .from(EVENT_OCCURRENCE_ROOM_PING_TABLE) - .where( + .where(and( + eq(EVENT_OCCURRENCE_ROOM_PING_TABLE.guildId, sql.placeholder("guildId")), eq(EVENT_OCCURRENCE_ROOM_PING_TABLE.occurrenceId, sql.placeholder("occurrenceId")) - ) + )) .prepare(); // Event Occurrence Room Pulls - const selectOccurrenceRoomPullsByOccurrenceId = db + const selectOccurrenceRoomPullsByGuildIdAndOccurrenceId = db .select() .from(EVENT_OCCURRENCE_ROOM_PULL_TABLE) - .where( + .where(and( + eq(EVENT_OCCURRENCE_ROOM_PULL_TABLE.guildId, sql.placeholder("guildId")), eq(EVENT_OCCURRENCE_ROOM_PULL_TABLE.occurrenceId, sql.placeholder("occurrenceId")) - ) + )) .prepare(); // Event Queues @@ -1239,7 +1326,10 @@ export namespace Queries { const selectEventMembershipCountQuery = db .select({ count: sql`COUNT(*)`.as("count") }) .from(MEMBER_TABLE) - .innerJoin(EVENT_QUEUE_TABLE, eq(MEMBER_TABLE.queueId, EVENT_QUEUE_TABLE.queueId)) + .innerJoin(EVENT_QUEUE_TABLE, and( + eq(MEMBER_TABLE.queueId, EVENT_QUEUE_TABLE.queueId), + eq(EVENT_QUEUE_TABLE.guildId, sql.placeholder("guildId")), + )) .where(and( eq(MEMBER_TABLE.guildId, sql.placeholder("guildId")), eq(EVENT_QUEUE_TABLE.eventId, sql.placeholder("eventId")), diff --git a/src/db/schema.ts b/src/db/schema.ts index 3a6a5ccf..28376e37 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -44,36 +44,105 @@ export type NewGuild = typeof GUILD_TABLE.$inferInsert; export type DbGuild = typeof GUILD_TABLE.$inferSelect; +/** Keys shared between QUEUE_TABLE config columns and nullable EVENT_DEFAULT_TABLE overrides. */ +export const QUEUE_CONFIG_COLUMN_KEYS = [ + "autopullToggle", + "badgeToggle", + "color", + "displayUpdateType", + "dmOnPullToggle", + "buttonsToggle", + "header", + "inlineToggle", + "lockToggle", + "memberDisplayType", + "pullBatchSize", + "pullMessage", + "pullMessageDisplayType", + "pullMessageChannelId", + "rejoinCooldownPeriod", + "rejoinGracePeriod", + "requireMessageToJoin", + "roleInQueueId", + "roleOnPullId", + "size", + "timestampType", + "voiceDestinationChannelId", + "voiceOnlyToggle", +] as const; + +export type QueueConfigColumnKey = typeof QUEUE_CONFIG_COLUMN_KEYS[number]; + +/** Factory for queue config columns mirrored on EVENT_DEFAULT_TABLE (nullable overrides). */ +function queueConfigColumns(mode: "required" | "nullable") { + const nullable = mode === "nullable"; + return { + autopullToggle: nullable + ? integer("autopull_toggle", { mode: "boolean" }) + : integer("autopull_toggle", { mode: "boolean" }).notNull().default(false), + badgeToggle: nullable + ? integer("badge_toggle", { mode: "boolean" }) + : integer("badge_toggle", { mode: "boolean" }).notNull().default(true), + color: nullable + ? text("color").$type() + : text("color").$type().notNull().default(get(Color, process.env.DEFAULT_COLOR) as ColorResolvable), + displayUpdateType: nullable + ? text("display_update_type").$type() + : text("display_update_type").$type().notNull().default(DisplayUpdateType.Edit), + dmOnPullToggle: nullable + ? integer("dm_on_pull_toggle", { mode: "boolean" }) + : integer("dm_on_pull_toggle", { mode: "boolean" }).notNull().default(true), + // SQL column: buttons_toggles + buttonsToggle: nullable + ? text("buttons_toggles").$type() + : text("buttons_toggles").$type().notNull().default(Scope.All), + header: text("header"), + inlineToggle: nullable + ? integer("inline_toggle", { mode: "boolean" }) + : integer("inline_toggle", { mode: "boolean" }).notNull().default(false), + lockToggle: nullable + ? integer("lock_toggle", { mode: "boolean" }) + : integer("lock_toggle", { mode: "boolean" }).notNull().default(false), + memberDisplayType: nullable + ? text("member_display_type").$type() + : text("member_display_type").$type().notNull().default(MemberDisplayType.Mention), + pullBatchSize: nullable + ? integer("pull_batch_size").$type() + : integer("pull_batch_size").$type().notNull().default(1 as any), + pullMessage: text("pull_message"), + pullMessageDisplayType: nullable + ? text("pull_message_display_type").$type() + : text("pull_message_display_type").$type().notNull().default(PullMessageDisplayType.Private), + pullMessageChannelId: text("pull_message_channel_id").$type(), + rejoinCooldownPeriod: nullable + ? integer("rejoin_cooldown_period").$type() + : integer("rejoin_cooldown_period").$type().notNull().default(0 as any), + rejoinGracePeriod: nullable + ? integer("rejoin_grace_period").$type() + : integer("rejoin_grace_period").$type().notNull().default(0 as any), + requireMessageToJoin: nullable + ? integer("require_message_to_join", { mode: "boolean" }) + : integer("require_message_to_join", { mode: "boolean" }).default(false), + roleInQueueId: text("role_in_queue_id").$type(), + roleOnPullId: text("role_on_pull_id").$type(), + size: integer("size").$type(), + timestampType: nullable + ? text("time_display_type").$type() + : text("time_display_type").$type().default(TimestampType.Off), + voiceDestinationChannelId: text("voice_destination_channel_id").$type(), + voiceOnlyToggle: nullable + ? integer("voice_only_toggle", { mode: "boolean" }) + : integer("voice_only_toggle", { mode: "boolean" }).notNull().default(false), + }; +} + export const QUEUE_TABLE = sqliteTable("queue", ({ id: integer("id").$type().primaryKey({ autoIncrement: true }), name: text("name").notNull(), guildId: text("guild_id").$type().notNull().references(() => GUILD_TABLE.guildId, { onDelete: "cascade" }), - // configurable queue properties - autopullToggle: integer("autopull_toggle", { mode: "boolean" }).notNull().default(false), - badgeToggle: integer("badge_toggle", { mode: "boolean" }).notNull().default(true), - color: text("color").$type().notNull().default(get(Color, process.env.DEFAULT_COLOR) as ColorResolvable), - displayUpdateType: text("display_update_type").$type().notNull().default(DisplayUpdateType.Edit), - dmOnPullToggle: integer("dm_on_pull_toggle", { mode: "boolean" }).notNull().default(true), - buttonsToggle: text("buttons_toggles").$type().notNull().default(Scope.All), - header: text("header"), - inlineToggle: integer("inline_toggle", { mode: "boolean" }).notNull().default(false), - lockToggle: integer("lock_toggle", { mode: "boolean" }).notNull().default(false), - memberDisplayType: text("member_display_type").$type().notNull().default(MemberDisplayType.Mention), - pullBatchSize: integer("pull_batch_size").$type().notNull().default(1 as any), - pullMessage: text("pull_message"), - pullMessageDisplayType: text("pull_message_display_type").$type().notNull().default(PullMessageDisplayType.Private), - pullMessageChannelId: text("pull_message_channel_id").$type(), - rejoinCooldownPeriod: integer("rejoin_cooldown_period").$type().notNull().default(0 as any), - rejoinGracePeriod: integer("rejoin_grace_period").$type().notNull().default(0 as any), - requireMessageToJoin: integer("require_message_to_join", { mode: "boolean" }).default(false), - roleInQueueId: text("role_in_queue_id").$type(), - roleOnPullId: text("role_on_pull_id").$type(), - size: integer("size").$type(), - timestampType: text("time_display_type").$type().default(TimestampType.Off), - voiceDestinationChannelId: text("voice_destination_channel_id").$type(), - voiceOnlyToggle: integer("voice_only_toggle", { mode: "boolean" }).notNull().default(false), + ...queueConfigColumns("required"), }), (table) => ({ unq: unique().on(table.name, table.guildId), @@ -82,6 +151,7 @@ export const QUEUE_TABLE = sqliteTable("queue", ({ export type NewQueue = typeof QUEUE_TABLE.$inferInsert; export type DbQueue = typeof QUEUE_TABLE.$inferSelect; +export type QueueConfig = Pick; export const VOICE_TABLE = sqliteTable("voice", ({ @@ -223,6 +293,7 @@ export type DbEventOccurrence = typeof EVENT_OCCURRENCE_TABLE.$inferSelect; export const EVENT_OCCURRENCE_ROOM_PING_TABLE = sqliteTable("event_occurrence_room_ping", ({ + guildId: text("guild_id").$type().notNull().references(() => GUILD_TABLE.guildId, { onDelete: "cascade" }), occurrenceId: integer("occurrence_id").$type().notNull() .references(() => EVENT_OCCURRENCE_TABLE.id, { onDelete: "cascade" }), eventQueueId: integer("event_queue_id").$type().notNull() @@ -231,7 +302,7 @@ export const EVENT_OCCURRENCE_ROOM_PING_TABLE = sqliteTable("event_occurrence_ro }), (table) => ({ pk: primaryKey({ columns: [table.occurrenceId, table.eventQueueId] }), - occurrenceIdIndex: index("event_occurrence_room_ping_occurrence_id_index").on(table.occurrenceId), + guildIdOccurrenceIdIndex: index("event_occurrence_room_ping_guild_id_occurrence_id_index").on(table.guildId, table.occurrenceId), })); export type NewEventOccurrenceRoomPing = typeof EVENT_OCCURRENCE_ROOM_PING_TABLE.$inferInsert; @@ -239,6 +310,7 @@ export type DbEventOccurrenceRoomPing = typeof EVENT_OCCURRENCE_ROOM_PING_TABLE. export const EVENT_OCCURRENCE_ROOM_PULL_TABLE = sqliteTable("event_occurrence_room_pull", ({ + guildId: text("guild_id").$type().notNull().references(() => GUILD_TABLE.guildId, { onDelete: "cascade" }), occurrenceId: integer("occurrence_id").$type().notNull() .references(() => EVENT_OCCURRENCE_TABLE.id, { onDelete: "cascade" }), eventQueueId: integer("event_queue_id").$type().notNull() @@ -247,13 +319,26 @@ export const EVENT_OCCURRENCE_ROOM_PULL_TABLE = sqliteTable("event_occurrence_ro }), (table) => ({ pk: primaryKey({ columns: [table.occurrenceId, table.eventQueueId] }), - occurrenceIdIndex: index("event_occurrence_room_pull_occurrence_id_index").on(table.occurrenceId), + guildIdOccurrenceIdIndex: index("event_occurrence_room_pull_guild_id_occurrence_id_index").on(table.guildId, table.occurrenceId), })); export type NewEventOccurrenceRoomPull = typeof EVENT_OCCURRENCE_ROOM_PULL_TABLE.$inferInsert; export type DbEventOccurrenceRoomPull = typeof EVENT_OCCURRENCE_ROOM_PULL_TABLE.$inferSelect; +export const EVENT_SYNC_LOCK_TABLE = sqliteTable("event_sync_lock", ({ + guildId: text("guild_id").$type().notNull(), + eventId: integer("event_id").$type().notNull(), + lockedAt: integer("locked_at").$type().notNull(), +}), +(table) => ({ + pk: primaryKey({ columns: [table.guildId, table.eventId] }), +})); + +export type NewEventSyncLock = typeof EVENT_SYNC_LOCK_TABLE.$inferInsert; +export type DbEventSyncLock = typeof EVENT_SYNC_LOCK_TABLE.$inferSelect; + + export const EVENT_QUEUE_TABLE = sqliteTable("event_queue", ({ id: integer("id").$type().primaryKey({ autoIncrement: true }), @@ -302,30 +387,8 @@ export const EVENT_DEFAULT_TABLE = sqliteTable("event_default", ({ eventId: integer("event_id").$type().notNull().references(() => EVENT_TABLE.id, { onDelete: "cascade" }), queueRole: text("queue_role").$type().notNull(), - // Mirrored QUEUE_TABLE config columns (all nullable for defaults) - autopullToggle: integer("autopull_toggle", { mode: "boolean" }), - badgeToggle: integer("badge_toggle", { mode: "boolean" }), - color: text("color").$type(), - displayUpdateType: text("display_update_type").$type(), - dmOnPullToggle: integer("dm_on_pull_toggle", { mode: "boolean" }), - buttonsToggle: text("buttons_toggles").$type(), - header: text("header"), - inlineToggle: integer("inline_toggle", { mode: "boolean" }), - lockToggle: integer("lock_toggle", { mode: "boolean" }), - memberDisplayType: text("member_display_type").$type(), - pullBatchSize: integer("pull_batch_size").$type(), - pullMessage: text("pull_message"), - pullMessageDisplayType: text("pull_message_display_type").$type(), - pullMessageChannelId: text("pull_message_channel_id").$type(), - rejoinCooldownPeriod: integer("rejoin_cooldown_period").$type(), - rejoinGracePeriod: integer("rejoin_grace_period").$type(), - requireMessageToJoin: integer("require_message_to_join", { mode: "boolean" }), - roleInQueueId: text("role_in_queue_id").$type(), - roleOnPullId: text("role_on_pull_id").$type(), - size: integer("size").$type(), - timestampType: text("time_display_type").$type(), - voiceDestinationChannelId: text("voice_destination_channel_id").$type(), - voiceOnlyToggle: integer("voice_only_toggle", { mode: "boolean" }), + // Mirrored QUEUE_TABLE config columns via queueConfigColumns("nullable") + ...queueConfigColumns("nullable"), }), (table) => ({ unq: unique().on(table.eventId, table.queueRole), @@ -334,6 +397,7 @@ export const EVENT_DEFAULT_TABLE = sqliteTable("event_default", ({ export type NewEventDefault = typeof EVENT_DEFAULT_TABLE.$inferInsert; export type DbEventDefault = typeof EVENT_DEFAULT_TABLE.$inferSelect; +export type EventDefaultQueueConfig = Pick; export const EVENT_ROOM_CHANNEL_TEMPLATE_TABLE = sqliteTable("event_room_channel_template", ({ diff --git a/src/db/store.ts b/src/db/store.ts index 1af71f25..27ea8142 100644 --- a/src/db/store.ts +++ b/src/db/store.ts @@ -580,19 +580,19 @@ export class Store { } // idempotent: composite PK conflict is a no-op - insertOccurrenceRoomPing(row: NewEventOccurrenceRoomPing): DbEventOccurrenceRoomPing { + insertOccurrenceRoomPing(row: Omit): DbEventOccurrenceRoomPing { return db .insert(EVENT_OCCURRENCE_ROOM_PING_TABLE) - .values(row) + .values({ ...row, guildId: this.guild.id }) .onConflictDoNothing() .returning().get(); } // idempotent: composite PK conflict is a no-op - insertOccurrenceRoomPull(row: NewEventOccurrenceRoomPull): DbEventOccurrenceRoomPull { + insertOccurrenceRoomPull(row: Omit): DbEventOccurrenceRoomPull { return db .insert(EVENT_OCCURRENCE_ROOM_PULL_TABLE) - .values(row) + .values({ ...row, guildId: this.guild.id }) .onConflictDoNothing() .returning().get(); } diff --git a/src/handlers/client.handler.test.ts b/src/handlers/client.handler.test.ts new file mode 100644 index 00000000..0152c1c1 --- /dev/null +++ b/src/handlers/client.handler.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { handleMock, interactionHandlerMock } = vi.hoisted(() => ({ + handleMock: vi.fn().mockResolvedValue(undefined), + interactionHandlerMock: vi.fn(), +})); + +vi.mock("../db/queries.ts", () => ({ + Queries: { + deleteGuild: vi.fn(), + }, +})); + +vi.mock("./interaction.handler.ts", () => ({ + InteractionHandler: class { + handle = handleMock; + + constructor(inter: unknown) { + interactionHandlerMock(inter); + } + }, +})); + +import { Queries } from "../db/queries.ts"; +import { ClientHandler } from "./client.handler.ts"; + +describe("ClientHandler", () => { + beforeEach(() => { + vi.mocked(Queries.deleteGuild).mockReset(); + handleMock.mockClear(); + interactionHandlerMock.mockClear(); + }); + + it("handleGuildDelete removes guild data", () => { + ClientHandler.handleGuildDelete({ id: "guild-1" } as any); + + expect(Queries.deleteGuild).toHaveBeenCalledWith({ guildId: "guild-1" }); + }); + + it("handleGuildDelete logs when deleteGuild throws", () => { + vi.mocked(Queries.deleteGuild).mockImplementation(() => { + throw new Error("db unavailable"); + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + + ClientHandler.handleGuildDelete({ id: "guild-1" } as any); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("ClientHandler.handleGuildDelete"), + expect.any(Error), + ); + errorSpy.mockRestore(); + }); + + it("handleInteraction delegates guild interactions to InteractionHandler", async () => { + const inter = { guild: { id: "guild-1" } } as any; + await ClientHandler.handleInteraction(inter); + + expect(interactionHandlerMock).toHaveBeenCalledWith(inter); + expect(handleMock).toHaveBeenCalled(); + }); + + it("handleInteraction replies when used outside a guild", async () => { + const reply = vi.fn().mockResolvedValue(undefined); + const inter = { guild: null, reply } as any; + + await ClientHandler.handleInteraction(inter); + + expect(reply).toHaveBeenCalledWith("This command can only be used in servers"); + expect(interactionHandlerMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/handlers/interaction.handler.test.ts b/src/handlers/interaction.handler.test.ts new file mode 100644 index 00000000..df04a99f --- /dev/null +++ b/src/handlers/interaction.handler.test.ts @@ -0,0 +1,51 @@ +import { EmbedBuilder, InteractionType } from "discord.js"; +import { describe, expect, it, vi } from "vitest"; + +import { CustomError } from "../utils/error.utils.ts"; +import { InteractionHandler } from "./interaction.handler.ts"; + +function makeInteraction(overrides: Record = {}) { + const respond = vi.fn().mockResolvedValue(undefined); + return { + guild: { id: "guild1" }, + guildId: "guild1", + type: InteractionType.ApplicationCommand, + isChatInputCommand: () => true, + isAutocomplete: () => false, + isButton: () => false, + isModalSubmit: () => false, + respond, + ...overrides, + }; +} + +describe("InteractionHandler error responses", () => { + it("always replies ephemeral on command errors", async () => { + const inter = makeInteraction(); + const handler = new InteractionHandler(inter as any); + + await (handler as any).handleInteractionError(new CustomError({ message: "test failure" })); + + expect(inter.respond).toHaveBeenCalledWith(expect.objectContaining({ ephemeral: true })); + }); + + it("always replies ephemeral on command warnings", async () => { + const inter = makeInteraction(); + const handler = new InteractionHandler(inter as any); + + await (handler as any).handleInteractionError(new CustomError({ message: "test warning" })); + + expect(inter.respond).toHaveBeenCalledWith(expect.objectContaining({ ephemeral: true })); + }); + + it("uses error styling for CustomError after taxonomy fix", async () => { + const inter = makeInteraction(); + const handler = new InteractionHandler(inter as any); + + await (handler as any).handleInteractionError(new CustomError({ message: "permission denied" })); + + const payload = inter.respond.mock.calls[0][0]; + const embed = payload.embeds[0] as EmbedBuilder; + expect(embed.data.title).toContain("ERROR"); + }); +}); diff --git a/src/handlers/interaction.handler.ts b/src/handlers/interaction.handler.ts index a0171d5c..604b3344 100644 --- a/src/handlers/interaction.handler.ts +++ b/src/handlers/interaction.handler.ts @@ -15,6 +15,7 @@ import { ModalHandler } from "./modal.handler.ts"; // SILENT=true (env) or `--silent` (argv) suppresses true-error logs. // Warnings are silent by default; opt-in via `log: true` on the class. const IS_SILENT = process.env.SILENT === "true" || process.argv.includes("--silent"); +const DEBUG_INTERACTIONS = process.env.DEBUG_INTERACTIONS === "true"; export class InteractionHandler implements Handler { private readonly inter: AnyInteraction; @@ -32,7 +33,9 @@ export class InteractionHandler implements Handler { : this.inter.isModalSubmit() ? "modal" : "other"; const name = (this.inter as any).commandName ?? (this.inter as any).customId ?? "?"; - console.log(`InteractionHandler: received ${kind} (${name}) guildId=${this.inter.guildId}`); + if (DEBUG_INTERACTIONS) { + console.log(`InteractionHandler: received ${kind} (${name}) guildId=${this.inter.guildId}`); + } if (this.inter.isChatInputCommand()) { await new CommandHandler(this.inter).handle(); @@ -53,7 +56,7 @@ export class InteractionHandler implements Handler { } private async handleInteractionError(error: Error | string) { - const { stack, embeds, log, ephemeral } = error as AbstractInteractionIssue; + const { stack, embeds, log } = error as AbstractInteractionIssue; const message = typeof error === "string" ? error : error.message; const isWarning = error instanceof AbstractWarning; @@ -80,10 +83,9 @@ export class InteractionHandler implements Handler { embed.setFooter({ text: "This error has been logged and will be investigated by the developers." }); } - const isButton = (this.inter as any).isButton?.() === true; await this.inter.respond({ embeds: compact(concat(embeds, embed)), - ...(isButton || ephemeral ? { ephemeral: true } : {}), + ephemeral: true, }); } } diff --git a/src/utils/blacklist.utils.ts b/src/utils/blacklist.utils.ts index f4eae2fb..07b149f4 100644 --- a/src/utils/blacklist.utils.ts +++ b/src/utils/blacklist.utils.ts @@ -1,7 +1,6 @@ import { type GuildMember, Role } from "discord.js"; -import { compact, uniq } from "lodash-es"; +import { compact } from "lodash-es"; -import { db } from "../db/db.ts"; import { Queries } from "../db/queries.ts"; import type { DbEvent, DbQueue } from "../db/schema.ts"; import type { Store } from "../db/store.ts"; @@ -10,6 +9,7 @@ import type { ArrayOrCollection } from "../types/misc.types.ts"; import type { Mentionable } from "../types/parsing.types.ts"; import { MemberUtils } from "./member.utils.ts"; import { filterDbObjectsOnJsMember, map } from "./misc.utils.ts"; +import { SubjectListUtils } from "./subject-list.utils.ts"; export namespace BlacklistUtils { export async function insertQueueBlacklisted( @@ -18,26 +18,32 @@ export namespace BlacklistUtils { mentionables: Mentionable[], reason?: string, ) { - return db.transaction(async () => { - const insertedBlacklisted = compact( - map(queues, queue => - mentionables.map(mentionable => { - const by = (mentionable instanceof Role) ? { roleId: mentionable.id } : { userId: mentionable.id }; - MemberUtils.deleteMembers({ store, queues, reason: MemberRemovalReason.Kicked, by, force: true }); - - return store.insertBlacklisted({ - guildId: store.guild.id, - queueId: queue.id, - subjectId: mentionable.id, - isRole: mentionable instanceof Role, - reason, - }); - }) - ) - ).flat(2); - const updatedQueueIds = uniq(insertedBlacklisted.map(blacklisted => blacklisted.queueId)); - - return { insertedBlacklisted, updatedQueueIds }; + return SubjectListUtils.runTransaction(async () => { + const insertedBlacklisted = []; + for (const queue of map(queues, queue => queue)) { + for (const mentionable of mentionables) { + await MemberUtils.deleteMembers({ + store, + queues, + reason: MemberRemovalReason.Kicked, + by: SubjectListUtils.mentionableFilter(mentionable), + force: true, + }); + + insertedBlacklisted.push(store.insertBlacklisted({ + guildId: store.guild.id, + queueId: queue.id, + subjectId: mentionable.id, + isRole: mentionable instanceof Role, + reason, + })); + } + } + + return { + insertedBlacklisted, + updatedQueueIds: SubjectListUtils.updatedQueueIdsForQueueScope(insertedBlacklisted), + }; }); } @@ -47,36 +53,33 @@ export namespace BlacklistUtils { mentionables: Mentionable[], reason?: string, ) { - return db.transaction(async () => { - const insertedBlacklisted = compact( - map(events, event => { - const eventQueues = store.dbEventQueues(event.id); - return mentionables.map(mentionable => { - const by = (mentionable instanceof Role) ? { roleId: mentionable.id } : { userId: mentionable.id }; - MemberUtils.deleteMembers({ - store, - queues: eventQueues.map(eq => store.dbQueues().get(eq.queueId)).filter(Boolean) as DbQueue[], - reason: MemberRemovalReason.Kicked, - by, - force: true, - }); - - return store.insertEventBlacklisted({ - guildId: store.guild.id, - eventId: event.id, - subjectId: mentionable.id, - isRole: mentionable instanceof Role, - reason, - }); + return SubjectListUtils.runTransaction(async () => { + const insertedBlacklisted = []; + for (const event of map(events, event => event)) { + const queues = SubjectListUtils.eventQueuesForEvents(store, [event]); + for (const mentionable of mentionables) { + await MemberUtils.deleteMembers({ + store, + queues, + reason: MemberRemovalReason.Kicked, + by: SubjectListUtils.mentionableFilter(mentionable), + force: true, }); - }) - ).flat(2); - const updatedQueueIds = uniq(insertedBlacklisted.flatMap(blacklisted => - store.dbEventQueues(blacklisted.eventId).map(eq => eq.queueId) - )); - - return { insertedBlacklisted, updatedQueueIds }; + insertedBlacklisted.push(store.insertEventBlacklisted({ + guildId: store.guild.id, + eventId: event.id, + subjectId: mentionable.id, + isRole: mentionable instanceof Role, + reason, + })); + } + } + + return { + insertedBlacklisted, + updatedQueueIds: SubjectListUtils.updatedQueueIdsForEventScope(store, insertedBlacklisted), + }; }); } @@ -85,69 +88,67 @@ export namespace BlacklistUtils { mentionables: Mentionable[], reason?: string, ) { - return db.transaction(async () => { + return SubjectListUtils.runTransaction(async () => { const allQueues = store.dbQueues(); - const insertedBlacklisted = compact( - mentionables.map(mentionable => { - const by = (mentionable instanceof Role) ? { roleId: mentionable.id } : { userId: mentionable.id }; - MemberUtils.deleteMembers({ - store, - queues: allQueues, - reason: MemberRemovalReason.Kicked, - by, - force: true, - }); - - return store.insertGuildBlacklisted({ - guildId: store.guild.id, - subjectId: mentionable.id, - isRole: mentionable instanceof Role, - reason, - }); - }) - ); - - const updatedQueueIds = uniq([...allQueues.values()].map(queue => queue.id)); - - return { insertedBlacklisted, updatedQueueIds }; + const insertedBlacklisted = []; + for (const mentionable of mentionables) { + await MemberUtils.deleteMembers({ + store, + queues: allQueues, + reason: MemberRemovalReason.Kicked, + by: SubjectListUtils.mentionableFilter(mentionable), + force: true, + }); + + insertedBlacklisted.push(store.insertGuildBlacklisted({ + guildId: store.guild.id, + subjectId: mentionable.id, + isRole: mentionable instanceof Role, + reason, + })); + } + + return { + insertedBlacklisted, + updatedQueueIds: SubjectListUtils.updatedQueueIdsForGuildScope(store, insertedBlacklisted.length), + }; }); } export function deleteBlacklisted(store: Store, blacklistedIds: bigint[]) { const deletedBlacklisted = compact(blacklistedIds.map(id => store.deleteBlacklisted({ id }))); - const updatedQueueIds = uniq(deletedBlacklisted.map(blacklisted => blacklisted.queueId)); - return { deletedBlacklisted, updatedQueueIds }; + return { + deletedBlacklisted, + updatedQueueIds: SubjectListUtils.updatedQueueIdsForQueueScope(deletedBlacklisted), + }; } export function deleteEventBlacklisted(store: Store, blacklistedIds: bigint[]) { const deletedBlacklisted = compact(blacklistedIds.map(id => store.deleteEventBlacklisted({ id }))); - const updatedQueueIds = uniq(deletedBlacklisted.flatMap(blacklisted => - store.dbEventQueues(blacklisted.eventId).map(eq => eq.queueId) - )); - return { deletedBlacklisted, updatedQueueIds }; + return { + deletedBlacklisted, + updatedQueueIds: SubjectListUtils.updatedQueueIdsForEventScope(store, deletedBlacklisted), + }; } export function deleteGuildBlacklisted(store: Store, blacklistedIds: bigint[]) { const deletedBlacklisted = compact(blacklistedIds.map(id => store.deleteGuildBlacklisted({ id }))); - const updatedQueueIds = deletedBlacklisted.length - ? uniq([...store.dbQueues().values()].map(queue => queue.id)) - : []; - return { deletedBlacklisted, updatedQueueIds }; + return { + deletedBlacklisted, + updatedQueueIds: SubjectListUtils.updatedQueueIdsForGuildScope(store, deletedBlacklisted.length), + }; } export function isBlockedByBlacklist(store: Store, queueId: bigint, jsMember: GuildMember): boolean { - // Queue scope const queueBlacklist = store.dbBlacklisted().filter(b => b.queueId === queueId); if (filterDbObjectsOnJsMember(queueBlacklist, jsMember).size > 0) return true; - // Event scope: find the event this queue belongs to, if any const eventQueue = Queries.selectEventQueueByQueueId({ guildId: store.guild.id, queueId }); if (eventQueue) { const eventBlacklist = store.dbEventBlacklisted().filter(b => b.eventId === eventQueue.eventId); if (filterDbObjectsOnJsMember(eventBlacklist, jsMember).size > 0) return true; } - // Guild scope const guildBlacklist = store.dbGuildBlacklisted(); if (filterDbObjectsOnJsMember(guildBlacklist, jsMember).size > 0) return true; diff --git a/src/utils/command-size.utils.test.ts b/src/utils/command-size.utils.test.ts new file mode 100644 index 00000000..3fd28870 --- /dev/null +++ b/src/utils/command-size.utils.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { DISCORD_COMMAND_SIZE_LIMIT, findOversizedCommands, getCommandSize } from "./command-size.utils.ts"; + +describe("getCommandSize", () => { + it("counts nested options and choice values", () => { + const node = { + name: "cmd", + description: "desc", + options: [{ + name: "sub", + description: "subdesc", + choices: [{ name: "a", value: "val" }], + }], + }; + + expect(getCommandSize(node)).toBe( + "cmd".length + "desc".length + "sub".length + "subdesc".length + "a".length + "val".length, + ); + }); + + it("treats missing fields as zero length", () => { + expect(getCommandSize({})).toBe(0); + }); + + it("does not flag a command exactly at the limit", () => { + const pad = "x".repeat(DISCORD_COMMAND_SIZE_LIMIT - 2); + const command = { name: "ok", description: pad }; + + expect(getCommandSize(command)).toBe(DISCORD_COMMAND_SIZE_LIMIT); + expect(findOversizedCommands([command])).toEqual([]); + }); + + it("findOversizedCommands returns offenders with computed sizes", () => { + const command = { name: "big", description: "x".repeat(DISCORD_COMMAND_SIZE_LIMIT) }; + const size = getCommandSize(command); + + expect(findOversizedCommands([command])).toEqual([{ name: "big", size }]); + expect(size).toBeGreaterThan(DISCORD_COMMAND_SIZE_LIMIT); + }); +}); diff --git a/src/utils/display.utils.test.ts b/src/utils/display.utils.test.ts new file mode 100644 index 00000000..48c48566 --- /dev/null +++ b/src/utils/display.utils.test.ts @@ -0,0 +1,28 @@ +import { Collection } from "discord.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("DisplayUtils.requestDisplayUpdate coalescing", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("coalesces duplicate queue updates into one immediate updateDisplays call", async () => { + const { DisplayUtils } = await import("./display.utils.ts"); + const updateSpy = vi.spyOn(DisplayUtils, "updateDisplays"); + const store = { + guild: { id: "1" }, + dbQueues: () => new Collection(), + dbDisplays: () => new Collection(), + } as any; + + await DisplayUtils.requestDisplayUpdate({ store, queueId: 99n }); + await DisplayUtils.requestDisplayUpdate({ store, queueId: 99n }); + + expect(updateSpy).toHaveBeenCalledTimes(1); + expect(updateSpy).toHaveBeenCalledWith({ store, queueId: 99n }); + }, 15_000); +}); diff --git a/src/utils/error.utils.ts b/src/utils/error.utils.ts index 76dd59c4..6aaedaac 100644 --- a/src/utils/error.utils.ts +++ b/src/utils/error.utils.ts @@ -17,7 +17,7 @@ export abstract class AbstractWarning extends AbstractInteractionIssue { message = "Unknown Warning"; } -export class CustomError extends AbstractWarning { +export class CustomError extends AbstractError { constructor(opts: { message: string embeds?: EmbedBuilder[], diff --git a/src/utils/event-channel.utils.ts b/src/utils/event-channel.utils.ts index e90427c1..17002353 100644 --- a/src/utils/event-channel.utils.ts +++ b/src/utils/event-channel.utils.ts @@ -151,10 +151,15 @@ export namespace EventChannelUtils { untrackedRows: string[]; recreatedMissing: string[]; nonOwnedAtTop: { id: Snowflake, name: string }[]; + errors: string[]; trackedCount: number; reorderApplied: boolean; } + type ChannelFetchResult = + | { status: "found", channel: GuildBasedChannel } + | { status: "missing" }; + function emptyReport(event: DbEvent): SyncReport { return { eventId: event.id, @@ -164,6 +169,7 @@ export namespace EventChannelUtils { untrackedRows: [], recreatedMissing: [], nonOwnedAtTop: [], + errors: [], trackedCount: 0, reorderApplied: false, }; @@ -251,18 +257,18 @@ export namespace EventChannelUtils { (adopted ? report.adopted : report.created).push(channelName); } else { - const channel = await tryFetchChannel(store, existingRow.channelId); - if (!channel) { - // Tracked channel is gone — drop the row and adopt/create afresh. + const fetchResult = await tryFetchChannel(store, existingRow.channelId); + if (fetchResult.status === "missing") { + // Tracked channel is gone — drop the row and adopt/create afresh. store.deleteEventRoomChannel({ id: existingRow.id }); const { id: channelId } = await ensureRoomChannel(store, event, d, overwrites, channelName); trackRoomChannel(store, event, d, channelId); report.recreatedMissing.push(channelName); } else { - await applyChannelSettings(channel, overwrites, d.slowmodeSeconds); - if (d.suffix === null && d.roomEventQueue.pingChannelId !== channel.id) { - store.updateEventQueue({ id: d.roomEventQueue.id, pingChannelId: channel.id }); + await applyChannelSettings(fetchResult.channel, overwrites, d.slowmodeSeconds); + if (d.suffix === null && d.roomEventQueue.pingChannelId !== fetchResult.channel.id) { + store.updateEventQueue({ id: d.roomEventQueue.id, pingChannelId: fetchResult.channel.id }); } } } @@ -417,15 +423,17 @@ export namespace EventChannelUtils { } } - async function tryFetchChannel(store: Store, channelId: Snowflake): Promise { + async function tryFetchChannel(store: Store, channelId: Snowflake): Promise { try { - return await store.guild.channels.fetch(channelId) ?? undefined; + const channel = await store.guild.channels.fetch(channelId); + if (!channel) return { status: "missing" }; + return { status: "found", channel }; } catch (e) { const { status } = e as DiscordAPIError; - if (status === 404) return undefined; + if (status === 404) return { status: "missing" }; console.error(`EventChannelUtils.tryFetchChannel: failed to fetch channel ${channelId}:`, e); - return undefined; + throw e; } } diff --git a/src/utils/event-core.utils.ts b/src/utils/event-core.utils.ts new file mode 100644 index 00000000..cbe98f4e --- /dev/null +++ b/src/utils/event-core.utils.ts @@ -0,0 +1,40 @@ +import { Queries } from "../db/queries.ts"; +import type { DbEvent } from "../db/schema.ts"; +import { EventQueueRole, RoomScheduling } from "../types/db.types.ts"; +import { LockBeforeOpenWarning } from "./error.utils.ts"; + +export const DEFAULT_ROOM_LENGTH_MS = 60 * 60 * 1000; + +export const EVENT_DEFAULT_NON_QUEUE_KEYS = new Set(["id", "guildId", "eventId", "queueRole"]); + +export function getRoomsFinishMs(event: DbEvent, startMs: number): number { + const perRoomMs = event.roomLengthMs != null + ? Number(event.roomLengthMs) + : DEFAULT_ROOM_LENGTH_MS; + const totalRoomsDurationMs = event.roomScheduling === RoomScheduling.Sequential + ? perRoomMs * Number(event.roomCount) + : perRoomMs; + return startMs + totalRoomsDurationMs; +} + +export function shouldEventQueueBeUnlocked( + event: DbEvent, + role: EventQueueRole, + nowMs: number = Date.now(), +): boolean { + const occurrences = Queries.selectManyOccurrences({ guildId: event.guildId, eventId: event.id }); + return occurrences.some((occ) => { + const start = Number(occ.startTime); + const openAt = start - Number(event.createOffsetMs); + const closeAt = role === EventQueueRole.Room + ? (event.autoPullSubsAtRoomStartToggle ? start : start + Number(event.lockOffsetMs)) + : getRoomsFinishMs(event, start) + Number(event.cleanupOffsetMs); + return nowMs >= openAt && nowMs < closeAt; + }); +} + +export function validateEventOffsets(createOffsetMs: bigint, lockOffsetMs: bigint) { + if (lockOffsetMs < -createOffsetMs) { + throw new LockBeforeOpenWarning(); + } +} diff --git a/src/utils/event-jobs.registry.ts b/src/utils/event-jobs.registry.ts new file mode 100644 index 00000000..a2d726aa --- /dev/null +++ b/src/utils/event-jobs.registry.ts @@ -0,0 +1,22 @@ +import type { Job } from "node-schedule"; + +export interface OccurrenceJobs { + open?: Job; + lock?: Job; + cleanup?: Job; + roomPings: Map; + roomPulls: Map; +} + +export const occurrenceIdToJobs = new Map(); + +export function unregisterJobs(occurrenceId: bigint) { + const jobs = occurrenceIdToJobs.get(occurrenceId); + if (!jobs) return; + jobs.open?.cancel(); + jobs.lock?.cancel(); + jobs.cleanup?.cancel(); + jobs.roomPings.forEach(job => job.cancel()); + jobs.roomPulls.forEach(job => job.cancel()); + occurrenceIdToJobs.delete(occurrenceId); +} diff --git a/src/utils/event-lifecycle.utils.ts b/src/utils/event-lifecycle.utils.ts new file mode 100644 index 00000000..f2a3f852 --- /dev/null +++ b/src/utils/event-lifecycle.utils.ts @@ -0,0 +1,375 @@ +import { + channelMention, + type DiscordAPIError, + type GuildScheduledEventCreateOptions, + GuildScheduledEventEntityType, + GuildScheduledEventPrivacyLevel, + type GuildTextBasedChannel, + type Snowflake, + time, + TimestampStyles, +} from "discord.js"; +import { compact } from "lodash-es"; + +import { Queries } from "../db/queries.ts"; +import { + type DbEvent, + type DbEventOccurrence, + type DbEventQueue, + type DbQueue, +} from "../db/schema.ts"; +import { Store } from "../db/store.ts"; +import { EventQueueRole, MemberRemovalReason, RoomScheduling, SubAutoPullMode } from "../types/db.types.ts"; +import { ClientUtils } from "./client.utils.ts"; +import { DisplayUtils } from "./display.utils.ts"; +import * as EventCore from "./event-core.utils.ts"; +import { unregisterJobs } from "./event-jobs.registry.ts"; +import { reshowEventQueueDisplays } from "./event-sync-queues.utils.ts"; +import { MemberUtils } from "./member.utils.ts"; +import { QueueUtils } from "./queue.utils.ts"; +import { WinnerUtils } from "./winner.utils.ts"; + +export async function getEventContext(guildId: Snowflake, occurrenceId: bigint) { + const occurrence = Queries.selectOccurrence({ guildId, id: occurrenceId }); + if (!occurrence) return; + + const event = Queries.selectEvent({ guildId: occurrence.guildId, id: occurrence.eventId }); + if (!event) return; + + const guild = await ClientUtils.getGuild(occurrence.guildId); + if (!guild) return; + + const store = new Store(guild); + const eventQueues = Queries.selectManyEventQueues({ guildId: occurrence.guildId, eventId: event.id }); + const queueIds = eventQueues.map(eq => eq.queueId); + const queuesById = new Map( + Queries.selectManyQueuesByIds({ guildId: occurrence.guildId, ids: queueIds }) + .map(queue => [queue.id, queue]), + ); + const queues = compact(queueIds.map(id => queuesById.get(id))); + + return { occurrence, event, store, eventQueues, queues }; +} + +export async function runOpenAction(guildId: Snowflake, occurrenceId: bigint) { + const ctx = await getEventContext(guildId, occurrenceId); + if (!ctx) return; + const { occurrence, event, store, queues } = ctx; + + // Revoke the previous occurrence's winner roles — the badge lasts only until the next opens. + await WinnerUtils.clearEventWinners(store, event); + + // Unlock all event queues + if (queues.length > 0) { + await QueueUtils.updateQueues(store, queues, { lockToggle: false } as Partial); + } + + // Re-show every event-queue display sequentially before announcing so the announcement + // remains the most-recent message when announcementChannelId coincides with a display channel. + await reshowEventQueueDisplays(store, event); + + // Send announcement + if (event.announcementChannelId && event.announcementMessage) { + try { + const channel = await store.jsChannel(event.announcementChannelId) as GuildTextBasedChannel; + if (channel) { + const content = renderTemplate(event.announcementMessage, buildAnnouncementContext(event, occurrence)); + await channel.send({ + content, + allowedMentions: { parse: ["everyone", "roles", "users"] }, + }); + } + } + catch (e) { + console.error("Failed to send event announcement:", (e as Error).message); + } + } +} + +export async function runLockAction(guildId: Snowflake, occurrenceId: bigint) { + const ctx = await getEventContext(guildId, occurrenceId); + if (!ctx) return; + const { store, eventQueues } = ctx; + + // Lock only room queues + const roomQueues = compact( + eventQueues + .filter(eq => eq.queueRole === EventQueueRole.Room) + .map(eq => Queries.selectQueue({ guildId: store.guild.id, id: eq.queueId })) + ); + + if (roomQueues.length > 0) { + await QueueUtils.updateQueues(store, roomQueues, { lockToggle: true } as Partial); + } +} + +export async function runRoomPingAction(guildId: Snowflake, occurrenceId: bigint, eventQueue: DbEventQueue) { + const occurrence = Queries.selectOccurrence({ guildId, id: occurrenceId }); + if (!occurrence) return; + + const event = Queries.selectEvent({ guildId: occurrence.guildId, id: occurrence.eventId }); + if (!event) return; + + const queue = Queries.selectQueue({ guildId: occurrence.guildId, id: eventQueue.queueId }); + if (!queue) return; + + const guild = await ClientUtils.getGuild(occurrence.guildId); + if (!guild) return; + const store = new Store(guild); + + const pingChannelId = eventQueue.pingChannelId ?? event.roomQueuesChannelId; + + try { + const channel = await store.jsChannel(pingChannelId) as GuildTextBasedChannel; + if (!channel) return; + + const template = event.roomPingMessage ?? "{room_role} — {room_name} is starting soon!"; + const ctx = buildRoomPingContext(event, occurrence, eventQueue, queue); + const content = renderTemplate(template, ctx); + + if (content.trim()) { + await channel.send({ + content, + allowedMentions: { parse: ["roles", "users"] }, + }); + } + } + catch (e) { + console.error("Failed to send room ping:", (e as Error).message); + } +} + +export async function runRoomPullAction(guildId: Snowflake, occurrenceId: bigint, roomEventQueue: DbEventQueue) { + const ctx = await getEventContext(guildId, occurrenceId); + if (!ctx) return; + const { event, store, eventQueues } = ctx; + + const subEventQueue = eventQueues.find(eq => + eq.queueRole === EventQueueRole.Sub && eq.queueIndex === roomEventQueue.queueIndex + ); + if (!subEventQueue) { + console.warn(`EventUtils.runRoomPullAction: no paired sub event-queue for room index ${roomEventQueue.queueIndex} of event ${event.id} — skipping`); + return; + } + + const roomQueue = Queries.selectQueue({ guildId: store.guild.id, id: roomEventQueue.queueId }); + const subQueue = Queries.selectQueue({ guildId: store.guild.id, id: subEventQueue.queueId }); + if (!roomQueue || !subQueue) { + console.warn(`EventUtils.runRoomPullAction: missing queue rows for event ${event.id} room index ${roomEventQueue.queueIndex} — skipping`); + return; + } + + // Always lock the paired sub queue first — auto-pull bundles sub-lock atomically. + await QueueUtils.updateQueues(store, [subQueue], { lockToggle: true } as Partial); + + if (event.shuffleSubsBeforeAutoPullToggle) { + await MemberUtils.shuffleMembers(store, subQueue, undefined); + } + + const currentRoomCount = Queries.selectManyMembers({ guildId: store.guild.id, queueId: roomQueue.id }).length; + const subAvailable = Queries.selectManyMembers({ guildId: store.guild.id, queueId: subQueue.id }).length; + const count = roomQueue.size == null + ? subAvailable + : Math.min(Number(roomQueue.size) - currentRoomCount, subAvailable); + + if (count <= 0) { + console.log(`EventUtils.runRoomPullAction: nothing to pull for event ${event.id} room index ${roomEventQueue.queueIndex} (currentRoomCount=${currentRoomCount}, subAvailable=${subAvailable}, size=${roomQueue.size}) — skipping pull`); + return; + } + + if (event.subAutoPullMode === SubAutoPullMode.Promote) { + const subMembers = Queries.selectManyMembers({ + guildId: store.guild.id, + queueId: subQueue.id, + count, + }); + for (const subMember of subMembers) { + const jsMember = await store.jsMember(subMember.userId); + if (!jsMember) continue; + + try { + // Insert into room queue first so a failed insert never leaves the member off both queues. + // force:true bypasses verifyMemberEligibility so the room queue's lockToggle=true + // (set by runLockAction) does not block this system insert. + await MemberUtils.insertMember({ + store, + queue: roomQueue, + jsMember, + message: subMember.message ?? undefined, + force: true, + }); + + // Delete from sub queue directly via store (skips MemberUtils.deleteMembers messaging, + // DM-on-pull, voice destination, and role-on-pull side effects — we promote silently). + store.deleteMember({ id: subMember.id }, MemberRemovalReason.Pulled); + + if (subQueue.roleInQueueId) { + await MemberUtils.modifyMemberRoles(store, subMember.userId, subQueue.roleInQueueId, "remove") + .catch(e => console.error(`EventUtils.runRoomPullAction: failed to remove sub roleInQueueId from user ${subMember.userId}:`, e)); + } + } + catch (e) { + console.error(`EventUtils.runRoomPullAction: failed to promote user ${subMember.userId} into room queue ${roomQueue.id}:`, e); + } + } + await DisplayUtils.requestDisplayUpdate({ store, queueId: subQueue.id }); + await DisplayUtils.requestDisplayUpdate({ store, queueId: roomQueue.id }); + } + else { + await MemberUtils.deleteMembers({ + store, + queues: [subQueue], + reason: MemberRemovalReason.Pulled, + by: { count }, + force: true, + }); + await DisplayUtils.requestDisplayUpdate({ store, queueId: roomQueue.id }); + } +} + +export async function runCleanupAction(guildId: Snowflake, occurrenceId: bigint) { + const ctx = await getEventContext(guildId, occurrenceId); + if (!ctx) return; + const { store, queues } = ctx; + + // Clear all members from all event queues + if (queues.length > 0) { + await MemberUtils.deleteMembers({ + store, + queues, + reason: MemberRemovalReason.Kicked, + by: { count: 9999 }, + force: true, + }); + + // Lock all queues + await QueueUtils.updateQueues(store, queues, { lockToggle: true } as Partial); + } + + // Delete the occurrence row + store.deleteOccurrence({ id: occurrenceId }); + unregisterJobs(occurrenceId); +} + +function renderTemplate(template: string, ctx: Record): string { + return template.replace(/\{(\w+)\}/g, (_, k) => ctx[k] ?? ""); +} + +function buildAnnouncementContext(event: DbEvent, occurrence: DbEventOccurrence): Record { + const startDate = new Date(Number(occurrence.startTime)); + return { + event_name: event.name, + start_time: time(startDate, TimestampStyles.LongDateTime), + start_time_relative: time(startDate, TimestampStyles.RelativeTime), + room_queues_channel: channelMention(event.roomQueuesChannelId), + sub_queues_channel: channelMention(event.subQueuesChannelId), + }; +} + +function buildRoomPingContext( + event: DbEvent, + occurrence: DbEventOccurrence, + eventQueue: DbEventQueue, + queue: DbQueue, +): Record { + const startDate = new Date(Number(occurrence.startTime)); + const roleStr = queue.roleInQueueId ? `<@&${queue.roleInQueueId}>` : ""; + const pingChId = eventQueue.pingChannelId ?? event.roomQueuesChannelId; + return { + event_name: event.name, + room_name: queue.name, + room_role: roleStr, + room_index: String(eventQueue.queueIndex), + room_queues_channel: channelMention(event.roomQueuesChannelId), + ping_channel: channelMention(pingChId), + start_time: time(startDate, TimestampStyles.LongDateTime), + start_time_relative: time(startDate, TimestampStyles.RelativeTime), + }; +} + +const DISCORD_EVENT_NAME_LIMIT = 100; +const DISCORD_EVENT_DESCRIPTION_LIMIT = 1000; +const DISCORD_EVENT_LOCATION_LIMIT = 100; +const DISCORD_UNKNOWN_GUILD_SCHEDULED_EVENT = 10070; + +function resolveRoomChannelName(store: Store, event: DbEvent): string { + const cached = store.guild.channels.cache.get(event.roomQueuesChannelId); + return cached?.name ?? event.roomQueuesChannelId; +} + +function renderDiscordEventDescription(event: DbEvent, occurrence: DbEventOccurrence): string { + if (event.discordEventDescription) { + return renderTemplate(event.discordEventDescription, buildAnnouncementContext(event, occurrence)); + } + const scheduling = (event.roomScheduling as RoomScheduling) === RoomScheduling.Sequential + ? "sequential" + : "parallel"; + return [ + `Room queues channel: ${channelMention(event.roomQueuesChannelId)}`, + `Sub queues channel: ${channelMention(event.subQueuesChannelId)}`, + `Rooms: ${event.roomCount} (${scheduling})`, + ].join("\n"); +} + +function buildDiscordEventOptions( + event: DbEvent, + occurrence: DbEventOccurrence, + roomChannelName: string, +): GuildScheduledEventCreateOptions { + const startMs = Number(occurrence.startTime); + const endMs = EventCore.getRoomsFinishMs(event, startMs) + Number(event.cleanupOffsetMs); + return { + name: event.name.substring(0, DISCORD_EVENT_NAME_LIMIT), + scheduledStartTime: new Date(startMs), + scheduledEndTime: new Date(endMs), + privacyLevel: GuildScheduledEventPrivacyLevel.GuildOnly, + entityType: GuildScheduledEventEntityType.External, + description: renderDiscordEventDescription(event, occurrence).substring(0, DISCORD_EVENT_DESCRIPTION_LIMIT), + entityMetadata: { + location: roomChannelName.substring(0, DISCORD_EVENT_LOCATION_LIMIT), + }, + }; +} + +function isUnknownDiscordEventError(e: unknown): boolean { + const err = e as DiscordAPIError; + return err?.code === DISCORD_UNKNOWN_GUILD_SCHEDULED_EVENT || err?.status === 404; +} + +export async function createDiscordScheduledEvent(store: Store, event: DbEvent, occurrence: DbEventOccurrence) { + if (Number(occurrence.startTime) <= Date.now()) { + // Discord rejects external events whose start time is not in the future + return; + } + try { + const options = buildDiscordEventOptions(event, occurrence, resolveRoomChannelName(store, event)); + const created = await store.guild.scheduledEvents.create(options); + store.updateOccurrence({ id: occurrence.id }, { discordEventId: created.id }); + } + catch (e) { + console.error(`Failed to create Discord scheduled event for occurrence ${occurrence.id}:`, e); + } +} + +export async function updateDiscordScheduledEvent(store: Store, event: DbEvent, occurrence: DbEventOccurrence) { + if (!occurrence.discordEventId) return; + try { + const options = buildDiscordEventOptions(event, occurrence, resolveRoomChannelName(store, event)); + await store.guild.scheduledEvents.edit(occurrence.discordEventId, options); + } + catch (e) { + if (isUnknownDiscordEventError(e)) return; + console.error(`Failed to update Discord scheduled event for occurrence ${occurrence.id}:`, e); + } +} + +export async function deleteDiscordScheduledEvent(store: Store, occurrence: DbEventOccurrence) { + if (!occurrence.discordEventId) return; + try { + await store.guild.scheduledEvents.delete(occurrence.discordEventId); + } + catch (e) { + if (isUnknownDiscordEventError(e)) return; + console.error(`Failed to delete Discord scheduled event for occurrence ${occurrence.id}:`, e); + } +} diff --git a/src/utils/event-promote.test.ts b/src/utils/event-promote.test.ts new file mode 100644 index 00000000..3481b399 --- /dev/null +++ b/src/utils/event-promote.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { MemberRemovalReason } from "../types/db.types.ts"; +import { MemberUtils } from "./member.utils.ts"; + +/** + * Contract test for promote auto-pull ordering in EventUtils.runRoomPullAction: + * room insert must complete before sub delete so a failed insert never orphans the member. + */ +describe("promote auto-pull ordering contract", () => { + const callOrder: string[] = []; + + beforeEach(() => { + callOrder.length = 0; + }); + + it("insertMember runs before deleteMember on success path", async () => { + const insertMember = vi.spyOn(MemberUtils, "insertMember").mockImplementation(async () => { + callOrder.push("insert"); + return { id: 2n, userId: "user1", queueId: 10n } as any; + }); + const deleteMember = vi.fn(() => { + callOrder.push("delete"); + }); + + const subMember = { id: 1n, userId: "user1", message: null }; + const jsMember = { id: "user1" } as any; + const roomQueue = { id: 10n } as any; + const store = { deleteMember: deleteMember } as any; + + try { + await MemberUtils.insertMember({ + store, + queue: roomQueue, + jsMember, + message: undefined, + force: true, + }); + store.deleteMember({ id: subMember.id }, MemberRemovalReason.Pulled); + } + catch { + // match runRoomPullAction catch + } + + expect(callOrder).toEqual(["insert", "delete"]); + insertMember.mockRestore(); + }); + + it("deleteMember is not called when insertMember fails", async () => { + const insertMember = vi.spyOn(MemberUtils, "insertMember").mockRejectedValue(new Error("room full")); + const deleteMember = vi.fn(); + + const subMember = { id: 1n, userId: "user1", message: null }; + const jsMember = { id: "user1" } as any; + const roomQueue = { id: 10n } as any; + const store = { deleteMember: deleteMember } as any; + + try { + await MemberUtils.insertMember({ + store, + queue: roomQueue, + jsMember, + message: undefined, + force: true, + }); + store.deleteMember({ id: subMember.id }, MemberRemovalReason.Pulled); + } + catch { + // match runRoomPullAction catch + } + + expect(deleteMember).not.toHaveBeenCalled(); + insertMember.mockRestore(); + }); +}); diff --git a/src/utils/event-schedule.utils.test.ts b/src/utils/event-schedule.utils.test.ts new file mode 100644 index 00000000..e80b3d17 --- /dev/null +++ b/src/utils/event-schedule.utils.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { DbEventOccurrence } from "../db/schema.ts"; + +const { + selectManyOccurrenceRoomPingsByOccurrenceIds, + selectManyOccurrenceRoomPullsByOccurrenceIds, + selectManyEventQueues, +} = vi.hoisted(() => ({ + selectManyOccurrenceRoomPingsByOccurrenceIds: vi.fn(() => []), + selectManyOccurrenceRoomPullsByOccurrenceIds: vi.fn(() => []), + selectManyEventQueues: vi.fn(() => []), +})); + +vi.mock("../db/queries.ts", () => ({ + Queries: { + selectManyOccurrenceRoomPingsByOccurrenceIds, + selectManyOccurrenceRoomPullsByOccurrenceIds, + selectManyEventQueues, + }, +})); + +import { buildArmOccurrenceContext } from "./event-schedule.utils.ts"; + +describe("buildArmOccurrenceContext", () => { + beforeEach(() => { + selectManyOccurrenceRoomPingsByOccurrenceIds.mockClear(); + selectManyOccurrenceRoomPullsByOccurrenceIds.mockClear(); + selectManyEventQueues.mockClear(); + }); + + it("batches junction queries once for multiple occurrences", () => { + const occurrences: DbEventOccurrence[] = [ + { id: 1n, guildId: "g1", eventId: 10n, startTime: 0n } as DbEventOccurrence, + { id: 2n, guildId: "g1", eventId: 10n, startTime: 0n } as DbEventOccurrence, + { id: 3n, guildId: "g1", eventId: 10n, startTime: 0n } as DbEventOccurrence, + ]; + + buildArmOccurrenceContext("g1", occurrences); + + expect(selectManyOccurrenceRoomPingsByOccurrenceIds).toHaveBeenCalledTimes(1); + expect(selectManyOccurrenceRoomPingsByOccurrenceIds).toHaveBeenCalledWith({ + guildId: "g1", + occurrenceIds: [1n, 2n, 3n], + }); + expect(selectManyOccurrenceRoomPullsByOccurrenceIds).toHaveBeenCalledTimes(1); + expect(selectManyOccurrenceRoomPullsByOccurrenceIds).toHaveBeenCalledWith({ + guildId: "g1", + occurrenceIds: [1n, 2n, 3n], + }); + }); + + it("loads room queues once per unique event id", () => { + const occurrences: DbEventOccurrence[] = [ + { id: 1n, guildId: "g1", eventId: 10n, startTime: 0n } as DbEventOccurrence, + { id: 2n, guildId: "g1", eventId: 20n, startTime: 0n } as DbEventOccurrence, + ]; + + buildArmOccurrenceContext("g1", occurrences); + + expect(selectManyEventQueues).toHaveBeenCalledTimes(2); + expect(selectManyEventQueues).toHaveBeenCalledWith({ guildId: "g1", eventId: 10n }); + expect(selectManyEventQueues).toHaveBeenCalledWith({ guildId: "g1", eventId: 20n }); + }); +}); diff --git a/src/utils/event-schedule.utils.ts b/src/utils/event-schedule.utils.ts new file mode 100644 index 00000000..797bd43c --- /dev/null +++ b/src/utils/event-schedule.utils.ts @@ -0,0 +1,310 @@ +import nodeSchedule, { type Job } from "node-schedule"; + +import { Queries } from "../db/queries.ts"; +import type { DbEvent, DbEventOccurrence, DbEventQueue } from "../db/schema.ts"; +import { Store } from "../db/store.ts"; +import { EventQueueRole, RoomScheduling } from "../types/db.types.ts"; +import { ClientUtils } from "./client.utils.ts"; +import { + OccurrenceInPastWarning, + SequentialEventRequiresRoomLengthWarning, +} from "./error.utils.ts"; +import * as EventCore from "./event-core.utils.ts"; +import { occurrenceIdToJobs, type OccurrenceJobs, unregisterJobs } from "./event-jobs.registry.ts"; +import { + createDiscordScheduledEvent, + deleteDiscordScheduledEvent, + runCleanupAction, + runLockAction, + runOpenAction, + runRoomPingAction, + runRoomPullAction, + updateDiscordScheduledEvent, +} from "./event-lifecycle.utils.ts"; + +const PHASE_RETRY_BACKOFF_MS = [5_000, 30_000, 120_000]; + +export type ArmOccurrenceContext = { + pingedQueueIdsByOccurrence?: Map> + pulledQueueIdsByOccurrence?: Map> + roomEventQueuesByEvent?: Map +}; + +async function runPhaseWithRetry(label: string, run: () => Promise, markDone: () => void) { + for (let attempt = 0; attempt <= PHASE_RETRY_BACKOFF_MS.length; attempt++) { + try { + await run(); + markDone(); + return; + } + catch (e) { + if (attempt === PHASE_RETRY_BACKOFF_MS.length) { + console.error(`Event ${label} action failed after retries:`, (e as Error).message); + return; + } + const delay = PHASE_RETRY_BACKOFF_MS[attempt]; + console.error(`Event ${label} action failed (attempt ${attempt + 1}), retrying in ${delay}ms:`, (e as Error).message); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } +} + +async function armPhase( + at: number, + now: number, + alreadyDone: boolean, + run: () => Promise, + markDone: () => void, + label: string, +): Promise { + if (alreadyDone) return; + if (at <= now) { + await runPhaseWithRetry(label, run, markDone); + return; + } + return nodeSchedule.scheduleJob(new Date(at), async () => { + await runPhaseWithRetry(label, run, markDone); + }); +} + +function computeRoomPingAt(event: DbEvent, startMs: number, queueIndex: bigint): number { + if (event.roomScheduling === RoomScheduling.Sequential && event.roomLengthMs) { + return startMs + (Number(queueIndex) - 1) * Number(event.roomLengthMs); + } + return startMs; +} + +function groupEventQueueIdsByOccurrence( + rows: Array<{ occurrenceId: bigint, eventQueueId: bigint }>, +): Map> { + const map = new Map>(); + for (const row of rows) { + let set = map.get(row.occurrenceId); + if (!set) { + set = new Set(); + map.set(row.occurrenceId, set); + } + set.add(row.eventQueueId); + } + return map; +} + +function roomQueuesForEvent(guildId: string, eventId: bigint): DbEventQueue[] { + return Queries.selectManyEventQueues({ guildId, eventId }) + .filter(eq => eq.queueRole === EventQueueRole.Room); +} + +export function buildArmOccurrenceContext( + guildId: string, + occurrences: DbEventOccurrence[], +): ArmOccurrenceContext { + const occurrenceIds = occurrences.map(o => o.id); + const pingedQueueIdsByOccurrence = groupEventQueueIdsByOccurrence( + Queries.selectManyOccurrenceRoomPingsByOccurrenceIds({ guildId, occurrenceIds }), + ); + const pulledQueueIdsByOccurrence = groupEventQueueIdsByOccurrence( + Queries.selectManyOccurrenceRoomPullsByOccurrenceIds({ guildId, occurrenceIds }), + ); + + const roomEventQueuesByEvent = new Map(); + for (const eventId of new Set(occurrences.map(o => o.eventId))) { + roomEventQueuesByEvent.set(eventId, roomQueuesForEvent(guildId, eventId)); + } + + return { + pingedQueueIdsByOccurrence, + pulledQueueIdsByOccurrence, + roomEventQueuesByEvent, + }; +} + +export async function armOccurrence( + event: DbEvent, + occurrence: DbEventOccurrence, + ctx?: ArmOccurrenceContext, +) { + unregisterJobs(occurrence.id); + + const now = Date.now(); + const startMs = Number(occurrence.startTime); + const openAt = startMs - Number(event.createOffsetMs); + // When autoPullSubsAtRoomStartToggle is on, the room queue must lock at exact startTime so the + // per-room auto-pull (which locks the paired sub queue) sees a consistent snapshot. lockOffsetMs + // is preserved on the schema for the legacy path but ignored here. + const lockAt = event.autoPullSubsAtRoomStartToggle + ? startMs + : startMs + Number(event.lockOffsetMs); + const cleanupAt = EventCore.getRoomsFinishMs(event, startMs) + Number(event.cleanupOffsetMs); + + const guild = await ClientUtils.getGuild(occurrence.guildId); + if (!guild) return; + const store = new Store(guild); + + const pingedQueueIds = ctx?.pingedQueueIdsByOccurrence?.get(occurrence.id) + ?? new Set( + Queries.selectOccurrenceRoomPings({ guildId: occurrence.guildId, occurrenceId: occurrence.id }) + .map(r => r.eventQueueId), + ); + const pulledRoomIds = ctx?.pulledQueueIdsByOccurrence?.get(occurrence.id) + ?? new Set( + Queries.selectOccurrenceRoomPulls({ guildId: occurrence.guildId, occurrenceId: occurrence.id }) + .map(r => r.eventQueueId), + ); + + const jobs: OccurrenceJobs = { roomPings: new Map(), roomPulls: new Map() }; + + // Open action + jobs.open = await armPhase( + openAt, + now, + occurrence.openHandledAt != null, + () => runOpenAction(occurrence.guildId, occurrence.id), + () => store.updateOccurrence({ id: occurrence.id }, { openHandledAt: BigInt(Date.now()) }), + "open", + ); + + // Lock action + jobs.lock = await armPhase( + lockAt, + now, + occurrence.lockHandledAt != null, + () => runLockAction(occurrence.guildId, occurrence.id), + () => store.updateOccurrence({ id: occurrence.id }, { lockHandledAt: BigInt(Date.now()) }), + "lock", + ); + + // Room pings (and optional room-start auto-pulls) + const roomEventQueues = ctx?.roomEventQueuesByEvent?.get(event.id) + ?? roomQueuesForEvent(event.guildId, event.id); + + for (const eq of roomEventQueues) { + const pingAt = computeRoomPingAt(event, startMs, eq.queueIndex); + + const pingJob = await armPhase( + pingAt, + now, + pingedQueueIds.has(eq.id), + () => runRoomPingAction(occurrence.guildId, occurrence.id, eq), + () => store.insertOccurrenceRoomPing({ + occurrenceId: occurrence.id, + eventQueueId: eq.id, + handledAt: BigInt(Date.now()), + }), + "room ping", + ); + if (pingJob) jobs.roomPings.set(eq.id, pingJob); + } + + if (event.autoPullSubsAtRoomStartToggle) { + for (const eq of roomEventQueues) { + const pullAt = computeRoomPingAt(event, startMs, eq.queueIndex); + + const pullJob = await armPhase( + pullAt, + now, + pulledRoomIds.has(eq.id), + () => runRoomPullAction(occurrence.guildId, occurrence.id, eq), + () => store.insertOccurrenceRoomPull({ + occurrenceId: occurrence.id, + eventQueueId: eq.id, + handledAt: BigInt(Date.now()), + }), + "room pull", + ); + if (pullJob) jobs.roomPulls.set(eq.id, pullJob); + } + } + + // Cleanup action — no flag needed; cleanup deletes the row (cascades the junction) + if (cleanupAt <= now) { + await runPhaseWithRetry("cleanup", () => runCleanupAction(occurrence.guildId, occurrence.id), () => undefined); + } + else { + jobs.cleanup = nodeSchedule.scheduleJob(new Date(cleanupAt), async () => { + await runPhaseWithRetry("cleanup", () => runCleanupAction(occurrence.guildId, occurrence.id), () => undefined); + }); + } + + occurrenceIdToJobs.set(occurrence.id, jobs); +} + +export async function rearmAllOccurrences(store: Store, event: DbEvent) { + const occurrences = Queries.selectManyOccurrences({ guildId: store.guild.id, eventId: event.id }); + const ctx = buildArmOccurrenceContext(store.guild.id, occurrences); + for (const occ of occurrences) { + await armOccurrence(event, occ, ctx); + await updateDiscordScheduledEvent(store, event, occ); + } +} + +export async function scheduleOccurrence( + store: Store, + event: DbEvent, + startTime: bigint, + timezone?: string, +) { + const cleanupAt = EventCore.getRoomsFinishMs(event, Number(startTime)) + Number(event.cleanupOffsetMs); + if (cleanupAt < Date.now()) { + throw new OccurrenceInPastWarning(); + } + + if (event.roomScheduling === RoomScheduling.Sequential) { + if (!event.roomLengthMs || BigInt(event.roomLengthMs) <= 0n) { + throw new SequentialEventRequiresRoomLengthWarning(); + } + } + + const occurrence = store.insertOccurrence({ + guildId: store.guild.id, + eventId: event.id, + startTime, + timezone, + }); + + await armOccurrence(event, occurrence); + + if (event.createDiscordEvent) { + await createDiscordScheduledEvent(store, event, occurrence); + } + + return occurrence; +} + +export async function cancelOccurrence(store: Store, occurrence: DbEventOccurrence) { + await deleteDiscordScheduledEvent(store, occurrence); + unregisterJobs(occurrence.id); + store.deleteOccurrence({ id: occurrence.id }); +} + +export async function loadOccurrences() { + const occurrences = Queries.selectAllOccurrences(); + console.time(`Loaded ${occurrences.length} event occurrences`); + + const occurrencesByGuild = new Map(); + for (const occurrence of occurrences) { + const list = occurrencesByGuild.get(occurrence.guildId); + if (list) { + list.push(occurrence); + } + else { + occurrencesByGuild.set(occurrence.guildId, [occurrence]); + } + } + + for (const [guildId, guildOccurrences] of occurrencesByGuild) { + const eventIds = [...new Set(guildOccurrences.map(o => o.eventId))]; + const events = Queries.selectManyEventsByIds({ guildId, ids: eventIds }); + const eventsById = new Map(events.map(event => [event.id, event])); + const ctx = buildArmOccurrenceContext(guildId, guildOccurrences); + + for (const occurrence of guildOccurrences) { + const event = eventsById.get(occurrence.eventId); + if (!event) { + continue; + } + await armOccurrence(event, occurrence, ctx); + } + } + + console.timeEnd(`Loaded ${occurrences.length} event occurrences`); +} diff --git a/src/utils/event-sync-lock.utils.test.ts b/src/utils/event-sync-lock.utils.test.ts new file mode 100644 index 00000000..7f025810 --- /dev/null +++ b/src/utils/event-sync-lock.utils.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { EventSyncInProgressWarning } from "./error.utils.ts"; + +const { + tryAcquireEventSyncLock, + releaseEventSyncLock, +} = vi.hoisted(() => ({ + tryAcquireEventSyncLock: vi.fn(() => true), + releaseEventSyncLock: vi.fn(), +})); + +vi.mock("../db/queries.ts", () => ({ + Queries: { + tryAcquireEventSyncLock, + releaseEventSyncLock, + deleteStaleEventSyncLocks: vi.fn(), + }, +})); + +import { EventSyncLock } from "./event-sync-lock.utils.ts"; + +describe("EventSyncLock", () => { + beforeEach(() => { + tryAcquireEventSyncLock.mockReset(); + tryAcquireEventSyncLock.mockReturnValue(true); + releaseEventSyncLock.mockReset(); + }); + + it("releases DB lock after withLock completes", async () => { + await EventSyncLock.withLock("guild1", 42n, async () => "ok"); + expect(tryAcquireEventSyncLock).toHaveBeenCalledWith({ guildId: "guild1", eventId: 42n }); + expect(releaseEventSyncLock).toHaveBeenCalledWith({ guildId: "guild1", eventId: 42n }); + }); + + it("throws when DB lock cannot be acquired", async () => { + tryAcquireEventSyncLock.mockReturnValue(false); + await expect( + EventSyncLock.withLock("guild1", 42n, async () => undefined), + ).rejects.toBeInstanceOf(EventSyncInProgressWarning); + expect(releaseEventSyncLock).not.toHaveBeenCalled(); + }); + + it("re-entrant withLock does not double-acquire", async () => { + await EventSyncLock.withLock("guild1", 42n, async () => { + await EventSyncLock.withLock("guild1", 42n, async () => undefined); + }); + expect(tryAcquireEventSyncLock).toHaveBeenCalledTimes(1); + expect(releaseEventSyncLock).toHaveBeenCalledTimes(1); + }); + + it("tryWithLock returns skipped when lock is held", async () => { + tryAcquireEventSyncLock.mockReturnValue(false); + const result = await EventSyncLock.tryWithLock("guild1", 42n, async () => "done"); + expect(result).toBe("skipped"); + }); +}); diff --git a/src/utils/event-sync-lock.utils.ts b/src/utils/event-sync-lock.utils.ts index a393c1c0..a29e0a2e 100644 --- a/src/utils/event-sync-lock.utils.ts +++ b/src/utils/event-sync-lock.utils.ts @@ -1,9 +1,13 @@ import { AsyncLocalStorage } from "node:async_hooks"; +import { Queries } from "../db/queries.ts"; import { EventSyncInProgressWarning } from "./error.utils.ts"; +/** Cross-process lock via SQLite; in-process re-entrancy via AsyncLocalStorage. */ export namespace EventSyncLock { + const SYNC_LOCK_TTL_MS = 10 * 60 * 1000; + const held = new Set(); const localHeld = new AsyncLocalStorage>(); @@ -11,6 +15,10 @@ export namespace EventSyncLock { return `${guildId}:${eventId}`; } + export function cleanupStaleLocks(ttlMs = SYNC_LOCK_TTL_MS) { + Queries.deleteStaleEventSyncLocks(BigInt(Date.now() - ttlMs)); + } + // Throw-on-contention. If the current async chain already holds the lock, // runs `fn` directly (re-entrant — needed because syncEventQueues itself // calls reconcileRoomChannels, which is also lock-wrapped). @@ -23,6 +31,9 @@ export namespace EventSyncLock { if (held.has(k)) { throw new EventSyncInProgressWarning(); } + if (!Queries.tryAcquireEventSyncLock({ guildId, eventId })) { + throw new EventSyncInProgressWarning(); + } held.add(k); try { const next = new Set(localSet ?? []); @@ -31,6 +42,7 @@ export namespace EventSyncLock { } finally { held.delete(k); + Queries.releaseEventSyncLock({ guildId, eventId }); } } @@ -49,6 +61,9 @@ export namespace EventSyncLock { if (held.has(k)) { return "skipped"; } + if (!Queries.tryAcquireEventSyncLock({ guildId, eventId })) { + return "skipped"; + } held.add(k); try { const next = new Set(localSet ?? []); @@ -57,10 +72,8 @@ export namespace EventSyncLock { } finally { held.delete(k); + Queries.releaseEventSyncLock({ guildId, eventId }); } } - export function isHeld(guildId: string, eventId: bigint): boolean { - return held.has(key(guildId, eventId)); - } } diff --git a/src/utils/event-sync-queues.utils.ts b/src/utils/event-sync-queues.utils.ts new file mode 100644 index 00000000..74924d23 --- /dev/null +++ b/src/utils/event-sync-queues.utils.ts @@ -0,0 +1,309 @@ +import type { GuildTextBasedChannel, Snowflake } from "discord.js"; +import { compact, isNil, omitBy } from "lodash-es"; + +import { db } from "../db/db.ts"; +import { Queries } from "../db/queries.ts"; +import { + type DbEvent, + type DbQueue, + QUEUE_CONFIG_COLUMN_KEYS, + QUEUE_TABLE, +} from "../db/schema.ts"; +import { Store } from "../db/store.ts"; +import { DisplayUpdateType, EventQueueRole } from "../types/db.types.ts"; +import { DisplayUtils } from "./display.utils.ts"; +import { QueueAlreadyExistsWarning } from "./error.utils.ts"; +import { EventChannelUtils } from "./event-channel.utils.ts"; +import * as EventCore from "./event-core.utils.ts"; +import { EventSyncLock } from "./event-sync-lock.utils.ts"; +import { QueueUtils } from "./queue.utils.ts"; + +// Mirrored queue-config keys live in QUEUE_CONFIG_COLUMN_KEYS (schema.ts). +const SYNC_QUEUE_CONFIG_KEYS = QUEUE_CONFIG_COLUMN_KEYS.filter(k => k !== "lockToggle"); + +export function insertEventQueueRowWithoutDisplayDb( + store: Store, + event: DbEvent, + role: EventQueueRole, + index: number, +) { + const roleLabel = role === EventQueueRole.Room ? "Room" : "Sub"; + let queueName = `${event.name} ${roleLabel} ${index}`; + + const defaults = Queries.selectEventDefault({ + guildId: store.guild.id, + eventId: event.id, + queueRole: role, + }); + const queueConfig = defaults ? omitBy(defaults, isNil) : {}; + delete queueConfig.id; + delete queueConfig.guildId; + delete queueConfig.eventId; + delete queueConfig.queueRole; + // Event queues are gated by their pre-start window — the schema default / event-default + // overlay must not be used to leave a queue unlocked outside that window. + queueConfig.lockToggle = !EventCore.shouldEventQueueBeUnlocked(event, role); + + let insertedQueue: DbQueue; + try { + insertedQueue = store.insertQueue({ + guildId: store.guild.id, + name: queueName, + ...queueConfig, + }); + } + catch (e) { + if (e instanceof QueueAlreadyExistsWarning) { + queueName = `${queueName} (event)`; + insertedQueue = store.insertQueue({ + guildId: store.guild.id, + name: queueName, + ...queueConfig, + }); + } + else { + throw e; + } + } + + store.insertEventQueue({ + guildId: store.guild.id, + eventId: event.id, + queueId: insertedQueue.id, + queueRole: role, + queueIndex: BigInt(index), + }); + + return insertedQueue; +} + +export async function insertEventQueueRowWithoutDisplay( + store: Store, + event: DbEvent, + role: EventQueueRole, + index: number, +) { + return db.transaction(() => + insertEventQueueRowWithoutDisplayDb(store, event, role, index) + ); +} + +export async function createEventQueue( + store: Store, + event: DbEvent, + role: EventQueueRole, + index: number, + displayChannelId: Snowflake, +) { + const insertedQueue = await insertEventQueueRowWithoutDisplay(store, event, role, index); + await DisplayUtils.insertDisplays(store, [insertedQueue], displayChannelId); + return insertedQueue; +} + +// Sequentially re-shows every event-queue display in queue-index order across the event's Room +// and Sub display channels: deletes each existing display (and its posted Discord message) then +// inserts a fresh row and awaits a Replace refresh. Awaiting per queue guarantees the new +// messages have landed before the caller continues (used by `syncEventQueues` Step C and +// `runOpenAction` so a same-channel announcement stays as the most-recent message). +export async function reshowEventQueueDisplays(store: Store, event: DbEvent): Promise { + let reshownCount = 0; + const roles: EventQueueRole[] = [EventQueueRole.Room, EventQueueRole.Sub]; + + for (const role of roles) { + const displayChannelId = role === EventQueueRole.Room + ? event.roomQueuesChannelId + : event.subQueuesChannelId; + const orderedEqs = Queries.selectManyEventQueues({ guildId: store.guild.id, eventId: event.id }) + .filter(eq => eq.queueRole === role) + .sort((a, b) => Number(a.queueIndex) - Number(b.queueIndex)); + + for (const eq of orderedEqs) { + const queue = Queries.selectQueue({ guildId: store.guild.id, id: eq.queueId }); + if (!queue) continue; + + const existingDisplays = [...store.dbDisplays().filter(d => d.queueId === queue.id).values()]; + for (const display of existingDisplays) { + if (display.lastMessageId) { + const channel = await store.jsChannel(display.displayChannelId) as GuildTextBasedChannel | undefined; + if (channel) { + const message = await channel.messages.fetch(display.lastMessageId).catch(e => { + console.error(`EventUtils.reshowEventQueueDisplays: failed to fetch stale display message ${display.lastMessageId} in channel ${display.displayChannelId}:`, e); + return null; + }); + if (message) { + await message.delete().catch(e => { + console.error(`EventUtils.reshowEventQueueDisplays: failed to delete stale display message ${display.lastMessageId} in channel ${display.displayChannelId}:`, e); + return null; + }); + } + } + } + store.deleteDisplay({ id: display.id }); + } + + const newDisplay = store.insertDisplay({ + guildId: store.guild.id, + queueId: queue.id, + displayChannelId, + }); + if (!newDisplay) continue; + + await DisplayUtils.updateDisplays({ + store, + queueId: queue.id, + opts: { + displayIds: [newDisplay.id], + updateTypeOverride: DisplayUpdateType.Replace, + }, + }); + + reshownCount++; + } + } + + return reshownCount; +} + +export async function syncEventQueues(store: Store, event: DbEvent) { + return EventSyncLock.withLock(store.guild.id, event.id, async () => { + let recreatedCount = 0; + let reappliedRoomCount = 0; + let reappliedSubCount = 0; + const roleInQueueUpdates: DbQueue[] = []; + + const roomCount = Number(event.roomCount); + const roles: EventQueueRole[] = [EventQueueRole.Room, EventQueueRole.Sub]; + + db.transaction(() => { + // Lock every existing event queue up-front so the sync runs from a known-locked baseline. + // Step A's new queues lock themselves via insertEventQueueRowWithoutDisplayDb; Step E unlocks + // any whose pre-start window contains now. Direct store.updateQueue (not QueueUtils.updateQueues) + // — its requestDisplaysUpdate is fire-and-forget and would race Step C. No display refresh + // needed: Step C reposts every display. + { + const existingEqs = Queries.selectManyEventQueues({ guildId: store.guild.id, eventId: event.id }); + const existingQueuesById = new Map( + Queries.selectManyQueuesByIds({ + guildId: store.guild.id, + ids: existingEqs.map(eq => eq.queueId), + }).map(queue => [queue.id, queue]), + ); + for (const eq of existingEqs) { + const q = existingQueuesById.get(eq.queueId); + if (!q) continue; + store.updateQueue({ id: q.id, lockToggle: true }); + } + } + + let allEventQueues = Queries.selectManyEventQueues({ guildId: store.guild.id, eventId: event.id }); + let allQueuesById = new Map( + Queries.selectManyQueuesByIds({ + guildId: store.guild.id, + ids: allEventQueues.map(eq => eq.queueId), + }).map(queue => [queue.id, queue]), + ); + + // Step A — recreate any missing (role, queueIndex) slots. Skip display creation here; + // Step C is the sole writer of displays so its sequential post order isn't racing + // against fire-and-forget updates from DisplayUtils.insertDisplays. + for (const role of roles) { + for (let i = 1; i <= roomCount; i++) { + const match = allEventQueues.find(eq => eq.queueRole === role && Number(eq.queueIndex) === i); + const existingQueue = match ? allQueuesById.get(match.queueId) : undefined; + if (!match || !existingQueue) { + insertEventQueueRowWithoutDisplayDb(store, event, role, i); + recreatedCount++; + } + } + } + + if (recreatedCount > 0) { + allEventQueues = Queries.selectManyEventQueues({ guildId: store.guild.id, eventId: event.id }); + allQueuesById = new Map( + Queries.selectManyQueuesByIds({ + guildId: store.guild.id, + ids: allEventQueues.map(eq => eq.queueId), + }).map(queue => [queue.id, queue]), + ); + } + + // Step B — reset queue-config columns to schema defaults, then overlay the stored event defaults. + // Direct store.updateQueue writes (not QueueUtils.updateQueues) so we don't fire an async + // requestDisplaysUpdate that would race with Step C and leave orphan messages in the channel. + // `lockToggle` is intentionally excluded — it's owned by the up-front lock above and Step E below. + for (const role of roles) { + const resetPatch: Record = {}; + for (const key of SYNC_QUEUE_CONFIG_KEYS) { + resetPatch[key] = (QUEUE_TABLE as any)[key]?.default ?? null; + } + + const storedDefault = Queries.selectEventDefault({ + guildId: store.guild.id, + eventId: event.id, + queueRole: role, + }); + const overlay = storedDefault ? omitBy(storedDefault, isNil) : {}; + delete overlay.id; + delete overlay.guildId; + delete overlay.eventId; + delete overlay.queueRole; + delete overlay.lockToggle; + + const update = { ...resetPatch, ...overlay } as Partial; + + const eventQueues = allEventQueues.filter(eq => eq.queueRole === role); + const queues = compact(eventQueues.map(eq => allQueuesById.get(eq.queueId))); + + if (queues.length > 0) { + const updatedQueues = compact(queues.map(q => store.updateQueue({ id: q.id, ...update }))); + if (update.roleInQueueId) { + roleInQueueUpdates.push(...updatedQueues); + } + if (role === EventQueueRole.Room) { + reappliedRoomCount = updatedQueues.length; + } + else { + reappliedSubCount = updatedQueues.length; + } + } + } + }); + + if (roleInQueueUpdates.length > 0) { + await QueueUtils.setRoleInQueue(store, roleInQueueUpdates); + } + + const allEventQueues = Queries.selectManyEventQueues({ guildId: store.guild.id, eventId: event.id }); + const allQueuesById = new Map( + Queries.selectManyQueuesByIds({ + guildId: store.guild.id, + ids: allEventQueues.map(eq => eq.queueId), + }).map(queue => [queue.id, queue]), + ); + + // Step C — re-show every queue display in queue-index order in the event's display channels. + const reshownCount = await reshowEventQueueDisplays(store, event); + + // Step D — reconcile channels + auto-created room roles + if (event.roomCategoryId) { + await EventChannelUtils.reconcileRoomChannels(store, event); + } + + // Step E — unlock any event queues whose role-appropriate pre-start window contains now. + { + const toUnlock: DbQueue[] = []; + for (const eq of allEventQueues) { + const q = allQueuesById.get(eq.queueId); + if (!q) continue; + if (EventCore.shouldEventQueueBeUnlocked(event, eq.queueRole as EventQueueRole)) { + toUnlock.push(q); + } + } + if (toUnlock.length > 0) { + await QueueUtils.updateQueues(store, toUnlock, { lockToggle: false } as Partial); + } + } + + return { recreatedCount, reappliedRoomCount, reappliedSubCount, reshownCount }; + }); +} diff --git a/src/utils/event.utils.ts b/src/utils/event.utils.ts index c0c21c5a..104bfe5c 100644 --- a/src/utils/event.utils.ts +++ b/src/utils/event.utils.ts @@ -1,23 +1,11 @@ -import { - channelMention, - type DiscordAPIError, - type GuildScheduledEventCreateOptions, - GuildScheduledEventEntityType, - GuildScheduledEventPrivacyLevel, - type GuildTextBasedChannel, - type Snowflake, - time, - TimestampStyles, -} from "discord.js"; +import type { Snowflake } from "discord.js"; import { SQLiteColumn } from "drizzle-orm/sqlite-core"; -import { compact, findKey, isNil, omitBy } from "lodash-es"; -import nodeSchedule, { type Job } from "node-schedule"; +import { compact, findKey } from "lodash-es"; +import { db } from "../db/db.ts"; import { Queries } from "../db/queries.ts"; import { type DbEvent, - type DbEventOccurrence, - type DbEventQueue, type DbQueue, EVENT_DEFAULT_TABLE, type NewEvent, @@ -25,44 +13,24 @@ import { QUEUE_TABLE, } from "../db/schema.ts"; import { Store } from "../db/store.ts"; -import { DisplayUpdateType, EventQueueRole, MemberRemovalReason, RoomScheduling, SubAutoPullMode } from "../types/db.types.ts"; -import { ClientUtils } from "./client.utils.ts"; +import { EventQueueRole, RoomScheduling } from "../types/db.types.ts"; import { DisplayUtils } from "./display.utils.ts"; import { CustomError, EventRoomCountShrinkWarning, - LockBeforeOpenWarning, - OccurrenceInPastWarning, - QueueAlreadyExistsWarning, SequentialEventRequiresRoomLengthWarning, } from "./error.utils.ts"; import { EventChannelUtils } from "./event-channel.utils.ts"; -import { EventSyncLock } from "./event-sync-lock.utils.ts"; -import { MemberUtils } from "./member.utils.ts"; +import * as EventCore from "./event-core.utils.ts"; +import { unregisterJobs } from "./event-jobs.registry.ts"; +import { deleteDiscordScheduledEvent } from "./event-lifecycle.utils.ts"; +import * as EventSchedule from "./event-schedule.utils.ts"; +import * as EventSyncQueues from "./event-sync-queues.utils.ts"; import { QueueUtils } from "./queue.utils.ts"; -import { WinnerUtils } from "./winner.utils.ts"; export namespace EventUtils { - // ==================================================================== - // In-memory job tracking - // ==================================================================== - - interface OccurrenceJobs { - open?: Job; - lock?: Job; - cleanup?: Job; - roomPings: Map; - roomPulls: Map; - } - - const occurrenceIdToJobs = new Map(); - - // ==================================================================== - // Public API - // ==================================================================== - - export function assertHasRoomCategory(event: DbEvent) { + export function assertHasRoomCategoryForChannelSync(event: DbEvent) { if (!event.roomCategoryId) { throw new CustomError({ message: `Event "${event.name}" has no \`room_category\`. Run \`/events set event:${event.name} room_category:…\` first.`, @@ -70,20 +38,13 @@ export namespace EventUtils { } } - const DEFAULT_ROOM_LENGTH_MS = 60 * 60 * 1000; + /** @deprecated Use assertHasRoomCategoryForChannelSync */ + export const assertHasRoomCategory = assertHasRoomCategoryForChannelSync; - export function getRoomsFinishMs(event: DbEvent, startMs: number): number { - const perRoomMs = event.roomLengthMs != null - ? Number(event.roomLengthMs) - : DEFAULT_ROOM_LENGTH_MS; - const totalRoomsDurationMs = event.roomScheduling === RoomScheduling.Sequential - ? perRoomMs * Number(event.roomCount) - : perRoomMs; - return startMs + totalRoomsDurationMs; - } + export const getRoomsFinishMs = EventCore.getRoomsFinishMs; export async function insertEvent(store: Store, newEvent: Omit) { - validateEventOffsets( + EventCore.validateEventOffsets( BigInt(newEvent.createOffsetMs ?? 86_400_000n), BigInt(newEvent.lockOffsetMs ?? 0n), ); @@ -94,12 +55,22 @@ export namespace EventUtils { } } - const event = store.insertEvent({ guildId: store.guild.id, ...newEvent }); + const displayTargets: { queue: DbQueue, channelId: Snowflake }[] = []; - const roomCount = Number(event.roomCount); - for (let i = 1; i <= roomCount; i++) { - await createEventQueue(store, event, EventQueueRole.Room, i, event.roomQueuesChannelId); - await createEventQueue(store, event, EventQueueRole.Sub, i, event.subQueuesChannelId); + const event = db.transaction(() => { + const insertedEvent = store.insertEvent({ guildId: store.guild.id, ...newEvent }); + const roomCount = Number(insertedEvent.roomCount); + for (let i = 1; i <= roomCount; i++) { + const roomQueue = EventSyncQueues.insertEventQueueRowWithoutDisplayDb(store, insertedEvent, EventQueueRole.Room, i); + displayTargets.push({ queue: roomQueue, channelId: insertedEvent.roomQueuesChannelId }); + const subQueue = EventSyncQueues.insertEventQueueRowWithoutDisplayDb(store, insertedEvent, EventQueueRole.Sub, i); + displayTargets.push({ queue: subQueue, channelId: insertedEvent.subQueuesChannelId }); + } + return insertedEvent; + }); + + for (const { queue, channelId } of displayTargets) { + await DisplayUtils.insertDisplays(store, [queue], channelId); } if (event.roomCategoryId) { @@ -112,7 +83,7 @@ export namespace EventUtils { export async function updateEvent(store: Store, event: DbEvent, update: Partial) { const newCreateOffset = BigInt(update.createOffsetMs ?? event.createOffsetMs); const newLockOffset = BigInt(update.lockOffsetMs ?? event.lockOffsetMs); - validateEventOffsets(newCreateOffset, newLockOffset); + EventCore.validateEventOffsets(newCreateOffset, newLockOffset); const newScheduling = (update.roomScheduling ?? event.roomScheduling) as RoomScheduling; const newRoomLengthMs = update.roomLengthMs !== undefined ? update.roomLengthMs : event.roomLengthMs; @@ -130,8 +101,8 @@ export namespace EventUtils { } if (newCount > oldCount) { for (let i = oldCount + 1; i <= newCount; i++) { - await createEventQueue(store, event, EventQueueRole.Room, i, event.roomQueuesChannelId); - await createEventQueue(store, event, EventQueueRole.Sub, i, event.subQueuesChannelId); + await EventSyncQueues.createEventQueue(store, event, EventQueueRole.Room, i, event.roomQueuesChannelId); + await EventSyncQueues.createEventQueue(store, event, EventQueueRole.Sub, i, event.subQueuesChannelId); } } } @@ -150,7 +121,7 @@ export namespace EventUtils { || update.subAutoPullMode !== undefined; if (timingChanged) { - await rearmAllOccurrences(store, updatedEvent); + await EventSchedule.rearmAllOccurrences(store, updatedEvent); } const channelsChanged = update.roomCategoryId !== undefined @@ -177,7 +148,7 @@ export namespace EventUtils { const eventQueues = Queries.selectManyEventQueues({ guildId: store.guild.id, eventId: event.id }); for (const eq of eventQueues) { - await QueueUtils.deleteQueue(store, eq.queueId); + await QueueUtils.deleteQueueDisplayMessages(store, eq.queueId); } const occurrences = Queries.selectManyOccurrences({ guildId: store.guild.id, eventId: event.id }); @@ -186,7 +157,13 @@ export namespace EventUtils { unregisterJobs(occ.id); } - store.deleteEvent({ id: event.id }); + // Discord cleanup above; atomic DB delete of queues then event row. + db.transaction(() => { + for (const eq of eventQueues) { + store.deleteQueue({ id: eq.queueId }); + } + store.deleteEvent({ id: event.id }); + }); } export async function setRoleDefaults( @@ -248,854 +225,9 @@ export namespace EventUtils { } } - // Columns on EVENT_DEFAULT_TABLE that mirror queue-config columns on QUEUE_TABLE. - // Anything not in this set is junction/identity metadata that must not be applied to a queue. - const EVENT_DEFAULT_NON_QUEUE_KEYS = new Set(["id", "guildId", "eventId", "queueRole"]); - - export async function syncEventQueues(store: Store, event: DbEvent) { - return EventSyncLock.withLock(store.guild.id, event.id, async () => { - let recreatedCount = 0; - let reappliedRoomCount = 0; - let reappliedSubCount = 0; - - const roomCount = Number(event.roomCount); - const roles: EventQueueRole[] = [EventQueueRole.Room, EventQueueRole.Sub]; - - // Lock every existing event queue up-front so the sync runs from a known-locked baseline. - // Step A's new queues lock themselves via insertEventQueueRowWithoutDisplay; Step E unlocks - // any whose pre-start window contains now. Direct store.updateQueue (not QueueUtils.updateQueues) - // — its requestDisplaysUpdate is fire-and-forget and would race Step C. No display refresh - // needed: Step C reposts every display. - { - const existingEqs = Queries.selectManyEventQueues({ guildId: store.guild.id, eventId: event.id }); - const existingQueues = compact(existingEqs.map(eq => - Queries.selectQueue({ guildId: store.guild.id, id: eq.queueId }) - )); - for (const q of existingQueues) { - store.updateQueue({ id: q.id, lockToggle: true }); - } - } - - // Step A — recreate any missing (role, queueIndex) slots. Skip display creation here; - // Step C is the sole writer of displays so its sequential post order isn't racing - // against fire-and-forget updates from DisplayUtils.insertDisplays. - for (const role of roles) { - for (let i = 1; i <= roomCount; i++) { - const eqs = Queries.selectManyEventQueues({ guildId: store.guild.id, eventId: event.id }); - const match = eqs.find(eq => eq.queueRole === role && Number(eq.queueIndex) === i); - const existingQueue = match - ? Queries.selectQueue({ guildId: store.guild.id, id: match.queueId }) - : undefined; - if (!match || !existingQueue) { - await insertEventQueueRowWithoutDisplay(store, event, role, i); - recreatedCount++; - } - } - } - - // Step B — reset queue-config columns to schema defaults, then overlay the stored event defaults. - // Direct store.updateQueue writes (not QueueUtils.updateQueues) so we don't fire an async - // requestDisplaysUpdate that would race with Step C and leave orphan messages in the channel. - // `lockToggle` is intentionally excluded — it's owned by the up-front lock above and Step E below. - const LOCK_KEY = "lockToggle"; - const defaultColumnKeys = Object.keys(EVENT_DEFAULT_TABLE) - .filter(k => !EVENT_DEFAULT_NON_QUEUE_KEYS.has(k) && k !== LOCK_KEY); - - for (const role of roles) { - const resetPatch: Record = {}; - for (const key of defaultColumnKeys) { - resetPatch[key] = (QUEUE_TABLE as any)[key]?.default ?? null; - } - - const storedDefault = Queries.selectEventDefault({ - guildId: store.guild.id, - eventId: event.id, - queueRole: role, - }); - const overlay = storedDefault ? omitBy(storedDefault, isNil) : {}; - delete overlay.id; - delete overlay.guildId; - delete overlay.eventId; - delete overlay.queueRole; - delete overlay[LOCK_KEY]; - - const update = { ...resetPatch, ...overlay } as Partial; - - const eventQueues = Queries.selectManyEventQueues({ guildId: store.guild.id, eventId: event.id }) - .filter(eq => eq.queueRole === role); - const queues = compact(eventQueues.map(eq => - Queries.selectQueue({ guildId: store.guild.id, id: eq.queueId }) - )); - - if (queues.length > 0) { - const updatedQueues = compact(queues.map(q => store.updateQueue({ id: q.id, ...update }))); - if (update.roleInQueueId) { - await QueueUtils.setRoleInQueue(store, updatedQueues); - } - if (role === EventQueueRole.Room) { - reappliedRoomCount = updatedQueues.length; - } - else { - reappliedSubCount = updatedQueues.length; - } - } - } - - // Step C — re-show every queue display in queue-index order in the event's display channels. - const reshownCount = await reshowEventQueueDisplays(store, event); - - // Step D — reconcile channels + auto-created room roles - if (event.roomCategoryId) { - await EventChannelUtils.reconcileRoomChannels(store, event); - } - - // Step E — unlock any event queues whose role-appropriate pre-start window contains now. - { - const allEqs = Queries.selectManyEventQueues({ guildId: store.guild.id, eventId: event.id }); - const toUnlock: DbQueue[] = []; - for (const eq of allEqs) { - const q = Queries.selectQueue({ guildId: store.guild.id, id: eq.queueId }); - if (!q) continue; - if (shouldEventQueueBeUnlocked(event, eq.queueRole as EventQueueRole)) { - toUnlock.push(q); - } - } - if (toUnlock.length > 0) { - await QueueUtils.updateQueues(store, toUnlock, { lockToggle: false } as Partial); - } - } - - return { recreatedCount, reappliedRoomCount, reappliedSubCount, reshownCount }; - }); - } - - export async function scheduleOccurrence( - store: Store, - event: DbEvent, - startTime: bigint, - timezone?: string, - ) { - const cleanupAt = getRoomsFinishMs(event, Number(startTime)) + Number(event.cleanupOffsetMs); - if (cleanupAt < Date.now()) { - throw new OccurrenceInPastWarning(); - } - - if (event.roomScheduling === RoomScheduling.Sequential) { - if (!event.roomLengthMs || BigInt(event.roomLengthMs) <= 0n) { - throw new SequentialEventRequiresRoomLengthWarning(); - } - } - - const occurrence = store.insertOccurrence({ - guildId: store.guild.id, - eventId: event.id, - startTime, - timezone, - }); - - await armOccurrence(event, occurrence); - - if (event.createDiscordEvent) { - await createDiscordScheduledEvent(store, event, occurrence); - } - - return occurrence; - } - - export async function cancelOccurrence(store: Store, occurrence: DbEventOccurrence) { - await deleteDiscordScheduledEvent(store, occurrence); - unregisterJobs(occurrence.id); - store.deleteOccurrence({ id: occurrence.id }); - } - - export async function loadOccurrences() { - const occurrences = Queries.selectAllOccurrences(); - console.time(`Loaded ${occurrences.length} event occurrences`); - - for (const occurrence of occurrences) { - const event = Queries.selectEvent({ guildId: occurrence.guildId, id: occurrence.eventId }); - if (!event) { - continue; - } - await armOccurrence(event, occurrence); - } - - console.timeEnd(`Loaded ${occurrences.length} event occurrences`); - } - - // ==================================================================== - // Job scheduling - // ==================================================================== - - async function armPhase( - at: number, - now: number, - alreadyDone: boolean, - run: () => Promise, - markDone: () => void, - label: string, - ): Promise { - if (alreadyDone) return; - if (at <= now) { - await run(); - markDone(); - return; - } - return nodeSchedule.scheduleJob(new Date(at), async () => { - try { - await run(); - markDone(); - } - catch (e) { - console.error(`Event ${label} action failed:`, (e as Error).message); - } - }); - } - - // True iff any of the event's occurrences has a window (role-dependent) that contains `nowMs`. - // Room window: [start − createOffsetMs, start + lockOffsetMs). Sub window extends to cleanup. - // When autoPullSubsAtRoomStartToggle is on, the room lock fires at exact start (lockOffsetMs is ignored). - // Empty occurrence list → false (covers `/events add` before any occurrence is scheduled). - function shouldEventQueueBeUnlocked( - event: DbEvent, - role: EventQueueRole, - nowMs: number = Date.now(), - ): boolean { - const occurrences = Queries.selectManyOccurrences({ guildId: event.guildId, eventId: event.id }); - return occurrences.some((occ) => { - const start = Number(occ.startTime); - const openAt = start - Number(event.createOffsetMs); - const closeAt = role === EventQueueRole.Room - ? (event.autoPullSubsAtRoomStartToggle ? start : start + Number(event.lockOffsetMs)) - : getRoomsFinishMs(event, start) + Number(event.cleanupOffsetMs); - return nowMs >= openAt && nowMs < closeAt; - }); - } - - function computeRoomPingAt(event: DbEvent, startMs: number, queueIndex: bigint): number { - if (event.roomScheduling === RoomScheduling.Sequential && event.roomLengthMs) { - return startMs + (Number(queueIndex) - 1) * Number(event.roomLengthMs); - } - return startMs; - } - - async function armOccurrence(event: DbEvent, occurrence: DbEventOccurrence) { - unregisterJobs(occurrence.id); - - const now = Date.now(); - const startMs = Number(occurrence.startTime); - const openAt = startMs - Number(event.createOffsetMs); - // When autoPullSubsAtRoomStartToggle is on, the room queue must lock at exact startTime so the - // per-room auto-pull (which locks the paired sub queue) sees a consistent snapshot. lockOffsetMs - // is preserved on the schema for the legacy path but ignored here. - const lockAt = event.autoPullSubsAtRoomStartToggle - ? startMs - : startMs + Number(event.lockOffsetMs); - const cleanupAt = getRoomsFinishMs(event, startMs) + Number(event.cleanupOffsetMs); - - const guild = await ClientUtils.getGuild(occurrence.guildId); - if (!guild) return; - const store = new Store(guild); - - const pingedQueueIds = new Set( - Queries.selectOccurrenceRoomPings({ occurrenceId: occurrence.id }).map(r => r.eventQueueId) - ); - const pulledRoomIds = new Set( - Queries.selectOccurrenceRoomPulls({ occurrenceId: occurrence.id }).map(r => r.eventQueueId) - ); - - const jobs: OccurrenceJobs = { roomPings: new Map(), roomPulls: new Map() }; - - // Open action - jobs.open = await armPhase( - openAt, - now, - occurrence.openHandledAt != null, - () => runOpenAction(occurrence.id), - () => store.updateOccurrence({ id: occurrence.id }, { openHandledAt: BigInt(Date.now()) }), - "open", - ); - - // Lock action - jobs.lock = await armPhase( - lockAt, - now, - occurrence.lockHandledAt != null, - () => runLockAction(occurrence.id), - () => store.updateOccurrence({ id: occurrence.id }, { lockHandledAt: BigInt(Date.now()) }), - "lock", - ); - - // Room pings (and optional room-start auto-pulls) - const roomEventQueues = Queries.selectManyEventQueues({ guildId: event.guildId, eventId: event.id }) - .filter(eq => eq.queueRole === EventQueueRole.Room); - - for (const eq of roomEventQueues) { - const pingAt = computeRoomPingAt(event, startMs, eq.queueIndex); - - const pingJob = await armPhase( - pingAt, - now, - pingedQueueIds.has(eq.id), - () => runRoomPingAction(occurrence.id, eq), - () => store.insertOccurrenceRoomPing({ - occurrenceId: occurrence.id, - eventQueueId: eq.id, - handledAt: BigInt(Date.now()), - }), - "room ping", - ); - if (pingJob) jobs.roomPings.set(eq.id, pingJob); - } - - if (event.autoPullSubsAtRoomStartToggle) { - for (const eq of roomEventQueues) { - const pullAt = computeRoomPingAt(event, startMs, eq.queueIndex); - - const pullJob = await armPhase( - pullAt, - now, - pulledRoomIds.has(eq.id), - () => runRoomPullAction(occurrence.id, eq), - () => store.insertOccurrenceRoomPull({ - occurrenceId: occurrence.id, - eventQueueId: eq.id, - handledAt: BigInt(Date.now()), - }), - "room pull", - ); - if (pullJob) jobs.roomPulls.set(eq.id, pullJob); - } - } - - // Cleanup action — no flag needed; cleanup deletes the row (cascades the junction) - if (cleanupAt <= now) { - await runCleanupAction(occurrence.id); - } - else { - jobs.cleanup = nodeSchedule.scheduleJob(new Date(cleanupAt), async () => { - try { await runCleanupAction(occurrence.id); } - catch (e) { console.error("Event cleanup action failed:", (e as Error).message); } - }); - } - - occurrenceIdToJobs.set(occurrence.id, jobs); - } - - function unregisterJobs(occurrenceId: bigint) { - const jobs = occurrenceIdToJobs.get(occurrenceId); - if (!jobs) return; - jobs.open?.cancel(); - jobs.lock?.cancel(); - jobs.cleanup?.cancel(); - jobs.roomPings.forEach(job => job.cancel()); - jobs.roomPulls.forEach(job => job.cancel()); - occurrenceIdToJobs.delete(occurrenceId); - } - - async function rearmAllOccurrences(store: Store, event: DbEvent) { - const occurrences = Queries.selectManyOccurrences({ guildId: store.guild.id, eventId: event.id }); - for (const occ of occurrences) { - await armOccurrence(event, occ); - await updateDiscordScheduledEvent(store, event, occ); - } - } - - // ==================================================================== - // Actions - // ==================================================================== - - async function getEventContext(occurrenceId: bigint) { - const occurrence = Queries.selectOccurrence({ id: occurrenceId }); - if (!occurrence) return; - - const event = Queries.selectEvent({ guildId: occurrence.guildId, id: occurrence.eventId }); - if (!event) return; - - const guild = await ClientUtils.getGuild(occurrence.guildId); - if (!guild) return; - - const store = new Store(guild); - const eventQueues = Queries.selectManyEventQueues({ guildId: occurrence.guildId, eventId: event.id }); - const queues = compact(eventQueues.map(eq => Queries.selectQueue({ guildId: occurrence.guildId, id: eq.queueId }))); - - return { occurrence, event, store, eventQueues, queues }; - } - - // Sequentially re-shows every event-queue display in queue-index order across the event's Room - // and Sub display channels: deletes each existing display (and its posted Discord message) then - // inserts a fresh row and awaits a Replace refresh. Awaiting per queue guarantees the new - // messages have landed before the caller continues (used by `syncEventQueues` Step C and - // `runOpenAction` so a same-channel announcement stays as the most-recent message). - async function reshowEventQueueDisplays(store: Store, event: DbEvent): Promise { - let reshownCount = 0; - const roles: EventQueueRole[] = [EventQueueRole.Room, EventQueueRole.Sub]; - - for (const role of roles) { - const displayChannelId = role === EventQueueRole.Room - ? event.roomQueuesChannelId - : event.subQueuesChannelId; - const orderedEqs = Queries.selectManyEventQueues({ guildId: store.guild.id, eventId: event.id }) - .filter(eq => eq.queueRole === role) - .sort((a, b) => Number(a.queueIndex) - Number(b.queueIndex)); - - for (const eq of orderedEqs) { - const queue = Queries.selectQueue({ guildId: store.guild.id, id: eq.queueId }); - if (!queue) continue; - - const existingDisplays = [...store.dbDisplays().filter(d => d.queueId === queue.id).values()]; - for (const display of existingDisplays) { - if (display.lastMessageId) { - const channel = await store.jsChannel(display.displayChannelId) as GuildTextBasedChannel | undefined; - if (channel) { - const message = await channel.messages.fetch(display.lastMessageId).catch(e => { - console.error(`EventUtils.reshowEventQueueDisplays: failed to fetch stale display message ${display.lastMessageId} in channel ${display.displayChannelId}:`, e); - return null; - }); - if (message) { - await message.delete().catch(e => { - console.error(`EventUtils.reshowEventQueueDisplays: failed to delete stale display message ${display.lastMessageId} in channel ${display.displayChannelId}:`, e); - return null; - }); - } - } - } - store.deleteDisplay({ id: display.id }); - } - - const newDisplay = store.insertDisplay({ - guildId: store.guild.id, - queueId: queue.id, - displayChannelId, - }); - if (!newDisplay) continue; - - await DisplayUtils.updateDisplays({ - store, - queueId: queue.id, - opts: { - displayIds: [newDisplay.id], - updateTypeOverride: DisplayUpdateType.Replace, - }, - }); - - reshownCount++; - } - } - - return reshownCount; - } - - async function runOpenAction(occurrenceId: bigint) { - const ctx = await getEventContext(occurrenceId); - if (!ctx) return; - const { occurrence, event, store, queues } = ctx; - - // Revoke the previous occurrence's winner roles — the badge lasts only until the next opens. - await WinnerUtils.revokeEventWinners(store, event); - - // Unlock all event queues - if (queues.length > 0) { - await QueueUtils.updateQueues(store, queues, { lockToggle: false } as Partial); - } - - // Re-show every event-queue display sequentially before announcing so the announcement - // remains the most-recent message when announcementChannelId coincides with a display channel. - await reshowEventQueueDisplays(store, event); - - // Send announcement - if (event.announcementChannelId && event.announcementMessage) { - try { - const channel = await store.jsChannel(event.announcementChannelId) as GuildTextBasedChannel; - if (channel) { - const content = renderTemplate(event.announcementMessage, buildAnnouncementContext(event, occurrence)); - await channel.send({ - content, - allowedMentions: { parse: ["everyone", "roles", "users"] }, - }); - } - } - catch (e) { - console.error("Failed to send event announcement:", (e as Error).message); - } - } - } - - async function runLockAction(occurrenceId: bigint) { - const ctx = await getEventContext(occurrenceId); - if (!ctx) return; - const { store, eventQueues } = ctx; - - // Lock only room queues - const roomQueues = compact( - eventQueues - .filter(eq => eq.queueRole === EventQueueRole.Room) - .map(eq => Queries.selectQueue({ guildId: store.guild.id, id: eq.queueId })) - ); - - if (roomQueues.length > 0) { - await QueueUtils.updateQueues(store, roomQueues, { lockToggle: true } as Partial); - } - } - - async function runRoomPingAction(occurrenceId: bigint, eventQueue: DbEventQueue) { - const occurrence = Queries.selectOccurrence({ id: occurrenceId }); - if (!occurrence) return; - - const event = Queries.selectEvent({ guildId: occurrence.guildId, id: occurrence.eventId }); - if (!event) return; - - const queue = Queries.selectQueue({ guildId: occurrence.guildId, id: eventQueue.queueId }); - if (!queue) return; - - const guild = await ClientUtils.getGuild(occurrence.guildId); - if (!guild) return; - const store = new Store(guild); - - const pingChannelId = eventQueue.pingChannelId ?? event.roomQueuesChannelId; - - try { - const channel = await store.jsChannel(pingChannelId) as GuildTextBasedChannel; - if (!channel) return; - - const template = event.roomPingMessage ?? "{room_role} — {room_name} is starting soon!"; - const ctx = buildRoomPingContext(event, occurrence, eventQueue, queue); - const content = renderTemplate(template, ctx); - - if (content.trim()) { - await channel.send({ - content, - allowedMentions: { parse: ["roles", "users"] }, - }); - } - } - catch (e) { - console.error("Failed to send room ping:", (e as Error).message); - } - } - - async function runRoomPullAction(occurrenceId: bigint, roomEventQueue: DbEventQueue) { - const ctx = await getEventContext(occurrenceId); - if (!ctx) return; - const { event, store, eventQueues } = ctx; - - const subEventQueue = eventQueues.find(eq => - eq.queueRole === EventQueueRole.Sub && eq.queueIndex === roomEventQueue.queueIndex - ); - if (!subEventQueue) { - console.warn(`EventUtils.runRoomPullAction: no paired sub event-queue for room index ${roomEventQueue.queueIndex} of event ${event.id} — skipping`); - return; - } - - const roomQueue = Queries.selectQueue({ guildId: store.guild.id, id: roomEventQueue.queueId }); - const subQueue = Queries.selectQueue({ guildId: store.guild.id, id: subEventQueue.queueId }); - if (!roomQueue || !subQueue) { - console.warn(`EventUtils.runRoomPullAction: missing queue rows for event ${event.id} room index ${roomEventQueue.queueIndex} — skipping`); - return; - } - - // Always lock the paired sub queue first — auto-pull bundles sub-lock atomically. - await QueueUtils.updateQueues(store, [subQueue], { lockToggle: true } as Partial); - - if (event.shuffleSubsBeforeAutoPullToggle) { - await MemberUtils.shuffleMembers(store, subQueue, undefined); - } - - const currentRoomCount = Queries.selectManyMembers({ guildId: store.guild.id, queueId: roomQueue.id }).length; - const subAvailable = Queries.selectManyMembers({ guildId: store.guild.id, queueId: subQueue.id }).length; - const count = roomQueue.size == null - ? subAvailable - : Math.min(Number(roomQueue.size) - currentRoomCount, subAvailable); - - if (count <= 0) { - console.log(`EventUtils.runRoomPullAction: nothing to pull for event ${event.id} room index ${roomEventQueue.queueIndex} (currentRoomCount=${currentRoomCount}, subAvailable=${subAvailable}, size=${roomQueue.size}) — skipping pull`); - return; - } - - if (event.subAutoPullMode === SubAutoPullMode.Promote) { - const subMembers = Queries.selectManyMembers({ - guildId: store.guild.id, - queueId: subQueue.id, - count, - }); - for (const subMember of subMembers) { - const jsMember = await store.jsMember(subMember.userId); - if (!jsMember) continue; - - // Delete from sub queue directly via store (skips MemberUtils.deleteMembers messaging, - // DM-on-pull, voice destination, and role-on-pull side effects — we promote silently). - store.deleteMember({ id: subMember.id }, MemberRemovalReason.Pulled); - - if (subQueue.roleInQueueId) { - await MemberUtils.modifyMemberRoles(store, subMember.userId, subQueue.roleInQueueId, "remove") - .catch(e => console.error(`EventUtils.runRoomPullAction: failed to remove sub roleInQueueId from user ${subMember.userId}:`, e)); - } - - try { - // force:true bypasses verifyMemberEligibility so the room queue's lockToggle=true - // (set by runLockAction) does not block this system insert. - await MemberUtils.insertMember({ - store, - queue: roomQueue, - jsMember, - message: subMember.message ?? undefined, - force: true, - }); - } - catch (e) { - console.error(`EventUtils.runRoomPullAction: failed to promote user ${subMember.userId} into room queue ${roomQueue.id}:`, e); - } - } - await DisplayUtils.requestDisplayUpdate({ store, queueId: subQueue.id }); - await DisplayUtils.requestDisplayUpdate({ store, queueId: roomQueue.id }); - } - else { - await MemberUtils.deleteMembers({ - store, - queues: [subQueue], - reason: MemberRemovalReason.Pulled, - by: { count }, - force: true, - }); - await DisplayUtils.requestDisplayUpdate({ store, queueId: roomQueue.id }); - } - } - - async function runCleanupAction(occurrenceId: bigint) { - const ctx = await getEventContext(occurrenceId); - if (!ctx) return; - const { store, queues } = ctx; - - // Clear all members from all event queues - if (queues.length > 0) { - await MemberUtils.deleteMembers({ - store, - queues, - reason: MemberRemovalReason.Kicked, - by: { count: 9999 }, - force: true, - }); - - // Lock all queues - await QueueUtils.updateQueues(store, queues, { lockToggle: true } as Partial); - } - - // Delete the occurrence row - store.deleteOccurrence({ id: occurrenceId }); - unregisterJobs(occurrenceId); - } - - // ==================================================================== - // Template rendering - // ==================================================================== - - function renderTemplate(template: string, ctx: Record): string { - return template.replace(/\{(\w+)\}/g, (_, k) => ctx[k] ?? ""); - } - - function buildAnnouncementContext(event: DbEvent, occurrence: DbEventOccurrence): Record { - const startDate = new Date(Number(occurrence.startTime)); - return { - event_name: event.name, - start_time: time(startDate, TimestampStyles.LongDateTime), - start_time_relative: time(startDate, TimestampStyles.RelativeTime), - room_queues_channel: channelMention(event.roomQueuesChannelId), - sub_queues_channel: channelMention(event.subQueuesChannelId), - }; - } - - function buildRoomPingContext( - event: DbEvent, - occurrence: DbEventOccurrence, - eventQueue: DbEventQueue, - queue: DbQueue, - ): Record { - const startDate = new Date(Number(occurrence.startTime)); - const roleStr = queue.roleInQueueId ? `<@&${queue.roleInQueueId}>` : ""; - const pingChId = eventQueue.pingChannelId ?? event.roomQueuesChannelId; - return { - event_name: event.name, - room_name: queue.name, - room_role: roleStr, - room_index: String(eventQueue.queueIndex), - room_queues_channel: channelMention(event.roomQueuesChannelId), - ping_channel: channelMention(pingChId), - start_time: time(startDate, TimestampStyles.LongDateTime), - start_time_relative: time(startDate, TimestampStyles.RelativeTime), - }; - } - - // ==================================================================== - // Discord scheduled events - // ==================================================================== - - const DISCORD_EVENT_NAME_LIMIT = 100; - const DISCORD_EVENT_DESCRIPTION_LIMIT = 1000; - const DISCORD_EVENT_LOCATION_LIMIT = 100; - const DISCORD_UNKNOWN_GUILD_SCHEDULED_EVENT = 10070; - - function resolveRoomChannelName(store: Store, event: DbEvent): string { - const cached = store.guild.channels.cache.get(event.roomQueuesChannelId); - return cached?.name ?? event.roomQueuesChannelId; - } - - function renderDiscordEventDescription(event: DbEvent, occurrence: DbEventOccurrence): string { - if (event.discordEventDescription) { - return renderTemplate(event.discordEventDescription, buildAnnouncementContext(event, occurrence)); - } - const scheduling = (event.roomScheduling as RoomScheduling) === RoomScheduling.Sequential - ? "sequential" - : "parallel"; - return [ - `Room queues channel: ${channelMention(event.roomQueuesChannelId)}`, - `Sub queues channel: ${channelMention(event.subQueuesChannelId)}`, - `Rooms: ${event.roomCount} (${scheduling})`, - ].join("\n"); - } - - function buildDiscordEventOptions( - event: DbEvent, - occurrence: DbEventOccurrence, - roomChannelName: string, - ): GuildScheduledEventCreateOptions { - const startMs = Number(occurrence.startTime); - const endMs = getRoomsFinishMs(event, startMs) + Number(event.cleanupOffsetMs); - return { - name: event.name.substring(0, DISCORD_EVENT_NAME_LIMIT), - scheduledStartTime: new Date(startMs), - scheduledEndTime: new Date(endMs), - privacyLevel: GuildScheduledEventPrivacyLevel.GuildOnly, - entityType: GuildScheduledEventEntityType.External, - description: renderDiscordEventDescription(event, occurrence).substring(0, DISCORD_EVENT_DESCRIPTION_LIMIT), - entityMetadata: { - location: roomChannelName.substring(0, DISCORD_EVENT_LOCATION_LIMIT), - }, - }; - } - - function isUnknownDiscordEventError(e: unknown): boolean { - const err = e as DiscordAPIError; - return err?.code === DISCORD_UNKNOWN_GUILD_SCHEDULED_EVENT || err?.status === 404; - } - - async function createDiscordScheduledEvent(store: Store, event: DbEvent, occurrence: DbEventOccurrence) { - if (Number(occurrence.startTime) <= Date.now()) { - // Discord rejects external events whose start time is not in the future - return; - } - try { - const options = buildDiscordEventOptions(event, occurrence, resolveRoomChannelName(store, event)); - const created = await store.guild.scheduledEvents.create(options); - store.updateOccurrence({ id: occurrence.id }, { discordEventId: created.id }); - } - catch (e) { - console.error(`Failed to create Discord scheduled event for occurrence ${occurrence.id}:`, e); - } - } - - async function updateDiscordScheduledEvent(store: Store, event: DbEvent, occurrence: DbEventOccurrence) { - if (!occurrence.discordEventId) return; - try { - const options = buildDiscordEventOptions(event, occurrence, resolveRoomChannelName(store, event)); - await store.guild.scheduledEvents.edit(occurrence.discordEventId, options); - } - catch (e) { - if (isUnknownDiscordEventError(e)) return; - console.error(`Failed to update Discord scheduled event for occurrence ${occurrence.id}:`, e); - } - } - - async function deleteDiscordScheduledEvent(store: Store, occurrence: DbEventOccurrence) { - if (!occurrence.discordEventId) return; - try { - await store.guild.scheduledEvents.delete(occurrence.discordEventId); - } - catch (e) { - if (isUnknownDiscordEventError(e)) return; - console.error(`Failed to delete Discord scheduled event for occurrence ${occurrence.id}:`, e); - } - } - - // ==================================================================== - // Helpers - // ==================================================================== - - function validateEventOffsets(createOffsetMs: bigint, lockOffsetMs: bigint) { - // lockAt = startTime + lockOffsetMs - // openAt = startTime - createOffsetMs - // lockAt must be >= openAt => lockOffsetMs >= -createOffsetMs - if (lockOffsetMs < -createOffsetMs) { - throw new LockBeforeOpenWarning(); - } - } - - async function insertEventQueueRowWithoutDisplay( - store: Store, - event: DbEvent, - role: EventQueueRole, - index: number, - ) { - const roleLabel = role === EventQueueRole.Room ? "Room" : "Sub"; - let queueName = `${event.name} ${roleLabel} ${index}`; - - const defaults = Queries.selectEventDefault({ - guildId: store.guild.id, - eventId: event.id, - queueRole: role, - }); - const queueConfig = defaults ? omitBy(defaults, isNil) : {}; - delete queueConfig.id; - delete queueConfig.guildId; - delete queueConfig.eventId; - delete queueConfig.queueRole; - // Event queues are gated by their pre-start window — the schema default / event-default - // overlay must not be used to leave a queue unlocked outside that window. - queueConfig.lockToggle = !shouldEventQueueBeUnlocked(event, role); - - let insertedQueue: DbQueue; - try { - const result = await QueueUtils.insertQueue(store, { - guildId: store.guild.id, - name: queueName, - ...queueConfig, - }); - insertedQueue = result.insertedQueue; - } - catch (e) { - if (e instanceof QueueAlreadyExistsWarning) { - queueName = `${queueName} (event)`; - const result = await QueueUtils.insertQueue(store, { - guildId: store.guild.id, - name: queueName, - ...queueConfig, - }); - insertedQueue = result.insertedQueue; - } - else { - throw e; - } - } - - store.insertEventQueue({ - guildId: store.guild.id, - eventId: event.id, - queueId: insertedQueue.id, - queueRole: role, - queueIndex: BigInt(index), - }); - - return insertedQueue; - } - - export async function createEventQueue( - store: Store, - event: DbEvent, - role: EventQueueRole, - index: number, - displayChannelId: Snowflake, - ) { - const insertedQueue = await insertEventQueueRowWithoutDisplay(store, event, role, index); - await DisplayUtils.insertDisplays(store, [insertedQueue], displayChannelId); - return insertedQueue; - } + export const syncEventQueues = EventSyncQueues.syncEventQueues; + export const createEventQueue = EventSyncQueues.createEventQueue; + export const scheduleOccurrence = EventSchedule.scheduleOccurrence; + export const cancelOccurrence = EventSchedule.cancelOccurrence; + export const loadOccurrences = EventSchedule.loadOccurrences; } diff --git a/src/utils/priority.utils.ts b/src/utils/priority.utils.ts index 5e6de1da..a00aebb9 100644 --- a/src/utils/priority.utils.ts +++ b/src/utils/priority.utils.ts @@ -1,7 +1,6 @@ import { type GuildMember, Role } from "discord.js"; -import { compact, min, uniq } from "lodash-es"; +import { compact, min } from "lodash-es"; -import { db } from "../db/db.ts"; import { Queries } from "../db/queries.ts"; import type { DbEvent, @@ -15,16 +14,17 @@ import type { ArrayOrCollection } from "../types/misc.types.ts"; import type { Mentionable } from "../types/parsing.types.ts"; import { DisplayUtils } from "./display.utils.ts"; import { filterDbObjectsOnJsMember, map } from "./misc.utils.ts"; +import { SubjectListUtils } from "./subject-list.utils.ts"; export namespace PriorityUtils { - export function insertQueuePrioritized( + export async function insertQueuePrioritized( store: Store, queues: ArrayOrCollection, mentionables: Mentionable[], priorityOrder?: bigint, reason?: string, ) { - const result = db.transaction(() => { + const result = await SubjectListUtils.runTransaction(async () => { const insertedPrioritized = compact( map(queues, queue => mentionables.map(mentionable => @@ -39,24 +39,26 @@ export namespace PriorityUtils { ) ) ).flat(2); - const updatedQueueIds = uniq(insertedPrioritized.map(prioritized => prioritized.queueId)); - return { insertedPrioritized, updatedQueueIds }; + return { + insertedPrioritized, + updatedQueueIds: SubjectListUtils.updatedQueueIdsForQueueScope(insertedPrioritized), + }; }); - reEvaluatePrioritized(store, result.updatedQueueIds); + await reEvaluatePrioritized(store, result.updatedQueueIds); return result; } - export function insertEventPrioritized( + export async function insertEventPrioritized( store: Store, events: ArrayOrCollection, mentionables: Mentionable[], priorityOrder?: bigint, reason?: string, ) { - const result = db.transaction(() => { + const result = await SubjectListUtils.runTransaction(async () => { const insertedPrioritized = compact( map(events, event => mentionables.map(mentionable => @@ -72,25 +74,24 @@ export namespace PriorityUtils { ) ).flat(2); - const updatedQueueIds = uniq(insertedPrioritized.flatMap(prioritized => - store.dbEventQueues(prioritized.eventId).map(eq => eq.queueId) - )); - - return { insertedPrioritized, updatedQueueIds }; + return { + insertedPrioritized, + updatedQueueIds: SubjectListUtils.updatedQueueIdsForEventScope(store, insertedPrioritized), + }; }); - reEvaluatePrioritized(store, result.updatedQueueIds); + await reEvaluatePrioritized(store, result.updatedQueueIds); return result; } - export function insertGuildPrioritized( + export async function insertGuildPrioritized( store: Store, mentionables: Mentionable[], priorityOrder?: bigint, reason?: string, ) { - const result = db.transaction(() => { + const result = await SubjectListUtils.runTransaction(async () => { const insertedPrioritized = compact( mentionables.map(mentionable => store.insertGuildPrioritized({ @@ -102,94 +103,98 @@ export namespace PriorityUtils { }) ) ); - const updatedQueueIds = insertedPrioritized.length - ? uniq([...store.dbQueues().values()].map(queue => queue.id)) - : []; - return { insertedPrioritized, updatedQueueIds }; + return { + insertedPrioritized, + updatedQueueIds: SubjectListUtils.updatedQueueIdsForGuildScope(store, insertedPrioritized.length), + }; }); - reEvaluatePrioritized(store, result.updatedQueueIds); + await reEvaluatePrioritized(store, result.updatedQueueIds); return result; } - export function updatePrioritized(store: Store, prioritizedIds: bigint[], update: Partial) { - const result = db.transaction(() => { + export async function updatePrioritized(store: Store, prioritizedIds: bigint[], update: Partial) { + const result = await SubjectListUtils.runTransaction(async () => { const updatedPrioritized = compact(prioritizedIds.map(id => store.updatePrioritized({ id, ...update }))); - const updatedQueueIds = uniq(updatedPrioritized.map(prioritized => prioritized.queueId)); - return { updatedPrioritized, updatedQueueIds }; + return { + updatedPrioritized, + updatedQueueIds: SubjectListUtils.updatedQueueIdsForQueueScope(updatedPrioritized), + }; }); - reEvaluatePrioritized(store, result.updatedQueueIds); + await reEvaluatePrioritized(store, result.updatedQueueIds); return result; } - export function updateEventPrioritized(store: Store, prioritizedIds: bigint[], update: Partial) { - const result = db.transaction(() => { + export async function updateEventPrioritized(store: Store, prioritizedIds: bigint[], update: Partial) { + const result = await SubjectListUtils.runTransaction(async () => { const updatedPrioritized = compact(prioritizedIds.map(id => store.updateEventPrioritized({ id, ...update }))); - const updatedQueueIds = uniq(updatedPrioritized.flatMap(prioritized => - store.dbEventQueues(prioritized.eventId).map(eq => eq.queueId) - )); - return { updatedPrioritized, updatedQueueIds }; + return { + updatedPrioritized, + updatedQueueIds: SubjectListUtils.updatedQueueIdsForEventScope(store, updatedPrioritized), + }; }); - reEvaluatePrioritized(store, result.updatedQueueIds); + await reEvaluatePrioritized(store, result.updatedQueueIds); return result; } - export function updateGuildPrioritized(store: Store, prioritizedIds: bigint[], update: Partial) { - const result = db.transaction(() => { + export async function updateGuildPrioritized(store: Store, prioritizedIds: bigint[], update: Partial) { + const result = await SubjectListUtils.runTransaction(async () => { const updatedPrioritized = compact(prioritizedIds.map(id => store.updateGuildPrioritized({ id, ...update }))); - const updatedQueueIds = updatedPrioritized.length - ? uniq([...store.dbQueues().values()].map(queue => queue.id)) - : []; - return { updatedPrioritized, updatedQueueIds }; + return { + updatedPrioritized, + updatedQueueIds: SubjectListUtils.updatedQueueIdsForGuildScope(store, updatedPrioritized.length), + }; }); - reEvaluatePrioritized(store, result.updatedQueueIds); + await reEvaluatePrioritized(store, result.updatedQueueIds); return result; } - export function deletePrioritized(store: Store, prioritizedIds: bigint[]) { - const result = db.transaction(() => { + export async function deletePrioritized(store: Store, prioritizedIds: bigint[]) { + const result = await SubjectListUtils.runTransaction(async () => { const deletedPrioritized = compact(prioritizedIds.map(id => store.deletePrioritized({ id }))); - const updatedQueueIds = uniq(deletedPrioritized.map(prioritized => prioritized.queueId)); - return { deletedPrioritized, updatedQueueIds }; + return { + deletedPrioritized, + updatedQueueIds: SubjectListUtils.updatedQueueIdsForQueueScope(deletedPrioritized), + }; }); - reEvaluatePrioritized(store, result.updatedQueueIds); + await reEvaluatePrioritized(store, result.updatedQueueIds); return result; } - export function deleteEventPrioritized(store: Store, prioritizedIds: bigint[]) { - const result = db.transaction(() => { + export async function deleteEventPrioritized(store: Store, prioritizedIds: bigint[]) { + const result = await SubjectListUtils.runTransaction(async () => { const deletedPrioritized = compact(prioritizedIds.map(id => store.deleteEventPrioritized({ id }))); - const updatedQueueIds = uniq(deletedPrioritized.flatMap(prioritized => - store.dbEventQueues(prioritized.eventId).map(eq => eq.queueId) - )); - return { deletedPrioritized, updatedQueueIds }; + return { + deletedPrioritized, + updatedQueueIds: SubjectListUtils.updatedQueueIdsForEventScope(store, deletedPrioritized), + }; }); - reEvaluatePrioritized(store, result.updatedQueueIds); + await reEvaluatePrioritized(store, result.updatedQueueIds); return result; } - export function deleteGuildPrioritized(store: Store, prioritizedIds: bigint[]) { - const result = db.transaction(() => { + export async function deleteGuildPrioritized(store: Store, prioritizedIds: bigint[]) { + const result = await SubjectListUtils.runTransaction(async () => { const deletedPrioritized = compact(prioritizedIds.map(id => store.deleteGuildPrioritized({ id }))); - const updatedQueueIds = deletedPrioritized.length - ? uniq([...store.dbQueues().values()].map(queue => queue.id)) - : []; - return { deletedPrioritized, updatedQueueIds }; + return { + deletedPrioritized, + updatedQueueIds: SubjectListUtils.updatedQueueIdsForGuildScope(store, deletedPrioritized.length), + }; }); - reEvaluatePrioritized(store, result.updatedQueueIds); + await reEvaluatePrioritized(store, result.updatedQueueIds); return result; } @@ -224,10 +229,11 @@ export namespace PriorityUtils { const members = store.dbMembers().filter(member => member.queueId === queueId); for (const member of members.values()) { const jsMember = await store.jsMember(member.userId); + if (!jsMember) continue; const priorityOrder = getMemberPriority(store, queueId, jsMember); store.updateMember({ ...member, priorityOrder }); } } - DisplayUtils.requestDisplaysUpdate({ store, queueIds }); + await DisplayUtils.requestDisplaysUpdate({ store, queueIds }); } } diff --git a/src/utils/queue.utils.ts b/src/utils/queue.utils.ts index 96e84126..9d85b005 100644 --- a/src/utils/queue.utils.ts +++ b/src/utils/queue.utils.ts @@ -37,7 +37,7 @@ export namespace QueueUtils { }); } - export async function deleteQueue(store: Store, queueId: bigint) { + export async function deleteQueueDisplayMessages(store: Store, queueId: bigint) { const displays = store.dbDisplays().filter(d => d.queueId === queueId); for (const display of displays.values()) { @@ -47,16 +47,20 @@ export namespace QueueUtils { if (!channel) continue; const message = await channel.messages.fetch(display.lastMessageId).catch(e => { - console.error(`QueueUtils.deleteQueue: failed to fetch display message ${display.lastMessageId} in channel ${display.displayChannelId}:`, e); + console.error(`QueueUtils.deleteQueueDisplayMessages: failed to fetch display message ${display.lastMessageId} in channel ${display.displayChannelId}:`, e); return null; }); if (!message) continue; await message.delete().catch(e => { - console.error(`QueueUtils.deleteQueue: failed to delete display message ${display.lastMessageId} in channel ${display.displayChannelId}:`, e); + console.error(`QueueUtils.deleteQueueDisplayMessages: failed to delete display message ${display.lastMessageId} in channel ${display.displayChannelId}:`, e); return null; }); } + } + + export async function deleteQueue(store: Store, queueId: bigint) { + await QueueUtils.deleteQueueDisplayMessages(store, queueId); return store.deleteQueue({ id: queueId }); } diff --git a/src/utils/string.utils.test.ts b/src/utils/string.utils.test.ts new file mode 100644 index 00000000..ab6882c6 --- /dev/null +++ b/src/utils/string.utils.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { SCHEDULE_TABLE } from "../db/schema.ts"; +import type { Store } from "../db/store.ts"; +import { Color } from "../types/db.types.ts"; +import { describeTable } from "./string.utils.ts"; + +describe("describeTable", () => { + it("uses entry label fallback in title when queue lookup misses", () => { + const store = { + dbQueues: () => new Map(), + } as Store; + + const result = describeTable({ + store, + table: SCHEDULE_TABLE, + tableLabel: "Schedules", + entryLabelProperty: "command", + entries: [{ + queueId: 999n, + command: "pull", + cron: "0 * * * *", + color: Color.Blue, + }], + }); + + expect(result.embeds).toHaveLength(1); + expect(result.embeds![0].data.title).toBe("**pull** schedules"); + }); + + it("returns empty-state content when there are no entries", () => { + const store = { + dbQueues: () => new Map(), + } as Store; + + const result = describeTable({ + store, + table: SCHEDULE_TABLE, + tableLabel: "Schedules", + entries: [], + }); + + expect(result).toEqual({ content: "No schedules found." }); + }); +}); diff --git a/src/utils/string.utils.ts b/src/utils/string.utils.ts index 9459e8b6..dfeb1272 100644 --- a/src/utils/string.utils.ts +++ b/src/utils/string.utils.ts @@ -223,7 +223,14 @@ export function describeTable(options: { const _queueId = BigIntSafe(queueId); const queue = _queueId ? store.dbQueues().get(_queueId) : null; - const title = queue ? `${queueMention(queue)} ${tableLabel.toLowerCase()}` : `${tableLabel} of all queues`; + const entryLabelFallback = entryLabelProperty && queueEntries[0] + ? formatPropertyValue(queueEntries[0], entryLabelProperty, bold) + : null; + const title = queue + ? `${queueMention(queue)} ${tableLabel.toLowerCase()}` + : entryLabelFallback + ? `${entryLabelFallback} ${tableLabel.toLowerCase()}` + : `${tableLabel} of all queues`; const _color = color ?? queue?.color ?? (queueEntries[0] as any).color ?? Color.Black; const description = queueEntries.map(entry => formatEntry(entry)).join("\n"); diff --git a/src/utils/subject-list.utils.ts b/src/utils/subject-list.utils.ts new file mode 100644 index 00000000..e5cb8544 --- /dev/null +++ b/src/utils/subject-list.utils.ts @@ -0,0 +1,44 @@ +import { Role } from "discord.js"; +import { uniq } from "lodash-es"; + +import { db } from "../db/db.ts"; +import type { DbEvent, DbQueue } from "../db/schema.ts"; +import type { Store } from "../db/store.ts"; +import type { ArrayOrCollection } from "../types/misc.types.ts"; +import type { Mentionable } from "../types/parsing.types.ts"; +import { map } from "./misc.utils.ts"; + +export namespace SubjectListUtils { + + export function mentionableFilter(mentionable: Mentionable) { + return mentionable instanceof Role ? { roleId: mentionable.id } : { userId: mentionable.id }; + } + + export async function runTransaction(fn: () => Promise): Promise { + return db.transaction(fn); + } + + export function updatedQueueIdsForQueueScope(rows: { queueId: bigint }[]): bigint[] { + return uniq(rows.map(row => row.queueId)); + } + + export function updatedQueueIdsForEventScope(store: Store, rows: { eventId: bigint }[]): bigint[] { + return uniq(rows.flatMap(row => store.dbEventQueues(row.eventId).map(eq => eq.queueId))); + } + + export function updatedQueueIdsForGuildScope(store: Store, rowCount: number): bigint[] { + return rowCount ? uniq([...store.dbQueues().values()].map(queue => queue.id)) : []; + } + + export function eventQueuesForEvents( + store: Store, + events: ArrayOrCollection, + ): DbQueue[] { + return map(events, event => + store.dbEventQueues(event.id) + .map(eq => store.dbQueues().get(eq.queueId)) + .filter(Boolean) as DbQueue[] + ).flat(); + } + +} diff --git a/src/utils/whitelist.utils.ts b/src/utils/whitelist.utils.ts index 2ccd6cd1..76af6a8d 100644 --- a/src/utils/whitelist.utils.ts +++ b/src/utils/whitelist.utils.ts @@ -1,22 +1,22 @@ import { type GuildMember, Role } from "discord.js"; -import { compact, uniq } from "lodash-es"; +import { compact } from "lodash-es"; -import { db } from "../db/db.ts"; import { Queries } from "../db/queries.ts"; import type { DbEvent, DbQueue } from "../db/schema.ts"; import type { Store } from "../db/store.ts"; import type { ArrayOrCollection } from "../types/misc.types.ts"; import type { Mentionable } from "../types/parsing.types.ts"; import { filterDbObjectsOnJsMember, map } from "./misc.utils.ts"; +import { SubjectListUtils } from "./subject-list.utils.ts"; export namespace WhitelistUtils { - export function insertQueueWhitelisted( + export async function insertQueueWhitelisted( store: Store, queues: ArrayOrCollection, mentionables: Mentionable[], reason?: string, ) { - return db.transaction(() => { + return SubjectListUtils.runTransaction(async () => { const insertedWhitelisted = compact( map(queues, queue => mentionables.map(mentionable => @@ -30,19 +30,21 @@ export namespace WhitelistUtils { ) ) ).flat(2); - const updatedQueueIds = uniq(insertedWhitelisted.map(whitelisted => whitelisted.queueId)); - return { insertedWhitelisted, updatedQueueIds }; + return { + insertedWhitelisted, + updatedQueueIds: SubjectListUtils.updatedQueueIdsForQueueScope(insertedWhitelisted), + }; }); } - export function insertEventWhitelisted( + export async function insertEventWhitelisted( store: Store, events: ArrayOrCollection, mentionables: Mentionable[], reason?: string, ) { - return db.transaction(() => { + return SubjectListUtils.runTransaction(async () => { const insertedWhitelisted = compact( map(events, event => mentionables.map(mentionable => @@ -57,20 +59,19 @@ export namespace WhitelistUtils { ) ).flat(2); - const updatedQueueIds = uniq(insertedWhitelisted.flatMap(whitelisted => - store.dbEventQueues(whitelisted.eventId).map(eq => eq.queueId) - )); - - return { insertedWhitelisted, updatedQueueIds }; + return { + insertedWhitelisted, + updatedQueueIds: SubjectListUtils.updatedQueueIdsForEventScope(store, insertedWhitelisted), + }; }); } - export function insertGuildWhitelisted( + export async function insertGuildWhitelisted( store: Store, mentionables: Mentionable[], reason?: string, ) { - return db.transaction(() => { + return SubjectListUtils.runTransaction(async () => { const insertedWhitelisted = compact( mentionables.map(mentionable => store.insertGuildWhitelisted({ @@ -81,34 +82,36 @@ export namespace WhitelistUtils { }) ) ); - const updatedQueueIds = insertedWhitelisted.length - ? uniq([...store.dbQueues().values()].map(queue => queue.id)) - : []; - return { insertedWhitelisted, updatedQueueIds }; + return { + insertedWhitelisted, + updatedQueueIds: SubjectListUtils.updatedQueueIdsForGuildScope(store, insertedWhitelisted.length), + }; }); } export function deleteWhitelisted(store: Store, whitelistedIds: bigint[]) { const deletedWhitelisted = compact(whitelistedIds.map(id => store.deleteWhitelisted({ id }))); - const updatedQueueIds = uniq(deletedWhitelisted.map(whitelisted => whitelisted.queueId)); - return { deletedWhitelisted, updatedQueueIds }; + return { + deletedWhitelisted, + updatedQueueIds: SubjectListUtils.updatedQueueIdsForQueueScope(deletedWhitelisted), + }; } export function deleteEventWhitelisted(store: Store, whitelistedIds: bigint[]) { const deletedWhitelisted = compact(whitelistedIds.map(id => store.deleteEventWhitelisted({ id }))); - const updatedQueueIds = uniq(deletedWhitelisted.flatMap(whitelisted => - store.dbEventQueues(whitelisted.eventId).map(eq => eq.queueId) - )); - return { deletedWhitelisted, updatedQueueIds }; + return { + deletedWhitelisted, + updatedQueueIds: SubjectListUtils.updatedQueueIdsForEventScope(store, deletedWhitelisted), + }; } export function deleteGuildWhitelisted(store: Store, whitelistedIds: bigint[]) { const deletedWhitelisted = compact(whitelistedIds.map(id => store.deleteGuildWhitelisted({ id }))); - const updatedQueueIds = deletedWhitelisted.length - ? uniq([...store.dbQueues().values()].map(queue => queue.id)) - : []; - return { deletedWhitelisted, updatedQueueIds }; + return { + deletedWhitelisted, + updatedQueueIds: SubjectListUtils.updatedQueueIdsForGuildScope(store, deletedWhitelisted.length), + }; } // Union-of-allow-lists: if any applicable scope (queue / event / guild) has any whitelist rows, diff --git a/src/utils/winner.utils.test.ts b/src/utils/winner.utils.test.ts new file mode 100644 index 00000000..7217182f --- /dev/null +++ b/src/utils/winner.utils.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from "vitest"; + +import { Queries } from "../db/queries.ts"; +import { MemberUtils } from "./member.utils.ts"; +import { WinnerUtils } from "./winner.utils.ts"; + +describe("WinnerUtils.declareWinners", () => { + it("returns [] when winnerRoleId is null without touching Discord or DB", async () => { + const insertEventWinner = vi.fn(); + const applyRoleSpy = vi.spyOn(MemberUtils, "modifyMemberRoles"); + + const store = { + guild: { id: "guild1" }, + jsMember: vi.fn(), + insertEventWinner, + } as any; + const event = { id: 1n, guildId: "guild1", winnerRoleId: null } as any; + + const result = await WinnerUtils.declareWinners(store, event, new Set(["user1"])); + + expect(result).toEqual([]); + expect(insertEventWinner).not.toHaveBeenCalled(); + expect(applyRoleSpy).not.toHaveBeenCalled(); + applyRoleSpy.mockRestore(); + }); + + it("grants role before DB insert and revokes on insert failure", async () => { + const callOrder: string[] = []; + vi.spyOn(Queries, "selectManyEventWinners").mockReturnValue([]); + vi.spyOn(MemberUtils, "modifyMemberRoles").mockImplementation(async (_store, _userId, _roleId, mod) => { + callOrder.push(mod === "add" ? "add" : "remove"); + }); + + const store = { + guild: { id: "guild1" }, + jsMember: vi.fn().mockResolvedValue({ id: "user1" }), + insertEventWinner: vi.fn(() => { + callOrder.push("insert"); + throw new Error("db fail"); + }), + } as any; + const event = { id: 1n, guildId: "guild1", winnerRoleId: "role1" } as any; + + const result = await WinnerUtils.declareWinners(store, event, new Set(["user1"])); + + expect(result).toEqual([]); + expect(callOrder).toEqual(["add", "insert", "remove"]); + }); +}); + +describe("WinnerUtils.clearEventWinners", () => { + it("revokes roles before deleting DB rows", async () => { + const callOrder: string[] = []; + vi.spyOn(Queries, "selectManyEventWinners").mockReturnValue([ + { userId: "user1", roleId: "role1" }, + ] as any); + vi.spyOn(MemberUtils, "modifyMemberRoles").mockImplementation(async () => { + callOrder.push("remove"); + }); + + const deleteManyEventWinners = vi.fn(() => { + callOrder.push("delete"); + }); + const store = { + guild: { id: "guild1" }, + jsMember: vi.fn().mockResolvedValue({ id: "user1" }), + deleteManyEventWinners, + } as any; + const event = { id: 1n, guildId: "guild1" } as any; + + await WinnerUtils.clearEventWinners(store, event); + + expect(callOrder).toEqual(["remove", "delete"]); + }); + + it("re-grants roles when delete fails", async () => { + const callOrder: string[] = []; + vi.spyOn(Queries, "selectManyEventWinners").mockReturnValue([ + { userId: "user1", roleId: "role1" }, + ] as any); + vi.spyOn(MemberUtils, "modifyMemberRoles").mockImplementation(async (_store, _userId, _roleId, mod) => { + callOrder.push(mod === "add" ? "add" : "remove"); + }); + + const store = { + guild: { id: "guild1" }, + jsMember: vi.fn().mockResolvedValue({ id: "user1" }), + deleteManyEventWinners: vi.fn(() => { + throw new Error("delete fail"); + }), + } as any; + const event = { id: 1n, guildId: "guild1" } as any; + + await expect(WinnerUtils.clearEventWinners(store, event)).rejects.toThrow("delete fail"); + expect(callOrder).toEqual(["remove", "add"]); + }); +}); diff --git a/src/utils/winner.utils.ts b/src/utils/winner.utils.ts index 6a2ebbce..5342621a 100644 --- a/src/utils/winner.utils.ts +++ b/src/utils/winner.utils.ts @@ -35,21 +35,32 @@ export namespace WinnerUtils { event: DbEvent, requested: Set, ): Promise { + if (!event.winnerRoleId) return []; + const rows = Queries.selectManyEventWinners({ guildId: store.guild.id, eventId: event.id }); const existing = new Set(rows.map(row => row.userId)); const toAdd = computeWinnersToAdd(existing, requested); + const roleId = event.winnerRoleId; + const added: string[] = []; for (const userId of toAdd) { - store.insertEventWinner({ - guildId: store.guild.id, - eventId: event.id, - userId, - roleId: event.winnerRoleId, - }); - await applyRole(store, userId, event.winnerRoleId, "add"); + await applyRole(store, userId, roleId, "add"); + try { + store.insertEventWinner({ + guildId: store.guild.id, + eventId: event.id, + userId, + roleId, + }); + added.push(userId); + } + catch (e) { + console.error(`WinnerUtils.declareWinners: failed to insert winner row for user ${userId}:`, e); + await applyRole(store, userId, roleId, "remove"); + } } - return toAdd; + return added; } /** @@ -62,22 +73,23 @@ export namespace WinnerUtils { event: DbEvent, ): Promise<{ userId: string, roleId: string }[]> { const deleted = Queries.selectManyEventWinners({ guildId: store.guild.id, eventId: event.id }); - - store.deleteManyEventWinners({ eventId: event.id }); - const removals = deleted.map(row => ({ userId: row.userId, roleId: row.roleId })); + for (const { userId, roleId } of removals) { await applyRole(store, userId, roleId, "remove"); } - return removals; - } - /** - * Revokes all of an event's winners — the single implementation called from the open-phase - * hook so the badge lasts exactly until the next occurrence opens. A redundant call (e.g. a - * restart re-running a handled open) simply finds no rows and is a no-op. - */ - export async function revokeEventWinners(store: Store, event: DbEvent) { - return clearEventWinners(store, event); + try { + store.deleteManyEventWinners({ eventId: event.id }); + } + catch (e) { + console.error(`WinnerUtils.clearEventWinners: failed to delete winner rows for event ${event.id}:`, e); + for (const { userId, roleId } of removals) { + await applyRole(store, userId, roleId, "add"); + } + throw e; + } + + return removals; } }