diff --git a/.dockerignore b/.dockerignore index 2bdf188c..bcffa0e8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,10 @@ node_modules -npm-debug.log +dist +data +tests +coverage .env .git .github -docs \ No newline at end of file +.claude +docs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..86a63dc0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.env.example b/.env.example index 1c6470f9..003ecb1f 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,23 @@ +# Required: bot token and application (client) ID from the Discord developer portal TOKEN= CLIENT_ID= -JOB_SCHEDULE=0 9 * * * \ No newline at end of file + +# Cron schedule (5 or 6 fields) for cleanup, birthday notifications and calendars +JOB_SCHEDULE=0 9 * * * + +# IANA time zone used for scheduling and birthday calculations +BOT_TIMEZONE=Europe/London + +# SQLite database file (directory is created if missing); use :memory: for throwaway runs +DB_PATH=data/dingir.sqlite + +# Data for unreachable guilds is only purged after at least this many failed checks... +PURGE_MIN_FAILURES=3 +# ...spanning at least this many days +PURGE_GRACE_DAYS=7 + +# Log verbosity: fatal, error, warn, info, debug or trace +LOG_LEVEL=info + +# Optional: register slash commands to this guild only (instant updates during development) +# DEV_GUILD_ID= diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 992c0ea8..148a4bed 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,27 +5,27 @@ version: 2 updates: - - package-ecosystem: "npm" - directory: "/" + - package-ecosystem: 'npm' + directory: '/' schedule: - interval: "daily" + interval: 'daily' labels: - - "dependencies" + - 'dependencies' groups: all-dependencies: patterns: - - "*" + - '*' - - package-ecosystem: "github-actions" - directory: "/" + - package-ecosystem: 'github-actions' + directory: '/' schedule: - interval: "daily" + interval: 'daily' labels: - - "dependencies" + - 'dependencies' - - package-ecosystem: "docker" - directory: "/" + - package-ecosystem: 'docker' + directory: '/' schedule: - interval: "daily" + interval: 'daily' labels: - - "dependencies" + - 'dependencies' diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index f374700d..336d7773 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -3,15 +3,15 @@ name: Build on Tag on: push: tags: - - '*' + - '*' permissions: checks: write packages: write - + jobs: - run-linters: - name: Run ESLint + verify: + name: Format, Lint, Typecheck and Test runs-on: ubuntu-latest steps: @@ -24,18 +24,27 @@ jobs: node-version: 24 cache: npm - # ESLint and Prettier must be in `package.json` - name: Install Node.js dependencies run: npm ci - name: Audit dependencies run: npm audit --audit-level=low - - name: Run linters + - name: Check formatting + run: npm run format:check + + - name: Run linter run: npm run lint + - name: Type check + run: npm run typecheck + + - name: Run tests + run: npm run test:coverage + docker: name: Build Docker Image + needs: verify runs-on: ubuntu-latest steps: @@ -58,6 +67,6 @@ jobs: with: push: true platforms: linux/amd64,linux/arm64 - tags: | - ghcr.io/brentoates/dingir:${{ github.ref_name }} - ghcr.io/brentoates/dingir:latest + tags: | + ghcr.io/brentoates/dingir:${{ github.ref_name }} + ghcr.io/brentoates/dingir:latest diff --git a/.github/workflows/lint-and-build.yml b/.github/workflows/lint-and-build.yml index 5bea0189..d8195688 100644 --- a/.github/workflows/lint-and-build.yml +++ b/.github/workflows/lint-and-build.yml @@ -1,4 +1,4 @@ -name: Lint and Build +name: Format, Lint, Typecheck and Test on: # Trigger the workflow on push or pull request, @@ -15,7 +15,7 @@ permissions: checks: write jobs: - lint-and-build: + verify: name: Lint and Build runs-on: ubuntu-latest @@ -35,8 +35,14 @@ jobs: - name: Audit dependencies run: npm audit --audit-level=low - - name: Run linters + - name: Check formatting + run: npm run format:check + + - name: Run linter run: npm run lint - - name: Build TypeScript - run: npm run build + - name: Type check + run: npm run typecheck + + - name: Run tests + run: npm run test:coverage diff --git a/.npmrc b/.npmrc index 5660f81a..a9f827fa 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ -registry=https://registry.npmjs.org/ \ No newline at end of file +registry=https://registry.npmjs.org/ +ignore-scripts=true \ No newline at end of file diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 00000000..3fdc0a81 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,11 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "endOfLine": "lf", + "ignorePatterns": ["package-lock.json", "coverage", "data", "src/resources/**", ".claude"] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 00000000..b159f4a1 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,26 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "unicorn", "import", "node", "oxc"], + "categories": { "correctness": "error" }, + "env": { "node": true, "es2024": true }, + "rules": { + "typescript/no-floating-promises": "error", + "typescript/no-misused-promises": "error", + "typescript/no-explicit-any": "error", + "typescript/consistent-type-imports": "error", + "eqeqeq": ["error", "always", { "null": "ignore" }], + "curly": ["error", "all"], + "no-console": "error" + }, + "overrides": [ + { + "files": ["tests/**/*.ts"], + "rules": { "typescript/no-floating-promises": "off" } + }, + { + "files": ["src/scripts/**/*.ts"], + "rules": { "no-console": "off" } + } + ], + "ignorePatterns": ["node_modules", "coverage", "data", ".claude"] +} diff --git a/Dockerfile b/Dockerfile index 8c5d791f..9a3a9bcf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,9 @@ -# Stage 1 - Build - -FROM node:24-trixie-slim AS build +FROM node:24-trixie-slim WORKDIR /usr/src/app -COPY package*.json ./ -RUN npm ci -COPY . . -RUN npm run build - -# Stage 2 - Dist Only - -FROM node:24-trixie-slim AS dist -WORKDIR /usr/src/app -COPY package*.json ./ +COPY package*.json .npmrc ./ RUN npm ci --omit=dev -COPY --from=build /usr/src/app/dist dist -ENTRYPOINT ["npm", "start"] +COPY src ./src +RUN mkdir -p data && chown node:node data +USER node +VOLUME /usr/src/app/data +CMD ["node", "src/index.ts"] diff --git a/README.md b/README.md index 4e439c83..aa9f0a6f 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,462 @@ # Dingir -Dingir is a Discord.js bot written in TypeScript, with some of the basic functions that we use in the Irkallu discord server. -Requires Node 24 or newer to run. -## Getting started -To run bot, all you need to do is... +A Discord bot for the Irkallu server, built with discord.js and TypeScript. Provides member onboarding, birthday management, activity tracking, and moderation tools. Requires Node 24 or newer. + +## Getting Started + +### Local Development + 1. Clone the repository -2. Install dependencies using `npm install` or `yarn` -3. Copy the `.env.example` file to `.env`, and provide a Discord Bot token ([Discord Developer Portal](https://discord.com/developers/docs/topics/oauth2#bots)) -4. Build the project with `npm run build` or `yarn run build` -5. Run the build project with `npm start` or `yarn start` - -## Persistence -Data is currently stored locally in a SQLite database, placed in a `data` folder at the root of the repository. -In the future, I want to implement functionality to be able to use a SQL Server purely by configuration. +2. Install dependencies: `npm ci` + - Install scripts are disabled via `.npmrc` because native dependencies (`better-sqlite3` and `@napi-rs/canvas`) ship prebuilt binaries; if a future dependency needs a postinstall step, run `npm rebuild ` or revisit this setting. +3. Copy `.env.example` to `.env` and fill in `TOKEN` and `CLIENT_ID` from the [Discord Developer Portal](https://discord.com/developers/applications) +4. Start: `npm start` (Node runs the TypeScript source directly; there is no build step) + +For watch mode during development: + +```bash +npm run dev +``` + +Logs are structured JSON. For human-readable output while developing, use `npm run dev:pretty` (pipes through `pino-pretty`, a dev dependency). In production, pipe `docker logs` or `npm start` through `npx pino-pretty` if you want the same. + +Test, lint and format: + +```bash +npm test # Run the test suite +npm run test:coverage # Run tests and enforce coverage thresholds +npm run typecheck # Type-check src and tests (TypeScript 7, tsc --noEmit) +npm run lint # Lint with oxlint (type-aware, via oxlint-tsgolint) +npm run lint:fix # Apply oxlint auto-fixes +npm run format # Format with oxfmt +npm run format:check # Verify formatting without writing +npm run check # format:check, lint, typecheck and test:coverage (what CI runs) +``` + +### Docker + +Build and run the bot in a container with a persistent database volume: + +```bash +docker build -t dingir . +docker run -d \ + --env-file .env \ + -v dingir-data:/usr/src/app/data \ + --name dingir \ + dingir +``` + +The database is stored in `/usr/src/app/data` inside the container; mount a volume there to persist it between runs. + +## Configuration + +All configuration is managed through environment variables in `.env`. See `.env.example` for defaults. + +| Variable | Required | Default | Description | +| -------------------- | -------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `TOKEN` | Yes | — | Discord bot token from the Developer Portal | +| `CLIENT_ID` | Yes | — | Discord application (client) ID from the Developer Portal | +| `JOB_SCHEDULE` | No | `0 9 * * *` | Cron schedule (5 or 6 fields) for daily cleanup, birthday notifications, and calendar updates. Example: `0 9 * * *` = 9:00 AM daily | +| `BOT_TIMEZONE` | No | `Europe/London` | IANA time zone used for scheduling and birthday calculations (e.g. `Europe/London`, `America/New_York`) | +| `DB_PATH` | No | `data/dingir.sqlite` | Path to SQLite database file. Use `:memory:` for throwaway runs (no persistence) | +| `PURGE_MIN_FAILURES` | No | `3` | Minimum number of failed guild access checks before purging orphaned data (see Data & Retention) | +| `PURGE_GRACE_DAYS` | No | `7` | Grace period (days) after first failure before purging. Data is purged only after both thresholds are met | +| `LOG_LEVEL` | No | `info` | Log verbosity: `fatal`, `error`, `warn`, `info`, `debug` or `trace`. Logs are JSON, one object per line | +| `DEV_GUILD_ID` | No | — | Guild ID for instant slash command registration during development (commands update immediately instead of within 1 hour). Omit in production | + +## Discord Setup + +### Required Intents + +The bot declares these gateway intents. The first two are **privileged** and must be enabled in the Developer Portal under "Bot" → "Privileged Gateway Intents": + +- **Guilds** (unprivileged) +- **Guild Members** (privileged) +- **Guild Messages** (unprivileged) +- **Message Content** (privileged) + +The **Guild Members** intent is required for membership screening tracking: on startup, the bot fetches all members for each server (one fetch per server) to record members who are pending screening, and onboards members whose screening finished while the bot was offline. + +### Bot Permissions + +The bot requires specific permissions on a per-feature basis. When inviting the bot, ensure it has: + +- **View Channels** — required to see and access channels for all operations +- **Send Messages** — required for welcome messages, announcements, audit logs +- **Embed Links** — required to send embeds (profile, about, audit logs) +- **Attach Files** — required to send welcome images +- **Read Message History** — required to edit birthday calendar messages +- **Manage Roles** — required to assign roles during onboarding +- **Ban Members** — required for honeypot enforcement + +The invite URL should include all these permissions. You can generate one in the Developer Portal under "OAuth2" → "URL Generator". + +Use `DEV_GUILD_ID` in `.env` to test commands instantly in a single guild instead of waiting 1 hour for global registration. + +On startup the bot hashes its command definitions and only re-registers them when the hash differs from the one stored for the target scope (global or `DEV_GUILD_ID`). The hash is stored per application (`CLIENT_ID`) and per scope, so the first start after upgrading re-registers once. The last registered scope is stored in BotState (`commandsScope:`). Switching from a dev guild (`DEV_GUILD_ID=X`) to global or another guild automatically clears the old dev guild's commands after the new registration succeeds (a failed cleanup is retried on the next start, unless the bot is no longer in that guild). Switching from global to a dev guild does NOT remove global commands (they may be production) — remove them via `npm run deploy:commands` without `DEV_GUILD_ID` set or the Developer Portal. `npm run deploy:commands` uses the same logic and opens the database to record the scope/hash. To force a registration without starting the bot, or to clean up orphaned commands, run: + +```bash +npm run deploy:commands +``` + +## Commands + +Commands are organized into categories. Only **admin-only** commands require the Administrator permission; all others are available to any member. + +### Info Commands + +| Command | Who Can Use | Description | +| ------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------- | +| `/ping` | Everyone | Show WebSocket latency and round-trip time | +| `/about` | Everyone | Display Dingir version, source code link, server info, and uptime | +| `/profile ` | Admin | Fetch a member's profile: nickname, username, join date, onboarding status, activity score, and birthday if set | +| `/mybirthday set ` | Everyone | Set your birthday for this server | +| `/mybirthday clear` | Everyone | Remove your birthday from this server | + +### Admin Commands + +| Command | Description | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/simulate join [member]` | Audit only: send the "member joined" audit for a member (no roles or welcome message). Defaults to you | +| `/simulate onboard [member]` | Dry run: show what onboarding would do (guest roles, welcome) without applying changes. Defaults to you | +| `/rolesince [days]` | List members in a role who joined the server at least N days ago. If Discord rate-limits member lookups, tells the admin to retry after N seconds | +| `/noroles` | List all members with no roles assigned (excluding bots). If Discord rate-limits member lookups, tells the admin to retry after N seconds | +| `/post [content] [attachment]` | Post a simple message and/or file to a channel. Requires at least one of content or attachment | + +### Config Commands + +Use `/config ` to manage server settings. All are admin-only. + +#### `/config announcements` — Announcements Channel + +| Subcommand | Description | +| --------------- | --------------------------------------------------- | +| `set ` | Set the announcements channel | +| `get` | Show the currently configured announcements channel | +| `clear` | Clear the announcements channel | + +#### `/config audit` — Audit Log Channel + +| Subcommand | Description | +| --------------- | ---------------------------------------------------------- | +| `set ` | Set the audit log channel (where member events are posted) | +| `get` | Show the currently configured audit channel | +| `clear` | Clear the audit channel | + +#### `/config sysmsgs` — Bot System Messages + +Toggle whether the bot uses Discord's system channel for welcome messages: + +| Subcommand | Description | +| --------------- | ------------------------------------------------------------------ | +| `set ` | Enable or disable: bot system messages (`enabled` = true or false) | +| `get` | Show whether bot system messages are enabled | + +#### `/config honeypot` — Honeypot Channel + +Configure a channel that automatically bans members who post in it: + +| Subcommand | Description | +| --------------- | ---------------------------------------------- | +| `set ` | Set the honeypot channel | +| `get` | Show the currently configured honeypot channel | +| `clear` | Clear the honeypot channel | + +#### `/config welcome` — Welcome Messages & Images + +| Subcommand | Description | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `set-message ` | Set the welcome message (max 1500 characters). Use `{member}` to mention the new member. Rejected if, after expanding every `{member}` to a mention, the message would exceed Discord's 2000-character limit | +| `set-image ` | Set the background image for welcome images (http/https URL) | +| `get` | Show the welcome message and image URL (long messages are truncated to fit Discord's 2000-character limit) | +| `clear ` | Clear the message, image, or both (`which` = `message`, `image`, or `all`) | +| `preview` | Preview the welcome message as it would be sent for you (long messages are truncated to fit Discord's 2000-character limit) | + +#### `/config newroles` — New Member Roles + +Roles given to members when they complete onboarding: + +| Subcommand | Description | +| ---------------------------------------- | ------------------------------------------- | +| `get` | Show the current new-member roles | +| `set [role-two] [role-three]` | Set up to three roles to give on onboarding | +| `clear` | Clear the new-member roles | + +#### `/config birthdays` — Birthday Calendar + +| Subcommand | Description | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `create ` | Create or recreate a birthday calendar in a text channel (fetches all server birthdays); the existing calendar is kept until the new one has been posted, saved and populated, then the old message is removed (if the new one can't be populated, nothing changes) | +| `sync` | Manually sync the birthday calendar (runs automatically on the scheduled job) | +| `remove` | Remove the birthday calendar message (the stored path is kept if Discord refuses the deletion, so you can retry) | + +#### `/config debug` — Onboarding Diagnostics + +Toggle diagnostic audits for onboarding runs: + +| Subcommand | Description | +| --------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `set ` | Enable or disable: onboarding diagnostics (`enabled` = true posts a summary of each onboarding run to the audit channel) | +| `get` | Show whether onboarding diagnostics are enabled | + +## Behaviour + +### Onboarding + +When a member joins without screening enabled, or completes server screening: + +1. **Audit**: A "New member joined" audit is posted to the configured audit channel (if set). When screening completes, a "Member completed onboarding" audit is posted. Servers without screening enabled only send the join audit. +2. **Guest Roles**: Configured new-member roles are assigned (if any are set) +3. **Welcome**: A welcome message is posted to the server's system channel, optionally with a background image (only if `/config sysmsgs` is enabled; long messages are truncated to fit Discord's 2000-character limit) +4. **Debug**: If debug mode is enabled, a diagnostic summary is posted to the audit channel showing what succeeded, failed, or was skipped + +Roles are only assigned if: + +- They are below the bot's highest role in the role hierarchy +- They are not managed by an integration +- They are not `@everyone` + +If a role cannot be assigned, the error is logged and the onboarding continues for other roles and steps. + +**Onboarding Tracking**: The bot records when a member is pending membership screening (when they join pending, on any update showing them pending, and via a startup sweep that fetches each server's members after login). If the bot doesn't have the member's previous state cached, it onboards them only when a pending state was recorded and they haven't been onboarded yet; otherwise it skips (logged). Onboarding clears the pending marker and records `onboardedAt`, so nobody is onboarded twice. Members already in the server before the upgrade are never re-onboarded. Leaving the server deletes the profile, so a rejoin is onboarded again. Known limitation: a member who was already pending at deploy time and completes screening before the startup sweep records them, while not cached, won't be onboarded automatically (an admin can assign roles manually). + +**Welcome Image**: The welcome image download is capped at 10 MB while streaming to prevent resource exhaustion. + +### Audit Log + +Member events are posted to the configured audit channel: + +- Member joined (join audit) +- Member completed onboarding +- Member left (with member data cleanup status) +- Role changes (new-member roles updated/cleared) +- Messages posted via `/post` +- Any failed or unusual events (role assignment failures, onboarding failures, honeypot triggers) + +### Honeypot + +If a honeypot channel is configured, any non-administrator member who posts in it is: + +1. Immediately banned (with messages from the past 7 days deleted) +2. Removed from the database +3. Logged to the audit channel + +### Activity Score + +Each message posted by a human (non-bot, non-webhook) in any channel increments the member's activity score by 1. This is used to rank member engagement and is visible in `/profile`. + +### Birthdays + +Members can set their birthday using `/mybirthday set `. Birthdays are stored per-server. + +**Birthday Announcements**: On the configured schedule (default 9:00 AM daily), the bot checks all servers and posts birthday announcements in the announcements channel for members with birthdays today. If a member's birthday is Feb 29 and it's not a leap year, they are celebrated on Feb 28 instead. Birthday announcements are split across multiple messages if a single day's mentions would exceed Discord's 2000-character limit. + +**Birthday Calendar**: If configured, a calendar message lists the next upcoming birthdays (up to 10 birthdays). The calendar is automatically updated on the scheduled job by editing the message. It is also refreshed when a member with a birthday leaves. If the message or channel is missing, or the bot lacks access, the refresh logs a warning and an admin must fix permissions or run `/config birthdays create` again; a temporary Discord error never clears the stored calendar. + +**Timezones**: All birthday calculations use the `BOT_TIMEZONE` setting. Members' birthdays are stored as month/day only (no year), and the next occurrence is calculated relative to the bot's configured time zone. + +### Scheduled Daily Job + +On the schedule defined by `JOB_SCHEDULE` (default: 9:00 AM in the configured `BOT_TIMEZONE`), three tasks run in order: + +1. **Data Check** — Checks access to each configured guild. If unreachable, increments the failure counter. After `PURGE_MIN_FAILURES` failures spanning `PURGE_GRACE_DAYS` days, the guild's data is purged. Transient errors (timeouts, API errors) do not count toward purging. Profiles are snapshotted before fetching the member list, so members who join during the check aren't treated as departed. A guild the bot is still connected to is never purged. +2. **Birthday Notifications** — Posts birthday announcements in the announcements channel for members with birthdays today +3. **Birthday Calendars** — Updates all configured birthday calendar messages + +## Data & Retention + +### Storage + +All data is persisted in SQLite at the path specified by `DB_PATH` (default: `data/dingir.sqlite`). The database directory is created automatically if it doesn't exist. + +Migrations run automatically on startup. Current migrations: + +- **001-baseline** — Initial schema (ServerConfigs and UserProfiles tables) +- **002-userprofile-unique** — Adds a unique constraint on (serverId, userId). If duplicates exist, they are merged: the profile with the most recent birthday is kept, and the maximum activity score is preserved. **Backups are strongly recommended before upgrading from v2 to v3** because this migration is permanent and irreversible. +- **003-serverconfig-access-tracking** — Adds access failure tracking for orphaned guild detection +- **004-bot-state** — Adds the `BotState` key/value table (currently stores the registered command hash) +- **005-userprofile-backfill** — Sets NULL activity scores to 0 and deletes profile rows with no server or user id, logging how many +- **006-userprofile-onboarded-at** — Adds nullable `UserProfiles.onboardedAt`, recording when a member was onboarded +- **007-userprofile-screening-pending** — Adds nullable `UserProfiles.screeningPendingAt`, recording that a member is waiting on membership screening + +### What Gets Deleted + +| Event | Data Deleted | +| ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| Member leaves server | User profile (birthday, activity score, settings) for that server | +| Bot removed from server | All configuration and user profiles for that server (immediate) | +| Guild unreachable for `PURGE_MIN_FAILURES` checks spanning `PURGE_GRACE_DAYS` days | All configuration and user profiles for that guild (purge) | + +**Note**: Transient errors (network timeouts, API rate limiting, temporary outages) never count toward the purge threshold. Only permanent failures (guild deleted, bot kicked) count. + +## Upgrading from 2.x + +When upgrading to v3, note the following: + +### Backups + +Back up the SQLite file before the first start. Migrations 002 and 005 permanently remove rows: + +- **002-userprofile-unique**: Deduplicates UserProfiles and merges activity scores +- **005-userprofile-backfill**: Deletes profiles with no server or user id + +```bash +# Before starting v3 +cp data/dingir.sqlite data/dingir.sqlite.backup +``` + +### Container User + +The v3 image runs as the non-root `node` user (uid 1000), but v2 ran as root. If you have an existing data volume or bind mount, it is root-owned and SQLite will fail to open it. + +Either keep running as root via Docker Compose: + +```yaml +services: + dingir: + image: ghcr.io/brentoates/dingir:3.0.0 + user: '0:0' + volumes: + - ./data:/usr/src/app/data +``` + +Or (preferred, non-root) fix ownership once: + +```bash +# For a bind mount +sudo chown -R 1000:1000 ./data + +# For a named volume +docker run --rm -v :/data alpine chown -R 1000:1000 /data +``` + +### Command Registration + +Commands are re-registered once on the first start after upgrading. The command set changed in v3: + +**Changed commands**: + +- `/mybirthday` — v2 took `day` and `month` as options; v3 uses subcommands: `set ` / `clear` +- `/config welcome` — v2 had `set|get|clear` with a `content` choice (message/image); v3 has separate subcommands `set-message|set-image|get|clear|preview` +- `/config birthdays` — v2 had `create|sync`; v3 added `remove` +- `/config newroles` — v2 `set` took 1–2 roles; v3 `set` takes 1–3 roles +- `/simulate screen` — renamed to `/simulate onboard` +- `/rolesince` — `days` is now a whole number of at least 0 (v2 accepted decimals) +- `/post` — `channel` is limited to text and announcement channels, and `content` to 2000 characters +- `/config announcements` and `/config audit` — `set` now only accepts text and announcement channels + +**Unchanged**: + +- `/config sysmsgs set |get` — both v2 and v3 use this syntax +- `/config debug set |get` — both v2 and v3 use this syntax +- `/config honeypot set|get|clear` — same options as v2 +- `/noroles` — same options as v2 +- `/about`, `/ping`, `/profile ` — same options as v2 + +### Releases + +Docker images are published by pushing a git tag (e.g. `git tag 3.0.0 && git push origin 3.0.0`), not by merging to main. + +## Contributing + +### Project Layout + +``` +src/ + client/ + DingirClient.ts # Main client class (intents, startup) + commands/ # Slash command definitions, organized by category + info/ + admin/ + config/groups/ # /config subcommand groups + app.ts # Composition root: the App (env, db, logger, clock, ...) + config/ + env.ts # Environment loading and validation + db/ # Drizzle schema, better-sqlite3 setup, migrations + events/ # Discord event handlers + framework/ + command.ts # Command definition and resolution + event.ts # Event definition + settings.ts # Reusable /config setting groups (channel, boolean) + scripts/ + deploy-commands.ts # One-off slash command registration (npm run deploy:commands) + services/ # Business logic (onboarding, birthdays, audit, etc.) + utilities/ # Logging, formatting + index.ts # Entry point +``` + +### Adding a Command + +Slash commands are defined in `src/commands/`. Here's a minimal example: + +```typescript +// src/commands/info/hello.ts +import { defineCommand } from '../../framework/command.ts'; + +export default defineCommand({ + name: 'hello', + description: 'Greet a member', + options: (b) => + b.addUserOption((opt) => + opt.setName('member').setDescription('Member to greet').setRequired(true), + ), + run: async (ctx) => { + const user = ctx.interaction.options.getUser('member', true); + await ctx.reply(`Hello ${user.toString()}!`); + }, +}); +``` + +Command handlers receive the shared `App` as `ctx.app` (database, logger, clock, env). Register new commands in `src/commands/index.ts`. Set `adminOnly: true` to require Administrator permission. Use `defer: 'ephemeral'` for private replies. + +### Adding a Config Setting + +Use the helpers in `src/framework/settings.ts`. For a channel setting: + +```typescript +// src/commands/config/config.ts (add to groups array) +channelSetting({ + name: 'myfeature', + description: 'Set the my feature channel', + field: 'myFeatureChannelId', // Must exist in ServerConfig + label: 'My feature channel', + channelTypes: [ChannelType.GuildText], +}); +``` + +Then ensure the field is defined in `src/db/schema.ts` and add a migration to add the column if needed. For a boolean setting, use `booleanSetting()` instead. + +**Migrations**: If adding a new config field, add a column to `serverConfigs` in `src/db/schema.ts` and a migration (see [Adding a Migration](#adding-a-migration)). Update settings with `updateConfig(ctx.app, serverId, { field: value })`. + +### Adding an Event + +Events are defined in `src/events/`. Discord.js events are mapped automatically. Example: + +```typescript +// src/events/myevent.ts +import { defineEvent } from '../framework/event.ts'; + +export default defineEvent({ + name: 'messageCreate', // discord.js event name + run: async (app, client, message) => { + // `app` carries env, db, logger, clock, honeypot and shutdown; see src/app.ts + }, +}); +``` + +Register new events in `src/events/index.ts`. Set `once: true` to only listen once (useful for `clientReady`). + +### Adding a Migration + +Migrations are plain synchronous functions over the better-sqlite3 handle. Create a new file in `src/db/migrations/` and register it in `src/db/migrator.ts`: + +```typescript +// src/db/migrations/004-add-myfeature.ts +import type { Database } from 'better-sqlite3'; + +export function up(db: Database): void { + db.exec('ALTER TABLE `ServerConfigs` ADD COLUMN `myFeatureChannelId` VARCHAR(255)'); +} +``` + +Migrations run on startup in order, each in its own transaction, and applied names are recorded in the `SequelizeMeta` table (kept from earlier versions). See `001-baseline.ts` and `002-userprofile-unique.ts` for examples. diff --git a/SECURITY.md b/SECURITY.md index 908a0083..7933e24e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,11 +2,7 @@ ## Supported Versions - | Version | Supported | | ------- | ------------------ | -| 1.0.x | :no_entry: | -| 1.1.x | :no_entry: | -| 1.2.x | :no_entry: | -| 1.3.x | :no_entry: | -| 2.0.x | :white_check_mark: | \ No newline at end of file +| 3.0.x | :white_check_mark: | +| < 3.0 | :x: | diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index 3690be9b..00000000 --- a/eslint.config.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import js from '@eslint/js'; -import { defineConfig, globalIgnores } from 'eslint/config'; -import tseslint from 'typescript-eslint'; - -export default defineConfig([ - globalIgnores(['dist/**', 'node_modules/**', 'package.json', '.github/**']), - js.configs.recommended, - tseslint.configs.recommended, - { - files: ['**/*.ts'], - rules: { - 'no-tabs': ['error', { allowIndentationTabs: true }], - 'quotes': [ - 'error', - 'single', - { avoidEscape: true, allowTemplateLiterals: false }, - ], - 'semi': ['error', 'always'], - 'brace-style': 'error', - 'curly': ['error', 'all'], - '@typescript-eslint/no-explicit-any': 'off', - }, - }, -]); diff --git a/package-lock.json b/package-lock.json index f004f70c..34a2c439 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,3555 +1,2373 @@ { - "name": "dingir", - "version": "2.6.2", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "dingir", - "version": "2.6.2", - "dependencies": { - "@napi-rs/canvas": "^1.0.2", - "discord.js": "^14.26.5", - "dotenv": "^17.4.2", - "glob": "^13.0.6", - "luxon": "^3.7.2", - "node-schedule": "^2.1.1", - "reflect-metadata": "^0.2.2", - "sequelize": "^6.37.8", - "sequelize-typescript": "^2.1.6", - "sqlite3": "^6.0.1", - "tslib": "^2.8.1", - "underscore": "^1.13.8" - }, - "devDependencies": { - "@eslint/js": "^10.0.1", - "@types/luxon": "^3.7.2", - "@types/node": "^24.13.3", - "@types/node-schedule": "^2.1.8", - "@types/underscore": "^1.13.0", - "@types/validator": "^13.15.10", - "copyfiles": "^2.4.1", - "eslint": "^10.6.0", - "rimraf": "^6.1.3", - "tsc-watch": "^7.2.1", - "typescript": "^6.0.3", - "typescript-eslint": "^8.63.0" - }, - "engines": { - "node": ">=24" - } - }, - "node_modules/@discordjs/builders": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", - "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", - "license": "Apache-2.0", - "dependencies": { - "@discordjs/formatters": "^0.6.2", - "@discordjs/util": "^1.2.0", - "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.38.40", - "fast-deep-equal": "^3.1.3", - "ts-mixer": "^6.0.4", - "tslib": "^2.6.3" - }, - "engines": { - "node": ">=16.11.0" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/collection": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", - "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=16.11.0" - } - }, - "node_modules/@discordjs/formatters": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", - "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", - "license": "Apache-2.0", - "dependencies": { - "discord-api-types": "^0.38.33" - }, - "engines": { - "node": ">=16.11.0" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/rest": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", - "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", - "license": "Apache-2.0", - "dependencies": { - "@discordjs/collection": "^2.1.1", - "@discordjs/util": "^1.2.0", - "@sapphire/async-queue": "^1.5.3", - "@sapphire/snowflake": "^3.5.5", - "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.40", - "magic-bytes.js": "^1.13.0", - "tslib": "^2.6.3", - "undici": "6.24.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", - "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", - "license": "Apache-2.0", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { - "version": "3.5.5", - "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", - "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", - "license": "MIT", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@discordjs/util": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", - "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", - "license": "Apache-2.0", - "dependencies": { - "discord-api-types": "^0.38.33" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/ws": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", - "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", - "license": "Apache-2.0", - "dependencies": { - "@discordjs/collection": "^2.1.0", - "@discordjs/rest": "^2.5.1", - "@discordjs/util": "^1.1.0", - "@sapphire/async-queue": "^1.5.2", - "@types/ws": "^8.5.10", - "@vladfrangu/async_event_emitter": "^2.2.4", - "discord-api-types": "^0.38.1", - "tslib": "^2.6.2", - "ws": "^8.17.0" - }, - "engines": { - "node": ">=16.11.0" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", - "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", - "license": "Apache-2.0", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.23.5", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", - "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^3.0.5", - "debug": "^4.3.1", - "minimatch": "^10.2.4" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", - "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", - "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", - "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "eslint": "^10.0.0" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", - "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", - "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1", - "levn": "^0.4.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@napi-rs/canvas": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.2.tgz", - "integrity": "sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==", - "license": "MIT", - "workspaces": [ - "e2e/*" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "1.0.2", - "@napi-rs/canvas-darwin-arm64": "1.0.2", - "@napi-rs/canvas-darwin-x64": "1.0.2", - "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.2", - "@napi-rs/canvas-linux-arm64-gnu": "1.0.2", - "@napi-rs/canvas-linux-arm64-musl": "1.0.2", - "@napi-rs/canvas-linux-riscv64-gnu": "1.0.2", - "@napi-rs/canvas-linux-x64-gnu": "1.0.2", - "@napi-rs/canvas-linux-x64-musl": "1.0.2", - "@napi-rs/canvas-win32-arm64-msvc": "1.0.2", - "@napi-rs/canvas-win32-x64-msvc": "1.0.2" - } - }, - "node_modules/@napi-rs/canvas-android-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.2.tgz", - "integrity": "sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.2.tgz", - "integrity": "sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.2.tgz", - "integrity": "sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.2.tgz", - "integrity": "sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA==", - "cpu": [ - "arm64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.2.tgz", - "integrity": "sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==", - "cpu": [ - "arm64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.2.tgz", - "integrity": "sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==", - "cpu": [ - "riscv64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.2.tgz", - "integrity": "sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==", - "cpu": [ - "x64" - ], - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.2.tgz", - "integrity": "sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==", - "cpu": [ - "x64" - ], - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-win32-arm64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.2.tgz", - "integrity": "sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.2.tgz", - "integrity": "sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@sapphire/async-queue": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", - "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", - "license": "MIT", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@sapphire/shapeshift": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", - "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "lodash": "^4.17.21" - }, - "engines": { - "node": ">=v16" - } - }, - "node_modules/@sapphire/snowflake": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", - "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", - "license": "MIT", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/@types/debug": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", - "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", - "license": "MIT", - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/luxon": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.2.tgz", - "integrity": "sha512-gW+Oib+vUtGJBtNC8V9Reww0oIpusw+4m81uncg9REGZAJfqOQHfo/nkabnc7w0QReXyPqjrbWMJk6NuAkiX3Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/node-schedule": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@types/node-schedule/-/node-schedule-2.1.8.tgz", - "integrity": "sha512-k00g6Yj/oUg/CDC+MeLHUzu0+OFxWbIqrFfDiLi6OPKxTujvpv29mHGM8GtKr7B+9Vv92FcK/8mRqi1DK5f3hA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/underscore": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.13.0.tgz", - "integrity": "sha512-L6LBgy1f0EFQZ+7uSA57+n2g/s4Qs5r06Vwrwn0/nuK1de+adz00NWaztRQ30aEqw5qOaWbPI8u2cGQ52lj6VA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/validator": { - "version": "13.15.10", - "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", - "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", - "license": "MIT" - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", - "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/type-utils": "8.63.0", - "@typescript-eslint/utils": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.63.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", - "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", - "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", - "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.63.0", - "@typescript-eslint/types": "^8.63.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", - "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", - "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", - "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/utils": "8.63.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", - "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", - "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.63.0", - "@typescript-eslint/tsconfig-utils": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", - "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", - "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.63.0", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@vladfrangu/async_event_emitter": { - "version": "2.4.7", - "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", - "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", - "license": "MIT", - "engines": { - "node": ">=v14.0.0", - "npm": ">=7.0.0" - } - }, - "node_modules/abbrev": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", - "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", - "license": "ISC", - "optional": true, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/bl/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/bl/node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz", - "integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT" - }, - "node_modules/copyfiles": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/copyfiles/-/copyfiles-2.4.1.tgz", - "integrity": "sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob": "^7.0.5", - "minimatch": "^3.0.3", - "mkdirp": "^1.0.4", - "noms": "0.0.0", - "through2": "^2.0.1", - "untildify": "^4.0.0", - "yargs": "^16.1.0" - }, - "bin": { - "copyfiles": "copyfiles", - "copyup": "copyfiles" - } - }, - "node_modules/copyfiles/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cron-parser": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", - "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", - "license": "MIT", - "dependencies": { - "luxon": "^3.2.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/discord-api-types": { - "version": "0.38.49", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.49.tgz", - "integrity": "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg==", - "license": "MIT", - "workspaces": [ - "scripts/actions/documentation" - ] - }, - "node_modules/discord.js": { - "version": "14.26.5", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.5.tgz", - "integrity": "sha512-SGYgjiAs0o8ZzMC97XmFXKONyairJ9YzVda+LvoSKs9YYh2gPtQhY+liaV6H/w72jxYbu9ggiSqAXoF3FLOH4A==", - "license": "Apache-2.0", - "dependencies": { - "@discordjs/builders": "^1.14.1", - "@discordjs/collection": "1.5.3", - "@discordjs/formatters": "^0.6.2", - "@discordjs/rest": "^2.6.1", - "@discordjs/util": "^1.2.0", - "@discordjs/ws": "^1.2.3", - "@sapphire/snowflake": "3.5.3", - "discord-api-types": "^0.38.48", - "fast-deep-equal": "3.1.3", - "lodash.snakecase": "4.1.1", - "magic-bytes.js": "^1.13.0", - "tslib": "^2.6.3", - "undici": "6.24.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/discordjs/discord.js?sponsor" - } - }, - "node_modules/dotenv": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dottie": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.7.tgz", - "integrity": "sha512-7lAK2A0b3zZr3UC5aE69CPdCFR4RHW1o2Dr74TqFykxkUCBXSRJum/yPc7g8zRHJqWKomPLHwFLLoUnn8PXXRg==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "MIT" - }, - "node_modules/duplexer": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", - "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", - "dev": true, - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", - "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", - "dev": true, - "license": "MIT", - "workspaces": [ - "packages/*" - ], - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.6.0", - "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.2", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.2", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.2.0", - "esquery": "^1.7.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", - "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/espree": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/event-stream": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", - "integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==", - "dev": true, - "license": "MIT", - "dependencies": { - "duplexer": "~0.1.1", - "from": "~0", - "map-stream": "~0.1.0", - "pause-stream": "0.0.11", - "split": "0.3", - "stream-combiner": "~0.0.4", - "through": "~2.3.1" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, - "node_modules/exponential-backoff": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", - "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", - "license": "Apache-2.0", - "optional": true - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, - "node_modules/from": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", - "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", - "dev": true, - "license": "MIT" - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC", - "optional": true - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflection": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", - "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==", - "engines": [ - "node >= 0.4.0" - ], - "license": "MIT" - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" - }, - "node_modules/lodash.snakecase": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", - "license": "MIT" - }, - "node_modules/long-timeout": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", - "integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==", - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/luxon": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", - "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/magic-bytes.js": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", - "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", - "license": "MIT" - }, - "node_modules/map-stream": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", - "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==", - "dev": true - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, - "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/moment-timezone": { - "version": "0.5.48", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", - "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", - "license": "MIT", - "dependencies": { - "moment": "^2.29.4" - }, - "engines": { - "node": "*" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-abi": { - "version": "3.94.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", - "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-addon-api": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", - "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, - "node_modules/node-cleanup": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/node-cleanup/-/node-cleanup-2.1.2.tgz", - "integrity": "sha512-qN8v/s2PAJwGUtr1/hYTpNKlD6Y9rc4p8KSmJXyGdYGZsDGKXrGThikLFP9OCHFeLeEpQzPwiAtdIvBLqm//Hw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-gyp": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", - "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", - "license": "MIT", - "optional": true, - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "graceful-fs": "^4.2.6", - "nopt": "^9.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "tar": "^7.5.4", - "tinyglobby": "^0.2.12", - "undici": "^6.25.0", - "which": "^6.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/node-gyp/node_modules/isexe": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", - "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", - "license": "BlueOak-1.0.0", - "optional": true, - "engines": { - "node": ">=20" - } - }, - "node_modules/node-gyp/node_modules/which": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", - "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", - "license": "ISC", - "optional": true, - "dependencies": { - "isexe": "^4.0.0" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/node-schedule": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/node-schedule/-/node-schedule-2.1.1.tgz", - "integrity": "sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==", - "license": "MIT", - "dependencies": { - "cron-parser": "^4.2.0", - "long-timeout": "0.1.1", - "sorted-array-functions": "^1.3.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/noms": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/noms/-/noms-0.0.0.tgz", - "integrity": "sha512-lNDU9VJaOPxUmXcLb+HQFeUgQQPtMI24Gt6hgfuMHRJgMRHMF/qZ4HJD3GDru4sSw9IQl2jPjAYnQrdIeLbwow==", - "dev": true, - "license": "ISC", - "dependencies": { - "inherits": "^2.0.1", - "readable-stream": "~1.0.31" - } - }, - "node_modules/nopt": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", - "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", - "license": "ISC", - "optional": true, - "dependencies": { - "abbrev": "^4.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/pause-stream": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", - "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", - "dev": true, - "license": [ - "MIT", - "Apache2" - ], - "dependencies": { - "through": "~2.3" - } - }, - "node_modules/pg-connection-string": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", - "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", - "license": "MIT" - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", - "license": "ISC", - "optional": true, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, - "license": "MIT" - }, - "node_modules/ps-tree": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.2.0.tgz", - "integrity": "sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "event-stream": "=3.3.4" - }, - "bin": { - "ps-tree": "bin/ps-tree.js" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/readable-stream": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", - "dev": true, - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, - "node_modules/reflect-metadata": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/retry-as-promised": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz", - "integrity": "sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw==", - "license": "MIT" - }, - "node_modules/rimraf": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", - "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "glob": "^13.0.3", - "package-json-from-dist": "^1.0.1" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sequelize": { - "version": "6.37.8", - "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.8.tgz", - "integrity": "sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/sequelize" - } - ], - "license": "MIT", - "dependencies": { - "@types/debug": "^4.1.8", - "@types/validator": "^13.7.17", - "debug": "^4.3.4", - "dottie": "^2.0.6", - "inflection": "^1.13.4", - "lodash": "^4.17.21", - "moment": "^2.29.4", - "moment-timezone": "^0.5.43", - "pg-connection-string": "^2.6.1", - "retry-as-promised": "^7.0.4", - "semver": "^7.5.4", - "sequelize-pool": "^7.1.0", - "toposort-class": "^1.0.1", - "uuid": "^8.3.2", - "validator": "^13.9.0", - "wkx": "^0.5.0" - }, - "engines": { - "node": ">=10.0.0" - }, - "peerDependenciesMeta": { - "ibm_db": { - "optional": true - }, - "mariadb": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "oracledb": { - "optional": true - }, - "pg": { - "optional": true - }, - "pg-hstore": { - "optional": true - }, - "snowflake-sdk": { - "optional": true - }, - "sqlite3": { - "optional": true - }, - "tedious": { - "optional": true - } - } - }, - "node_modules/sequelize-pool": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz", - "integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==", - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/sequelize-typescript": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/sequelize-typescript/-/sequelize-typescript-2.1.6.tgz", - "integrity": "sha512-Vc2N++3en346RsbGjL3h7tgAl2Y7V+2liYTAOZ8XL0KTw3ahFHsyAUzOwct51n+g70I1TOUDgs06Oh6+XGcFkQ==", - "license": "MIT", - "dependencies": { - "glob": "7.2.0" - }, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "@types/validator": "*", - "reflect-metadata": "*", - "sequelize": ">=6.20.1" - } - }, - "node_modules/sequelize-typescript/node_modules/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/sorted-array-functions": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz", - "integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==", - "license": "MIT" - }, - "node_modules/split": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", - "integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "through": "2" - }, - "engines": { - "node": "*" - } - }, - "node_modules/sqlite3": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-6.0.1.tgz", - "integrity": "sha512-X0czUUMG2tmSqJpEQa3tCuZSHKIx8PwM53vLZzKp/o6Rpy25fiVfjdbnZ988M8+O3ZWR1ih0K255VumCb3MAnQ==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "bindings": "^1.5.0", - "node-addon-api": "^8.0.0", - "prebuild-install": "^7.1.3", - "tar": "^7.5.10" - }, - "engines": { - "node": ">=20.17.0" - }, - "optionalDependencies": { - "node-gyp": "12.x" - }, - "peerDependencies": { - "node-gyp": "12.x" - }, - "peerDependenciesMeta": { - "node-gyp": { - "optional": true - } - } - }, - "node_modules/stream-combiner": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", - "integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "duplexer": "~0.1.1" - } - }, - "node_modules/string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-argv": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", - "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6.19" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tar": { - "version": "7.5.22", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", - "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tar-fs": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", - "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-fs/node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tar-stream/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/tar-stream/node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/through2": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", - "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "readable-stream": "~2.3.6", - "xtend": "~4.0.1" - } - }, - "node_modules/through2/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/through2/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/through2/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/through2/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/toposort-class": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz", - "integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==", - "license": "MIT" - }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/ts-mixer": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", - "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", - "license": "MIT" - }, - "node_modules/tsc-watch": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/tsc-watch/-/tsc-watch-7.2.1.tgz", - "integrity": "sha512-FcntBnz5bXGQ/q265acfDzU7xi6R/sgwMokI+Iu293n14Wu8ovAtcwqNfU9vv/DM1THlnmmxatSSyGOvWsSblg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.6", - "node-cleanup": "^2.1.2", - "ps-tree": "^1.2.0", - "string-argv": "^0.3.2" - }, - "bin": { - "tsc-watch": "dist/lib/tsc-watch.js" - }, - "engines": { - "node": ">=12.12.0" - }, - "peerDependencies": { - "typescript": "*" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", - "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.63.0", - "@typescript-eslint/parser": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/utils": "8.63.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/underscore": { - "version": "1.13.8", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", - "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", - "license": "MIT" - }, - "node_modules/undici": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", - "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "license": "MIT" - }, - "node_modules/untildify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", - "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/uuid": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", - "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, - "node_modules/validator": { - "version": "13.15.35", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", - "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wkx": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", - "integrity": "sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/yargs": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", - "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } + "name": "dingir", + "version": "3.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dingir", + "version": "3.0.0", + "dependencies": { + "@napi-rs/canvas": "^1.0.2", + "better-sqlite3": "^13.0.3", + "croner": "^10.0.1", + "discord.js": "^14.26.5", + "drizzle-orm": "^0.45.3", + "luxon": "^3.7.2", + "pino": "^10.4.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^9.6.0", + "@types/luxon": "^3.7.2", + "@types/node": "^24.13.3", + "oxfmt": "^0.72.0", + "oxlint": "^1.87.0", + "oxlint-tsgolint": "^7.0.2003", + "pino-pretty": "^13.2.0", + "typescript": "^7.0.2" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@napi-rs/canvas": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.2.tgz", + "integrity": "sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==", + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.0.2", + "@napi-rs/canvas-darwin-arm64": "1.0.2", + "@napi-rs/canvas-darwin-x64": "1.0.2", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.2", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.2", + "@napi-rs/canvas-linux-arm64-musl": "1.0.2", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.2", + "@napi-rs/canvas-linux-x64-gnu": "1.0.2", + "@napi-rs/canvas-linux-x64-musl": "1.0.2", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.2", + "@napi-rs/canvas-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.2.tgz", + "integrity": "sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.2.tgz", + "integrity": "sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.2.tgz", + "integrity": "sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.72.0.tgz", + "integrity": "sha512-u4U1uaDOVBuorGNC/07NLm48nECHOiJJka002YGML16Md2DRvf38RGBt01lH60EOGvNlFVmbLoksQedNBoZHKw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.72.0.tgz", + "integrity": "sha512-GCnM+Ae2mWOKC+92y3FOb6eZJeVnBAVz82e3lITUUg+kxukYKzBNAvJ/hhHIfa2A0md55SYYhdWPddSSy3KPVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.72.0.tgz", + "integrity": "sha512-LjQ8tlevdtwraFlWC6AnQ1Forzde0clRmnsP3QmK+ydSElDt/J+5Jc+m+//Ly1G97aP7WCNUw110QMIUw2K7xA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.72.0.tgz", + "integrity": "sha512-+ZbcxhB1C34RQR5IldSyY6Z/yalJIxHXhP36PGE4sEq0f/lgL5jCLmOKoDeVm8dqd9gFGDsd1bNWHSGCAwdSkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.72.0.tgz", + "integrity": "sha512-yGPhA8sePEBFV9iGcprhyOk3Bz3fZshswMH3DpYIPZFLT1DG5LjiZ0j6AJQpSueaL6bUK0av1K+5+fe+ldy5EQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.72.0.tgz", + "integrity": "sha512-/g5axuqFMcAtoQrFLR5EJEMDjR5Ea4GuzpdDmMpPWAgrF1DmL4kD1NPNVlpXEOxpFJnD6lPvLKYj1lzdlrfifA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.72.0.tgz", + "integrity": "sha512-InVitdHbS09PM+w+WinIiyX37rC9R3L7lUP9mKy9uKTEDSUFaLjWsLoreRIxKivnv7Ndqc6Trvi+K+b6ZzhiJg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.72.0.tgz", + "integrity": "sha512-WKjlyRAxpPjXoqeuwFBim5LscgvY+RNEiZ971C9JlRQZONhJTsXDki/oHFYQE+fzzRpzXh3owZvOdT3kNL2g+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.72.0.tgz", + "integrity": "sha512-poafnbsPpJhR+5Mbwpf8CscugTyiRAzjTOxRVn8ra3X2jnURs2xL5Du2xl5g2iW85lICMglpmLf8jDjZEbNgzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.72.0.tgz", + "integrity": "sha512-zNUi0gKRsO7S3IfPzsTbnm88OedPuX32hmZFWZRWHvWJlAoZRwVbeO6Nb3hng4vDgL7qUKp4TGqzPz1mhsycYg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.72.0.tgz", + "integrity": "sha512-qTY7z/iyF/rWI2HWwWMevXqZW5PZUOofUy2afK7v6rseXnpld3yjgyIk6pCux3DqWQ0lB+f5atvLJNBwzLvFsw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.72.0.tgz", + "integrity": "sha512-w18qfo8vEMD6EKEHtzTvjc6jH8yCyXIT1Bn7PFWJd0PpZyzH/VQDx4xvjCh0LGz6GRzqiezbisTdG9AoXtpkZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.72.0.tgz", + "integrity": "sha512-grW8HwEir+vK7fCcq1ZgL3NHQCfZ1PidiERE9Hbf+24xGys+DoDaoqTht4ccfn0PcBwbViLkwkda/KNpJu2KRQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.72.0.tgz", + "integrity": "sha512-wQJRQfWBRIn88Pkr449EUViwRayrYUScg7ty+Ihiem21mQ9BUA6S9e/4RAgyNk9lMag0KBouyKskjq61rOC0WQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.72.0.tgz", + "integrity": "sha512-T1B0uQzafxuGPjjaPrZJ497nxoi1BiI65JfsYc56nNpmev3msfb/iaTmH2kQuNFqa8PKxpMlf3GhWrWBvBPORQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.72.0.tgz", + "integrity": "sha512-OKxxgTurl7+hypVplO8QRAbbQlwEs0j1Y6FZv+ZN2qNbdxQk2muMlzuJNBGTCWwxQ0rkkOD1uK/DW1fvzoUSJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.72.0.tgz", + "integrity": "sha512-L78TKzURqvjuZOCo88ixMbPXf/RP7u31Y0cmjJ/KEmrHmfYhmhEvsDaTYPiyTRne64jUgCbADfY0YeoQk4/Xyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.72.0.tgz", + "integrity": "sha512-gQtFW+Ii8BWgtJTAD17TY/WsdNS5gUfAPov8iI1jeq4r9lRRYImmxPL08UU00/RLaCfBJwh2UgINnDY0iOiZ+A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.72.0.tgz", + "integrity": "sha512-uhooIN+DCzWX5uH7mQkSVCsODqsGluGJ4n3ch4+mITtWKpESMDjUJUSDBAVFuYQDwqY2McPDKdKJbM0oZVW/Qw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint-tsgolint/darwin-arm64": { + "version": "7.0.2003", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-7.0.2003.tgz", + "integrity": "sha512-TgV33rXr6ueXBwvc+0nssUkTBSXHJxv77I8p4RCjDjCnvexHtmoPiudVRDfj3S3puMDdeQdHW6jdUgUPy3nr/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint-tsgolint/darwin-x64": { + "version": "7.0.2003", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-7.0.2003.tgz", + "integrity": "sha512-hY3FMAjIaPDdK3FNxyRXRfHPOFeoBn6dmnLyKZgQ2IbTTD1adXhl6PfCIqHhCPvurigv7nf7mYuWZZ19MmJzmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint-tsgolint/linux-arm64": { + "version": "7.0.2003", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-7.0.2003.tgz", + "integrity": "sha512-eAET4JpyfBbg8SO0K74o4R55tEjVC292pIyxGOw2XGf8x/HrPNyxwQ478jMYOM54FIVOHc8sJ6VRHxluy6lucw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint-tsgolint/linux-x64": { + "version": "7.0.2003", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-7.0.2003.tgz", + "integrity": "sha512-GXdyO/XyqDJ3s/llR/oOktLsYNjZtWSQBy0JMc+/0gsNPvTcseKPhn9c6KcFyWmnr6H4vljYALbujonqSzzEGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint-tsgolint/win32-arm64": { + "version": "7.0.2003", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-7.0.2003.tgz", + "integrity": "sha512-TWauXnPfet0VgpmrstoAK58eJ4gbuwPUuUTQmYQAzE/RG1CpnxXtrKUJULf6lyerPE4KN3gM+DflIA1hOH+38Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint-tsgolint/win32-x64": { + "version": "7.0.2003", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-7.0.2003.tgz", + "integrity": "sha512-DoRmfe7j8VqNlukp8liRVW45GQDhzRccNenjD/pdzelgtffW47pCMd1xbJLkPaPbKnTwID3onn3VZlL7JbebEA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.87.0.tgz", + "integrity": "sha512-22tVIHlB9898OYsBKmYB6He1MCSqdTmK1BVWuSNUTVrpS0KWNege+U9uEYqGJ+KatQag0r/oU44i3OOs46z7HQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.87.0.tgz", + "integrity": "sha512-JfZQ3MWf74n8ov0DojZpkOduIubYJvZL5tbTMMhDutsal6u1xH1QuNpN+lK+EQwF8AeAfgwTR2fgh5U2br27XA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.87.0.tgz", + "integrity": "sha512-rkx6Xvvw93z2GImVOyvbZwAZcoY0Z6MJW177XNsSB+i/CBsH7ANyelTp01VZyIkM3iSXtqKVb4+ptBgPHap5pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.87.0.tgz", + "integrity": "sha512-cw5nYw7L/pQ2SgGBgOcg/Qo89llQre3vIc/kuBLhSICzu1r5s8L76H3ug6AIFsD98WpROj8/z5JVzkf4daxHaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.87.0.tgz", + "integrity": "sha512-x/SuznE6loRZK9gz9AS4ZmzNzSu+5KdeoCS+sVHhnrE8HM6AD8UyTjwldQTTqlXGvkwHASgB0NiTNOc3o3PdAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.87.0.tgz", + "integrity": "sha512-+JEDmCAXiiFmKhAjZhgAOwaHWY6WENOQ5QA+lFuY59tgsUCDOTRi46B+lENK6CWvtxsXpwkZXNOgpjAQcpIpnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.87.0.tgz", + "integrity": "sha512-/uC9XqXLAC9AHFLNycwmSQEvKqGrOCgU+uXALI7gNW0DG81TJ/3T2eHnKdRvcb5R1gFEfjxDwI4e4zIa29rICQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.87.0.tgz", + "integrity": "sha512-FTRYPsBy/M728vnUv7l+KAwSAa3Qi4BQbhVvy7NYuMTZ0sUa/OWsbMx79HVFw6u/5KWcaYayDsoOiv7xbevzDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.87.0.tgz", + "integrity": "sha512-6sC+N7IY0h1egd8SrYUGNN7CUyKJZ4bIZakKa7V7hfinf4jfi8Kq4+klJvFp5DqXcwMn44I5MeBwmJluO1odFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.87.0.tgz", + "integrity": "sha512-d5Mnn+ICbhoJPi37Ccjy+86T5qfH2+mSTrTMBXNPfB+wqSWOayBBLLIrCXdUy1YssluCVdGkl+DO9w5r5ODmUA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.87.0.tgz", + "integrity": "sha512-sf5YWuUo1Dx9loqWCgF8P278MycfcD4JHjDC/LV5j11L3Lqa4PFiwCjD6B8kP4AjsgZZkLn3xbpJEtZBaccs4Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.87.0.tgz", + "integrity": "sha512-+aCraYbmK3uU3P0rFojjeaK9pGY6JvBDXh/0CM+gPyWKmNmXmnFe0qj4qj02U4ibFpbd9Pa9CmDvIdjJUd29rA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.87.0.tgz", + "integrity": "sha512-7rzbZQIljo6VLWVPqUyLa2P/WV3VFsJB4M52R4NCUJn1A50yjtUsHxiMHFL9opEKGFIiTbZ3VQzmtcJOPUq4NQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.87.0.tgz", + "integrity": "sha512-Dry46Fa+AXE/xt9J/YriujU1pU9mzfNWHGQwtOYibAFH8HLrKWCi93AhIGnuUMiwn2JbuBkRyz51f50Ly4r4ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.87.0.tgz", + "integrity": "sha512-f/HMZez74gJvnRpGz+b5sdGyWrEgRLmFTXg9sNoqjPWogGxYHUhXKTKyMiYsu9rUGR2ciYqL3fk+Ua4JhMJbRA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.87.0.tgz", + "integrity": "sha512-lEC6ZfPZm/07JlB7FsOWX9o1RiHlXiBW594LSUGuXmekC8LZmvUPP9XzE2WZcNxJi5cHA/Fw56o02c3JxgMA5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.87.0.tgz", + "integrity": "sha512-afBWKOD36dUiA9AenaiWpCg8xfyUVPYznyAAVXu413brK4RA5UI/Xoc4yfeGIT8G1a44MV+JR7hioryerBDzGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.87.0.tgz", + "integrity": "sha512-daptNsEOuru4/nJTxV4DBHNJ5GepzmaBVQf+jjgKQGi9NRlZKik+RFq4hUL7ewsF1pwjKM+SkYaMbF82UwFGnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.87.0.tgz", + "integrity": "sha512-k+m/EShyXE29J/r1fjk0HlAzhN5JhIvj044oSJkXTEkCLrWZQgXb/3r4q5LTw/eaKW/4GuMXG2xmiJs3s5rPNw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz", + "integrity": "sha512-ZEEwBSgMu7GYJOynoagg5X9JbxfL6dTJDsgViJIqh67jV44kyOr9RXfmFjLK5rzC4MWssP06t9hu/JwGDnUbCg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-gW+Oib+vUtGJBtNC8V9Reww0oIpusw+4m81uncg9REGZAJfqOQHfo/nkabnc7w0QReXyPqjrbWMJk6NuAkiX3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/better-sqlite3": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/croner": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz", + "integrity": "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==", + "funding": [ + { + "type": "other", + "url": "https://paypal.me/hexagonpp" + }, + { + "type": "github", + "url": "https://github.com/sponsors/hexagon" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/discord-api-types": { + "version": "0.38.49", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.49.tgz", + "integrity": "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.26.5", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.5.tgz", + "integrity": "sha512-SGYgjiAs0o8ZzMC97XmFXKONyairJ9YzVda+LvoSKs9YYh2gPtQhY+liaV6H/w72jxYbu9ggiSqAXoF3FLOH4A==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.14.1", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.1", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.48", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.3", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.3.tgz", + "integrity": "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@netlify/db": ">=0.4.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@netlify/db": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/fast-copy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.1.2.tgz", + "integrity": "sha512-DUHggaYcouhy5I1LacDJ+JDSqGlumVJP2EAi5Nwt1pWPeLpSvIjkrY+x4fsoI1/+GlTyq+DM9zdgWCFSfqNODA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/oxfmt": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.72.0.tgz", + "integrity": "sha512-16OQeL0uhPZbfmT9S9WWM0Nv9+BmVi8Ra1uTYaSKj/qSCs6FB30KOhz9JoNYw6thT+K5bVxsk/tSEOu497i34g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.2.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.72.0", + "@oxfmt/binding-android-arm64": "0.72.0", + "@oxfmt/binding-darwin-arm64": "0.72.0", + "@oxfmt/binding-darwin-x64": "0.72.0", + "@oxfmt/binding-freebsd-x64": "0.72.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.72.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.72.0", + "@oxfmt/binding-linux-arm64-gnu": "0.72.0", + "@oxfmt/binding-linux-arm64-musl": "0.72.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.72.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.72.0", + "@oxfmt/binding-linux-riscv64-musl": "0.72.0", + "@oxfmt/binding-linux-s390x-gnu": "0.72.0", + "@oxfmt/binding-linux-x64-gnu": "0.72.0", + "@oxfmt/binding-linux-x64-musl": "0.72.0", + "@oxfmt/binding-openharmony-arm64": "0.72.0", + "@oxfmt/binding-win32-arm64-msvc": "0.72.0", + "@oxfmt/binding-win32-ia32-msvc": "0.72.0", + "@oxfmt/binding-win32-x64-msvc": "0.72.0" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/oxlint": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.87.0.tgz", + "integrity": "sha512-drxVlaW7Oqq7wSy1CN25znRu00aRq8n57Q2wniJRxCmtYVz07ufCm7EVDPQFZURrTgQIjzKrVEHAD/HcvJXObg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.87.0", + "@oxlint/binding-android-arm64": "1.87.0", + "@oxlint/binding-darwin-arm64": "1.87.0", + "@oxlint/binding-darwin-x64": "1.87.0", + "@oxlint/binding-freebsd-x64": "1.87.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.87.0", + "@oxlint/binding-linux-arm-musleabihf": "1.87.0", + "@oxlint/binding-linux-arm64-gnu": "1.87.0", + "@oxlint/binding-linux-arm64-musl": "1.87.0", + "@oxlint/binding-linux-ppc64-gnu": "1.87.0", + "@oxlint/binding-linux-riscv64-gnu": "1.87.0", + "@oxlint/binding-linux-riscv64-musl": "1.87.0", + "@oxlint/binding-linux-s390x-gnu": "1.87.0", + "@oxlint/binding-linux-x64-gnu": "1.87.0", + "@oxlint/binding-linux-x64-musl": "1.87.0", + "@oxlint/binding-openharmony-arm64": "1.87.0", + "@oxlint/binding-win32-arm64-msvc": "1.87.0", + "@oxlint/binding-win32-ia32-msvc": "1.87.0", + "@oxlint/binding-win32-x64-msvc": "1.87.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2003", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/oxlint-tsgolint": { + "version": "7.0.2003", + "resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-7.0.2003.tgz", + "integrity": "sha512-VnK4zlqgmgq/7ZcjzCk/WpN8kKFsYGcB85Io9qT3wL4K8Un3RKmJkp793crNETieMusPMKOA4a+Mw2wbteI6TQ==", + "dev": true, + "license": "MIT", + "bin": { + "tsgolint": "bin/tsgolint.js" + }, + "optionalDependencies": { + "@oxlint-tsgolint/darwin-arm64": "7.0.2003", + "@oxlint-tsgolint/darwin-x64": "7.0.2003", + "@oxlint-tsgolint/linux-arm64": "7.0.2003", + "@oxlint-tsgolint/linux-x64": "7.0.2003", + "@oxlint-tsgolint/win32-arm64": "7.0.2003", + "@oxlint-tsgolint/win32-x64": "7.0.2003" + } + }, + "node_modules/pino": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.4.0.tgz", + "integrity": "sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^1.0.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.2.0.tgz", + "integrity": "sha512-jjghD1ML+Di/WxjJDz6j6Y4FT7hIXOlb8h27AyJ+ohtAXJXH4PS/xkv7si+RCCIW59HMgZsMKTDgzdr0RhuRrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.2", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.3", + "secure-json-parse": "^4.1.0", + "sonic-boom": "^5.0.0", + "strip-json-comments": "^5.0.3" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/sonic-boom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-5.0.1.tgz", + "integrity": "sha512-I1E9tHuKe6SdS/Uab4bl2o1ZUBm2SCTDFF9eQ66ehyVcFIBixfecy9DlwXCtnRF8CUzPw7Q4Dn6JQE/8iVl/FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tinypool": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.2.0.tgz", + "integrity": "sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici": { + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } } diff --git a/package.json b/package.json index ea411a7c..77fc978f 100644 --- a/package.json +++ b/package.json @@ -1,58 +1,50 @@ { - "name": "dingir", - "author": "Brent Oates", - "repository": { - "type": "git", - "url": "https://github.com/BrentOates/dingir.git" - }, - "version": "2.6.2", - "engines": { - "node": ">=24" - }, - "scripts": { - "prebuild": "rimraf dist", - "build": "tsc", - "postbuild": "copyfiles -u 1 -e \"**/*.ts\" \"src/**/*\" dist/src/", - "lint": "eslint .", - "dev": "tsc-watch --noClear -p ./tsconfig.json --onSuccess \"npm run copy-and-start\"", - "copy-and-start": "npm run postbuild && npm start", - "start": "node dist/src/index.js" - }, - "dependencies": { - "@napi-rs/canvas": "^1.0.2", - "discord.js": "^14.26.5", - "dotenv": "^17.4.2", - "glob": "^13.0.6", - "luxon": "^3.7.2", - "node-schedule": "^2.1.1", - "reflect-metadata": "^0.2.2", - "sequelize": "^6.37.8", - "sequelize-typescript": "^2.1.6", - "sqlite3": "^6.0.1", - "tslib": "^2.8.1", - "underscore": "^1.13.8" - }, - "devDependencies": { - "@eslint/js": "^10.0.1", - "@types/luxon": "^3.7.2", - "@types/node": "^24.13.3", - "@types/node-schedule": "^2.1.8", - "@types/underscore": "^1.13.0", - "@types/validator": "^13.15.10", - "copyfiles": "^2.4.1", - "eslint": "^10.6.0", - "rimraf": "^6.1.3", - "tsc-watch": "^7.2.1", - "typescript": "^6.0.3", - "typescript-eslint": "^8.63.0" - }, - "overrides": { - "brace-expansion@<=1.1.12": "1.1.17", - "dottie@<=2.0.6": "2.0.7", - "lodash@<=4.17.23": "4.18.1", - "minimatch@<=3.1.3": "3.1.5", - "undici@<=6.26.0": "6.27.0", - "uuid@<11.1.1": "11.1.1", - "validator@<=13.15.20": "13.15.35" - } + "name": "dingir", + "version": "3.0.0", + "author": "Brent Oates", + "repository": { + "type": "git", + "url": "https://github.com/BrentOates/dingir.git" + }, + "type": "module", + "scripts": { + "lint": "oxlint --type-aware --deny-warnings", + "test": "node --test \"tests/**/*.test.ts\"", + "test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-branches=70 --test-coverage-functions=80 --test-coverage-include='src/**' --test-coverage-exclude='src/index.ts' --test-coverage-exclude='src/scripts/**' \"tests/**/*.test.ts\"", + "dev": "node --watch --env-file-if-exists=.env src/index.ts", + "dev:pretty": "node --watch --env-file-if-exists=.env src/index.ts | pino-pretty", + "start": "node --env-file-if-exists=.env src/index.ts", + "deploy:commands": "node --env-file-if-exists=.env src/scripts/deploy-commands.ts", + "typecheck": "tsc --noEmit", + "lint:fix": "oxlint --type-aware --fix", + "format": "oxfmt", + "format:check": "oxfmt --check", + "check": "npm run format:check && npm run lint && npm run typecheck && npm run test:coverage" + }, + "dependencies": { + "@napi-rs/canvas": "^1.0.2", + "better-sqlite3": "^13.0.3", + "croner": "^10.0.1", + "discord.js": "^14.26.5", + "drizzle-orm": "^0.45.3", + "luxon": "^3.7.2", + "pino": "^10.4.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^9.6.0", + "@types/luxon": "^3.7.2", + "@types/node": "^24.13.3", + "oxfmt": "^0.72.0", + "oxlint": "^1.87.0", + "oxlint-tsgolint": "^7.0.2003", + "pino-pretty": "^13.2.0", + "typescript": "^7.0.2" + }, + "overrides": { + "lodash@<=4.17.23": "4.18.1", + "undici@<=6.28.0": "6.29.0" + }, + "engines": { + "node": ">=24" + } } diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 00000000..75a48a4e --- /dev/null +++ b/src/app.ts @@ -0,0 +1,46 @@ +import { loadEnv, type Env } from './config/env.ts'; +import { openDb, type Db } from './db/db.ts'; +import { migrate } from './db/migrator.ts'; +import { createShutdownRegistry, type ShutdownRegistry } from './framework/shutdown.ts'; +import { type ConfigCache, createConfigCache } from './services/ConfigService.ts'; +import { createHoneypotTracker, type HoneypotTracker } from './services/HoneypotTracker.ts'; +import { createConsoleLogger, type Logger } from './utilities/Logger.ts'; + +/** Everything the bot's handlers and services depend on, built once at startup. */ +export interface App { + env: Readonly; + db: Db; + logger: Logger; + clock: () => Date; + honeypot: HoneypotTracker; + configCache: ConfigCache; + shutdown: ShutdownRegistry; +} + +/** Builds the app, opening and migrating the database unless one is supplied. */ +export function createApp(overrides: Partial = {}): App { + const env = overrides.env ?? loadEnv(); + const logger = overrides.logger ?? createConsoleLogger({ level: env.logLevel }); + const shutdown = overrides.shutdown ?? createShutdownRegistry(logger); + + let db = overrides.db; + if (!db) { + const opened = openDb(env.dbPath); + const applied = migrate(opened.$client, logger); + logger.info('Database migrations complete', { applied: applied.length ? applied : 'none' }); + shutdown.register(() => { + opened.$client.close(); + }); + db = opened; + } + + return { + env, + db, + logger, + clock: overrides.clock ?? (() => new Date()), + honeypot: overrides.honeypot ?? createHoneypotTracker(), + configCache: overrides.configCache ?? createConfigCache(), + shutdown, + }; +} diff --git a/src/client/DingirClient.ts b/src/client/DingirClient.ts new file mode 100644 index 00000000..a3174c04 --- /dev/null +++ b/src/client/DingirClient.ts @@ -0,0 +1,48 @@ +import { Client, Collection, Partials, GatewayIntentBits } from 'discord.js'; +import type { App } from '../app.ts'; +import type { Command } from '../framework/command.ts'; +import { bindEvent } from '../framework/event.ts'; +import { validateRegistry } from '../framework/registry.ts'; +import { commands } from '../commands/index.ts'; +import { events } from '../events/index.ts'; + +class DingirClient extends Client { + public slashCommands: Collection = new Collection(); + private readonly app: App; + + public constructor(app: App) { + super({ + partials: [Partials.Message, Partials.Channel, Partials.GuildMember], + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + ], + }); + this.app = app; + } + + public async start(): Promise { + const { env, logger } = this.app; + + validateRegistry('command', commands); + validateRegistry('event', events); + + for (const command of commands) { + this.slashCommands.set(command.name, command); + } + for (const event of events) { + bindEvent(this.app, this, event); + } + + this.on('error', (error) => logger.error('Discord client error', undefined, error)); + this.on('warn', (message) => logger.warn(message)); + this.app.shutdown.register(() => this.destroy()); + + await this.login(env.token); + logger.info('Logged in'); + } +} + +export { DingirClient }; diff --git a/src/client/NovaClient.ts b/src/client/NovaClient.ts deleted file mode 100644 index b411b88a..00000000 --- a/src/client/NovaClient.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { glob } from 'glob'; -import { Client, Collection, Partials, GatewayIntentBits } from 'discord.js'; -import { Event } from '../types/Event'; -import { Logger } from '../utilities/Logger'; -import 'dotenv/config'; -import { sequelize } from './database/sequelize'; -import { SlashCommand } from '../types/SlashCommand'; - -class NovaClient extends Client { - public events: Collection = new Collection(); - public slashCommands: Collection = new Collection(); - - public constructor() { - super({ - partials: [ - Partials.Message, - Partials.Channel, - Partials.Reaction, - Partials.GuildMember, - ], - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMembers, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.GuildMessageReactions, - GatewayIntentBits.DirectMessages, - GatewayIntentBits.MessageContent, - ], - }); - } - - public async start(): Promise { - await sequelize.sync({alter: true,}); - - const eventFiles: string[] = await glob( - `${__dirname}/../events/**/*{.js,.ts}` - ); - const slashCommandFiles: string[] = await glob( - `${__dirname}/../slash-commands/*/*{.js,.ts}` - ); - - eventFiles.forEach(async (eventFile: string) => { - const importedEvent = await import(eventFile); - const event = (importedEvent.default ?? importedEvent) as Event; - this.events.set(event.name, event); - this.on(event.name, event.run.bind(null, this)); - }); - - slashCommandFiles.forEach(async (slashCommandFile: string) => { - const importedCommand = await import(slashCommandFile); - const cmd = (importedCommand.default ?? importedCommand) as SlashCommand; - this.slashCommands.set(cmd.commandData.name, cmd); - }); - - process.on('SIGTERM', () => { - Logger.writeLog('SIGTERM Received, destroying client & shutting down.'); - this.destroy(); - process.exit(); - }); - - await this.login(process.env.TOKEN); - Logger.writeLog('Logged in'); - } -} - -export { NovaClient }; diff --git a/src/client/database/sequelize.ts b/src/client/database/sequelize.ts deleted file mode 100644 index 1a0f3c54..00000000 --- a/src/client/database/sequelize.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Sequelize } from 'sequelize-typescript'; -import { Logger } from '../../utilities/Logger'; - -import { ServerConfig } from '../models/ServerConfig'; -import { UserProfile } from '../models/UserProfile'; - -export const sequelize = new Sequelize({ - dialect: 'sqlite', - storage: 'data/dingir.sqlite', - models: [ServerConfig, UserProfile], - logging: (msg) => Logger.writeLog(msg), -}); diff --git a/src/client/models/ServerConfig.ts b/src/client/models/ServerConfig.ts deleted file mode 100644 index dd0c65d7..00000000 --- a/src/client/models/ServerConfig.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Table, Column, Model, PrimaryKey, Default } from 'sequelize-typescript'; - -@Table -export class ServerConfig extends Model { - @PrimaryKey - @Column - declare serverId: string; - - @Default('^') - @Column - declare prefix: string; - - @Column - declare rulesMessagePath: string; - - @Column - declare rulesMessage: string; - - @Column - declare guestRoleIds: string; - - @Column - declare adminRoleId: string; - - @Column - declare welcomeMessage: string; - - @Default(false) - @Column - declare debug: boolean; - - @Column - declare auditChannelId: string; - - @Column - declare welcomeMessageBackgroundUrl: string; - - @Default(false) - @Column - declare systemMessagesEnabled: boolean; - - @Column - declare announcementsChannelId: string; - - @Column - declare birthdayCalendarMessagePath: string; - - @Column - declare honeyPotChannelId: string; -} diff --git a/src/client/models/UserProfile.ts b/src/client/models/UserProfile.ts deleted file mode 100644 index 9dd885f6..00000000 --- a/src/client/models/UserProfile.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Table, Column, Model, Default } from 'sequelize-typescript'; - -@Table -export class UserProfile extends Model { - @Column - declare serverId: string; - - @Column - declare userId: string; - - @Column - declare birthdayYear: number; - - @Column - declare birthdayMonth: number; - - @Column - declare birthdayDay: number; - - @Default(0) - @Column - declare activityScore: number; -} diff --git a/src/commands/admin/noroles.ts b/src/commands/admin/noroles.ts new file mode 100644 index 00000000..c93d9caf --- /dev/null +++ b/src/commands/admin/noroles.ts @@ -0,0 +1,19 @@ +import { defineCommand } from '../../framework/command.ts'; +import { fetchAllMembers } from '../../services/MemberResolver.ts'; +import { buildMemberListing } from '../../services/MemberListing.ts'; + +export default defineCommand({ + name: 'noroles', + description: 'List members of this guild with no roles assigned', + adminOnly: true, + defer: 'ephemeral', + run: async (ctx) => { + const all = await fetchAllMembers(ctx.guild); + const members = [...all.values()].filter( + (member) => !member.user.bot && member.roles.cache.size === 1, + ); + await ctx.reply( + buildMemberListing('**Users with no roles**', 'There are no users with no roles.', members), + ); + }, +}); diff --git a/src/commands/admin/post.ts b/src/commands/admin/post.ts new file mode 100644 index 00000000..21efa98b --- /dev/null +++ b/src/commands/admin/post.ts @@ -0,0 +1,85 @@ +import type { Message } from 'discord.js'; +import { ChannelType, PermissionFlagsBits } from 'discord.js'; +import { defineCommand } from '../../framework/command.ts'; +import { UserError } from '../../framework/errors.ts'; +import { EmbedColours } from '../../resources/EmbedColours.ts'; +import { memberAuditEmbed } from '../../services/AuditEmbed.ts'; +import { sendAudit } from '../../services/AuditService.ts'; +import { resolveTextChannel } from '../../services/MemberResolver.ts'; + +export default defineCommand({ + name: 'post', + description: 'Post a simple message and/or attachment to the given channel', + adminOnly: true, + defer: 'ephemeral', + options: (b) => + b + .addChannelOption((opt) => + opt + .setName('channel') + .setDescription('Channel to post in') + .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement) + .setRequired(true), + ) + .addStringOption((opt) => + opt.setName('content').setDescription('Optional simple message to send').setMaxLength(2000), + ) + .addAttachmentOption((opt) => + opt.setName('attachment').setDescription('Optional attachment to send'), + ), + run: async (ctx) => { + const options = ctx.interaction.options; + const target = options.getChannel('channel', true); + const content = options.getString('content'); + const attachment = options.getAttachment('attachment'); + + if (!content && !attachment) { + throw new UserError('You must provide at least text or an attachment.'); + } + + const channel = await resolveTextChannel(ctx.guild, target.id); + if (!channel) { + throw new UserError('The provided channel is not valid.'); + } + + const required = [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages]; + if (attachment) { + required.push(PermissionFlagsBits.AttachFiles); + } + const botPermissions = channel.permissionsFor(ctx.guild.members.me ?? ctx.guild.client.user); + const missing = required.filter((flag) => !botPermissions?.has(flag)); + if (missing.length > 0) { + const names = Object.entries(PermissionFlagsBits) + .filter(([, flag]) => missing.includes(flag)) + .map(([name]) => name); + throw new UserError( + `I am missing permissions in ${channel.toString()}: ${names.join(', ')}.`, + ); + } + + let sent: Message; + try { + sent = await channel.send({ + content: content ?? undefined, + files: attachment ? [attachment] : undefined, + allowedMentions: { parse: ['users', 'roles'] }, + }); + } catch (error) { + ctx.app.logger.warn( + 'Failed to send /post message', + { guildId: ctx.guild.id, channelId: channel.id }, + error, + ); + throw new UserError('An error was encountered sending this message.', { cause: error }); + } + + const audit = memberAuditEmbed(ctx.member, EmbedColours.neutral, 'Post created via Dingir') + .addField('Channel', channel.toString()) + .addField('Content', content ?? 'No text') + .addField('Attachment', attachment ? attachment.name : 'No') + .addField('Message', sent.url); + await sendAudit(ctx.app, ctx.interaction.client, ctx.config, audit); + + await ctx.reply(`Posted in ${channel.toString()}: ${sent.url}`); + }, +}); diff --git a/src/commands/admin/rolesince.ts b/src/commands/admin/rolesince.ts new file mode 100644 index 00000000..cbed46bb --- /dev/null +++ b/src/commands/admin/rolesince.ts @@ -0,0 +1,43 @@ +import { defineCommand } from '../../framework/command.ts'; +import { fetchAllMembers } from '../../services/MemberResolver.ts'; +import { buildMemberListing, wholeDaysSinceJoin } from '../../services/MemberListing.ts'; + +export default defineCommand({ + name: 'rolesince', + description: 'List members of a role who joined the server at least N days ago', + adminOnly: true, + defer: 'ephemeral', + options: (b) => + b + .addRoleOption((opt) => + opt.setName('role').setDescription('Role to search').setRequired(true), + ) + .addIntegerOption((opt) => + opt + .setName('days') + .setDescription('Minimum number of days since the member joined the server') + .setMinValue(0), + ), + run: async (ctx) => { + const role = ctx.interaction.options.getRole('role', true); + const days = ctx.interaction.options.getInteger('days') ?? 0; + + const now = ctx.app.clock().getTime(); + await fetchAllMembers(ctx.guild); + const members = [...role.members.values()].filter((member) => { + if (member.user.bot) { + return false; + } + const joinedDays = wholeDaysSinceJoin(member, now); + return joinedDays === null ? days === 0 : joinedDays >= days; + }); + + await ctx.reply( + buildMemberListing( + `**Users in ${role.toString()} that joined the server at least ${days} days ago**`, + `There are no users in ${role.toString()} that joined the server at least ${days} days ago.`, + members, + ), + ); + }, +}); diff --git a/src/commands/admin/simulate.ts b/src/commands/admin/simulate.ts new file mode 100644 index 00000000..b88492f2 --- /dev/null +++ b/src/commands/admin/simulate.ts @@ -0,0 +1,68 @@ +import type { GuildMember, SlashCommandSubcommandBuilder } from 'discord.js'; +import { type CommandContext, defineCommand } from '../../framework/command.ts'; +import { UserError } from '../../framework/errors.ts'; +import { resolveMember } from '../../services/MemberResolver.ts'; +import { auditJoin, complete, formatOnboardingSummary } from '../../services/OnboardingService.ts'; +import { fitMessage } from '../../utilities/format.ts'; + +const NO_MENTIONS = { parse: [] }; + +const targetOf = async (ctx: CommandContext): Promise => { + const user = ctx.interaction.options.getUser('member'); + if (!user) { + return ctx.member; + } + const member = await resolveMember(ctx.guild, user); + if (!member) { + throw new UserError(`Could not find ${user.toString()} in this server.`); + } + return member; +}; + +const memberOption = (description: string) => (b: SlashCommandSubcommandBuilder) => + b.addUserOption((opt) => opt.setName('member').setDescription(description)); + +export default defineCommand({ + name: 'simulate', + description: 'Simulate member events safely (no roles or welcome messages are applied)', + adminOnly: true, + subcommands: [ + { + name: 'join', + description: 'Send only the "member joined" audit for a member (no roles or welcome)', + defer: 'ephemeral', + options: memberOption('Member to simulate joining as (defaults to you)'), + run: async (ctx) => { + const target = await targetOf(ctx); + const sent = await auditJoin(ctx.app, ctx.interaction.client, target, ctx.config); + await ctx.reply({ + content: sent + ? `Sent the "member joined" audit for ${target.toString()}. No roles or welcome message were applied.` + : 'No audit message was sent: the audit channel is not configured or not usable.', + allowedMentions: NO_MENTIONS, + }); + }, + }, + { + name: 'onboard', + description: 'Dry run: show what onboarding (guest roles, welcome) would do for a member', + defer: 'ephemeral', + options: memberOption('Member to simulate completing onboarding as (defaults to you)'), + run: async (ctx) => { + const target = await targetOf(ctx); + const result = await complete(ctx.app, ctx.interaction.client, target, ctx.config, { + dryRun: true, + }); + const payload = result.welcomePayload; + const imageNote = payload?.imageError ? `\n\nImage failed: ${payload.imageError}` : ''; + const welcome = payload ? `\n\nWelcome preview:\n${payload.content ?? '*(no text)*'}` : ''; + const head = `Dry run for ${target.toString()} (nothing was changed or sent):\n${formatOnboardingSummary(result)}${imageNote}`; + await ctx.reply({ + content: fitMessage(head, welcome), + files: payload?.image ? [payload.image] : undefined, + allowedMentions: NO_MENTIONS, + }); + }, + }, + ], +}); diff --git a/src/commands/config/config.ts b/src/commands/config/config.ts new file mode 100644 index 00000000..9df569ad --- /dev/null +++ b/src/commands/config/config.ts @@ -0,0 +1,48 @@ +import { ChannelType } from 'discord.js'; +import { defineCommand } from '../../framework/command.ts'; +import { booleanSetting, channelSetting } from '../../framework/settings.ts'; +import BirthdaysGroup from './groups/birthdays.ts'; +import DebugGroup from './groups/debug.ts'; +import NewRolesGroup from './groups/newroles.ts'; +import WelcomeGroup from './groups/welcome.ts'; + +const textChannels = [ChannelType.GuildText, ChannelType.GuildAnnouncement] as const; + +export default defineCommand({ + name: 'config', + description: 'Manage configuration data for this server', + adminOnly: true, + groups: [ + channelSetting({ + name: 'announcements', + description: 'Configure the announcements channel for this server', + field: 'announcementsChannelId', + label: 'Announcements channel', + channelTypes: [...textChannels], + }), + DebugGroup, + booleanSetting({ + name: 'sysmsgs', + description: "Toggle bot-created welcome messages in the server's system channel", + field: 'systemMessagesEnabled', + label: 'Bot system messages', + }), + channelSetting({ + name: 'audit', + description: 'Configure the audit channel for this server', + field: 'auditChannelId', + label: 'Audit channel', + channelTypes: [...textChannels], + }), + WelcomeGroup, + NewRolesGroup, + BirthdaysGroup, + channelSetting({ + name: 'honeypot', + description: 'Configure the honey-pot channel for this server', + field: 'honeyPotChannelId', + label: 'Honey-pot channel', + channelTypes: [...textChannels], + }), + ], +}); diff --git a/src/commands/config/groups/birthdays.ts b/src/commands/config/groups/birthdays.ts new file mode 100644 index 00000000..4938eeca --- /dev/null +++ b/src/commands/config/groups/birthdays.ts @@ -0,0 +1,171 @@ +import { ChannelType, PermissionFlagsBits } from 'discord.js'; +import { defineSubcommandGroup } from '../../../framework/command.ts'; +import { UserError } from '../../../framework/errors.ts'; +import { + calendarMessageUrl, + deleteCalendarMessage, + refreshCalendar, +} from '../../../services/BirthdayService.ts'; +import { findConfig, swapConfig, updateConfig } from '../../../services/ConfigService.ts'; +import { resolveTextChannel } from '../../../services/MemberResolver.ts'; + +export const BirthdaysGroup = defineSubcommandGroup({ + name: 'birthdays', + description: 'Configure the birthday calendar for this server', + subcommands: [ + { + name: 'create', + description: 'Create or recreate a birthday calendar for this server', + defer: 'ephemeral', + options: (sub) => + sub.addChannelOption((opt) => + opt + .setName('channel') + .setDescription('Channel to create the birthday calendar in') + .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement) + .setRequired(true), + ), + run: async (ctx) => { + const { id } = ctx.interaction.options.getChannel('channel', true); + const channel = await resolveTextChannel(ctx.guild, id); + if (!channel) { + throw new UserError('I cannot post in that channel. Pick a text channel.'); + } + const me = ctx.guild.members.me; + const perms = me ? channel.permissionsFor(me) : null; + if ( + !perms?.has(PermissionFlagsBits.ViewChannel) || + !perms.has(PermissionFlagsBits.SendMessages) || + !perms.has(PermissionFlagsBits.ReadMessageHistory) + ) { + throw new UserError( + 'I need permission to view the channel, send messages and read message history in that channel.', + ); + } + + const client = ctx.interaction.client; + + // Send first and persist before touching the old calendar, so a failure at either step + // leaves the existing calendar and its stored path intact. + const message = await channel.send({ + content: 'Placeholder calendar message - populating...', + allowedMentions: { parse: [] }, + }); + const path = `${message.channelId}/${message.id}`; + // The calendar this create replaces is whatever is stored at the moment of saving: a + // concurrent create may have replaced the one ctx.config saw, and must not be orphaned. + let previousPath: string | null | undefined; + try { + const swapped = await swapConfig(ctx.app, ctx.config.serverId, { + birthdayCalendarMessagePath: path, + }); + previousPath = swapped.previous.birthdayCalendarMessagePath; + ctx.config = swapped.updated; + } catch (error) { + await message.delete().catch((deleteError: unknown) => { + ctx.app.logger.warn( + 'Could not remove the new birthday calendar message after a failed save', + { guild: ctx.guild.id }, + deleteError, + ); + }); + throw error; + } + + const status = await refreshCalendar(ctx.app, client, ctx.config); + const link = calendarMessageUrl(ctx.guild.id, path); + if (status !== 'updated') { + // Keep whatever calendar existed before: restore its path (unless a concurrent create + // has since replaced ours) and drop the new placeholder. + try { + const stored = await findConfig(ctx.app, ctx.guild.id); + if (stored?.birthdayCalendarMessagePath === path) { + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { + birthdayCalendarMessagePath: previousPath ?? null, + }); + } + } catch (error) { + ctx.app.logger.error( + 'Could not restore the previous birthday calendar path', + { guild: ctx.guild.id }, + error, + ); + } + await message.delete().catch((deleteError: unknown) => { + ctx.app.logger.warn( + 'Could not remove the new birthday calendar message after a failed population', + { guild: ctx.guild.id }, + deleteError, + ); + }); + throw new UserError( + previousPath + ? "I couldn't populate the new birthday calendar, so the existing calendar was kept. Try again later." + : "I couldn't populate the new birthday calendar, so nothing was changed. Try again later.", + ); + } + if (previousPath && previousPath !== path) { + const deleted = await deleteCalendarMessage(ctx.app, client, previousPath); + if (deleted === 'failed') { + ctx.app.logger.warn('Previous birthday calendar message was left in place', { + guild: ctx.guild.id, + path: previousPath, + }); + } + } + await ctx.reply(`Birthday calendar has been created: ${link}`); + }, + }, + { + name: 'sync', + description: 'Sync the birthday calendar for this server', + defer: 'ephemeral', + run: async (ctx) => { + const status = await refreshCalendar(ctx.app, ctx.interaction.client, ctx.config); + const replies = { + updated: `Calendar successfully synchronised for ${ctx.guild.name}.`, + 'not-configured': + 'The birthday calendar is not configured. Use `/config birthdays create`.', + 'channel-missing': + 'The calendar channel is missing or inaccessible. Recreate it with `/config birthdays create`.', + 'message-missing': + 'The calendar message is missing. Recreate it with `/config birthdays create`.', + 'no-access': + 'I do not have permission to view or edit the calendar message. Fix my permissions in that channel, or recreate it with `/config birthdays create`.', + failed: 'An error occurred syncing the calendar. Try again later.', + } as const; + await ctx.reply(replies[status]); + }, + }, + { + name: 'remove', + description: 'Remove the birthday calendar for this server', + defer: 'ephemeral', + run: async (ctx) => { + if (!ctx.config.birthdayCalendarMessagePath) { + throw new UserError('There is no birthday calendar configured.'); + } + const deleted = await deleteCalendarMessage( + ctx.app, + ctx.interaction.client, + ctx.config.birthdayCalendarMessagePath, + ); + if (deleted === 'failed') { + throw new UserError( + "Couldn't delete the calendar message right now; nothing was changed. Try again.", + ); + } + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { + birthdayCalendarMessagePath: null, + }); + await ctx.reply( + deleted === 'deleted' + ? 'Birthday calendar removed.' + : 'Birthday calendar removed (the message was already gone).', + ); + }, + }, + ], +}); + +export default BirthdaysGroup; diff --git a/src/commands/config/groups/debug.ts b/src/commands/config/groups/debug.ts new file mode 100644 index 00000000..d24eeb08 --- /dev/null +++ b/src/commands/config/groups/debug.ts @@ -0,0 +1,10 @@ +import { booleanSetting } from '../../../framework/settings.ts'; + +export const DebugGroup = booleanSetting({ + name: 'debug', + description: 'Post a diagnostic audit summarising each onboarding run', + field: 'debug', + label: 'Onboarding diagnostics', +}); + +export default DebugGroup; diff --git a/src/commands/config/groups/newroles.ts b/src/commands/config/groups/newroles.ts new file mode 100644 index 00000000..76263e51 --- /dev/null +++ b/src/commands/config/groups/newroles.ts @@ -0,0 +1,104 @@ +import type { Role } from 'discord.js'; +import { type CommandContext, defineSubcommandGroup } from '../../../framework/command.ts'; +import { UserError } from '../../../framework/errors.ts'; +import { EmbedColours } from '../../../resources/EmbedColours.ts'; +import { memberAuditEmbed } from '../../../services/AuditEmbed.ts'; +import { sendAudit } from '../../../services/AuditService.ts'; +import { updateConfig } from '../../../services/ConfigService.ts'; +import { parseRoleIds, roleProblem } from '../../../services/OnboardingService.ts'; + +const ROLE_OPTIONS = ['role-one', 'role-two', 'role-three'] as const; +const NO_MENTIONS = { parse: [] }; + +const auditChange = (ctx: CommandContext, description: string, roleIds: string[]) => { + const embed = memberAuditEmbed(ctx.member, EmbedColours.info, description); + if (roleIds.length > 0) { + embed.addField('New member roles', roleIds.map((id) => `<@&${id}>`).join('\n')); + } + return sendAudit(ctx.app, ctx.interaction.client, ctx.config, embed); +}; + +const get = async (ctx: CommandContext) => { + const ids = parseRoleIds(ctx.config.guestRoleIds); + if (ids.length === 0) { + await ctx.reply('No new-member roles are configured.'); + return; + } + const lines = ids.map((id) => + ctx.guild.roles.cache.has(id) ? `<@&${id}>` : `${id} (this role no longer exists)`, + ); + await ctx.reply({ + content: `Roles given to members when they complete onboarding:\n${lines.join('\n')}`, + allowedMentions: NO_MENTIONS, + }); +}; + +export const NewRolesGroup = defineSubcommandGroup({ + name: 'newroles', + description: 'Roles given to members when they complete onboarding', + subcommands: [ + { + name: 'get', + description: 'Show the roles given to members when they complete onboarding', + run: get, + }, + { + name: 'set', + description: 'Set the roles given to members when they complete onboarding', + defer: 'ephemeral', + options: (sub) => + sub + .addRoleOption((option) => + option + .setName(ROLE_OPTIONS[0]) + .setDescription('Role to give to members when they complete onboarding') + .setRequired(true), + ) + .addRoleOption((option) => + option.setName(ROLE_OPTIONS[1]).setDescription('Optional second role to give'), + ) + .addRoleOption((option) => + option.setName(ROLE_OPTIONS[2]).setDescription('Optional third role to give'), + ), + run: async (ctx) => { + const roles = new Map(); + for (const name of ROLE_OPTIONS) { + const role = ctx.interaction.options.getRole(name); + if (role) { + roles.set(role.id, role); + } + } + + const problems = [...roles.values()].flatMap((role) => { + const problem = roleProblem(ctx.guild, role); + return problem ? [`${role.name}: ${problem}`] : []; + }); + if (problems.length > 0) { + throw new UserError(`Those roles cannot be used:\n${problems.join('\n')}`); + } + + const ids = [...roles.keys()]; + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { + guestRoleIds: ids.join(','), + }); + await auditChange(ctx, 'New member roles updated', ids); + await ctx.reply({ + content: `New-member roles set to ${ids.map((id) => `<@&${id}>`).join(', ')}.`, + allowedMentions: NO_MENTIONS, + }); + }, + }, + { + name: 'clear', + description: 'Clear the roles given to members when they complete onboarding', + defer: 'ephemeral', + run: async (ctx) => { + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { guestRoleIds: null }); + await auditChange(ctx, 'New member roles cleared', []); + await ctx.reply('New-member roles cleared.'); + }, + }, + ], +}); + +export default NewRolesGroup; diff --git a/src/commands/config/groups/welcome.ts b/src/commands/config/groups/welcome.ts new file mode 100644 index 00000000..68b03405 --- /dev/null +++ b/src/commands/config/groups/welcome.ts @@ -0,0 +1,183 @@ +import { type CommandContext, defineSubcommandGroup } from '../../../framework/command.ts'; +import { UserError } from '../../../framework/errors.ts'; +import { type ServerConfigPatch, updateConfig } from '../../../services/ConfigService.ts'; +import { + complete, + expandWelcomeMessage, + MAX_WELCOME_LENGTH, +} from '../../../services/OnboardingService.ts'; +import { fitMessage } from '../../../utilities/format.ts'; +import { isHttpUrl, render } from '../../../services/WelcomeImage.ts'; + +export const MAX_WELCOME_MESSAGE_LENGTH = 1500; + +/** Worst-case length once `{member}` becomes a mention (snowflakes are at most 20 digits). */ +const expandedLength = (text: string): number => expandWelcomeMessage(text, '0'.repeat(20)).length; + +export const validateWelcomeMessage = (text: string): string | null => + text.trim() === '' + ? 'The welcome message cannot be empty. Use /config welcome clear to remove it.' + : text.length > MAX_WELCOME_MESSAGE_LENGTH + ? `The welcome message is too long (${text.length} characters, maximum ${MAX_WELCOME_MESSAGE_LENGTH}).` + : expandedLength(text) > MAX_WELCOME_LENGTH + ? `Once every {member} is expanded to a mention the message would be ${expandedLength(text)} characters, over Discord's ${MAX_WELCOME_LENGTH} character limit. Shorten it or use fewer {member}.` + : null; + +export const validateImageUrl = (url: string): string | null => + isHttpUrl(url) ? null : 'The image URL must start with http:// or https://'; + +const NO_MENTIONS = { parse: [] }; + +const MAX_IMAGE_URL_LENGTH = 1000; +/** Legacy stored URLs may be longer than the option allows; keep `get` replies within Discord's limit. */ +const MAX_SHOWN_URL_LENGTH = 500; + +const setMessage = async (ctx: CommandContext) => { + const text = ctx.interaction.options.getString('text', true); + const problem = validateWelcomeMessage(text); + if (problem) { + throw new UserError(problem); + } + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { + welcomeMessage: text, + }); + await ctx.reply({ + content: 'Welcome message saved. Use `{member}` in the text to mention the new member.', + allowedMentions: NO_MENTIONS, + }); +}; + +const setImage = async (ctx: CommandContext) => { + const url = ctx.interaction.options.getString('url', true).trim(); + const problem = validateImageUrl(url); + if (problem) { + throw new UserError(problem); + } + try { + await render(ctx.app, ctx.member, url); + } catch (error) { + throw new UserError( + `That image could not be used: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { + welcomeMessageBackgroundUrl: url, + }); + await ctx.reply('Welcome image saved. Use /config welcome preview to see how it looks.'); +}; + +const get = async (ctx: CommandContext) => { + const { welcomeMessage, welcomeMessageBackgroundUrl } = ctx.config; + const shownUrl = + welcomeMessageBackgroundUrl && welcomeMessageBackgroundUrl.length > MAX_SHOWN_URL_LENGTH + ? `${welcomeMessageBackgroundUrl.slice(0, MAX_SHOWN_URL_LENGTH - 1)}…` + : (welcomeMessageBackgroundUrl ?? 'Not set'); + const image = `\nWelcome image: ${shownUrl}`; + await ctx.reply({ + content: + fitMessage( + 'Welcome message: ', + welcomeMessage ? `\n${welcomeMessage}` : 'Not set', + 2000 - image.length, + ) + image, + allowedMentions: NO_MENTIONS, + }); +}; + +const clear = async (ctx: CommandContext) => { + const which = ctx.interaction.options.getString('which', true); + const patch: ServerConfigPatch = {}; + if (which === 'message' || which === 'all') { + patch.welcomeMessage = null; + } + if (which === 'image' || which === 'all') { + patch.welcomeMessageBackgroundUrl = null; + } + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, patch); + await ctx.reply(`Cleared welcome ${which === 'all' ? 'message and image' : which}.`); +}; + +const preview = async (ctx: CommandContext) => { + const result = await complete(ctx.app, ctx.interaction.client, ctx.member, ctx.config, { + dryRun: true, + }); + const payload = result.welcomePayload; + if (!payload) { + await ctx.reply(`Nothing would be sent: ${result.welcome.replace(/^(skipped|failed):/, '')}.`); + return; + } + const notes = payload.imageError ? `The image failed to render: ${payload.imageError}\n\n` : ''; + await ctx.reply({ + content: fitMessage( + `Preview of the welcome message for you:\n\n${notes}`, + payload.content ?? '*(no text)*', + ), + files: payload.image ? [payload.image] : undefined, + allowedMentions: NO_MENTIONS, + }); +}; + +export const WelcomeGroup = defineSubcommandGroup({ + name: 'welcome', + description: 'Control welcome information for this server', + subcommands: [ + { + name: 'set-message', + description: 'Set the welcome message ({member} is replaced by a mention)', + options: (sub) => + sub.addStringOption((opt) => + opt + .setName('text') + .setDescription('The welcome text; use {member} to mention the new member') + .setMaxLength(MAX_WELCOME_MESSAGE_LENGTH) + .setRequired(true), + ), + run: setMessage, + }, + { + name: 'set-image', + description: 'Set the background image used for the welcome image', + defer: 'ephemeral', + options: (sub) => + sub.addStringOption((opt) => + opt + .setName('url') + .setDescription('http(s) URL of the background image') + .setMaxLength(MAX_IMAGE_URL_LENGTH) + .setRequired(true), + ), + run: setImage, + }, + { + name: 'get', + description: 'Show the welcome message and image for this server', + run: get, + }, + { + name: 'clear', + description: 'Clear the welcome message, image, or both', + options: (sub) => + sub.addStringOption((opt) => + opt + .setName('which') + .setDescription('What to clear') + .setRequired(true) + .addChoices( + { name: 'Welcome message', value: 'message' }, + { name: 'Welcome image', value: 'image' }, + { name: 'Both', value: 'all' }, + ), + ), + run: clear, + }, + { + name: 'preview', + description: 'Preview the welcome message as it would be sent for you', + defer: 'ephemeral', + run: preview, + }, + ], +}); + +export default WelcomeGroup; diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 00000000..62831fce --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,23 @@ +import type { Command } from '../framework/command.ts'; +import noroles from './admin/noroles.ts'; +import post from './admin/post.ts'; +import rolesince from './admin/rolesince.ts'; +import simulate from './admin/simulate.ts'; +import config from './config/config.ts'; +import about from './info/about.ts'; +import mybirthday from './info/mybirthday.ts'; +import ping from './info/ping.ts'; +import profile from './info/profile.ts'; + +/** Every slash command the bot registers. Add new commands here. */ +export const commands: Command[] = [ + noroles, + post, + rolesince, + simulate, + config, + about, + mybirthday, + ping, + profile, +]; diff --git a/src/commands/info/about.ts b/src/commands/info/about.ts new file mode 100644 index 00000000..2b1baa57 --- /dev/null +++ b/src/commands/info/about.ts @@ -0,0 +1,34 @@ +import { EmbedBuilder } from 'discord.js'; +import { defineCommand } from '../../framework/command.ts'; +import { EmbedColours } from '../../resources/EmbedColours.ts'; +import { toRepoUrl, formatUptime } from '../../utilities/format.ts'; +import packageJson from '../../../package.json' with { type: 'json' }; + +export default defineCommand({ + name: 'about', + description: 'Show information about Dingir and this server', + defer: 'ephemeral', + run: async (ctx) => { + const version = packageJson.version; + const projectUrl = packageJson.repository.url; + const repoUrl = toRepoUrl(projectUrl); + const serverCount = ctx.interaction.client.guilds.cache.size; + const guildName = ctx.guild.name; + const memberCount = ctx.guild.memberCount; + const uptime = formatUptime(ctx.interaction.client.uptime); + + const embed = new EmbedBuilder() + .setColor(EmbedColours.info) + .setTitle('About Dingir') + .setDescription('A Discord bot for the Irkallu server') + .addFields( + { name: 'Version', value: version, inline: false }, + { name: 'Source', value: repoUrl, inline: false }, + { name: 'Servers', value: serverCount.toString(), inline: false }, + { name: 'This server', value: `${guildName} (${memberCount} members)`, inline: false }, + { name: 'Uptime', value: uptime, inline: false }, + ); + + await ctx.reply({ embeds: [embed] }); + }, +}); diff --git a/src/commands/info/mybirthday.ts b/src/commands/info/mybirthday.ts new file mode 100644 index 00000000..6cd27e7b --- /dev/null +++ b/src/commands/info/mybirthday.ts @@ -0,0 +1,79 @@ +import { DateTime } from 'luxon'; +import { type CommandContext, defineCommand } from '../../framework/command.ts'; +import { UserError } from '../../framework/errors.ts'; +import { isValidBirthday, nextOccurrence } from '../../services/BirthdayDates.ts'; +import { refreshCalendar } from '../../services/BirthdayService.ts'; +import { clearBirthday, setBirthday } from '../../services/UserProfileService.ts'; + +const refresh = async (ctx: CommandContext): Promise => { + try { + const status = await refreshCalendar(ctx.app, ctx.interaction.client, ctx.config); + if (status !== 'updated' && status !== 'not-configured') { + ctx.app.logger.warn('Birthday calendar not refreshed', { guild: ctx.guild.id, status }); + } + } catch (error) { + ctx.app.logger.error('Birthday calendar refresh failed', { guild: ctx.guild.id }, error); + } +}; + +export default defineCommand({ + name: 'mybirthday', + description: 'Manage your birthday in this server', + subcommands: [ + { + name: 'set', + description: 'Set your birthday in this server', + options: (sub) => + sub + .addIntegerOption((opt) => + opt + .setName('day') + .setDescription('Day of the month of your birthday') + .setRequired(true) + .setMinValue(1) + .setMaxValue(31), + ) + .addIntegerOption((opt) => + opt + .setName('month') + .setDescription('Month of your birthday') + .setRequired(true) + .setMinValue(1) + .setMaxValue(12), + ), + run: async (ctx) => { + const day = ctx.interaction.options.getInteger('day', true); + const month = ctx.interaction.options.getInteger('month', true); + + if (!isValidBirthday(month, day)) { + throw new UserError('That date is invalid; check the day and month and try again.'); + } + + await setBirthday(ctx.app.db, ctx.guild.id, ctx.interaction.user.id, month, day); + + const zone = ctx.app.env.timezone; + const now = DateTime.fromJSDate(ctx.app.clock()); + const next = nextOccurrence(month, day, now, zone); + const isToday = next.hasSame(now.setZone(zone), 'day'); + const text = isToday + ? 'today 🎉' + : next.setLocale('en-GB').toLocaleString(DateTime.DATE_FULL); + await ctx.reply(`Saved. Your next birthday is ${text}`); + await refresh(ctx); + }, + }, + { + name: 'clear', + description: 'Remove your birthday from this server', + run: async (ctx) => { + const cleared = await clearBirthday(ctx.app.db, ctx.guild.id, ctx.interaction.user.id); + await ctx.reply( + cleared ? 'Your birthday has been removed.' : "You don't have a birthday set.", + ); + if (cleared) { + await refresh(ctx); + } + }, + }, + ], +}); diff --git a/src/commands/info/ping.ts b/src/commands/info/ping.ts new file mode 100644 index 00000000..20f4337e --- /dev/null +++ b/src/commands/info/ping.ts @@ -0,0 +1,12 @@ +import { defineCommand } from '../../framework/command.ts'; + +export default defineCommand({ + name: 'ping', + description: 'Ping Dingir', + defer: 'ephemeral', + run: async (ctx) => { + const roundTrip = ctx.app.clock().getTime() - ctx.interaction.createdTimestamp; + const websocket = Math.round(ctx.interaction.client.ws.ping); + await ctx.reply(`Pong! WebSocket: ${websocket}ms, round-trip: ${roundTrip}ms`); + }, +}); diff --git a/src/commands/info/profile.ts b/src/commands/info/profile.ts new file mode 100644 index 00000000..18822f72 --- /dev/null +++ b/src/commands/info/profile.ts @@ -0,0 +1,59 @@ +import { EmbedBuilder } from 'discord.js'; +import { DateTime } from 'luxon'; +import { defineCommand } from '../../framework/command.ts'; +import { UserError } from '../../framework/errors.ts'; +import { EmbedColours } from '../../resources/EmbedColours.ts'; +import { resolveMember } from '../../services/MemberResolver.ts'; +import { findUserProfile } from '../../services/UserProfileService.ts'; + +export default defineCommand({ + name: 'profile', + description: 'Fetch profiles for server members', + adminOnly: true, + defer: 'ephemeral', + options: (b) => + b.addUserOption((opt) => + opt.setName('member').setDescription('Member to fetch profile for').setRequired(true), + ), + run: async (ctx) => { + const user = ctx.interaction.options.getUser('member', true); + const member = await resolveMember(ctx.guild, user); + if (!member) { + throw new UserError("That user isn't a member of this server."); + } + + const profile = await findUserProfile(ctx.app.db, ctx.guild.id, member.id); + const birthday = + profile?.birthdayMonth && profile.birthdayDay + ? DateTime.utc(2024, profile.birthdayMonth, profile.birthdayDay) + .setLocale('en-GB') + .toFormat('d MMMM') + : null; + + const embed = new EmbedBuilder() + .setThumbnail(member.displayAvatarURL()) + .setColor(EmbedColours.info) + .setTitle('User Profile') + .setTimestamp() + .addFields( + { name: 'Member', value: member.toString() }, + { name: 'Nickname', value: member.nickname ?? 'Not set' }, + { name: 'Username', value: member.user.username }, + { + name: 'Joined', + value: + member.joinedTimestamp === null + ? 'Unknown' + : ``, + }, + { name: 'Onboarding', value: member.pending ? 'Not completed' : 'Completed' }, + { name: 'Activity Score', value: String(profile?.activityScore ?? 0) }, + ); + if (birthday) { + embed.addFields({ name: 'Birthday', value: birthday }); + } + embed.addFields({ name: 'ID', value: member.id }); + + await ctx.reply({ embeds: [embed] }); + }, +}); diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 00000000..46d99b87 --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,125 @@ +import { Cron } from 'croner'; +import { IANAZone } from 'luxon'; +import { + DEFAULT_LOG_LEVEL, + LOG_LEVELS, + type LogLevel, + parseLogLevel, +} from '../utilities/Logger.ts'; + +export interface Env { + readonly token: string; + readonly clientId: string; + readonly jobSchedule: string; + readonly timezone: string; + readonly dbPath: string; + readonly purgeMinFailures: number; + readonly purgeGraceDays: number; + readonly logLevel: LogLevel; + readonly devGuildId?: string; +} + +type Source = Record; + +const CRON_FIELD = /^[\w*/,?#-]+$/; + +function parseInteger(value: string, min: number): number | undefined { + if (!/^-?\d+$/.test(value)) { + return undefined; + } + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= min ? parsed : undefined; +} + +export function loadEnv(source: Source = process.env): Readonly { + const problems: string[] = []; + const read = (key: string): string | undefined => { + const value = source[key]?.trim(); + return value ? value : undefined; + }; + const required = (key: string): string => { + const value = read(key); + if (!value) { + problems.push(`${key} is required`); + } + return value ?? ''; + }; + + const token = required('TOKEN'); + const clientId = required('CLIENT_ID'); + + const jobSchedule = read('JOB_SCHEDULE') ?? '0 9 * * *'; + const timezone = read('BOT_TIMEZONE') ?? 'Europe/London'; + const timezoneValid = IANAZone.isValidZone(timezone); + if (!timezoneValid) { + problems.push(`BOT_TIMEZONE must be a valid IANA time zone (got "${timezone}")`); + } + + const cronFields = jobSchedule.split(/\s+/); + if ( + (cronFields.length !== 5 && cronFields.length !== 6) || + !cronFields.every((field) => CRON_FIELD.test(field)) + ) { + problems.push(`JOB_SCHEDULE must be a 5- or 6-field cron expression (got "${jobSchedule}")`); + } else { + try { + new Cron(jobSchedule, timezoneValid ? { paused: true, timezone } : { paused: true }).stop(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + problems.push(`JOB_SCHEDULE is not a valid cron expression: ${message}`); + } + } + + const dbPath = read('DB_PATH') ?? 'data/dingir.sqlite'; + + let purgeMinFailures = 3; + const rawFailures = read('PURGE_MIN_FAILURES'); + if (rawFailures !== undefined) { + const parsed = parseInteger(rawFailures, 1); + if (parsed === undefined) { + problems.push(`PURGE_MIN_FAILURES must be an integer >= 1 (got "${rawFailures}")`); + } else { + purgeMinFailures = parsed; + } + } + + let purgeGraceDays = 7; + const rawGrace = read('PURGE_GRACE_DAYS'); + if (rawGrace !== undefined) { + const parsed = parseInteger(rawGrace, 0); + if (parsed === undefined) { + problems.push(`PURGE_GRACE_DAYS must be an integer >= 0 (got "${rawGrace}")`); + } else { + purgeGraceDays = parsed; + } + } + + let logLevel = DEFAULT_LOG_LEVEL; + const rawLogLevel = read('LOG_LEVEL'); + if (rawLogLevel !== undefined) { + const parsed = parseLogLevel(rawLogLevel); + if (parsed === undefined) { + problems.push(`LOG_LEVEL must be one of ${LOG_LEVELS.join(', ')} (got "${rawLogLevel}")`); + } else { + logLevel = parsed; + } + } + + const devGuildId = read('DEV_GUILD_ID'); + + if (problems.length > 0) { + throw new Error(`Invalid environment configuration:\n - ${problems.join('\n - ')}`); + } + + return Object.freeze({ + token, + clientId, + jobSchedule, + timezone, + dbPath, + purgeMinFailures, + purgeGraceDays, + logLevel, + devGuildId, + }); +} diff --git a/src/db/db.ts b/src/db/db.ts new file mode 100644 index 00000000..a65e9879 --- /dev/null +++ b/src/db/db.ts @@ -0,0 +1,19 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import * as schema from './schema.ts'; + +export type Db = BetterSQLite3Database & { $client: Database.Database }; + +/** Opens (creating the directory if needed) a SQLite database with the pragmas the bot relies on. */ +export function openDb(file: string): Db { + if (file !== ':memory:') { + fs.mkdirSync(path.dirname(file), { recursive: true }); + } + const sqlite = new Database(file); + sqlite.pragma('journal_mode = WAL'); + sqlite.pragma('foreign_keys = ON'); + sqlite.pragma('busy_timeout = 5000'); + return drizzle(sqlite, { schema }); +} diff --git a/src/db/migrations/001-baseline.ts b/src/db/migrations/001-baseline.ts new file mode 100644 index 00000000..d04020f4 --- /dev/null +++ b/src/db/migrations/001-baseline.ts @@ -0,0 +1,56 @@ +import type { Database } from 'better-sqlite3'; + +type Columns = Record; + +const serverConfigColumns: Columns = { + serverId: 'VARCHAR(255) PRIMARY KEY', + prefix: "VARCHAR(255) DEFAULT '^'", + rulesMessagePath: 'VARCHAR(255)', + rulesMessage: 'VARCHAR(255)', + guestRoleIds: 'VARCHAR(255)', + adminRoleId: 'VARCHAR(255)', + welcomeMessage: 'VARCHAR(255)', + debug: 'TINYINT(1) DEFAULT 0', + auditChannelId: 'VARCHAR(255)', + welcomeMessageBackgroundUrl: 'VARCHAR(255)', + systemMessagesEnabled: 'TINYINT(1) DEFAULT 0', + announcementsChannelId: 'VARCHAR(255)', + birthdayCalendarMessagePath: 'VARCHAR(255)', + honeyPotChannelId: 'VARCHAR(255)', + createdAt: 'DATETIME NOT NULL', + updatedAt: 'DATETIME NOT NULL', +}; + +const userProfileColumns: Columns = { + id: 'INTEGER PRIMARY KEY AUTOINCREMENT', + serverId: 'VARCHAR(255)', + userId: 'VARCHAR(255)', + birthdayYear: 'INTEGER', + birthdayMonth: 'INTEGER', + birthdayDay: 'INTEGER', + activityScore: 'INTEGER DEFAULT 0', + createdAt: 'DATETIME NOT NULL', + updatedAt: 'DATETIME NOT NULL', +}; + +function ensureTable(db: Database, table: string, columns: Columns): void { + const existing = db.pragma(`table_info(\`${table}\`)`) as { name: string }[]; + if (existing.length === 0) { + const definitions = Object.entries(columns).map(([name, type]) => `\`${name}\` ${type}`); + db.exec(`CREATE TABLE \`${table}\` (${definitions.join(', ')})`); + return; + } + + // Tables created by the old sync() may predate some columns. + const present = new Set(existing.map((column) => column.name)); + for (const [name, type] of Object.entries(columns)) { + if (!present.has(name) && !/PRIMARY KEY|NOT NULL/.test(type)) { + db.exec(`ALTER TABLE \`${table}\` ADD COLUMN \`${name}\` ${type}`); + } + } +} + +export function up(db: Database): void { + ensureTable(db, 'ServerConfigs', serverConfigColumns); + ensureTable(db, 'UserProfiles', userProfileColumns); +} diff --git a/src/db/migrations/002-userprofile-unique.ts b/src/db/migrations/002-userprofile-unique.ts new file mode 100644 index 00000000..60244194 --- /dev/null +++ b/src/db/migrations/002-userprofile-unique.ts @@ -0,0 +1,85 @@ +import type { Database } from 'better-sqlite3'; +import type { Logger } from '../../utilities/Logger.ts'; + +const INDEX_NAME = 'user_profiles_server_user_unique'; + +interface ProfileRow { + id: number; + serverId: string; + userId: string; + birthdayYear: number | null; + birthdayMonth: number | null; + birthdayDay: number | null; + activityScore: number | null; + updatedAt: string | null; +} + +const toTime = (value: string | null): number => { + const parsed = Date.parse( + String(value ?? '') + .replace(' ', 'T') + .replace(/ ([+-])/, '$1'), + ); + return Number.isNaN(parsed) ? 0 : parsed; +}; + +const hasBirthday = (row: ProfileRow): boolean => + row.birthdayMonth !== null && row.birthdayDay !== null; + +const birthdayKey = (row: ProfileRow): string => + `${row.birthdayYear ?? ''}-${row.birthdayMonth}-${row.birthdayDay}`; + +const newestFirst = (a: ProfileRow, b: ProfileRow): number => + toTime(b.updatedAt) - toTime(a.updatedAt) || b.id - a.id; + +export function up(db: Database, logger: Logger): void { + const rows = db + .prepare( + 'SELECT id, serverId, userId, birthdayYear, birthdayMonth, birthdayDay, activityScore, updatedAt FROM `UserProfiles` WHERE serverId IS NOT NULL AND userId IS NOT NULL', + ) + .all() as ProfileRow[]; + + const groups = new Map(); + for (const row of rows) { + const key = JSON.stringify([row.serverId, row.userId]); + const group = groups.get(key); + if (group) { + group.push(row); + } else { + groups.set(key, [row]); + } + } + + const setScore = db.prepare('UPDATE `UserProfiles` SET activityScore = ? WHERE id = ?'); + const remove = db.prepare('DELETE FROM `UserProfiles` WHERE id = ?'); + + for (const group of groups.values()) { + if (group.length < 2) { + continue; + } + + const withBirthday = group.filter(hasBirthday).sort(newestFirst); + const keep = withBirthday[0] ?? [...group].sort(newestFirst)[0]; + if (!keep) { + continue; + } + + if (new Set(withBirthday.map(birthdayKey)).size > 1) { + logger.warn('Conflicting birthdays while deduplicating UserProfiles; keeping most recent', { + serverId: keep.serverId, + userId: keep.userId, + keptId: keep.id, + birthdays: withBirthday.map((row) => ({ id: row.id, birthday: birthdayKey(row) })), + }); + } + + setScore.run(Math.max(...group.map((row) => row.activityScore ?? 0)), keep.id); + for (const row of group) { + if (row.id !== keep.id) { + remove.run(row.id); + } + } + } + + db.exec(`CREATE UNIQUE INDEX \`${INDEX_NAME}\` ON \`UserProfiles\` (\`serverId\`, \`userId\`)`); +} diff --git a/src/db/migrations/003-serverconfig-access-tracking.ts b/src/db/migrations/003-serverconfig-access-tracking.ts new file mode 100644 index 00000000..eeee3e48 --- /dev/null +++ b/src/db/migrations/003-serverconfig-access-tracking.ts @@ -0,0 +1,16 @@ +import type { Database } from 'better-sqlite3'; + +export function up(db: Database): void { + const columns = new Set( + (db.pragma('table_info(`ServerConfigs`)') as { name: string }[]).map((column) => column.name), + ); + + if (!columns.has('accessFailureCount')) { + db.exec( + 'ALTER TABLE `ServerConfigs` ADD COLUMN `accessFailureCount` INTEGER NOT NULL DEFAULT 0', + ); + } + if (!columns.has('firstAccessFailureAt')) { + db.exec('ALTER TABLE `ServerConfigs` ADD COLUMN `firstAccessFailureAt` DATETIME'); + } +} diff --git a/src/db/migrations/004-bot-state.ts b/src/db/migrations/004-bot-state.ts new file mode 100644 index 00000000..3fa5ac21 --- /dev/null +++ b/src/db/migrations/004-bot-state.ts @@ -0,0 +1,7 @@ +import type { Database } from 'better-sqlite3'; + +export function up(db: Database): void { + db.exec( + 'CREATE TABLE IF NOT EXISTS `BotState` (`key` TEXT PRIMARY KEY, `value` TEXT NOT NULL, `updatedAt` DATETIME NOT NULL)', + ); +} diff --git a/src/db/migrations/005-userprofile-backfill.ts b/src/db/migrations/005-userprofile-backfill.ts new file mode 100644 index 00000000..0c72933a --- /dev/null +++ b/src/db/migrations/005-userprofile-backfill.ts @@ -0,0 +1,20 @@ +import type { Database } from 'better-sqlite3'; +import type { Logger } from '../../utilities/Logger.ts'; + +/** Brings legacy NULLs in line with the schema (activityScore and the ids are NOT NULL). */ +export function up(db: Database, logger: Logger): void { + const orphans = ( + db + .prepare( + 'SELECT COUNT(*) AS count FROM `UserProfiles` WHERE serverId IS NULL OR userId IS NULL', + ) + .get() as { count: number } + ).count; + if (orphans > 0) { + logger.warn('Deleting UserProfiles rows with no server or user id; they cannot be read', { + count: orphans, + }); + } + db.exec('UPDATE `UserProfiles` SET activityScore = 0 WHERE activityScore IS NULL'); + db.exec('DELETE FROM `UserProfiles` WHERE serverId IS NULL OR userId IS NULL'); +} diff --git a/src/db/migrations/006-userprofile-onboarded-at.ts b/src/db/migrations/006-userprofile-onboarded-at.ts new file mode 100644 index 00000000..232472b7 --- /dev/null +++ b/src/db/migrations/006-userprofile-onboarded-at.ts @@ -0,0 +1,11 @@ +import type { Database } from 'better-sqlite3'; + +/** Records when a member's onboarding was last run so replayed gateway events stay idempotent. */ +export function up(db: Database): void { + const columns = new Set( + (db.pragma('table_info(`UserProfiles`)') as { name: string }[]).map((column) => column.name), + ); + if (!columns.has('onboardedAt')) { + db.exec('ALTER TABLE `UserProfiles` ADD COLUMN `onboardedAt` DATETIME'); + } +} diff --git a/src/db/migrations/007-userprofile-screening-pending.ts b/src/db/migrations/007-userprofile-screening-pending.ts new file mode 100644 index 00000000..3b7a76b2 --- /dev/null +++ b/src/db/migrations/007-userprofile-screening-pending.ts @@ -0,0 +1,11 @@ +import type { Database } from 'better-sqlite3'; + +/** Records when a member was seen pending membership screening, so onboarding never relies on inference. */ +export function up(db: Database): void { + const columns = new Set( + (db.pragma('table_info(`UserProfiles`)') as { name: string }[]).map((column) => column.name), + ); + if (!columns.has('screeningPendingAt')) { + db.exec('ALTER TABLE `UserProfiles` ADD COLUMN `screeningPendingAt` DATETIME'); + } +} diff --git a/src/db/migrator.ts b/src/db/migrator.ts new file mode 100644 index 00000000..45c10737 --- /dev/null +++ b/src/db/migrator.ts @@ -0,0 +1,48 @@ +import type { Database } from 'better-sqlite3'; +import type { Logger } from '../utilities/Logger.ts'; +import * as baseline from './migrations/001-baseline.ts'; +import * as userProfileUnique from './migrations/002-userprofile-unique.ts'; +import * as serverConfigAccessTracking from './migrations/003-serverconfig-access-tracking.ts'; +import * as botState from './migrations/004-bot-state.ts'; +import * as userProfileBackfill from './migrations/005-userprofile-backfill.ts'; +import * as userProfileOnboardedAt from './migrations/006-userprofile-onboarded-at.ts'; +import * as userProfileScreeningPending from './migrations/007-userprofile-screening-pending.ts'; + +export interface Migration { + name: string; + up: (db: Database, logger: Logger) => void; +} + +export const migrations: Migration[] = [ + { name: '001-baseline', up: baseline.up }, + { name: '002-userprofile-unique', up: userProfileUnique.up }, + { name: '003-serverconfig-access-tracking', up: serverConfigAccessTracking.up }, + { name: '004-bot-state', up: botState.up }, + { name: '005-userprofile-backfill', up: userProfileBackfill.up }, + { name: '006-userprofile-onboarded-at', up: userProfileOnboardedAt.up }, + { name: '007-userprofile-screening-pending', up: userProfileScreeningPending.up }, +]; + +/** Applies pending migrations in order, each in its own transaction, and returns their names. */ +export function migrate(db: Database, logger: Logger, list: Migration[] = migrations): string[] { + db.exec('CREATE TABLE IF NOT EXISTS `SequelizeMeta` (`name` VARCHAR(255) PRIMARY KEY)'); + + const done = new Set( + (db.prepare('SELECT name FROM `SequelizeMeta`').all() as { name: string }[]).map((r) => r.name), + ); + const record = db.prepare('INSERT INTO `SequelizeMeta` (name) VALUES (?)'); + const applied: string[] = []; + + for (const migration of list) { + if (done.has(migration.name)) { + continue; + } + db.transaction(() => { + migration.up(db, logger); + record.run(migration.name); + })(); + applied.push(migration.name); + } + + return applied; +} diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 00000000..43e83bcf --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,72 @@ +import { customType, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; + +const SEQUELIZE_DATE = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}(?:\.\d+)?) ([+-]\d{2}:\d{2})$/; + +/** Formats like Sequelize did: `2024-01-01 12:00:00.000 +00:00` (always UTC). */ +export const formatDate = (date: Date): string => + `${date.toISOString().replace('T', ' ').replace('Z', '')} +00:00`; + +export const parseDate = (value: string): Date => { + const match = SEQUELIZE_DATE.exec(value); + return new Date(match ? `${match[1]}T${match[2]}${match[3]}` : value); +}; + +const datetime = customType<{ data: Date; driverData: string }>({ + dataType: () => 'DATETIME', + toDriver: formatDate, + fromDriver: parseDate, +}); + +export const serverConfigs = sqliteTable('ServerConfigs', { + serverId: text('serverId', { length: 255 }).primaryKey(), + guestRoleIds: text('guestRoleIds', { length: 255 }), + welcomeMessage: text('welcomeMessage', { length: 255 }), + debug: integer('debug', { mode: 'boolean' }).notNull().default(false), + auditChannelId: text('auditChannelId', { length: 255 }), + welcomeMessageBackgroundUrl: text('welcomeMessageBackgroundUrl', { length: 255 }), + systemMessagesEnabled: integer('systemMessagesEnabled', { mode: 'boolean' }) + .notNull() + .default(false), + announcementsChannelId: text('announcementsChannelId', { length: 255 }), + birthdayCalendarMessagePath: text('birthdayCalendarMessagePath', { length: 255 }), + honeyPotChannelId: text('honeyPotChannelId', { length: 255 }), + accessFailureCount: integer('accessFailureCount').notNull().default(0), + firstAccessFailureAt: datetime('firstAccessFailureAt'), + createdAt: datetime('createdAt') + .notNull() + .$defaultFn(() => new Date()), + updatedAt: datetime('updatedAt') + .notNull() + .$defaultFn(() => new Date()), +}); + +export const userProfiles = sqliteTable( + 'UserProfiles', + { + id: integer('id').primaryKey({ autoIncrement: true }), + serverId: text('serverId', { length: 255 }).notNull(), + userId: text('userId', { length: 255 }).notNull(), + birthdayYear: integer('birthdayYear'), + birthdayMonth: integer('birthdayMonth'), + birthdayDay: integer('birthdayDay'), + activityScore: integer('activityScore').notNull().default(0), + onboardedAt: datetime('onboardedAt'), + screeningPendingAt: datetime('screeningPendingAt'), + createdAt: datetime('createdAt') + .notNull() + .$defaultFn(() => new Date()), + updatedAt: datetime('updatedAt') + .notNull() + .$defaultFn(() => new Date()), + }, + (table) => [uniqueIndex('user_profiles_server_user_unique').on(table.serverId, table.userId)], +); + +export const botState = sqliteTable('BotState', { + key: text('key').primaryKey(), + value: text('value').notNull(), + updatedAt: datetime('updatedAt').notNull(), +}); + +export type ServerConfig = typeof serverConfigs.$inferSelect; +export type UserProfile = typeof userProfiles.$inferSelect; diff --git a/src/events/guildCreate.ts b/src/events/guildCreate.ts index c210eece..d216a338 100644 --- a/src/events/guildCreate.ts +++ b/src/events/guildCreate.ts @@ -1,26 +1,14 @@ -import { Guild } from 'discord.js'; -import { NovaClient } from '../client/NovaClient'; -import { RunFunction } from '../types/Event'; -import { ConfigService } from '../utilities/ConfigService'; -import { Logger } from '../utilities/Logger'; +import { defineEvent } from '../framework/event.ts'; +import { getConfig, resetAccessFailures } from '../services/ConfigService.ts'; +import { syncGuildScreening } from '../services/ScreeningService.ts'; -export const name = 'guildCreate'; -export const run: RunFunction = async (client: NovaClient, guild: Guild) => { - ConfigService.getConfig(guild.id) - .then((config) => { - if (config) { - return Logger.writeLog( - `Bot added to new guild: ${guild.name} (${guild.id}).` - ); - } - return Logger.writeError( - `Bot added to new guild, but config could not be generated: ${guild.name} (${guild.id}).` - ); - }) - .catch((err) => { - return Logger.writeError( - `Bot added to new guild, but config could not be generated: ${guild.name} (${guild.id}).`, - err - ); - }); -}; +export default defineEvent({ + name: 'guildCreate', + run: async (app, client, guild) => { + const config = await getConfig(app, guild.id); + await resetAccessFailures(app, config); + app.logger.info('Bot added to guild', { guild: guild.id, name: guild.name }); + // Members already pending screening when the bot arrives have no recorded state yet. + await syncGuildScreening(app, client, guild); + }, +}); diff --git a/src/events/guildDelete.ts b/src/events/guildDelete.ts index 8d805efc..0da5e679 100644 --- a/src/events/guildDelete.ts +++ b/src/events/guildDelete.ts @@ -1,18 +1,15 @@ -import { Guild } from 'discord.js'; -import { NovaClient } from '../client/NovaClient'; -import { RunFunction } from '../types/Event'; -import { ConfigService } from '../utilities/ConfigService'; -import { Logger } from '../utilities/Logger'; -import { UserProfileService } from '../utilities/UserProfileService'; +import { defineEvent } from '../framework/event.ts'; +import { purgeGuild } from '../services/ConfigService.ts'; -export const name = 'guildDelete'; -export const run: RunFunction = async (client: NovaClient, guild: Guild) => { - const configDeleted = await ConfigService.deleteConfig(guild.id); - const userProfilesDeleted = await UserProfileService.deleteUsersByServer( - guild.id - ); - - Logger.writeLog(`Bot removed from guild: ${guild.name} (${guild.id}).`); - Logger.writeLog(`Config deleted: ${configDeleted}.`); - Logger.writeLog(`User profiles deleted: ${userProfilesDeleted}.`); -}; +export default defineEvent({ + name: 'guildDelete', + run: async (app, _client, guild) => { + const result = await purgeGuild(app, guild.id); + app.logger.info('Bot removed from guild', { + guild: guild.id, + name: guild.name, + config: result.config, + profiles: result.profiles, + }); + }, +}); diff --git a/src/events/guildMemberAdd.ts b/src/events/guildMemberAdd.ts index 6a9f6422..fb156d6a 100644 --- a/src/events/guildMemberAdd.ts +++ b/src/events/guildMemberAdd.ts @@ -1,27 +1,26 @@ -import { GuildMember } from 'discord.js'; -import { NovaClient } from '../client/NovaClient'; -import { RunFunction } from '../types/Event'; -import { EmbedColours } from '../resources/EmbedColours'; -import { ChannelService } from '../utilities/ChannelService'; -import { ConfigService } from '../utilities/ConfigService'; -import { EmbedCompatLayer } from '../types/EmbedCompatLayer'; +import { defineEvent } from '../framework/event.ts'; +import { getConfig } from '../services/ConfigService.ts'; +import { auditJoin, complete } from '../services/OnboardingService.ts'; +import { claimOnboarding, startMembership } from '../services/UserProfileService.ts'; -export const name = 'guildMemberAdd'; -export const run: RunFunction = async ( - client: NovaClient, - member: GuildMember -) => { - const serverConfig = await ConfigService.getConfig(member.guild.id); +export default defineEvent({ + name: 'guildMemberAdd', + run: async (app, client, member) => { + const config = await getConfig(app, member.guild.id); + const ids = [app.db, member.guild.id, member.id] as const; - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.positive) - .setAuthor({ - name: member.displayName, - iconURL: member.displayAvatarURL(), - }) - .setDescription('New member joined') - .addField('ID', member.user.id) - .setTimestamp(); + // A join is a new membership. Reset and claim before the first network await, so a concurrent + // leave or update event sees the new state and the join audit cannot delay or duplicate it. + let claimed = false; + if (!member.user.bot) { + await startMembership(...ids, app.clock(), member.pending); + claimed = !member.pending && (await claimOnboarding(...ids, app.clock())); + } - await ChannelService.sendAuditMessage(client, serverConfig, audit); -}; + await auditJoin(app, client, member, config); + + if (claimed) { + await complete(app, client, member, config, { skipAudit: true }); + } + }, +}); diff --git a/src/events/guildMemberRemove.ts b/src/events/guildMemberRemove.ts index 0044246e..d33031bd 100644 --- a/src/events/guildMemberRemove.ts +++ b/src/events/guildMemberRemove.ts @@ -1,46 +1,37 @@ -import { GuildMember } from 'discord.js'; -import { NovaClient } from '../client/NovaClient'; -import { EmbedColours } from '../resources/EmbedColours'; -import { RunFunction } from '../types/Event'; -import { ChannelService } from '../utilities/ChannelService'; -import { ConfigService } from '../utilities/ConfigService'; -import { EmbedCompatLayer } from '../types/EmbedCompatLayer'; -import { UserProfileService } from '../utilities/UserProfileService'; -import { HoneyPotEnforcementService } from '../utilities/HoneyPotEnforcementService'; +import { defineEvent } from '../framework/event.ts'; +import { EmbedColours } from '../resources/EmbedColours.ts'; +import { memberAuditEmbed } from '../services/AuditEmbed.ts'; +import { sendAudit } from '../services/AuditService.ts'; +import { refreshCalendar } from '../services/BirthdayService.ts'; +import { getConfig } from '../services/ConfigService.ts'; +import { deleteUser } from '../services/UserProfileService.ts'; -export const name = 'guildMemberRemove'; -export const run: RunFunction = async ( - client: NovaClient, - member: GuildMember -) => { - if (member.user.bot) { - return; - } +export default defineEvent({ + name: 'guildMemberRemove', + run: async (app, client, member) => { + const user = member.user; + if (user.bot) { + return; + } - if (HoneyPotEnforcementService.isActive(member.guild.id, member.user.id)) { - await UserProfileService.deleteUser(member.guild.id, member.user.id); - return; - } + const removed = await deleteUser(app.db, member.guild.id, user.id); + const config = await getConfig(app, member.guild.id); + if (removed?.birthdayMonth != null) { + // The calendar must not keep listing a member who is no longer here. + await refreshCalendar(app, client, config); + } + if (app.honeypot.isActive(member.guild.id, user.id)) { + return; + } - const dataDeleted = await UserProfileService.deleteUser( - member.guild.id, - member.user.id - ); - - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.negative) - .setAuthor({ - name: member.displayName, - iconURL: member.displayAvatarURL(), - }) - .setDescription('Member left') - .addField('ID', member.user.id) - .addField( - 'Member data cleanup', - dataDeleted ? 'Deleted' : 'No stored member data' + const audit = memberAuditEmbed( + member.partial ? user : member, + EmbedColours.negative, + 'Member left', ) - .setTimestamp(); + .addField('ID', user.id) + .addField('Member data cleanup', removed ? 'Deleted' : 'No stored member data'); - const serverConfig = await ConfigService.getConfig(member.guild.id); - await ChannelService.sendAuditMessage(client, serverConfig, audit); -}; + await sendAudit(app, client, config, audit); + }, +}); diff --git a/src/events/guildMemberUpdate.ts b/src/events/guildMemberUpdate.ts index 31676abd..af723259 100644 --- a/src/events/guildMemberUpdate.ts +++ b/src/events/guildMemberUpdate.ts @@ -1,195 +1,33 @@ -import { GuildMember, AttachmentBuilder } from 'discord.js'; -import { NovaClient } from '../client/NovaClient'; -import { RunFunction } from '../types/Event'; -import { EmbedColours } from '../resources/EmbedColours'; -import { ChannelService } from '../utilities/ChannelService'; -import { ConfigService } from '../utilities/ConfigService'; -import { DateTime } from 'luxon'; -import { ServerConfig } from '../client/models/ServerConfig'; -import { Logger } from '../utilities/Logger'; -import { EmbedCompatLayer } from '../types/EmbedCompatLayer'; -import { Canvas, createCanvas, GlobalFonts, loadImage } from '@napi-rs/canvas'; - -const applyText = (canvas: Canvas, text: string, baseSize: number) => { - const ctx = canvas.getContext('2d'); - - let fontSize = baseSize; - - do { - fontSize--; - ctx.font = `${fontSize}px Roboto`; - } while (ctx.measureText(text).width > canvas.width - 300); - - return ctx.font; -}; - -const getWelcomeMessage = async (config: ServerConfig, member: GuildMember) => { - return config.welcomeMessage.replace( - '{member}', - `<@${member.id}>` - ); -}; - -const getWelcomeImage = async (config: ServerConfig, member: GuildMember) => { - GlobalFonts.registerFromPath( - `${__dirname}/../resources/fonts/Roboto-Regular.ttf`, - 'Roboto' - ); - - const canvas = createCanvas(700, 250); - const ctx = canvas.getContext('2d'); - const joinedTs = DateTime.fromMillis(member.joinedTimestamp).toLocaleString( - DateTime.DATE_FULL - ); - - // Draw background - const background = await loadImage(config.welcomeMessageBackgroundUrl); - ctx.drawImage(background, 0, 0, canvas.width, canvas.height); - - // Draw Username - ctx.font = applyText(canvas, member.displayName, 48); - ctx.fillStyle = '#ffffff'; - ctx.fillText(member.displayName, 225, 125); - - // Draw Server name - ctx.font = applyText(canvas, `Welcome to ${member.guild.name}!`, 34); - ctx.fillStyle = '#ffffff'; - ctx.fillText(`Welcome to ${member.guild.name}!`, 225, 160); - - // Draw Joined at - ctx.font = applyText(canvas, `Joined: ${joinedTs}`, 20); - ctx.fillStyle = '#ffffff'; - ctx.fillText(`Joined: ${joinedTs}`, 225, 200); - - // Draw Avatar - ctx.beginPath(); - ctx.arc(125, 125, 75, 0, Math.PI * 2, true); - ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)'; - ctx.lineWidth = 10; - ctx.stroke(); - ctx.closePath(); - ctx.clip(); - - const avatar = await loadImage( - member.displayAvatarURL({ - extension: 'jpg', - }) - ); - ctx.drawImage(avatar, 50, 50, 150, 150); - - const attachment = new AttachmentBuilder(await canvas.encode('png')).setName( - 'welcome-image.png' - ); - - return attachment; -}; - -const addGuestRoles = async(serverConfig: ServerConfig, newMember: GuildMember, client: NovaClient) => { - try { - const guestRoleIds = serverConfig.guestRoleIds.split(','); - const guildRoles = await newMember.guild.roles.fetch(); - - await newMember.roles.add( - guildRoles.filter((role) => guestRoleIds.includes(role.id)) - ); - } catch (e) { - Logger.writeError( - `Adding guest roles failed in guildMemberUpdate for server: ${serverConfig.id}.`, - e - ); - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.negative) - .setAuthor({ - name: newMember.displayName, - iconURL: newMember.displayAvatarURL(), - }) - .setDescription('Unable to provide guest role(s) to user.') - .addField('ID', newMember.user.id) - .setTimestamp(); - - return ChannelService.sendAuditMessage(client, serverConfig, audit); - } -}; - -const sendScreenAudit = async(serverConfig: ServerConfig, newMember: GuildMember, client: NovaClient) => { - try { - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.neutral) - .setAuthor({ - name: newMember.displayName, - iconURL: newMember.displayAvatarURL(), - }) - .setDescription('Rules accepted by member.') - .addField('ID', newMember.user.id) - .setTimestamp(); - - await ChannelService.sendAuditMessage(client, serverConfig, audit); - } catch (e) { - return Logger.writeError( - `Sending audit failed in guildMemberUpdate for server: ${serverConfig.id}.`, - e - ); - } -}; - -export const name = 'guildMemberUpdate'; -export const run: RunFunction = async ( - client: NovaClient, - oldMember: GuildMember, - newMember: GuildMember -) => { - if (newMember.partial) { - await newMember.fetch(); - } - - const serverConfig = await ConfigService.getConfig(newMember.guild.id); - const notPassedScreen = - oldMember.pending || - (oldMember.pending === null && newMember.roles.cache.size === 1); - - if (notPassedScreen && !newMember.pending) { - sendScreenAudit(serverConfig, newMember, client); - - if (serverConfig.guestRoleIds) { - addGuestRoles(serverConfig, newMember, client); +import { defineEvent } from '../framework/event.ts'; +import { completedScreening, onboard } from '../services/OnboardingService.ts'; +import { getConfig } from '../services/ConfigService.ts'; +import { currentOnboardingState } from '../services/ScreeningService.ts'; +import { recordScreeningPending } from '../services/UserProfileService.ts'; + +export default defineEvent({ + name: 'guildMemberUpdate', + run: async (app, client, oldMember, newMember) => { + if (newMember.user.bot) { + return; } + const state = await currentOnboardingState(app, newMember); - if ( - serverConfig.systemMessagesEnabled && - (serverConfig.welcomeMessage || serverConfig.welcomeMessageBackgroundUrl) - ) { - try { - let attachment: AttachmentBuilder; - let content: string; - if (serverConfig.welcomeMessageBackgroundUrl) { - attachment = await getWelcomeImage(serverConfig, newMember); - } - - if (serverConfig.welcomeMessage) { - content = await getWelcomeMessage(serverConfig, newMember); - } + if (newMember.pending === true) { + await recordScreeningPending(app.db, newMember.guild.id, newMember.id, app.clock()); + return; + } + if (newMember.pending !== false) { + return; + } - await newMember.guild.systemChannel.send({ - content: content ?? undefined, - files: attachment ? [attachment] : undefined, - }); - - } catch (e) { - Logger.writeError( - `Sending welcome message failed in guildMemberUpdate for server: ${serverConfig.serverId}.`, - e - ); - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.negative) - .setAuthor({ - name: newMember.displayName, - iconURL: newMember.displayAvatarURL(), - }) - .setDescription('Unable to send welcome message.') - .addField('ID', newMember.user.id) - .setTimestamp(); - return ChannelService.sendAuditMessage(client, serverConfig, audit); - } + // With an unknown previous state only a recorded pending state proves screening just ended. + const screeningEnded = oldMember.partial + ? state.screeningPendingAt !== null + : completedScreening(oldMember, newMember); + if (!screeningEnded) { + return; } - } -}; + + await onboard(app, client, newMember, await getConfig(app, newMember.guild.id)); + }, +}); diff --git a/src/events/index.ts b/src/events/index.ts new file mode 100644 index 00000000..09fe7ae5 --- /dev/null +++ b/src/events/index.ts @@ -0,0 +1,25 @@ +import guildCreate from './guildCreate.ts'; +import guildDelete from './guildDelete.ts'; +import guildMemberAdd from './guildMemberAdd.ts'; +import guildMemberRemove from './guildMemberRemove.ts'; +import guildMemberUpdate from './guildMemberUpdate.ts'; +import interactionCreate from './interactionCreate.ts'; +import messageCreate from './messageCreate.ts'; +import messageDelete from './messageDelete.ts'; +import messageUpdate from './messageUpdate.ts'; +import ready from './ready.ts'; +import type { AnyEventDefinition } from '../framework/event.ts'; + +/** Every gateway event the bot listens to. Add new events here. */ +export const events: AnyEventDefinition[] = [ + guildCreate, + guildDelete, + guildMemberAdd, + guildMemberRemove, + guildMemberUpdate, + interactionCreate, + messageCreate, + messageDelete, + messageUpdate, + ready, +]; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index d9f23cac..95a6948e 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -1,47 +1,73 @@ -import { ChatInputCommandInteraction, Interaction } from 'discord.js'; -import { NovaClient } from '../client/NovaClient'; -import { RunFunction } from '../types/Event'; -import { ConfigService } from '../utilities/ConfigService'; -import { Logger } from '../utilities/Logger'; -import { UserProfileService } from '../utilities/UserProfileService'; -import { ServerConfig } from '../client/models/ServerConfig'; - -export const name = 'interactionCreate'; - -const runCommand = async (client: NovaClient, cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const slashCmd = client.slashCommands.get(cmd.commandName); - - if (!slashCmd) { - return; - } - - await slashCmd.execute(cmd, config) - .catch((err: string) => { - if (cmd.deferred && !cmd.replied) { - cmd.editReply({ content: 'Something went wrong, was this command run in the correct place?' }); - } else if (!cmd.replied) { - cmd.reply({ content: 'Something went wrong, was this command run in the correct place?', ephemeral: true }); - } - Logger.writeError(err); - }); -}; - -export const run: RunFunction = async (client: NovaClient, interaction: Interaction) => { - if (!interaction.isChatInputCommand()) { - return; - } - - if (!interaction.guild) { - return interaction.reply({ - content: 'Dingir only supports interactions in Discord Servers.', - ephemeral: true - }); - } - - const serverConfig = await ConfigService.getConfigByMessage(interaction); - await UserProfileService.incrementActivityScore(interaction.guild.id, interaction.user.id); - - if (serverConfig) { - await runCommand(client, interaction, serverConfig); - } -}; +import { MessageFlags } from 'discord.js'; +import { type CommandContext, createReply } from '../framework/command.ts'; +import { UserError } from '../framework/errors.ts'; +import { defineEvent } from '../framework/event.ts'; +import { getConfig } from '../services/ConfigService.ts'; + +export default defineEvent({ + name: 'interactionCreate', + run: async (app, client, interaction) => { + if (!interaction.isChatInputCommand()) { + return; + } + + if (!interaction.inCachedGuild()) { + await interaction.reply({ + content: 'Dingir only works in servers.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const command = client.slashCommands.get(interaction.commandName); + const resolved = command?.resolve(interaction); + if (!resolved) { + app.logger.warn('Unknown command', { + command: interaction.commandName, + subcommand: interaction.options.getSubcommand(false), + guild: interaction.guildId, + user: interaction.user.id, + }); + await interaction.reply({ content: 'Unknown command.', flags: MessageFlags.Ephemeral }); + return; + } + + const reply = createReply(interaction); + try { + if (resolved.defer) { + await interaction.deferReply( + resolved.defer === 'ephemeral' ? { flags: MessageFlags.Ephemeral } : {}, + ); + } + const config = await getConfig(app, interaction.guildId); + const ctx: CommandContext = { + app, + interaction, + guild: interaction.guild, + member: interaction.member, + config, + reply, + }; + await resolved.run(ctx); + } catch (error) { + const context = { + command: interaction.commandName, + path: resolved.path, + guild: interaction.guildId, + user: interaction.user.id, + }; + let message = 'Something went wrong running this command.'; + if (error instanceof UserError) { + message = error.message; + app.logger.info('Command rejected', { ...context, reason: message }); + } else { + app.logger.error('Command failed', context, error); + } + try { + await reply(message); + } catch (replyError) { + app.logger.error('Could not send command error reply', { path: resolved.path }, replyError); + } + } + }, +}); diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index 7032bbdb..09d948d6 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -1,75 +1,71 @@ -import { Message, PermissionFlagsBits } from 'discord.js'; -import { NovaClient } from '../client/NovaClient'; -import { EmbedColours } from '../resources/EmbedColours'; -import { RunFunction } from '../types/Event'; -import { EmbedCompatLayer } from '../types/EmbedCompatLayer'; -import { ChannelService } from '../utilities/ChannelService'; -import { ConfigService } from '../utilities/ConfigService'; -import { HoneyPotEnforcementService } from '../utilities/HoneyPotEnforcementService'; -import { Logger } from '../utilities/Logger'; -import { UserProfileService } from '../utilities/UserProfileService'; +import type { GuildMember, Message } from 'discord.js'; +import { PermissionFlagsBits, type Snowflake } from 'discord.js'; +import type { App } from '../app.ts'; +import type { DingirClient } from '../client/DingirClient.ts'; +import type { ServerConfig } from '../db/schema.ts'; +import { defineEvent } from '../framework/event.ts'; +import { EmbedColours } from '../resources/EmbedColours.ts'; +import { memberAuditEmbed } from '../services/AuditEmbed.ts'; +import { sendAudit } from '../services/AuditService.ts'; +import { getConfig } from '../services/ConfigService.ts'; +import { deleteUser, incrementActivityScore } from '../services/UserProfileService.ts'; const DELETE_MESSAGE_SECONDS = 7 * 24 * 60 * 60; -const formatError = (error: unknown): string => { - return error instanceof Error ? error.message : String(error); -}; +const formatError = (error: unknown): string => + error instanceof Error ? error.message : String(error); -const sendAudit = async ( - client: NovaClient, - message: Message, +const auditHoneypot = async ( + app: App, + client: DingirClient, + config: ServerConfig, + member: GuildMember, + channelId: Snowflake, description: string, - action: string + action: string, ): Promise => { - const config = await ConfigService.getConfig(message.guild.id); - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.negative) - .setAuthor({ - name: message.author.tag, - iconURL: message.author.displayAvatarURL(), - }) - .setDescription(description) - .addField('Member ID', message.author.id) - .addField('Channel', message.channel.toString()) - .addField('Action', action) - .setTimestamp(); - - await ChannelService.sendAuditMessage(client, config, audit); + const audit = memberAuditEmbed(member, EmbedColours.negative, description) + .addField('Member ID', member.id) + .addField('Channel', `<#${channelId}>`) + .addField('Action', action); + await sendAudit(app, client, config, audit); }; -export const name = 'messageCreate'; -export const run: RunFunction = async ( - client: NovaClient, - message: Message -) => { - if (!message.guild || message.author.bot) { - return; - } - - const config = await ConfigService.getConfig(message.guild.id); - if (config.honeyPotChannelId !== message.channelId) { - return; +/** Returns true when the message was posted in the honeypot channel and has been dealt with. */ +export const handleHoneypot = async ( + app: App, + client: DingirClient, + message: Message, + config: ServerConfig, +): Promise => { + if (!config.honeyPotChannelId || config.honeyPotChannelId !== message.channelId) { + return false; } const member = - message.member ?? - (await message.guild.members.fetch(message.author.id).catch(() => null)); + message.member ?? (await message.guild.members.fetch(message.author.id).catch(() => null)); if (!member || member.permissions.has(PermissionFlagsBits.Administrator)) { - return; + return true; } - if (!HoneyPotEnforcementService.begin(message.guild.id, member.id)) { - return; + if (!app.honeypot.begin(message.guild.id, member.id)) { + return true; } if (!member.bannable) { - HoneyPotEnforcementService.cancel(message.guild.id, member.id); + app.honeypot.cancel(message.guild.id, member.id); const error = 'The bot cannot ban this member because of its permissions or role hierarchy.'; - Logger.writeError(`Honey-pot ban failed for ${member.id}.`, error); - await sendAudit(client, message, 'Honey-pot ban failed', error).catch((auditError) => { - Logger.writeError('Could not send honey-pot failure audit.', formatError(auditError)); - }); - return; + app.logger.error(`Honey-pot ban failed for ${member.id}.`, undefined, error); + await auditHoneypot( + app, + client, + config, + member, + message.channelId, + 'Honey-pot ban failed', + error, + ); + return true; } try { @@ -78,24 +74,52 @@ export const run: RunFunction = async ( reason: `Posted in honey-pot channel ${message.channelId}`, }); } catch (error) { - HoneyPotEnforcementService.cancel(message.guild.id, member.id); + app.honeypot.cancel(message.guild.id, member.id); const errorMessage = formatError(error); - Logger.writeError(`Honey-pot ban failed for ${member.id}.`, errorMessage); - await sendAudit(client, message, 'Honey-pot ban failed', errorMessage).catch((auditError) => { - Logger.writeError('Could not send honey-pot failure audit.', formatError(auditError)); - }); - return; + app.logger.error(`Honey-pot ban failed for ${member.id}.`, undefined, errorMessage); + await auditHoneypot( + app, + client, + config, + member, + message.channelId, + 'Honey-pot ban failed', + errorMessage, + ); + return true; } - await UserProfileService.deleteUser(message.guild.id, member.id).catch((error) => { - Logger.writeError(`Could not delete profile data for honey-pot ban ${member.id}.`, formatError(error)); + await deleteUser(app.db, message.guild.id, member.id).catch((error) => { + app.logger.error( + `Could not delete profile data for honey-pot ban ${member.id}.`, + undefined, + formatError(error), + ); }); - await sendAudit( + await auditHoneypot( + app, client, - message, + config, + member, + message.channelId, 'Honey-pot triggered', - 'Banned and deleted the member\'s messages from the past 7 days.' - ).catch((error) => { - Logger.writeError('Could not send honey-pot success audit.', formatError(error)); - }); + "Banned and deleted the member's messages from the past 7 days.", + ); + return true; }; + +export default defineEvent({ + name: 'messageCreate', + run: async (app, client, message) => { + if (!message.inGuild() || message.author.bot || message.webhookId || message.system) { + return; + } + + const config = await getConfig(app, message.guildId); + if (await handleHoneypot(app, client, message, config)) { + return; + } + + await incrementActivityScore(app.db, message.guildId, message.author.id); + }, +}); diff --git a/src/events/messageDelete.ts b/src/events/messageDelete.ts index 0875baba..92aeb97f 100644 --- a/src/events/messageDelete.ts +++ b/src/events/messageDelete.ts @@ -1,51 +1,48 @@ -import { Message } from 'discord.js'; -import { NovaClient } from '../client/NovaClient'; -import { EmbedColours } from '../resources/EmbedColours'; -import { RunFunction } from '../types/Event'; -import { ChannelService } from '../utilities/ChannelService'; -import { ConfigService } from '../utilities/ConfigService'; -import { EmbedCompatLayer } from '../types/EmbedCompatLayer'; -import { UserProfileService } from '../utilities/UserProfileService'; -import { HoneyPotEnforcementService } from '../utilities/HoneyPotEnforcementService'; +import { defineEvent } from '../framework/event.ts'; +import { EmbedColours } from '../resources/EmbedColours.ts'; +import { AuditEmbed, memberAuditEmbed } from '../services/AuditEmbed.ts'; +import { sendAudit } from '../services/AuditService.ts'; +import { getConfig } from '../services/ConfigService.ts'; -export const name = 'messageDelete'; -export const run: RunFunction = async ( - client: NovaClient, - message: Message -) => { - if (!message.author || !message.guild) { - return; - } +export default defineEvent({ + name: 'messageDelete', + run: async (app, client, message) => { + if (!message.guildId) { + return; + } + if (message.author?.bot) { + return; + } + if (message.author && app.honeypot.isActive(message.guildId, message.author.id)) { + return; + } - if (HoneyPotEnforcementService.isActive(message.guild.id, message.author.id)) { - return; - } + const config = await getConfig(app, message.guildId); - const serverConfig = await ConfigService.getConfig(message.guild.id); + if (message.partial || !message.author) { + const audit = new AuditEmbed() + .setColor(EmbedColours.neutral) + .setDescription('A message was deleted (not cached — content unavailable)') + .setTimestamp() + .addField('Channel', `<#${message.channelId}>`) + .addField('Message ID', message.id); + await sendAudit(app, client, config, audit); + return; + } - await UserProfileService.decrementActivityScore( - message.guild.id, - message.author.id - ); - - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.neutral) - .setAuthor({ - name: message.author.tag, - iconURL: message.author.displayAvatarURL(), - }) - .setDescription('A message was deleted') - .setTimestamp(); - - if (message.content) { - audit.addField('Message', message.content); - } - if (message.embeds.length > 0) { - audit.addField('Embeds', message.embeds.length.toString()); - } - if (message.attachments.size > 0) { - audit.addField('Attachments', message.attachments.size.toString()); - } - - await ChannelService.sendAuditMessage(client, serverConfig, audit); -}; + const audit = memberAuditEmbed(message.author, EmbedColours.neutral, 'A message was deleted') + .addField('Channel', `<#${message.channelId}>`) + .addField('Author ID', message.author.id); + if (message.content) { + audit.addField('Message', message.content); + } + if (message.attachments.size > 0) { + const names = [...message.attachments.values()].map((a) => a.name).join(', '); + audit.addField('Attachments', `${message.attachments.size}: ${names}`); + } + if (message.embeds.length > 0) { + audit.addField('Embeds', message.embeds.length.toString()); + } + await sendAudit(app, client, config, audit); + }, +}); diff --git a/src/events/messageUpdate.ts b/src/events/messageUpdate.ts index 239eab99..829710d2 100644 --- a/src/events/messageUpdate.ts +++ b/src/events/messageUpdate.ts @@ -1,44 +1,41 @@ -import { Message } from 'discord.js'; -import { NovaClient } from '../client/NovaClient'; -import { EmbedColours } from '../resources/EmbedColours'; -import { RunFunction } from '../types/Event'; -import { ChannelService } from '../utilities/ChannelService'; -import { ConfigService } from '../utilities/ConfigService'; -import { EmbedCompatLayer } from '../types/EmbedCompatLayer'; +import { defineEvent } from '../framework/event.ts'; +import { EmbedColours } from '../resources/EmbedColours.ts'; +import { memberAuditEmbed } from '../services/AuditEmbed.ts'; +import { sendAudit } from '../services/AuditService.ts'; +import { getConfig } from '../services/ConfigService.ts'; -export const name = 'messageUpdate'; -export const run: RunFunction = async ( - client: NovaClient, - oldMessage: Message, - newMessage: Message -) => { - if (!oldMessage.content) { - return; - } +export default defineEvent({ + name: 'messageUpdate', + run: async (app, client, oldMessage, partialNew) => { + if (!partialNew.guildId) { + return; + } - if (newMessage.partial) { - newMessage = await newMessage.fetch(); - } + let newMessage = partialNew; + if (newMessage.partial) { + try { + newMessage = await newMessage.fetch(); + } catch { + return; + } + } - if (newMessage.author.bot || !newMessage.guild) { - return; - } + if (newMessage.author.bot || !newMessage.guildId) { + return; + } - if (oldMessage.content === newMessage.content) { - return; - } + const previous = oldMessage.partial ? null : oldMessage.content; + if (previous === newMessage.content) { + return; + } - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.neutral) - .setAuthor({ - name: newMessage.author.tag, - iconURL: newMessage.author.displayAvatarURL(), - }) - .setDescription('A message was edited') - .addField('Previous', oldMessage.content) - .addField('Current', newMessage.content) - .setTimestamp(); + const audit = memberAuditEmbed(newMessage.author, EmbedColours.neutral, 'A message was edited') + .addField('Channel', `<#${newMessage.channelId}>`) + .addField('Previous', previous === null ? '*(not cached)*' : previous) + .addField('Current', newMessage.content) + .addField('Jump to message', newMessage.url); - const serverConfig = await ConfigService.getConfig(newMessage.guild.id); - await ChannelService.sendAuditMessage(client, serverConfig, audit); -}; + const config = await getConfig(app, newMessage.guildId); + await sendAudit(app, client, config, audit); + }, +}); diff --git a/src/events/ready.ts b/src/events/ready.ts index cb5f6105..11ac8a5b 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,27 +1,39 @@ -import schedule from 'node-schedule'; -import { NovaClient } from '../client/NovaClient'; -import { RunFunction } from '../types/Event'; -import { BirthdayManager } from '../utilities/BirthdayManager'; -import { CommandRegistrar } from '../utilities/CommandRegistrar'; -import { DataCheck } from '../utilities/DataCheck'; -import { Logger } from '../utilities/Logger'; +import { defineEvent } from '../framework/event.ts'; +import { syncCommands } from '../framework/registrar.ts'; +import { notifyBirthdays, refreshAllCalendars } from '../services/BirthdayService.ts'; +import { runDataCheck } from '../services/DataCheckService.ts'; +import { syncScreeningState } from '../services/ScreeningService.ts'; +import { Scheduler } from '../services/Scheduler.ts'; -export const name = 'ready'; -export const run: RunFunction = async (client: NovaClient) => { - client.user.setPresence({ status: 'online' }); +export default defineEvent({ + name: 'clientReady', + once: true, + run: async (app, client) => { + const { env, logger } = app; + client.user!.setPresence({ status: 'online' }); - Logger.writeLog('Online'); - await CommandRegistrar.registerGlobalCommands(client); - - const birthdaySchedule = schedule.scheduleJob( - process.env.JOB_SCHEDULE, - () => { - DataCheck.dataCleanup(client); - BirthdayManager.notifyBirthdays(client); - BirthdayManager.populateCalendars(client); + logger.info('Online'); + await syncCommands(app, [...client.slashCommands.values()]); + + try { + const scheduler = new Scheduler(logger, env.jobSchedule, env.timezone, [ + { name: 'data-check', run: () => runDataCheck(app, client) }, + { name: 'birthday-notifications', run: () => notifyBirthdays(app, client) }, + { name: 'birthday-calendars', run: () => refreshAllCalendars(app, client) }, + ]); + scheduler.start(); + app.shutdown.register(() => scheduler.stop()); + logger.info('Scheduler started', { + schedule: env.jobSchedule, + timezone: env.timezone, + next: scheduler.nextInvocation()?.toISOString(), + }); + } catch (error) { + logger.error('Could not start scheduler; scheduled jobs are disabled', undefined, error); } - ); - Logger.writeLog( - `Primary schedule set, next run at ${birthdaySchedule.nextInvocation()}` - ); -}; + + void syncScreeningState(app, client).catch((error: unknown) => { + logger.error('Could not sync screening state', undefined, error); + }); + }, +}); diff --git a/src/framework/command.ts b/src/framework/command.ts new file mode 100644 index 00000000..056a73e0 --- /dev/null +++ b/src/framework/command.ts @@ -0,0 +1,231 @@ +import type { + Attachment, + AttachmentBuilder, + ChatInputCommandInteraction, + EmbedBuilder, + Guild, + GuildMember, + SlashCommandSubcommandBuilder, + SlashCommandSubcommandGroupBuilder, +} from 'discord.js'; +import { + type APIEmbed, + type AttachmentPayload, + InteractionContextType, + type InteractionEditReplyOptions, + type InteractionReplyOptions, + MessageFlags, + type MessageMentionOptions, + PermissionFlagsBits, + type RESTPostAPIChatInputApplicationCommandsJSONBody, + SlashCommandBuilder, + type SlashCommandOptionsOnlyBuilder, +} from 'discord.js'; +import type { App } from '../app.ts'; +import type { ServerConfig } from '../db/schema.ts'; + +export type DeferMode = 'ephemeral' | 'public' | false; + +export interface ReplyOptions { + content?: string; + embeds?: APIEmbed[] | EmbedBuilder[]; + files?: AttachmentPayload[] | AttachmentBuilder[] | Attachment[]; + allowedMentions?: MessageMentionOptions; + ephemeral?: boolean; +} + +export interface CommandContext { + app: App; + interaction: ChatInputCommandInteraction<'cached'>; + guild: Guild; + member: GuildMember; + config: ServerConfig; + reply: (response: string | ReplyOptions) => Promise; +} + +export type Handler = (ctx: CommandContext) => Promise; + +export interface SubcommandDefinition { + name: string; + description: string; + defer?: DeferMode; + options?: (b: SlashCommandSubcommandBuilder) => SlashCommandSubcommandBuilder; + run: Handler; +} + +export interface SubcommandGroupDefinition { + name: string; + description: string; + subcommands: SubcommandDefinition[]; +} + +export interface CommandDefinition { + name: string; + description: string; + adminOnly?: boolean; + defer?: DeferMode; + options?: (b: SlashCommandOptionsOnlyBuilder) => SlashCommandOptionsOnlyBuilder; + run?: Handler; + subcommands?: SubcommandDefinition[]; + groups?: SubcommandGroupDefinition[]; +} + +export interface ResolvedCommand { + run: Handler; + defer: DeferMode; + /** Human-readable path such as "config welcome set", used for logging. */ + path: string; +} + +export interface Command { + name: string; + toJSON(): RESTPostAPIChatInputApplicationCommandsJSONBody; + resolve(interaction: ChatInputCommandInteraction): ResolvedCommand | undefined; +} + +export function buildReplyPayload( + response: string | ReplyOptions, +): Omit & { flags?: MessageFlags.Ephemeral } { + const { ephemeral = true, ...rest } = + typeof response === 'string' ? { content: response } : response; + return ephemeral ? { ...rest, flags: MessageFlags.Ephemeral } : rest; +} + +/** Replies, edits the deferred reply, or follows up depending on the interaction's state. */ +export function createReply(interaction: ChatInputCommandInteraction): CommandContext['reply'] { + return async (response) => { + const payload = buildReplyPayload(response); + if (!interaction.deferred && !interaction.replied) { + await interaction.reply(payload as InteractionReplyOptions); + } else if (interaction.deferred && !interaction.replied) { + // The ephemeral flag is fixed when deferring and cannot be changed by an edit + const edit: Omit & { flags?: unknown } = { ...payload }; + delete edit.flags; + await interaction.editReply(edit as InteractionEditReplyOptions); + } else { + await interaction.followUp(payload as InteractionReplyOptions); + } + }; +} + +const assertUnique = (kind: string, where: string, names: string[]): void => { + const seen = new Set(); + for (const name of names) { + if (seen.has(name)) { + throw new Error(`Duplicate ${kind} name "${name}" in ${where}`); + } + seen.add(name); + } +}; + +const subcommandBuilder = + (def: SubcommandDefinition) => + (b: SlashCommandSubcommandBuilder): SlashCommandSubcommandBuilder => { + const named = b.setName(def.name).setDescription(def.description); + return def.options ? def.options(named) : named; + }; + +export function defineCommand(def: CommandDefinition): Command { + const where = `command "${def.name}"`; + const hasSubs = (def.subcommands?.length ?? 0) > 0; + const hasGroups = (def.groups?.length ?? 0) > 0; + const isLeaf = def.run !== undefined; + + if (isLeaf && (hasSubs || hasGroups)) { + throw new Error(`${where} cannot define both run and subcommands/groups`); + } + if (!isLeaf && !hasSubs && !hasGroups) { + throw new Error(`${where} must define either run or subcommands/groups`); + } + if (!isLeaf && (def.options || def.defer !== undefined)) { + throw new Error( + `${where} sets options/defer but is not a leaf command; set them on subcommands`, + ); + } + + assertUnique('subcommand/group', where, [ + ...(def.subcommands ?? []).map((s) => s.name), + ...(def.groups ?? []).map((g) => g.name), + ]); + + for (const group of def.groups ?? []) { + const groupWhere = `group "${group.name}" in ${where}`; + if (group.subcommands.length === 0) { + throw new Error(`${groupWhere} must define at least one subcommand`); + } + assertUnique( + 'subcommand', + groupWhere, + group.subcommands.map((s) => s.name), + ); + } + + const routeKey = (group: string | null, sub: string | null): string => + `${group ?? ''}/${sub ?? ''}`; + const routes = new Map(); + let json: RESTPostAPIChatInputApplicationCommandsJSONBody; + try { + const builder = new SlashCommandBuilder() + .setName(def.name) + .setDescription(def.description) + .setContexts([InteractionContextType.Guild]); + + if (def.adminOnly) { + builder.setDefaultMemberPermissions(PermissionFlagsBits.Administrator); + } + + if (def.run) { + if (def.options) { + def.options(builder); + } + routes.set(routeKey(null, null), { run: def.run, defer: def.defer ?? false, path: def.name }); + } + + for (const sub of def.subcommands ?? []) { + builder.addSubcommand(subcommandBuilder(sub)); + routes.set(routeKey(null, sub.name), { + run: sub.run, + defer: sub.defer ?? false, + path: `${def.name} ${sub.name}`, + }); + } + + for (const group of def.groups ?? []) { + builder.addSubcommandGroup((g: SlashCommandSubcommandGroupBuilder) => { + g.setName(group.name).setDescription(group.description); + for (const sub of group.subcommands) { + g.addSubcommand(subcommandBuilder(sub)); + routes.set(routeKey(group.name, sub.name), { + run: sub.run, + defer: sub.defer ?? false, + path: `${def.name} ${group.name} ${sub.name}`, + }); + } + return g; + }); + } + + // Building the JSON now surfaces invalid names, descriptions and options at definition time + json = builder.toJSON(); + } catch (error) { + throw new Error(`Invalid ${where}: ${error instanceof Error ? error.message : String(error)}`, { + cause: error, + }); + } + + return { + name: def.name, + toJSON: () => json, + resolve: (interaction) => + routes.get( + routeKey( + interaction.options.getSubcommandGroup(false), + interaction.options.getSubcommand(false), + ), + ), + }; +} + +export function defineSubcommandGroup(def: SubcommandGroupDefinition): SubcommandGroupDefinition { + return def; +} diff --git a/src/framework/errors.ts b/src/framework/errors.ts new file mode 100644 index 00000000..44ed808b --- /dev/null +++ b/src/framework/errors.ts @@ -0,0 +1,7 @@ +/** An expected failure whose message is safe to show to the user as the command's reply. */ +export class UserError extends Error { + public constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'UserError'; + } +} diff --git a/src/framework/event.ts b/src/framework/event.ts new file mode 100644 index 00000000..e55081d3 --- /dev/null +++ b/src/framework/event.ts @@ -0,0 +1,52 @@ +import type { Client } from 'discord.js'; +import { type ClientEvents } from 'discord.js'; +import type { App } from '../app.ts'; +import type { DingirClient } from '../client/DingirClient.ts'; + +export interface EventDefinition { + name: K; + once?: boolean; + run(app: App, client: DingirClient, ...args: ClientEvents[K]): Promise; +} + +export type AnyEventDefinition = { + [K in keyof ClientEvents]: EventDefinition; +}[keyof ClientEvents]; + +export function defineEvent( + def: EventDefinition, +): EventDefinition { + return def; +} + +const guildIdOf = (arg: unknown): string | undefined => { + if (typeof arg !== 'object' || arg === null) { + return undefined; + } + const candidate = arg as { guildId?: unknown; guild?: { id?: unknown } | null }; + const id = candidate.guildId ?? candidate.guild?.id; + return typeof id === 'string' ? id : undefined; +}; + +/** Registers an event on the client, logging (never throwing) handler failures. */ +export function bindEvent( + app: App, + client: DingirClient, + def: EventDefinition, +): void { + const listener = (...args: ClientEvents[K]): void => { + def.run(app, client, ...args).catch((error: unknown) => { + app.logger.error( + 'Event handler failed', + { event: def.name, guild: guildIdOf(args[0]) }, + error, + ); + }); + }; + const target: Client = client; + if (def.once) { + target.once(def.name, listener); + } else { + target.on(def.name, listener); + } +} diff --git a/src/framework/process.ts b/src/framework/process.ts new file mode 100644 index 00000000..1d0ec62a --- /dev/null +++ b/src/framework/process.ts @@ -0,0 +1,88 @@ +import type { Logger } from '../utilities/Logger.ts'; + +export interface ProcessHandlerDeps { + proc: Pick; + logger: Logger; + /** Runs the registered shutdown hooks; called lazily because the app may not exist yet. */ + runShutdown: () => Promise; + exit: (code: number) => void; + /** How long shutdown hooks may take after an uncaught exception. */ + crashTimeoutMs?: number; + /** How long shutdown hooks may take after SIGTERM/SIGINT. */ + signalTimeoutMs?: number; +} + +export interface ProcessHandlers { + onUnhandledRejection: (reason: unknown) => void; + onUncaughtException: (error: unknown) => Promise; + onSignal: (signal: string) => Promise; +} + +/** Resolves true if `run` finished (even by throwing) before the timeout, false if it timed out. */ +async function runBounded(run: () => Promise, ms: number, logger: Logger): Promise { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), ms); + }); + const finished = run().then( + () => true as const, + (error: unknown) => { + logger.error('Shutdown failed', undefined, error); + return true as const; + }, + ); + try { + return await Promise.race([finished, timeout]); + } finally { + clearTimeout(timer); + } +} + +/** Registers process-wide crash and signal handlers; call before anything else runs. */ +export function installProcessHandlers(deps: ProcessHandlerDeps): ProcessHandlers { + const { proc, logger, runShutdown, exit } = deps; + const crashTimeoutMs = deps.crashTimeoutMs ?? 5_000; + const signalTimeoutMs = deps.signalTimeoutMs ?? 10_000; + let stopping = false; + + const handlers: ProcessHandlers = { + onUnhandledRejection: (reason) => { + logger.error('Unhandled promise rejection', undefined, reason); + }, + onUncaughtException: async (error) => { + if (stopping) { + return; + } + stopping = true; + logger.fatal('Uncaught exception, shutting down', undefined, error); + if (!(await runBounded(runShutdown, crashTimeoutMs, logger))) { + logger.error('Shutdown hooks timed out, forcing exit', { timeoutMs: crashTimeoutMs }); + } + exit(1); + }, + onSignal: async (signal) => { + if (stopping) { + return; + } + stopping = true; + logger.info(`${signal} received, shutting down`); + if (await runBounded(runShutdown, signalTimeoutMs, logger)) { + exit(0); + } else { + logger.error('Shutdown hooks timed out, forcing exit', { timeoutMs: signalTimeoutMs }); + exit(1); + } + }, + }; + + proc.on('unhandledRejection', handlers.onUnhandledRejection); + proc.on('uncaughtException', (error) => { + void handlers.onUncaughtException(error); + }); + for (const signal of ['SIGTERM', 'SIGINT'] as const) { + proc.on(signal, () => { + void handlers.onSignal(signal); + }); + } + return handlers; +} diff --git a/src/framework/registrar.ts b/src/framework/registrar.ts new file mode 100644 index 00000000..25771236 --- /dev/null +++ b/src/framework/registrar.ts @@ -0,0 +1,210 @@ +/* + * COMMAND REGISTRATION SPEC + * + * Persisted state (BotState, keyed by application id so another bot sharing the database is + * independent): + * commandsHash:: hash of the commands last registered successfully in + * commandsScope: the scope last registered successfully and fully cleaned up + * A scope is `global` or `guild:`; the env's DEV_GUILD_ID picks the current one. + * + * Transitions (last recorded scope -> current scope, on startup or `npm run deploy:commands`): + * same scope, hash equal skip (startup) / register again (deploy script) + * same scope, hash differs or missing register, record hash + * none recorded register, record hash and scope (re-registers once) + * guild:X -> global | guild:Y register, then clear guild X, then record the scope + * global -> guild:Y register in Y; global commands are never cleared + * automatically (may be production), a warning says so + * + * Invariants: + * 1. The hash is recorded only after a successful register, so a failed or interrupted + * registration is retried on the next start; nothing is cleared or recorded when it fails. + * 2. The scope is recorded only after the previous dev guild was cleaned up (or cannot be: + * the bot is gone from it), so a failed cleanup is retried on every start until it works. + * 3. A recorded hash is trusted only while the recorded scope equals the current scope, so + * switching scope always re-registers, even back to a scope whose old hash still matches. + * 4. Registration failure at startup is logged and never stops the bot. + * + * Failure handling: Discord errors on register -> 'failed' (retry next start); on cleanup -> + * keep the old scope (retry), except Unknown Guild / Missing Access, where the guild's commands + * are unreachable and nothing remains to be done. + */ + +import { createHash } from 'node:crypto'; +import { REST, Routes } from 'discord.js'; +import type { App } from '../app.ts'; +import type { Env } from '../config/env.ts'; +import { getState, setState } from '../services/BotState.ts'; +import { classifyGuildFetchError } from '../services/RetentionPolicy.ts'; +import type { Command } from './command.ts'; + +export interface CommandScope { + /** Human-readable target, for logs. */ + label: string; + /** BotState key under which the last registered hash is stored. */ + stateKey: string; + /** BotState key under which the last successfully registered scope is stored. */ + scopeKey: string; + /** `global` or `guild:`; what is stored under scopeKey. */ + scope: string; +} + +/** The key includes the application id so a different bot sharing this database re-registers. */ +export const commandScope = (env: Pick): CommandScope => + env.devGuildId + ? { + label: `dev guild ${env.devGuildId}`, + stateKey: `commandsHash:${env.clientId}:guild:${env.devGuildId}`, + scopeKey: `commandsScope:${env.clientId}`, + scope: `guild:${env.devGuildId}`, + } + : { + label: 'globally', + stateKey: `commandsHash:${env.clientId}:global`, + scopeKey: `commandsScope:${env.clientId}`, + scope: 'global', + }; + +const canonical = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(canonical); + } + if (typeof value === 'object' && value !== null) { + return Object.fromEntries( + Object.entries(value) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, entry]) => [key, canonical(entry)]), + ); + } + return value; +}; + +/** SHA-256 of the commands' JSON with object keys sorted, so it only changes with their content. */ +export function hashCommands(commands: readonly Command[]): string { + const body = commands.map((command) => canonical(command.toJSON())); + return createHash('sha256').update(JSON.stringify(body)).digest('hex'); +} + +/** Bulk-overwrites the application's commands in the env's scope and returns how many were set. */ +export async function putCommands( + env: Pick, + commands: readonly Command[], +): Promise { + const rest = new REST({ version: '10' }).setToken(env.token); + const route = env.devGuildId + ? Routes.applicationGuildCommands(env.clientId, env.devGuildId) + : Routes.applicationCommands(env.clientId); + const data = (await rest.put(route, { body: commands.map((c) => c.toJSON()) })) as unknown[]; + return data.length; +} + +export type PutCommands = typeof putCommands; + +/** Removes every application command from one guild. */ +export async function clearGuildCommands( + env: Pick, + guildId: string, +): Promise { + const rest = new REST({ version: '10' }).setToken(env.token); + await rest.put(Routes.applicationGuildCommands(env.clientId, guildId), { body: [] }); +} + +export type ClearGuildCommands = typeof clearGuildCommands; + +export interface RegistrarDeps { + put?: PutCommands; + clear?: ClearGuildCommands; +} + +/** + * Registers commands in the env's scope, then records the hash and scope. When the last scope + * was a dev guild and the scope has changed, that guild's commands are cleared so they do not + * linger as duplicates. Global commands are never cleared automatically (they may be production). + * Throws if registration fails, in which case nothing is cleared or recorded. + */ +export async function registerCommands( + app: Pick, + commands: readonly Command[], + deps: RegistrarDeps = {}, +): Promise { + const { env, db, logger, clock } = app; + const { put = putCommands, clear = clearGuildCommands } = deps; + const scope = commandScope(env); + const hash = hashCommands(commands); + + const count = await put(env, commands); + setState(db, scope.stateKey, hash, clock()); + + const previous = getState(db, scope.scopeKey); + let cleaned = false; + let scopeRecorded = true; + if (previous?.startsWith('guild:') && previous !== scope.scope) { + const guildId = previous.slice('guild:'.length); + try { + await clear(env, guildId); + cleaned = true; + logger.info('Cleared application commands from the previous dev guild', { guild: guildId }); + } catch (error) { + if (classifyGuildFetchError(error) === 'gone') { + // The bot is no longer in that guild, so its commands there are gone with it. + logger.info('Previous dev guild is unreachable; nothing to clear', { guild: guildId }); + } else { + scopeRecorded = false; // keep the old scope so the next run retries the cleanup + logger.warn( + 'Could not clear application commands from the previous dev guild; remove them manually', + { guild: guildId }, + error, + ); + } + } + } + if (scopeRecorded) { + setState(db, scope.scopeKey, scope.scope, clock()); + } + + if (env.devGuildId) { + logger.warn( + 'Commands were registered to the dev guild only; global commands may also exist and show up as duplicates. Run `npm run deploy:commands` without DEV_GUILD_ID to manage global commands, or remove them manually.', + { scope: scope.label }, + ); + } else if (!cleaned && (previous === undefined || !scopeRecorded)) { + logger.warn( + 'Commands were registered globally; guild-scoped commands from an earlier DEV_GUILD_ID run may still exist and show up as duplicates. Remove them manually if so.', + { scope: scope.label }, + ); + } + return count; +} + +/** Registers commands only when they differ from what was last registered for this scope. */ +export async function syncCommands( + app: Pick, + commands: readonly Command[], + put: PutCommands | RegistrarDeps = {}, +): Promise<'registered' | 'unchanged' | 'failed'> { + const { env, db, logger } = app; + const deps = typeof put === 'function' ? { put } : put; + const scope = commandScope(env); + const hash = hashCommands(commands); + const previousScope = getState(db, scope.scopeKey); + + // A changed (or unrecorded) scope must re-register even when this scope's stored hash still + // matches, so the previous dev guild gets cleaned up and the scope gets recorded. + if (getState(db, scope.stateKey) === hash && previousScope === scope.scope) { + logger.info(`Application commands unchanged, skipping registration ${scope.label}`, { + hash: hash.slice(0, 12), + }); + return 'unchanged'; + } + + logger.info(`Registering ${commands.length} application commands ${scope.label}`); + try { + const count = await registerCommands(app, commands, deps); + logger.info(`Registered ${count} application commands ${scope.label}`, { + hash: hash.slice(0, 12), + }); + return 'registered'; + } catch (error) { + logger.error('Failed to register application commands', { scope: scope.label }, error); + return 'failed'; + } +} diff --git a/src/framework/registry.ts b/src/framework/registry.ts new file mode 100644 index 00000000..89c15116 --- /dev/null +++ b/src/framework/registry.ts @@ -0,0 +1,13 @@ +/** Throws if two registry entries share a name. `kind` is only used in the error message. */ +export function validateRegistry(kind: string, entries: readonly { name: string }[]): void { + const seen = new Set(); + for (const entry of entries) { + if (typeof entry?.name !== 'string' || entry.name.length === 0) { + throw new Error(`Every ${kind} must have a non-empty name`); + } + if (seen.has(entry.name)) { + throw new Error(`Duplicate ${kind} "${entry.name}"`); + } + seen.add(entry.name); + } +} diff --git a/src/framework/settings.ts b/src/framework/settings.ts new file mode 100644 index 00000000..15085370 --- /dev/null +++ b/src/framework/settings.ts @@ -0,0 +1,131 @@ +import type { Guild } from 'discord.js'; +import { type ApplicationCommandOptionAllowedChannelTypes } from 'discord.js'; +import { defineSubcommandGroup, type SubcommandGroupDefinition } from './command.ts'; +import { updateConfig } from '../services/ConfigService.ts'; + +export type ChannelField = 'auditChannelId' | 'announcementsChannelId' | 'honeyPotChannelId'; +export type BooleanField = 'debug' | 'systemMessagesEnabled'; + +export interface ChannelSettingOptions { + name: string; + description: string; + field: ChannelField; + label: string; + channelTypes: ApplicationCommandOptionAllowedChannelTypes[]; +} + +export interface BooleanSettingOptions { + name: string; + description: string; + field: BooleanField; + label: string; +} + +export const channelSetText = (label: string, channelId: string): string => + `${label} set to <#${channelId}>.`; + +export const channelGetText = ( + label: string, + channelId: string | null, + exists: boolean, +): string => { + if (!channelId) { + return `${label}: not set`; + } + return exists + ? `${label}: <#${channelId}>` + : `${label}: <#${channelId}> (this channel no longer exists in this server)`; +}; + +export const channelClearText = (label: string): string => `${label} cleared.`; + +export const booleanText = (label: string, enabled: boolean): string => + `${label}: ${enabled ? 'enabled' : 'disabled'}`; + +const channelExists = async (guild: Guild, id: string): Promise => { + if (guild.channels.cache.has(id)) { + return true; + } + const fetched = await guild.channels.fetch(id).catch(() => null); + return fetched !== null; +}; + +export function channelSetting(opts: ChannelSettingOptions): SubcommandGroupDefinition { + const { field, label } = opts; + return defineSubcommandGroup({ + name: opts.name, + description: opts.description, + subcommands: [ + { + name: 'set', + description: `Set the ${label.toLowerCase()}`, + options: (b) => + b.addChannelOption((o) => + o + .setName('channel') + .setDescription(`Channel to use for: ${label.toLowerCase()}`) + .addChannelTypes(...opts.channelTypes) + .setRequired(true), + ), + run: async (ctx) => { + const channel = ctx.interaction.options.getChannel('channel', true); + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { + [field]: channel.id, + }); + await ctx.reply(channelSetText(label, channel.id)); + }, + }, + { + name: 'get', + description: `Show the ${label.toLowerCase()}`, + defer: 'ephemeral', + run: async (ctx) => { + const id = ctx.config[field]; + const exists = id ? await channelExists(ctx.guild, id) : false; + await ctx.reply(channelGetText(label, id, exists)); + }, + }, + { + name: 'clear', + description: `Clear the ${label.toLowerCase()}`, + run: async (ctx) => { + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { [field]: null }); + await ctx.reply(channelClearText(label)); + }, + }, + ], + }); +} + +export function booleanSetting(opts: BooleanSettingOptions): SubcommandGroupDefinition { + const { field, label } = opts; + return defineSubcommandGroup({ + name: opts.name, + description: opts.description, + subcommands: [ + { + name: 'set', + description: `Enable or disable: ${label.toLowerCase()}`, + options: (b) => + b.addBooleanOption((o) => + o + .setName('enabled') + .setDescription(`Whether ${label.toLowerCase()} are on`) + .setRequired(true), + ), + run: async (ctx) => { + const enabled = ctx.interaction.options.getBoolean('enabled', true); + ctx.config = await updateConfig(ctx.app, ctx.config.serverId, { [field]: enabled }); + await ctx.reply(booleanText(label, enabled)); + }, + }, + { + name: 'get', + description: `Show whether ${label.toLowerCase()} are enabled`, + run: async (ctx) => { + await ctx.reply(booleanText(label, ctx.config[field])); + }, + }, + ], + }); +} diff --git a/src/framework/shutdown.ts b/src/framework/shutdown.ts new file mode 100644 index 00000000..7f31d33c --- /dev/null +++ b/src/framework/shutdown.ts @@ -0,0 +1,27 @@ +import type { Logger } from '../utilities/Logger.ts'; + +export type ShutdownHook = () => Promise | void; + +export interface ShutdownRegistry { + register(hook: ShutdownHook): void; + /** Runs hooks in reverse registration order; a failing hook never blocks the rest. */ + run(): Promise; +} + +export function createShutdownRegistry(logger: Logger): ShutdownRegistry { + const hooks: ShutdownHook[] = []; + return { + register: (hook) => { + hooks.push(hook); + }, + run: async () => { + for (const hook of [...hooks].reverse()) { + try { + await hook(); + } catch (error) { + logger.error('Shutdown hook failed', undefined, error); + } + } + }, + }; +} diff --git a/src/index.ts b/src/index.ts index c2ea7de8..1bd71910 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,35 @@ -import { NovaClient } from './client/NovaClient'; +import { type App, createApp } from './app.ts'; +import { DingirClient } from './client/DingirClient.ts'; +import { type Env, loadEnv } from './config/env.ts'; +import { installProcessHandlers } from './framework/process.ts'; +import { createConsoleLogger, parseLogLevel } from './utilities/Logger.ts'; -const novaClient = new NovaClient(); -novaClient.start(); +// Read leniently: loadEnv() reports an invalid LOG_LEVEL once the logger exists. +const logger = createConsoleLogger({ level: parseLogLevel(process.env.LOG_LEVEL?.trim()) }); +let app: App | undefined; + +installProcessHandlers({ + proc: process, + logger, + runShutdown: async () => app?.shutdown.run(), + exit: (code) => process.exit(code), +}); + +async function main(): Promise { + let env: Env; + try { + env = loadEnv(); + } catch (error) { + logger.error(error instanceof Error ? error.message : String(error)); + logger.error('Invalid configuration, shutting down.'); + process.exit(1); + } + + app = createApp({ env, logger }); + await new DingirClient(app).start(); +} + +main().catch((error: unknown) => { + logger.fatal('Fatal error during startup.', undefined, error); + process.exit(1); +}); diff --git a/src/resources/EmbedColours.ts b/src/resources/EmbedColours.ts index a4a6a7a6..d428015c 100644 --- a/src/resources/EmbedColours.ts +++ b/src/resources/EmbedColours.ts @@ -1,12 +1,6 @@ -import { ColorResolvable } from 'discord.js'; - -interface EmbedColors { - [key: string]: ColorResolvable; -} - -export const EmbedColours: EmbedColors = { +export const EmbedColours = { positive: '#30d70c', negative: '#de2525', neutral: '#cc37f1', info: '#30bcf6', -}; +} as const; diff --git a/src/scripts/deploy-commands.ts b/src/scripts/deploy-commands.ts new file mode 100644 index 00000000..59f544fb --- /dev/null +++ b/src/scripts/deploy-commands.ts @@ -0,0 +1,24 @@ +import { commands } from '../commands/index.ts'; +import { createApp } from '../app.ts'; +import { loadEnv } from '../config/env.ts'; +import { commandScope, registerCommands } from '../framework/registrar.ts'; +import { validateRegistry } from '../framework/registry.ts'; +import { createConsoleLogger, parseLogLevel } from '../utilities/Logger.ts'; + +const logger = createConsoleLogger({ level: parseLogLevel(process.env.LOG_LEVEL?.trim()) }); + +let app: ReturnType | undefined; +try { + const env = loadEnv(); + validateRegistry('command', commands); + const { label } = commandScope(env); + app = createApp({ env, logger }); + logger.info(`Deploying ${commands.length} application commands ${label}`); + const count = await registerCommands(app, commands); + logger.info(`Deployed ${count} application commands ${label}`); +} catch (error) { + logger.error('Command deployment failed', undefined, error); + process.exitCode = 1; +} finally { + app?.db.$client.close(); +} diff --git a/src/services/AuditEmbed.ts b/src/services/AuditEmbed.ts new file mode 100644 index 00000000..2b2b357d --- /dev/null +++ b/src/services/AuditEmbed.ts @@ -0,0 +1,97 @@ +import type { GuildMember } from 'discord.js'; +import { type ColorResolvable, EmbedBuilder, User } from 'discord.js'; + +const LIMITS = { + title: 256, + description: 4096, + fieldName: 256, + fieldValue: 1024, + fields: 25, + total: 6000, +}; +const ELLIPSIS = '…'; +const EMPTY_VALUE = '*(empty)*'; +const NOTE_NAME = 'Truncated'; +const NOTE_VALUE = 'Some content was omitted to fit Discord limits.'; + +const truncate = (text: string, max: number): string => + text.length <= max ? text : `${text.slice(0, Math.max(0, max - ELLIPSIS.length))}${ELLIPSIS}`; + +export class AuditEmbed extends EmbedBuilder { + private cut = false; + private storedFields = 0; + + public override setTitle(title: string | null): this { + return super.setTitle(title === null ? null : this.limit(title, LIMITS.title)); + } + + public override setDescription(description: string | null): this { + return super.setDescription( + description === null ? null : this.limit(description, LIMITS.description), + ); + } + + public addField(name: string, value: string, inline = false): this { + if (this.storedFields >= LIMITS.fields) { + this.cut = true; + return this; + } + const safeName = this.limit(name.trim() === '' ? '​' : name, LIMITS.fieldName); + const safeValue = value.trim() === '' ? EMPTY_VALUE : this.limit(value, LIMITS.fieldValue); + this.addFields([{ name: safeName, value: safeValue, inline }]); + this.storedFields += 1; + return this; + } + + public override toJSON(): ReturnType { + const data = super.toJSON(); + const fields = [...(data.fields ?? [])]; + const textLength = (): number => + (data.title?.length ?? 0) + + (data.description?.length ?? 0) + + (data.author?.name.length ?? 0) + + (data.footer?.text.length ?? 0); + const fieldsLength = (list: typeof fields): number => + list.reduce((sum, f) => sum + f.name.length + f.value.length, 0); + const noteLength = NOTE_NAME.length + NOTE_VALUE.length; + + let truncated = this.cut; + let kept = fields; + if (textLength() + fieldsLength(fields) > LIMITS.total) { + truncated = true; + } + if (truncated) { + kept = fields.slice(0, LIMITS.fields - 1); + while (kept.length > 0 && textLength() + fieldsLength(kept) + noteLength > LIMITS.total) { + kept.pop(); + } + const excess = textLength() + noteLength - LIMITS.total; + if (excess > 0 && data.description) { + data.description = truncate(data.description, data.description.length - excess); + } + kept.push({ name: NOTE_NAME, value: NOTE_VALUE, inline: false }); + } + return { ...data, fields: kept.length > 0 ? kept : undefined }; + } + + private limit(text: string, max: number): string { + if (text.length > max) { + this.cut = true; + } + return truncate(text, max); + } +} + +export function memberAuditEmbed( + member: GuildMember | User, + colour: ColorResolvable, + description: string, +): AuditEmbed { + const user = member instanceof User ? member : member.user; + const name = member.displayName || user.tag; + return new AuditEmbed() + .setColor(colour) + .setAuthor({ name: truncate(name, LIMITS.fieldName), iconURL: member.displayAvatarURL() }) + .setDescription(description) + .setTimestamp(); +} diff --git a/src/services/AuditService.ts b/src/services/AuditService.ts new file mode 100644 index 00000000..1f343302 --- /dev/null +++ b/src/services/AuditService.ts @@ -0,0 +1,30 @@ +import type { Attachment, Client, EmbedBuilder } from 'discord.js'; +import type { App } from '../app.ts'; +import type { ServerConfig } from '../db/schema.ts'; + +export const sendAudit = async ( + app: App, + client: Client, + config: ServerConfig, + embed: EmbedBuilder, + files?: Attachment[], +): Promise => { + const channelId = config.auditChannelId; + if (!channelId) { + return false; + } + const context = { guildId: config.serverId, channelId }; + + try { + const channel = await client.channels.fetch(channelId); + if (!channel || !channel.isSendable()) { + app.logger.warn('Audit channel missing or not sendable', context); + return false; + } + await channel.send({ embeds: [embed], files: files?.length ? files : undefined }); + return true; + } catch (error) { + app.logger.warn('Failed to send audit message', context, error); + return false; + } +}; diff --git a/src/services/BirthdayDates.ts b/src/services/BirthdayDates.ts new file mode 100644 index 00000000..4db29aad --- /dev/null +++ b/src/services/BirthdayDates.ts @@ -0,0 +1,90 @@ +import { DateTime } from 'luxon'; + +export interface BirthdayProfile { + userId: string; + month: number; + day: number; +} + +export interface UpcomingGroup { + date: DateTime; + userIds: string[]; +} + +export const isValidBirthday = (month: number, day: number): boolean => + Number.isInteger(month) && + Number.isInteger(day) && + DateTime.fromObject({ year: 2024, month, day }, { zone: 'utc' }).isValid; + +export const celebrationDate = ( + month: number, + day: number, + year: number, +): { year: number; month: number; day: number } => { + if (month === 2 && day === 29 && !DateTime.utc(year).isInLeapYear) { + return { year, month: 2, day: 28 }; + } + return { year, month, day }; +}; + +const startOfToday = (now: DateTime, zone: string): DateTime => now.setZone(zone).startOf('day'); + +export const nextOccurrence = ( + month: number, + day: number, + now: DateTime, + zone: string, +): DateTime => { + const start = startOfToday(now, zone); + for (let year = start.year; year <= start.year + 8; year += 1) { + const candidate = DateTime.fromObject(celebrationDate(month, day, year), { zone }); + if (candidate >= start) { + return candidate; + } + } + throw new Error(`No occurrence found for ${month}/${day}`); +}; + +export const isBirthdayToday = ( + month: number, + day: number, + now: DateTime, + zone: string, +): boolean => { + const start = startOfToday(now, zone); + const celebration = celebrationDate(month, day, start.year); + return start.month === celebration.month && start.day === celebration.day; +}; + +/** Groups share a date and are never split: the group that crosses `limit` is included in full. */ +export const upcoming = ( + profiles: readonly BirthdayProfile[], + now: DateTime, + zone: string, + limit = 10, +): UpcomingGroup[] => { + const byDate = new Map(); + for (const profile of profiles) { + if (!isValidBirthday(profile.month, profile.day)) { + continue; + } + const date = nextOccurrence(profile.month, profile.day, now, zone); + const key = date.toMillis(); + const group = byDate.get(key) ?? { date, userIds: [] }; + group.userIds.push(profile.userId); + byDate.set(key, group); + } + + const groups = [...byDate.values()].sort((a, b) => a.date.toMillis() - b.date.toMillis()); + const result: UpcomingGroup[] = []; + let total = 0; + for (const group of groups) { + if (total >= limit) { + break; + } + group.userIds.sort(); + result.push(group); + total += group.userIds.length; + } + return result; +}; diff --git a/src/services/BirthdayService.ts b/src/services/BirthdayService.ts new file mode 100644 index 00000000..052d0b38 --- /dev/null +++ b/src/services/BirthdayService.ts @@ -0,0 +1,298 @@ +/* + * BIRTHDAY CALENDAR SPEC + * + * Persisted state: + * ServerConfigs.birthdayCalendarMessagePath "channelId/messageId" of the calendar message, or null. + * UserProfiles.birthdayMonth/Day a member's birthday (the calendar's only data). + * The path is written only by `/config birthdays create`, and only for a message the bot just sent + * and edited successfully. It is cleared only by `remove` (after the message is deleted or + * confirmed gone) or by a create that fails before replacing a previous calendar. + * + * Transitions (path state x event): + * none create ok send, save path, populate, -> new path + * none create fails delete the new message, -> none + * old create ok send, save, populate, delete old message, -> new path + * old create fails (populate) restore old path (if still ours), delete new msg, -> old + * old create fails (send/save) nothing saved, old calendar untouched, -> old + * old remove, deleted/gone -> none + * old remove, delete fails error with reason, -> old (retry, or create to replace it) + * any refresh (sync, mybirthday, scheduled job, member leaves with a birthday) + * edit message in place; path never changes + * + * Invariants: + * 1. A stored path always points to a message the bot created. A refresh that cannot reach it + * reports why (not-configured, channel-missing, message-missing, no-access, failed) and never + * clears or rewrites the path: only the admin commands do, so a transient error cannot lose it. + * 2. Only definite "unknown channel/message" answers count as missing; permission errors are + * "no-access"; anything else (network, 5xx, rate limit) is "failed" and expected to recover. + * 3. Refreshes of one guild run one at a time and read the profiles immediately before editing, + * so the last edit always reflects the latest data. + * 4. A create never leaves two live calendars: the replaced message is deleted once the new one + * is populated, and a concurrent create's message is cleaned up by whichever finishes last. + * 5. Background jobs only act on configs that still exist (a purged guild is skipped). + * + * Failure handling: deleting an old calendar that fails is logged and left in place (never blocks + * the new calendar); a bot restart mid-create can leave an orphan placeholder message, which is + * harmless; announcements missed while the bot is offline at the scheduled time are not replayed. + */ + +import type { Client } from 'discord.js'; +import { type Snowflake } from 'discord.js'; +import { DateTime } from 'luxon'; +import type { ServerConfig } from '../db/schema.ts'; +import type { App } from '../app.ts'; +import { findConfig, getConfigs } from './ConfigService.ts'; +import { getServerBirthdays } from './UserProfileService.ts'; +import { type BirthdayProfile, isBirthdayToday, upcoming } from './BirthdayDates.ts'; +import { resolveMember, resolveTextChannel } from './MemberResolver.ts'; + +export type CalendarStatus = + | 'updated' + | 'not-configured' + | 'channel-missing' + | 'message-missing' + | 'no-access' + | 'failed'; + +const MESSAGE_LIMIT = 2000; +const SEPARATOR = '-------------'; +const LOCALE = 'en-GB'; + +export const parseCalendarPath = ( + path: string | null | undefined, +): { channelId: Snowflake; messageId: Snowflake } | null => { + const [channelId, messageId, ...rest] = (path ?? '').split('/'); + return channelId && messageId && rest.length === 0 ? { channelId, messageId } : null; +}; + +export const calendarMessageUrl = (guildId: Snowflake, path: string): string => + `https://discord.com/channels/${guildId}/${path}`; + +export const buildCalendarContent = ( + profiles: readonly BirthdayProfile[], + now: DateTime, + zone: string, +): string => { + const groups = upcoming(profiles, now, zone); + let content = ':tada: ~ Upcoming Birthdays ~ :tada:\n'; + if (groups.length === 0) { + return `${content}${SEPARATOR}\nThere are no birthdays in this server, set yours with \`/mybirthday set\``; + } + content += "Here's the next birthdays in this guild!\n"; + content += 'Set yours with `/mybirthday set`\n'; + content += SEPARATOR; + for (const group of groups) { + const date = group.date.setLocale(LOCALE).toLocaleString(DateTime.DATE_FULL); + const section = `\n**${date}**\n${group.userIds.map((id) => `<@${id}>`).join('\n')}\n${SEPARATOR}`; + if (content.length + section.length > MESSAGE_LIMIT) { + break; + } + content += section; + } + return content; +}; + +const nowOf = (app: App): DateTime => DateTime.fromJSDate(app.clock()); + +const MISSING_CODES = new Set([10003, 10008]); // Unknown Channel, Unknown Message +const NO_ACCESS_CODES = new Set([50001, 50005, 50013]); // Missing Access, Cannot edit, Missing Permissions + +const codeOf = (error: unknown): number | undefined => { + const code = + typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined; + return typeof code === 'number' ? code : undefined; +}; + +/** Only a definite Discord answer is "missing" or "no access"; anything else may recover. */ +const statusOfError = (error: unknown, missing: CalendarStatus): CalendarStatus => { + const code = codeOf(error); + if (code !== undefined && MISSING_CODES.has(code)) { + return missing; + } + return code !== undefined && NO_ACCESS_CODES.has(code) ? 'no-access' : 'failed'; +}; + +const queues = new Map>(); + +/** Runs tasks for one guild strictly one after another. */ +const serialised = (guild: Snowflake, task: () => Promise): Promise => { + const run = (queues.get(guild) ?? Promise.resolve()).then(task, task); + const tail = run.catch(() => undefined); + queues.set(guild, tail); + void tail.then(() => { + if (queues.get(guild) === tail) { + queues.delete(guild); + } + }); + return run; +}; + +export const refreshCalendar = ( + app: App, + client: Client, + config: ServerConfig, +): Promise => { + const target = parseCalendarPath(config.birthdayCalendarMessagePath); + if (!target) { + return Promise.resolve('not-configured'); + } + return serialised(config.serverId, async () => { + const guild = config.serverId; + let stage: 'channel' | 'message' | 'edit' = 'channel'; + try { + const channel = await client.channels.fetch(target.channelId); + if (!channel?.isTextBased()) { + return 'channel-missing'; + } + stage = 'message'; + const message = await channel.messages.fetch(target.messageId); + stage = 'edit'; + // Read the profiles last so this edit reflects everything saved before it. + const profiles = (await getServerBirthdays(app.db, guild)).map((p) => ({ + userId: p.userId, + month: p.birthdayMonth!, + day: p.birthdayDay!, + })); + const content = buildCalendarContent(profiles, nowOf(app), app.env.timezone); + await message.edit({ content, allowedMentions: { parse: [] } }); + return 'updated'; + } catch (error) { + const status = statusOfError( + error, + stage === 'channel' ? 'channel-missing' : 'message-missing', + ); + app.logger.warn(`Birthday calendar ${stage} step failed`, { guild, status }, error); + return status; + } + }); +}; + +export type DeleteCalendarStatus = 'deleted' | 'already-missing' | 'failed'; + +export const deleteCalendarMessage = async ( + app: App, + client: Client, + path: string | null | undefined, +): Promise => { + const target = parseCalendarPath(path); + if (!target) { + return 'already-missing'; + } + try { + const channel = await client.channels.fetch(target.channelId); + if (!channel?.isTextBased()) { + return 'already-missing'; + } + const message = await channel.messages.fetch(target.messageId); + if (!message) { + return 'already-missing'; + } + await message.delete(); + return 'deleted'; + } catch (error) { + const code = codeOf(error); + if (code !== undefined && MISSING_CODES.has(code)) { + return 'already-missing'; + } + app.logger.warn('Could not delete the birthday calendar message', { path }, error); + return 'failed'; + } +}; + +export const refreshAllCalendars = async (app: App, client: Client): Promise => { + const { logger } = app; + logger.info('Refreshing birthday calendars'); + const configs = await getConfigs(app.db); + for (const listed of configs) { + if (!listed.birthdayCalendarMessagePath) { + continue; + } + try { + // Re-read: the guild may have been purged, or its calendar recreated, since the listing. + const config = await findConfig(app, listed.serverId); + if (!config) { + continue; + } + const status = await refreshCalendar(app, client, config); + if (status !== 'updated') { + logger.warn('Birthday calendar not updated', { guild: listed.serverId, status }); + } + } catch (error) { + logger.error('Birthday calendar refresh crashed', { guild: listed.serverId }, error); + } + } +}; + +const notifyGuild = async ( + app: App, + client: Client, + config: ServerConfig, + now: DateTime, +): Promise => { + const { logger } = app; + const zone = app.env.timezone; + const guild = + client.guilds.cache.get(config.serverId) ?? (await client.guilds.fetch(config.serverId)); + const channel = await resolveTextChannel(guild, config.announcementsChannelId!); + if (!channel) { + logger.warn('Announcements channel unavailable', { guild: config.serverId }); + return; + } + const profiles = await getServerBirthdays(app.db, config.serverId); + const ids: string[] = []; + for (const profile of profiles) { + if (!isBirthdayToday(profile.birthdayMonth!, profile.birthdayDay!, now, zone)) { + continue; + } + try { + if (await resolveMember(guild, profile.userId)) { + ids.push(profile.userId); + } + } catch (error) { + logger.warn( + 'Could not resolve birthday member', + { guild: config.serverId, user: profile.userId }, + error, + ); + } + } + if (ids.length === 0) { + return; + } + // Split across messages so a big day never exceeds Discord's 2000 character limit. + const prefix = 'Happy Birthday to '; + let chunk: string[] = []; + const flush = async () => { + await channel.send({ + content: `${prefix}${chunk.map((id) => `<@${id}>`).join(', ')}!`, + allowedMentions: { users: chunk }, + }); + chunk = []; + }; + for (const id of ids) { + const next = [...chunk, id].map((u) => `<@${u}>`).join(', '); + if (chunk.length > 0 && prefix.length + next.length + 1 > MAX_MESSAGE_LENGTH) { + await flush(); + } + chunk.push(id); + } + await flush(); +}; + +const MAX_MESSAGE_LENGTH = 2000; + +export const notifyBirthdays = async (app: App, client: Client): Promise => { + app.logger.info('Sending birthday notifications'); + const now = nowOf(app); + const configs = await getConfigs(app.db); + for (const config of configs) { + if (!config.announcementsChannelId) { + continue; + } + try { + await notifyGuild(app, client, config, now); + } catch (error) { + app.logger.error('Birthday notification failed', { guild: config.serverId }, error); + } + } +}; diff --git a/src/services/BotState.ts b/src/services/BotState.ts new file mode 100644 index 00000000..9962c67e --- /dev/null +++ b/src/services/BotState.ts @@ -0,0 +1,14 @@ +import { eq } from 'drizzle-orm'; +import type { Db } from '../db/db.ts'; +import { botState } from '../db/schema.ts'; + +export function getState(db: Db, key: string): string | undefined { + return db.select().from(botState).where(eq(botState.key, key)).get()?.value; +} + +export function setState(db: Db, key: string, value: string, now: Date = new Date()): void { + db.insert(botState) + .values({ key, value, updatedAt: now }) + .onConflictDoUpdate({ target: botState.key, set: { value, updatedAt: now } }) + .run(); +} diff --git a/src/services/ConfigService.ts b/src/services/ConfigService.ts new file mode 100644 index 00000000..5f043d5f --- /dev/null +++ b/src/services/ConfigService.ts @@ -0,0 +1,137 @@ +import type { Snowflake } from 'discord.js'; +import { eq } from 'drizzle-orm'; +import type { Db } from '../db/db.ts'; +import { serverConfigs, userProfiles, type ServerConfig } from '../db/schema.ts'; + +/** Per-app cache of server configs, kept coherent by the functions below. */ +export type ConfigCache = Map; + +export interface ConfigDeps { + db: Db; + configCache: ConfigCache; +} + +export const createConfigCache = (): ConfigCache => new Map(); + +export type ServerConfigPatch = Partial>; + +export async function getConfig( + { db, configCache }: ConfigDeps, + serverId: Snowflake, +): Promise { + const cached = configCache.get(serverId); + if (cached) { + return cached; + } + db.insert(serverConfigs).values({ serverId }).onConflictDoNothing().run(); + const config = db.select().from(serverConfigs).where(eq(serverConfigs.serverId, serverId)).get(); + if (!config) { + throw new Error(`Server config for ${serverId} is missing after insert`); + } + configCache.set(serverId, config); + return config; +} + +/** Reads a config without creating it: null when the guild has no (or no longer has a) row. */ +export async function findConfig( + { db, configCache }: ConfigDeps, + serverId: Snowflake, +): Promise { + return ( + configCache.get(serverId) ?? + db.select().from(serverConfigs).where(eq(serverConfigs.serverId, serverId)).get() ?? + null + ); +} + +export async function getConfigs(db: Db): Promise { + return db.select().from(serverConfigs).all(); +} + +export async function updateConfig( + deps: ConfigDeps, + serverId: Snowflake, + patch: ServerConfigPatch, +): Promise { + return (await swapConfig(deps, serverId, patch)).updated; +} + +/** Like updateConfig, but also returns the config as it was in the same atomic step. */ +export async function swapConfig( + { db, configCache }: ConfigDeps, + serverId: Snowflake, + patch: ServerConfigPatch, +): Promise<{ previous: ServerConfig; updated: ServerConfig }> { + configCache.delete(serverId); + const result = db.transaction((tx) => { + tx.insert(serverConfigs).values({ serverId }).onConflictDoNothing().run(); + const previous = tx + .select() + .from(serverConfigs) + .where(eq(serverConfigs.serverId, serverId)) + .get()!; + const updated = tx + .update(serverConfigs) + .set({ ...patch, updatedAt: new Date() }) + .where(eq(serverConfigs.serverId, serverId)) + .returning() + .get(); + return { previous, updated }; + }); + configCache.set(serverId, result.updated); + return result; +} + +/** + * Patch a config only if its row still exists (no upsert). For background jobs, which must not + * resurrect a config that was purged while they were running. + */ +export async function updateExistingConfig( + { db, configCache }: ConfigDeps, + serverId: Snowflake, + patch: ServerConfigPatch, +): Promise { + configCache.delete(serverId); + const updated = db + .update(serverConfigs) + .set({ ...patch, updatedAt: new Date() }) + .where(eq(serverConfigs.serverId, serverId)) + .returning() + .get(); + if (!updated) { + return null; + } + configCache.set(serverId, updated); + return updated; +} + +export async function deleteConfig( + { db, configCache }: ConfigDeps, + serverId: Snowflake, +): Promise { + configCache.delete(serverId); + const result = db.delete(serverConfigs).where(eq(serverConfigs.serverId, serverId)).run(); + return result.changes > 0; +} + +export async function resetAccessFailures( + deps: ConfigDeps, + config: ServerConfig, +): Promise { + if (config.accessFailureCount === 0 && config.firstAccessFailureAt === null) { + return config; + } + return updateConfig(deps, config.serverId, { accessFailureCount: 0, firstAccessFailureAt: null }); +} + +export async function purgeGuild( + { db, configCache }: ConfigDeps, + serverId: Snowflake, +): Promise<{ config: boolean; profiles: number }> { + configCache.delete(serverId); + return db.transaction((tx) => { + const profiles = tx.delete(userProfiles).where(eq(userProfiles.serverId, serverId)).run(); + const configs = tx.delete(serverConfigs).where(eq(serverConfigs.serverId, serverId)).run(); + return { config: configs.changes > 0, profiles: profiles.changes }; + }); +} diff --git a/src/services/DataCheckService.ts b/src/services/DataCheckService.ts new file mode 100644 index 00000000..2a4143f0 --- /dev/null +++ b/src/services/DataCheckService.ts @@ -0,0 +1,141 @@ +/* + * DATA RETENTION SPEC + * + * Persisted state: + * ServerConfigs one row per guild; accessFailureCount / firstAccessFailureAt count consecutive + * "gone" answers (Unknown Guild / Missing Access) from the scheduled data check. + * UserProfiles per (guild, user) member data, deleted when the member leaves. + * + * Deletion paths and their triggers (nothing else may delete a guild's data): + * guildDelete Discord says the bot was removed purge the guild at once + * guildMemberRemove the member left delete that member's profile + * data check, gone guild fetch says gone, count >= N and purge the guild + * first failure >= G days ago (RetentionPolicy) + * data check, members profile row read BEFORE the member list delete those rows by row id + * was fetched, and its user is not in it + * Events: guildCreate resets the failure counters (and re-records pending screenings). + * + * Transitions of the data check, per config row (outcome of fetching the guild): + * ok reset counters, then clean departed members' profiles + * transient (any other error) change nothing and delete nothing + * gone count + 1; purge only past both thresholds, otherwise just record the count + * + * Invariants: + * 1. A transient error never changes counters or deletes data; only a definite "gone" answer, + * repeated over a grace period, can purge, and a guild the gateway still lists never is. + * 2. Background jobs never create rows: counters are written with updateExistingConfig, and the + * config is re-read per guild, so a guild purged by a concurrent guildDelete is skipped. + * purgeGuild is one transaction and idempotent, so racing purges are harmless. + * 3. Member cleanup never deletes a profile created after it read the profiles (joiners during + * the slow member fetch, or a member who left and rejoined), and never runs on an empty + * member list. + * 4. A failure in one guild is logged and does not stop the others. + */ + +import type { Client } from 'discord.js'; +import { type Snowflake } from 'discord.js'; +import type { App } from '../app.ts'; +import { findConfig, getConfigs, purgeGuild, updateExistingConfig } from './ConfigService.ts'; +import { deleteProfiles, getServerProfiles } from './UserProfileService.ts'; +import { + type AccessOutcome, + type RetentionPolicyConfig, + classifyGuildFetchError, + nextAccessState, +} from './RetentionPolicy.ts'; + +const cleanMembers = async (app: App, client: Client, serverId: Snowflake): Promise => { + const guild = await client.guilds.fetch(serverId); + // Snapshot the profiles first: anyone who joins during the (slow) member fetch gets a profile + // that is missing from the fetched list, and must survive until the next run. + const profiles = await getServerProfiles(app.db, serverId); + const members = await guild.members.fetch(); + if (members.size === 0) { + app.logger.warn('Member list empty; skipping profile cleanup', { guild: serverId }); + return; + } + const departed = profiles.filter((p) => !members.has(p.userId)).map((p) => p.id); + if (departed.length > 0) { + const removed = await deleteProfiles(app.db, serverId, departed); + app.logger.info('Removed profiles of departed members', { guild: serverId, removed }); + } +}; + +export const runDataCheck = async ( + app: App, + client: Client, + policy: RetentionPolicyConfig = { + minFailures: app.env.purgeMinFailures, + graceDays: app.env.purgeGraceDays, + }, +): Promise => { + const { logger } = app; + const now = app.clock(); + logger.info('Running data check'); + const configs = await getConfigs(app.db); + for (const listed of configs) { + const guild = listed.serverId; + try { + // Re-read: a guildDelete may have purged this guild since the list was taken. + const config = await findConfig(app, guild); + if (!config) { + logger.debug('Config removed before data check; skipping guild', { guild }); + continue; + } + let outcome: AccessOutcome = 'ok'; + try { + await client.guilds.fetch(guild); + } catch (error) { + outcome = classifyGuildFetchError(error); + logger.warn('Guild fetch failed', { guild, outcome }, error); + } + + const next = nextAccessState( + { + accessFailureCount: config.accessFailureCount, + firstAccessFailureAt: config.firstAccessFailureAt, + }, + outcome, + now, + policy, + ); + + if (next.purge) { + if (client.guilds.cache.has(guild)) { + // The gateway says the bot is in this guild, so the fetch result was stale. + logger.warn('Guild is in the gateway cache; not purging', { guild }); + continue; + } + const result = await purgeGuild(app, guild); + logger.warn('Purged guild after sustained loss of access', { + guild, + failures: next.accessFailureCount, + config: result.config, + profiles: result.profiles, + }); + continue; + } + + if ( + next.accessFailureCount !== config.accessFailureCount || + next.firstAccessFailureAt?.getTime() !== config.firstAccessFailureAt?.getTime() + ) { + const updated = await updateExistingConfig(app, config.serverId, { + accessFailureCount: next.accessFailureCount, + firstAccessFailureAt: next.firstAccessFailureAt, + }); + if (!updated) { + logger.debug('Config removed during data check; skipping guild', { guild }); + continue; + } + } + + if (outcome === 'ok') { + await cleanMembers(app, client, guild); + } + } catch (error) { + logger.error('Data check failed for guild', { guild }, error); + } + } + logger.info('Data check complete'); +}; diff --git a/src/services/HoneypotTracker.ts b/src/services/HoneypotTracker.ts new file mode 100644 index 00000000..114dbc94 --- /dev/null +++ b/src/services/HoneypotTracker.ts @@ -0,0 +1,39 @@ +import type { Snowflake } from 'discord.js'; + +const ENFORCEMENT_WINDOW_MS = 5 * 60 * 1000; + +export interface HoneypotTracker { + /** Marks enforcement as started; false when it is already in progress for this member. */ + begin(serverId: Snowflake, userId: Snowflake): boolean; + isActive(serverId: Snowflake, userId: Snowflake): boolean; + cancel(serverId: Snowflake, userId: Snowflake): void; +} + +export function createHoneypotTracker(): HoneypotTracker { + const active = new Map(); + const keyOf = (serverId: Snowflake, userId: Snowflake): string => `${serverId}:${userId}`; + + return { + begin: (serverId, userId) => { + const key = keyOf(serverId, userId); + if (active.has(key)) { + return false; + } + const timeout = setTimeout(() => { + active.delete(key); + }, ENFORCEMENT_WINDOW_MS); + timeout.unref(); + active.set(key, timeout); + return true; + }, + isActive: (serverId, userId) => active.has(keyOf(serverId, userId)), + cancel: (serverId, userId) => { + const key = keyOf(serverId, userId); + const timeout = active.get(key); + if (timeout) { + clearTimeout(timeout); + active.delete(key); + } + }, + }; +} diff --git a/src/services/MemberListing.ts b/src/services/MemberListing.ts new file mode 100644 index 00000000..8ffd0f94 --- /dev/null +++ b/src/services/MemberListing.ts @@ -0,0 +1,54 @@ +import type { GuildMember } from 'discord.js'; +import { AttachmentBuilder } from 'discord.js'; +import { DateTime } from 'luxon'; +import type { ReplyOptions } from '../framework/command.ts'; + +export const INLINE_LIMIT = 2000; +const DAY_MS = 24 * 60 * 60 * 1000; + +const byJoinDate = (a: GuildMember, b: GuildMember): number => + (a.joinedTimestamp ?? Infinity) - (b.joinedTimestamp ?? Infinity); + +const inlineLine = (member: GuildMember): string => + member.joinedTimestamp === null + ? `<@${member.id}> join date unknown` + : `<@${member.id}> joined `; + +const fileLine = (member: GuildMember): string => + `${member.user.username} (${member.id}) — ${ + member.joinedTimestamp === null + ? 'join date unknown' + : `joined ${DateTime.fromMillis(member.joinedTimestamp, { zone: 'utc' }).toISODate()}` + }`; + +export const wholeDaysSinceJoin = (member: GuildMember, now: number): number | null => { + if (member.joinedTimestamp === null) { + return null; + } + const joined = DateTime.fromMillis(member.joinedTimestamp, { zone: 'utc' }).startOf('day'); + const today = DateTime.fromMillis(now, { zone: 'utc' }).startOf('day'); + return Math.floor((today.toMillis() - joined.toMillis()) / DAY_MS); +}; + +export const buildMemberListing = ( + header: string, + emptyMessage: string, + members: GuildMember[], +): ReplyOptions => { + if (members.length === 0) { + return { content: emptyMessage, allowedMentions: { parse: [] } }; + } + const sorted = [...members].sort(byJoinDate); + const inline = `${header}\n------\n${sorted.map(inlineLine).join('\n')}`; + if (inline.length <= INLINE_LIMIT) { + return { content: inline, allowedMentions: { parse: [] } }; + } + const file = new AttachmentBuilder(Buffer.from(sorted.map(fileLine).join('\n'), 'utf8'), { + name: 'members.txt', + }); + return { + content: `${header}\n${sorted.length} members matched; the full list is attached.`, + files: [file], + allowedMentions: { parse: [] }, + }; +}; diff --git a/src/services/MemberResolver.ts b/src/services/MemberResolver.ts new file mode 100644 index 00000000..b345da93 --- /dev/null +++ b/src/services/MemberResolver.ts @@ -0,0 +1,61 @@ +import type { Guild, GuildMember, User } from 'discord.js'; +import { type GuildTextBasedChannel, type Snowflake } from 'discord.js'; +import { UserError } from '../framework/errors.ts'; + +const UNKNOWN_MEMBER = 10007; +const UNKNOWN_USER = 10013; + +const errorCode = (error: unknown): unknown => + typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined; + +export const resolveMember = async ( + guild: Guild, + userOrId: User | Snowflake, +): Promise => { + const id = typeof userOrId === 'string' ? userOrId : userOrId.id; + const cached = guild.members.cache.get(id); + if (cached && !cached.partial) { + return cached; + } + try { + return await guild.members.fetch(cached ? { user: id, force: true } : id); + } catch (error) { + const code = errorCode(error); + if (code === UNKNOWN_MEMBER || code === UNKNOWN_USER) { + return null; + } + throw error; + } +}; + +export const resolveTextChannel = async ( + guild: Guild, + id: Snowflake, +): Promise => { + const channel = + guild.channels.cache.get(id) ?? (await guild.channels.fetch(id).catch(() => null)); + if (!channel || !channel.isTextBased() || channel.isDMBased() || !channel.isSendable()) { + return null; + } + return channel; +}; + +/** + * Fetches the full member list. A gateway rate limit (discord.js GatewayRateLimitError) becomes a + * UserError with the retry time; any other failure propagates unchanged. + */ +export const fetchAllMembers = async (guild: Guild) => { + try { + return await guild.members.fetch(); + } catch (error) { + if (error instanceof Error && error.name === 'GatewayRateLimitError') { + const retry = Number((error as { data?: { retry_after?: unknown } }).data?.retry_after); + const wait = + Number.isFinite(retry) && retry > 0 ? `${Math.ceil(retry)} seconds` : 'a few seconds'; + throw new UserError(`Discord is rate-limiting member lookups — try again in ${wait}.`, { + cause: error, + }); + } + throw error; + } +}; diff --git a/src/services/OnboardingService.ts b/src/services/OnboardingService.ts new file mode 100644 index 00000000..5df2581f --- /dev/null +++ b/src/services/OnboardingService.ts @@ -0,0 +1,414 @@ +/* + * ONBOARDING SPEC + * + * Persisted state, per (guild, user) in UserProfiles, describes the CURRENT membership only: + * screeningPendingAt member was seen awaiting membership screening (cleared on onboarding) + * onboardedAt the one onboarding of this membership has been claimed + * Rows are deleted when the member leaves (guildMemberRemove) or the guild is purged. + * + * Transitions (state = pending/onboarded; "-" = none): + * event state action new state + * join, pending any reset, no onboarding pending + * join, not pending any reset, claim, onboard onboarded + * update pending (true) - record pending pending + * update pending (true) pending/onb. nothing unchanged + * update true -> false (known) -/pending claim, onboard onboarded + * update true -> false (known) onboarded nothing (replay) onboarded + * update ? -> false (old partial) pending claim, onboard onboarded + * update ? -> false (old partial) -/onboarded nothing (cannot prove screening) unchanged + * update, any other any nothing unchanged + * leave any delete row - + * startup sweep (per member) any drop state older than the member's see rows above + * joinedTimestamp; pending -> record + * pending; recorded pending but no + * longer pending -> claim, onboard + * + * Invariants: + * 1. A member is onboarded at most once per membership: the claim is one atomic conditional + * upsert made before any step runs. A crash after the claim loses the welcome; it is never + * repeated. Individual step failures (roles, welcome, audit) are reported, never retried. + * 2. State left by a previous membership never suppresses onboarding: joins reset it, and where + * the join was missed (bot offline) any timestamp older than joinedTimestamp is dropped + * whenever the member is next seen (update event or sweep). + * 3. Dry runs (/simulate) never touch persisted state. + * 4. Bots are never onboarded or tracked. + * + * Failure handling: + * - Discord send/role calls fail: the step reports failed, an audit is attempted, other steps + * and the claim stand. + * - Old member partial or uncached: only a recorded pending state proves screening just ended. + * - Bot offline during a join/leave/update: the sweep reconciles at the next startup; members + * whose screening ended while offline are onboarded late. + * - Concurrent events during an await: the claim is the only synchronisation point and is atomic. + */ + +import type { AttachmentBuilder, Client, Guild, GuildMember, Role } from 'discord.js'; +import { type PartialGuildMember, type Snowflake } from 'discord.js'; +import type { App } from '../app.ts'; +import type { ServerConfig } from '../db/schema.ts'; +import { EmbedColours } from '../resources/EmbedColours.ts'; +import { memberAuditEmbed } from './AuditEmbed.ts'; +import { sendAudit } from './AuditService.ts'; +import { claimOnboarding } from './UserProfileService.ts'; +import { WelcomeImage, type WelcomeImageRenderer } from './WelcomeImage.ts'; + +/** 'done' | 'skipped:' | 'failed:' */ +export type StepResult = string; + +export interface WelcomePayload { + content?: string; + image?: AttachmentBuilder; + imageError?: string; +} + +export interface RoleSkip { + id: Snowflake; + reason: string; +} + +export interface OnboardingResult { + dryRun: boolean; + audit: StepResult; + roles: StepResult; + welcome: StepResult; + debug: StepResult; + rolesAdded: Snowflake[]; + rolesSkipped: RoleSkip[]; + welcomePayload?: WelcomePayload; +} + +export interface OnboardingDeps { + renderImage?: WelcomeImageRenderer; +} + +export interface OnboardingOptions { + dryRun?: boolean; + /** Skip the "completed onboarding" audit when the caller already audited this member. */ + skipAudit?: boolean; +} + +const MAX_ERROR_LENGTH = 200; + +/** Discord's message content limit. */ +export const MAX_WELCOME_LENGTH = 2000; + +export const expandWelcomeMessage = (template: string, memberId: Snowflake): string => + template.replaceAll('{member}', `<@${memberId}>`); + +const messageOf = (error: unknown): string => { + const text = error instanceof Error ? error.message : String(error); + return text.length > MAX_ERROR_LENGTH ? `${text.slice(0, MAX_ERROR_LENGTH)}…` : text; +}; + +/** Returns why the bot could not hand out this role, or null when it can. */ +export const roleProblem = (guild: Guild, role: Role): string | null => { + if (role.id === guild.id) { + return '@everyone cannot be assigned'; + } + if (role.managed) { + return 'managed by an integration'; + } + const me = guild.members.me; + if (!me) { + return 'bot member unavailable'; + } + if (role.position >= me.roles.highest.position) { + return "at or above the bot's highest role"; + } + return null; +}; + +export const parseRoleIds = (raw: string | null | undefined): Snowflake[] => + (raw ?? '') + .split(',') + .map((id) => id.trim()) + .filter((id) => id !== ''); + +/** True only when a member is known to have just passed membership screening. */ +export const completedScreening = ( + oldMember: GuildMember | PartialGuildMember, + newMember: GuildMember | PartialGuildMember, +): boolean => oldMember.pending === true && newMember.pending === false; + +export const formatOnboardingSummary = (result: OnboardingResult): string => { + const lines = [ + `Audit: ${result.audit}`, + `Guest roles: ${result.roles}`, + `Welcome message: ${result.welcome}`, + ]; + if (result.rolesAdded.length > 0) { + lines.push( + `${result.dryRun ? 'Would add' : 'Added'}: ${result.rolesAdded.map((id) => `<@&${id}>`).join(', ')}`, + ); + } + for (const skip of result.rolesSkipped) { + lines.push(`Skipped role ${skip.id}: ${skip.reason}`); + } + return lines.join('\n'); +}; + +export const auditJoin = ( + app: App, + client: Client, + member: GuildMember, + config: ServerConfig, +): Promise => + sendAudit( + app, + client, + config, + memberAuditEmbed(member, EmbedColours.positive, 'New member joined').addField('ID', member.id), + ); + +const auditFailure = ( + app: App, + client: Client, + member: GuildMember, + config: ServerConfig, + description: string, + detail: string, +): Promise => + sendAudit( + app, + client, + config, + memberAuditEmbed(member, EmbedColours.negative, description) + .addField('ID', member.id) + .addField('Reason', detail), + ); + +async function runStep( + app: App, + name: string, + member: GuildMember, + step: () => Promise, +): Promise { + try { + return await step(); + } catch (error) { + app.logger.error( + `Onboarding step failed: ${name}`, + { guild: member.guild.id, member: member.id }, + error, + ); + return `failed:${messageOf(error)}`; + } +} + +async function addGuestRoles( + app: App, + client: Client, + member: GuildMember, + config: ServerConfig, + dryRun: boolean, + result: OnboardingResult, +): Promise { + const ids = parseRoleIds(config.guestRoleIds); + if (ids.length === 0) { + return 'skipped:no guest roles configured'; + } + const { guild } = member; + const assignable: Role[] = []; + for (const id of ids) { + const role = guild.roles.cache.get(id) ?? (await guild.roles.fetch(id).catch(() => null)); + if (!role) { + result.rolesSkipped.push({ id, reason: 'role no longer exists' }); + continue; + } + const problem = roleProblem(guild, role); + if (problem) { + result.rolesSkipped.push({ id, reason: problem }); + continue; + } + assignable.push(role); + } + result.rolesAdded = assignable.map((r) => r.id); + if (assignable.length === 0) { + return 'skipped:no assignable guest roles'; + } + if (dryRun) { + return 'skipped:dry run'; + } + try { + await member.roles.add(assignable.map((r) => r.id)); + } catch (error) { + result.rolesAdded = []; + await auditFailure( + app, + client, + member, + config, + 'Unable to provide guest role(s) to member.', + messageOf(error), + ); + throw error; + } + return 'done'; +} + +async function sendWelcome( + app: App, + client: Client, + member: GuildMember, + config: ServerConfig, + dryRun: boolean, + result: OnboardingResult, + renderImage: WelcomeImageRenderer, +): Promise { + const text = config.welcomeMessage ?? ''; + const url = config.welcomeMessageBackgroundUrl; + if (!config.systemMessagesEnabled) { + return 'skipped:system messages are disabled'; + } + if (text === '' && !url) { + return 'skipped:no welcome message or image configured'; + } + const channel = member.guild.systemChannel; + if (!channel) { + return 'skipped:server has no system channel'; + } + + const payload: WelcomePayload = {}; + if (text !== '') { + let content = expandWelcomeMessage(text, member.id); + if (content.length > MAX_WELCOME_LENGTH) { + app.logger.warn('Welcome message exceeds the Discord limit after expansion; truncating', { + guild: member.guild.id, + member: member.id, + length: content.length, + }); + content = `${content.slice(0, MAX_WELCOME_LENGTH - 1)}…`; + } + payload.content = content; + } + if (url) { + try { + payload.image = await renderImage(app, member, url); + } catch (error) { + payload.imageError = messageOf(error); + app.logger.warn('Welcome image failed', { guild: member.guild.id, member: member.id }, error); + if (!dryRun) { + await auditFailure( + app, + client, + member, + config, + 'Unable to generate welcome image.', + payload.imageError, + ); + } + } + } + if (payload.content === undefined && !payload.image) { + throw new Error(`Welcome image failed: ${payload.imageError ?? 'unknown error'}`); + } + + result.welcomePayload = payload; + if (dryRun) { + return 'skipped:dry run'; + } + try { + await channel.send({ + content: payload.content, + files: payload.image ? [payload.image] : undefined, + allowedMentions: { users: [member.id] }, + }); + } catch (error) { + await auditFailure( + app, + client, + member, + config, + 'Unable to send welcome message.', + messageOf(error), + ); + throw error; + } + return 'done'; +} + +export async function complete( + app: App, + client: Client, + member: GuildMember, + config: ServerConfig, + options: OnboardingOptions = {}, + deps: OnboardingDeps = {}, +): Promise { + const dryRun = options.dryRun ?? false; + const renderImage = deps.renderImage ?? WelcomeImage.render; + const result: OnboardingResult = { + dryRun, + audit: 'skipped:dry run', + roles: '', + welcome: '', + debug: 'skipped:debug is disabled', + rolesAdded: [], + rolesSkipped: [], + }; + + if (!dryRun && options.skipAudit) { + result.audit = 'skipped:already audited on join'; + } else if (!dryRun) { + result.audit = await runStep(app, 'audit', member, async () => { + const sent = await sendAudit( + app, + client, + config, + memberAuditEmbed(member, EmbedColours.neutral, 'Member completed onboarding').addField( + 'ID', + member.id, + ), + ); + return sent ? 'done' : 'skipped:no usable audit channel'; + }); + } + + result.roles = await runStep(app, 'roles', member, () => + addGuestRoles(app, client, member, config, dryRun, result), + ); + result.welcome = await runStep(app, 'welcome', member, () => + sendWelcome(app, client, member, config, dryRun, result, renderImage), + ); + + if (config.debug && !dryRun) { + result.debug = await runStep(app, 'debug', member, async () => { + const sent = await sendAudit( + app, + client, + config, + memberAuditEmbed(member, EmbedColours.info, 'Onboarding diagnostics') + .addField('ID', member.id) + .addField('Steps', formatOnboardingSummary(result)), + ); + return sent ? 'done' : 'skipped:no usable audit channel'; + }); + } else if (config.debug) { + result.debug = 'skipped:dry run'; + } + + return result; +} + +/** + * Onboards a member at most once per membership: the claim is recorded before any step runs, so a + * concurrent or replayed event finds it taken. Returns null when the member was already onboarded. + */ +export async function onboard( + app: App, + client: Client, + member: GuildMember, + config: ServerConfig, + options: OnboardingOptions = {}, +): Promise { + if (!(await claimOnboarding(app.db, member.guild.id, member.id, app.clock()))) { + app.logger.info('Skipping onboarding: already onboarded this membership', { + guild: member.guild.id, + member: member.id, + }); + return null; + } + return complete(app, client, member, config, options); +} + +export const OnboardingService = { complete, onboard }; diff --git a/src/services/RetentionPolicy.ts b/src/services/RetentionPolicy.ts new file mode 100644 index 00000000..a4b7fcf0 --- /dev/null +++ b/src/services/RetentionPolicy.ts @@ -0,0 +1,39 @@ +export type AccessOutcome = 'ok' | 'gone' | 'transient'; + +export interface AccessState { + accessFailureCount: number; + firstAccessFailureAt: Date | null; +} + +export interface RetentionPolicyConfig { + minFailures: number; + graceDays: number; +} + +const GONE_CODES = new Set([50001, 10004]); +const DAY_MS = 24 * 60 * 60 * 1000; + +export const classifyGuildFetchError = (err: unknown): 'gone' | 'transient' => { + const code = + typeof err === 'object' && err !== null ? (err as { code?: unknown }).code : undefined; + return typeof code === 'number' && GONE_CODES.has(code) ? 'gone' : 'transient'; +}; + +export const nextAccessState = ( + state: AccessState, + outcome: AccessOutcome, + now: Date, + policy: RetentionPolicyConfig, +): AccessState & { purge: boolean } => { + if (outcome === 'ok') { + return { accessFailureCount: 0, firstAccessFailureAt: null, purge: false }; + } + if (outcome === 'transient') { + return { ...state, purge: false }; + } + const accessFailureCount = state.accessFailureCount + 1; + const firstAccessFailureAt = state.firstAccessFailureAt ?? now; + const elapsed = now.getTime() - firstAccessFailureAt.getTime(); + const purge = accessFailureCount >= policy.minFailures && elapsed >= policy.graceDays * DAY_MS; + return { accessFailureCount, firstAccessFailureAt, purge }; +}; diff --git a/src/services/Scheduler.ts b/src/services/Scheduler.ts new file mode 100644 index 00000000..2dbb4916 --- /dev/null +++ b/src/services/Scheduler.ts @@ -0,0 +1,103 @@ +import { Cron } from 'croner'; +import type { Logger } from '../utilities/Logger.ts'; + +export interface SchedulerTask { + name: string; + run: () => Promise; +} + +export interface ScheduledJobLike { + cancel(): unknown; + nextInvocation(): { toDate(): Date } | Date | null | undefined; +} + +export type ScheduleFn = ( + spec: { rule: string; tz: string }, + callback: () => void, +) => ScheduledJobLike | null; + +const defaultScheduleFn: ScheduleFn = (spec, callback) => { + try { + const job = new Cron(spec.rule, { timezone: spec.tz, protect: true }, callback); + return { cancel: () => job.stop(), nextInvocation: () => job.nextRun() }; + } catch { + return null; + } +}; + +export class Scheduler { + private job: ScheduledJobLike | null = null; + private readonly logger: Logger; + private current: Promise | null = null; + private readonly cron: string; + private readonly timezone: string; + private readonly tasks: SchedulerTask[]; + private readonly scheduleFn: ScheduleFn; + + public constructor( + logger: Logger, + cron: string, + timezone: string, + tasks: SchedulerTask[], + scheduleFn: ScheduleFn = defaultScheduleFn, + ) { + this.logger = logger; + this.cron = cron; + this.timezone = timezone; + this.tasks = tasks; + this.scheduleFn = scheduleFn; + } + + public start(): void { + if (this.job) { + this.logger.warn('Scheduler already started', { cron: this.cron }); + return; + } + const job = this.scheduleFn({ rule: this.cron, tz: this.timezone }, () => { + void this.runNow(); + }); + if (!job) { + throw new Error(`Invalid cron schedule "${this.cron}" (timezone ${this.timezone})`); + } + this.job = job; + } + + /** Cancels future runs and waits for any in-flight run to finish. */ + public async stop(): Promise { + this.job?.cancel(); + this.job = null; + await this.current; + } + + public async runNow(): Promise { + if (this.current) { + this.logger.warn('Scheduled run skipped; previous run still in progress'); + return; + } + const run = this.runTasks(); + this.current = run; + try { + await run; + } finally { + this.current = null; + } + } + + private async runTasks(): Promise { + for (const task of this.tasks) { + try { + await task.run(); + } catch (error) { + this.logger.error('Scheduled task failed', { task: task.name }, error); + } + } + } + + public nextInvocation(): Date | null { + const next = this.job?.nextInvocation(); + if (!next) { + return null; + } + return next instanceof Date ? next : next.toDate(); + } +} diff --git a/src/services/ScreeningService.ts b/src/services/ScreeningService.ts new file mode 100644 index 00000000..4958fa26 --- /dev/null +++ b/src/services/ScreeningService.ts @@ -0,0 +1,87 @@ +import type { Client, Guild, GuildMember } from 'discord.js'; +import type { App } from '../app.ts'; +import { getConfig } from './ConfigService.ts'; +import { onboard } from './OnboardingService.ts'; +import { + dropStaleOnboardingState, + getOnboardingState, + recordScreeningPending, + type OnboardingState, +} from './UserProfileService.ts'; + +/** Tolerance for clock differences between Discord's joinedTimestamp and the bot's clock. */ +const JOIN_SKEW_MS = 60_000; + +/** + * The member's recorded onboarding state, after dropping anything recorded before they last + * joined (a previous membership whose leave or join was missed while the bot was offline). + */ +export async function currentOnboardingState( + app: App, + member: GuildMember, +): Promise { + if (member.joinedTimestamp) { + await dropStaleOnboardingState( + app.db, + member.guild.id, + member.id, + new Date(member.joinedTimestamp - JOIN_SKEW_MS), + ); + } + return getOnboardingState(app.db, member.guild.id, member.id); +} + +export interface SyncResult { + recorded: number; + caughtUp: number; +} + +/** + * Reconciles one guild's members with the recorded screening state, for events missed while the + * bot was offline or not yet in the guild: records members currently pending screening (so a later + * update with an unknown previous state can be trusted), and onboards members recorded as pending + * whose screening has since ended. Failures are logged and leave the state as it was. + */ +export async function syncGuildScreening( + app: App, + client: Client, + guild: Guild, +): Promise { + const result: SyncResult = { recorded: 0, caughtUp: 0 }; + try { + const members = await guild.members.fetch(); + for (const member of members.values()) { + // A guildDelete during the sweep purges this guild's rows; never recreate them. + if (member.user.bot || !client.guilds.cache.has(guild.id)) { + continue; + } + const state = await currentOnboardingState(app, member); + if (member.pending === true) { + if (state.screeningPendingAt === null && state.onboardedAt === null) { + await recordScreeningPending(app.db, guild.id, member.id, app.clock()); + result.recorded++; + } + } else if (state.screeningPendingAt !== null && state.onboardedAt === null) { + const config = await getConfig(app, guild.id); + if (await onboard(app, client, member, config)) { + result.caughtUp++; + } + } + } + } catch (error) { + app.logger.error('Could not sync screening state', { guild: guild.id }, error); + } + return result; +} + +/** Startup reconciliation of every guild the bot is in. */ +export async function syncScreeningState(app: App, client: Client): Promise { + const total: SyncResult = { recorded: 0, caughtUp: 0 }; + for (const guild of client.guilds.cache.values()) { + const result = await syncGuildScreening(app, client, guild); + total.recorded += result.recorded; + total.caughtUp += result.caughtUp; + } + app.logger.info('Synced screening state', { ...total }); + return total; +} diff --git a/src/services/UserProfileService.ts b/src/services/UserProfileService.ts new file mode 100644 index 00000000..038d64b2 --- /dev/null +++ b/src/services/UserProfileService.ts @@ -0,0 +1,219 @@ +import type { Snowflake } from 'discord.js'; +import { and, eq, inArray, isNotNull, isNull, lt, or, sql } from 'drizzle-orm'; +import type { Db } from '../db/db.ts'; +import { userProfiles, type UserProfile } from '../db/schema.ts'; + +const forUser = (serverId: Snowflake, userId: Snowflake) => + and(eq(userProfiles.serverId, serverId), eq(userProfiles.userId, userId)); + +export async function getServerBirthdays(db: Db, serverId: Snowflake): Promise { + return db + .select() + .from(userProfiles) + .where( + and( + eq(userProfiles.serverId, serverId), + isNotNull(userProfiles.birthdayDay), + isNotNull(userProfiles.birthdayMonth), + ), + ) + .all(); +} + +export async function getServerProfiles(db: Db, serverId: Snowflake): Promise { + return db.select().from(userProfiles).where(eq(userProfiles.serverId, serverId)).all(); +} + +export async function setBirthday( + db: Db, + serverId: Snowflake, + userId: Snowflake, + month: number, + day: number, +): Promise { + db.insert(userProfiles) + .values({ serverId, userId, birthdayMonth: month, birthdayDay: day }) + .onConflictDoUpdate({ + target: [userProfiles.serverId, userProfiles.userId], + set: { birthdayMonth: month, birthdayDay: day, updatedAt: new Date() }, + }) + .run(); +} + +export async function clearBirthday( + db: Db, + serverId: Snowflake, + userId: Snowflake, +): Promise { + const result = db + .update(userProfiles) + .set({ birthdayYear: null, birthdayMonth: null, birthdayDay: null, updatedAt: new Date() }) + .where( + and( + forUser(serverId, userId), + or( + isNotNull(userProfiles.birthdayMonth), + isNotNull(userProfiles.birthdayDay), + isNotNull(userProfiles.birthdayYear), + ), + ), + ) + .run(); + return result.changes > 0; +} + +/** + * Deletes profiles by row id. A row id is never reused, so a profile created after the ids were + * read (a member who left and rejoined) is not deleted along with the stale row. + */ +export async function deleteProfiles(db: Db, serverId: Snowflake, ids: number[]): Promise { + let removed = 0; + for (let i = 0; i < ids.length; i += 500) { + removed += db + .delete(userProfiles) + .where( + and(eq(userProfiles.serverId, serverId), inArray(userProfiles.id, ids.slice(i, i + 500))), + ) + .run().changes; + } + return removed; +} + +export async function getUserProfile( + db: Db, + serverId: Snowflake, + userId: Snowflake, +): Promise { + db.insert(userProfiles).values({ serverId, userId }).onConflictDoNothing().run(); + return db.select().from(userProfiles).where(forUser(serverId, userId)).get()!; +} + +export async function findUserProfile( + db: Db, + serverId: Snowflake, + userId: Snowflake, +): Promise { + return db.select().from(userProfiles).where(forUser(serverId, userId)).get() ?? null; +} + +export async function incrementActivityScore( + db: Db, + serverId: Snowflake, + userId: Snowflake, +): Promise { + db.insert(userProfiles) + .values({ serverId, userId, activityScore: 1 }) + .onConflictDoUpdate({ + target: [userProfiles.serverId, userProfiles.userId], + set: { + activityScore: sql`coalesce(${userProfiles.activityScore}, 0) + 1`, + updatedAt: new Date(), + }, + }) + .run(); +} + +/** Deletes a member's profile and returns what was stored, or null when there was none. */ +export async function deleteUser( + db: Db, + serverId: Snowflake, + userId: Snowflake, +): Promise { + return db.delete(userProfiles).where(forUser(serverId, userId)).returning().get() ?? null; +} + +/** + * Begins a new membership: forgets every state recorded for a previous one. Pass `pending` when + * the member is awaiting screening. + */ +export async function startMembership( + db: Db, + serverId: Snowflake, + userId: Snowflake, + at: Date, + pending: boolean, +): Promise { + const screeningPendingAt = pending ? at : null; + db.insert(userProfiles) + .values({ serverId, userId, screeningPendingAt }) + .onConflictDoUpdate({ + target: [userProfiles.serverId, userProfiles.userId], + set: { screeningPendingAt, onboardedAt: null, updatedAt: at }, + }) + .run(); +} + +/** + * Atomically claims the single onboarding of the current membership. Returns false when it was + * already claimed, so concurrent or replayed events cannot onboard a member twice. + */ +export async function claimOnboarding( + db: Db, + serverId: Snowflake, + userId: Snowflake, + at: Date, +): Promise { + return ( + db + .insert(userProfiles) + .values({ serverId, userId, onboardedAt: at }) + .onConflictDoUpdate({ + target: [userProfiles.serverId, userProfiles.userId], + set: { onboardedAt: at, screeningPendingAt: null, updatedAt: at }, + setWhere: isNull(userProfiles.onboardedAt), + }) + .run().changes > 0 + ); +} + +/** Records that a member is awaiting screening, unless anything is already known about them. */ +export async function recordScreeningPending( + db: Db, + serverId: Snowflake, + userId: Snowflake, + at: Date, +): Promise { + db.insert(userProfiles) + .values({ serverId, userId, screeningPendingAt: at }) + .onConflictDoUpdate({ + target: [userProfiles.serverId, userProfiles.userId], + set: { screeningPendingAt: at, updatedAt: at }, + setWhere: and(isNull(userProfiles.screeningPendingAt), isNull(userProfiles.onboardedAt)), + }) + .run(); +} + +/** Drops recorded timestamps older than `cutoff`: they belong to a membership that has ended. */ +export async function dropStaleOnboardingState( + db: Db, + serverId: Snowflake, + userId: Snowflake, + cutoff: Date, +): Promise { + const mine = forUser(serverId, userId); + db.update(userProfiles) + .set({ onboardedAt: null }) + .where(and(mine, lt(userProfiles.onboardedAt, cutoff))) + .run(); + db.update(userProfiles) + .set({ screeningPendingAt: null }) + .where(and(mine, lt(userProfiles.screeningPendingAt, cutoff))) + .run(); +} + +export interface OnboardingState { + screeningPendingAt: Date | null; + onboardedAt: Date | null; +} + +export async function getOnboardingState( + db: Db, + serverId: Snowflake, + userId: Snowflake, +): Promise { + const profile = await findUserProfile(db, serverId, userId); + return { + screeningPendingAt: profile?.screeningPendingAt ?? null, + onboardedAt: profile?.onboardedAt ?? null, + }; +} diff --git a/src/services/WelcomeImage.ts b/src/services/WelcomeImage.ts new file mode 100644 index 00000000..6b5f69dc --- /dev/null +++ b/src/services/WelcomeImage.ts @@ -0,0 +1,171 @@ +import path from 'node:path'; +import type { GuildMember } from 'discord.js'; +import { AttachmentBuilder } from 'discord.js'; +import { DateTime } from 'luxon'; +import type { Canvas } from '@napi-rs/canvas'; +import { createCanvas, GlobalFonts, loadImage } from '@napi-rs/canvas'; +import type { App } from '../app.ts'; + +const FONT_FAMILY = 'Roboto'; +const FONT_PATH = path.join(import.meta.dirname, '..', 'resources', 'fonts', 'Roboto-Regular.ttf'); +const DOWNLOAD_TIMEOUT_MS = 10_000; +const MAX_DOWNLOAD_BYTES = 10 * 1024 * 1024; +const MIN_FONT_SIZE = 8; +const AVATAR_CENTER = { x: 125, y: 125 }; +const AVATAR_RADIUS = 75; +const RING_WIDTH = 5; +const RING_COLOUR = 'rgba(255, 255, 255, 0.7)'; +const AVATAR_BACKDROP = 'rgba(0, 0, 0, 0.4)'; + +let fontRegistered = false; +const ensureFont = (): void => { + if (!fontRegistered) { + GlobalFonts.registerFromPath(FONT_PATH, FONT_FAMILY); + fontRegistered = true; + } +}; + +export const isHttpUrl = (value: string): boolean => { + try { + const { protocol } = new URL(value); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +}; + +export const applyText = (canvas: Canvas, text: string, baseSize: number): string => { + const ctx = canvas.getContext('2d'); + let fontSize = baseSize; + ctx.font = `${fontSize}px ${FONT_FAMILY}`; + while (fontSize > MIN_FONT_SIZE && ctx.measureText(text).width > canvas.width - 300) { + fontSize -= 1; + ctx.font = `${fontSize}px ${FONT_FAMILY}`; + } + return ctx.font; +}; + +class DownloadError extends Error {} + +const downloadImage = async (url: string): Promise => { + if (!isHttpUrl(url)) { + throw new Error('Image URL must start with http:// or https://'); + } + const tooLarge = () => new DownloadError('The image is larger than 10 MB'); + // One signal covers connecting and reading the whole body. + const signal = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS); + try { + const response = await fetch(url, { signal }); + if (!response.ok) { + await response.body?.cancel(); + throw new DownloadError(`Could not download the image (HTTP ${response.status})`); + } + const declared = Number(response.headers.get('content-length')); + if (Number.isFinite(declared) && declared > MAX_DOWNLOAD_BYTES) { + await response.body?.cancel(); + throw tooLarge(); + } + const chunks: Uint8Array[] = []; + let total = 0; + const reader = response.body?.getReader(); + while (reader) { + const { done, value } = await reader.read(); + if (done) { + break; + } + total += value.length; + if (total > MAX_DOWNLOAD_BYTES) { + await reader.cancel(); + throw tooLarge(); + } + chunks.push(value); + } + return Buffer.concat(chunks); + } catch (error) { + if (error instanceof DownloadError) { + throw error; + } + if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) { + throw new Error( + `Downloading the image timed out after ${DOWNLOAD_TIMEOUT_MS / 1000} seconds`, + { cause: error }, + ); + } + throw new Error( + `Could not download the image: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } +}; + +export type WelcomeImageRenderer = ( + app: App, + member: GuildMember, + backgroundUrl: string, +) => Promise; + +export const render: WelcomeImageRenderer = async (app, member, backgroundUrl) => { + ensureFont(); + if (member.joinedTimestamp === null) { + throw new Error(`Join date unavailable for member ${member.id}`); + } + + const data = await downloadImage(backgroundUrl); + let background; + try { + background = await loadImage(data); + } catch (error) { + throw new Error('The URL did not return a valid image', { cause: error }); + } + + const canvas = createCanvas(700, 250); + const ctx = canvas.getContext('2d'); + const joined = DateTime.fromMillis(member.joinedTimestamp, { + zone: app.env.timezone, + }).toLocaleString(DateTime.DATE_FULL); + const name = member.displayName; + const welcome = `Welcome to ${member.guild.name}!`; + const joinedText = `Joined: ${joined}`; + + ctx.drawImage(background, 0, 0, canvas.width, canvas.height); + ctx.fillStyle = '#ffffff'; + + ctx.font = applyText(canvas, name, 48); + ctx.fillText(name, 225, 125); + ctx.font = applyText(canvas, welcome, 34); + ctx.fillText(welcome, 225, 160); + ctx.font = applyText(canvas, joinedText, 20); + ctx.fillText(joinedText, 225, 200); + + const cx = AVATAR_CENTER.x; + const cy = AVATAR_CENTER.y; + const r = AVATAR_RADIUS; + + // Create a clipping region for the avatar + ctx.save(); + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.closePath(); + ctx.clip(); + + // Fill the clipped region with backdrop + ctx.fillStyle = AVATAR_BACKDROP; + ctx.fillRect(cx - r, cy - r, 2 * r, 2 * r); + + // Draw the avatar + const avatar = await loadImage(member.displayAvatarURL({ extension: 'png', size: 256 })); + ctx.drawImage(avatar, cx - r, cy - r, 2 * r, 2 * r); + + ctx.restore(); + + // Draw the ring outside the clipped region + ctx.beginPath(); + ctx.arc(cx, cy, r + RING_WIDTH / 2, 0, Math.PI * 2); + ctx.strokeStyle = RING_COLOUR; + ctx.lineWidth = RING_WIDTH; + ctx.stroke(); + + return new AttachmentBuilder(await canvas.encode('png')).setName('welcome-image.png'); +}; + +export const WelcomeImage = { render }; diff --git a/src/slash-commands/Admin/noroles.ts b/src/slash-commands/Admin/noroles.ts deleted file mode 100644 index 432cdd9f..00000000 --- a/src/slash-commands/Admin/noroles.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ChatInputCommandInteraction, SlashCommandBuilder, PermissionFlagsBits, InteractionContextType } from 'discord.js'; -import { SlashCommand } from '../../types/SlashCommand'; - -const execute = async (cmd: ChatInputCommandInteraction) => { - const allMembers = await cmd.guild.members.fetch(); - - const members = allMembers.filter((member) => member.roles.cache.size === 1); - - let response: string; - - if (members.size < 1) { - response = 'There are no users with no roles.'; - } else { - response = '**Users with no roles**\n------\n'; - members.each(async (mem) => { - if (mem.partial) { - await mem.fetch(); - } - response += `${mem.toString()} joined \n`; - }); - } - - return cmd.reply({ - content: response, - allowedMentions: { - parse: [], - }, - ephemeral: true - }); -}; - -const commandData = new SlashCommandBuilder() - .setName('noroles') - .setDescription('Returns members of this guild with no roles assigned') - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .setContexts([InteractionContextType.Guild]); - -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute -}; -export = slashCommand; \ No newline at end of file diff --git a/src/slash-commands/Admin/post.ts b/src/slash-commands/Admin/post.ts deleted file mode 100644 index fba67ad0..00000000 --- a/src/slash-commands/Admin/post.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { ChatInputCommandInteraction, SlashCommandBuilder, PermissionFlagsBits, SlashCommandChannelOption, SlashCommandStringOption, SlashCommandAttachmentOption, Attachment, InteractionContextType } from 'discord.js'; -import { ServerConfig } from '../../client/models/ServerConfig'; -import { EmbedColours } from '../../resources/EmbedColours'; -import { EmbedCompatLayer } from '../../types/EmbedCompatLayer'; -import { SlashCommand } from '../../types/SlashCommand'; -import { ChannelService } from '../../utilities/ChannelService'; - -const sendAudit = async (cmd: ChatInputCommandInteraction, config: ServerConfig, file: Attachment, content: string) => { - const embed = new EmbedCompatLayer(); - const member = cmd.guild.members.cache.get(cmd.user.id); - - embed - .setColor(EmbedColours.neutral) - .setAuthor({ - name: member.displayName, - iconURL: member.displayAvatarURL(), - }) - .addField('Content', content ? content : 'No') - .addField('Attachment', file ? 'Yes' : 'No') - .setDescription('Post created via Dingir') - .setTimestamp(); - - ChannelService.sendAuditMessage(cmd.client, config, embed, file); -}; - -const execute = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - await cmd.deferReply({ ephemeral: true }); - const channel = cmd.options.getChannel('channel'); - const content = cmd.options.getString('content'); - const attachment = cmd.options.getAttachment('attachment'); - - if (!content && !attachment) { - return cmd.editReply({ - content: 'You must provide at least text or an attachment' - }); - } - - const guildChannel = cmd.guild.channels.cache.get(channel.id); - if (!guildChannel || !guildChannel.isTextBased()) { - return cmd.editReply({ - content: 'The provided channel is not valid' - }); - } - - await guildChannel.send({ - content: content, - files: attachment ? [attachment] : null - }).catch(() => { - return cmd.editReply({ - content: 'An error was encountered sending this message' - }); - }); - - sendAudit(cmd, config, attachment, content); - - return cmd.editReply({ - content: 'Message successfully sent' - }); -}; - -const commandData = new SlashCommandBuilder() - .setName('post') - .setDescription('Posts a simple message and/or attachment to the given channel') - .addChannelOption((opt: SlashCommandChannelOption) => opt - .setName('channel') - .setDescription('Channel to post in') - .setRequired(true)) - .addStringOption((opt: SlashCommandStringOption) => opt - .setName('content') - .setDescription('Optional simple message to send')) - .addAttachmentOption((opt: SlashCommandAttachmentOption) => opt - .setName('attachment') - .setDescription('Optional attachment to send')) - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .setContexts([InteractionContextType.Guild]); - -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute -}; -export = slashCommand; \ No newline at end of file diff --git a/src/slash-commands/Admin/rolesince.ts b/src/slash-commands/Admin/rolesince.ts deleted file mode 100644 index f8a02c3f..00000000 --- a/src/slash-commands/Admin/rolesince.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { ChatInputCommandInteraction, SlashCommandBuilder, PermissionFlagsBits, SlashCommandRoleOption, SlashCommandNumberOption, InteractionContextType } from 'discord.js'; -import { DateTime } from 'luxon'; -import { SlashCommand } from '../../types/SlashCommand'; - -const execute = async (cmd: ChatInputCommandInteraction) => { - const role = cmd.options.getRole('role'); - const days = cmd.options.getNumber('days') ?? 0; - - const allMembers = await cmd.guild.members.fetch(); - - const members = allMembers.filter((member) => { - const joined = DateTime.fromMillis(member.joinedTimestamp).startOf('day'); - const daysInServer = DateTime.local() - .startOf('day') - .diff(joined, 'days') - .toObject().days; - return ( - member.roles.cache.find((r) => r.id === role.id) && - daysInServer >= days - ); - }); - - let response: string; - - if (members.size < 1) { - response = `There are no users in ${role.toString()} that have been in the server for at least ${days ?? 0 - } days.`; - } else { - response = `**Users in ${role.toString()} that have been in the server for at least ${days ?? 0 - } days.**\n------\n`; - members.each(async (mem) => { - if (mem.partial) { - await mem.fetch(); - } - response += `${mem.toString()} joined \n`; - }); - } - - return cmd.reply({ - content: response, - allowedMentions: { - parse: [], - }, - ephemeral: true - }); -}; - -const commandData = new SlashCommandBuilder() - .setName('rolesince') - .setDescription('Returns members in the given role for the specified number of days') - .addRoleOption((opt: SlashCommandRoleOption) => opt - .setName('role') - .setDescription('Role to search again') - .setRequired(true)) - .addNumberOption((opt: SlashCommandNumberOption) => opt - .setName('days') - .setDescription('Minimum number of days in the role')) - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .setContexts([InteractionContextType.Guild]); - -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute -}; -export = slashCommand; \ No newline at end of file diff --git a/src/slash-commands/Admin/simulate.ts b/src/slash-commands/Admin/simulate.ts deleted file mode 100644 index 0b7d26bd..00000000 --- a/src/slash-commands/Admin/simulate.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { ChatInputCommandInteraction, SlashCommandBuilder, PermissionFlagsBits, SlashCommandSubcommandBuilder, SlashCommandUserOption, InteractionContextType } from 'discord.js'; -import { SlashCommand } from '../../types/SlashCommand'; - -const testJoin = async (cmd: ChatInputCommandInteraction) => { - const user = cmd.options.getUser('member'); - const guildMember = cmd.guild.members.cache.get(user?.id) ?? cmd.guild.members.cache.get(cmd.user.id); - cmd.client.emit('guildMemberAdd', guildMember); - - cmd.reply({ content: `Emitted guildMemberAdd for ${guildMember.toString()}`, ephemeral: true }); -}; - -const testScreen = async (cmd: ChatInputCommandInteraction) => { - const user = cmd.options.getUser('member'); - const guildMember = cmd.guild.members.cache.get(user?.id) ?? cmd.guild.members.cache.get(cmd.user.id); - - const oldMemberMock = Object.assign({}, - guildMember, - { - pending: true, - } - ); - const newMemberMock = guildMember; - - cmd.client.emit('guildMemberUpdate', oldMemberMock, newMemberMock); - - cmd.reply({ content: `Emitted guildMemberUpdate for ${guildMember.toString()}`, ephemeral: true }); -}; - -const execute = async (cmd: ChatInputCommandInteraction) => { - const subCommand = cmd.options.getSubcommand(); - - if (!subCommand) { - cmd.reply({ content: 'This command is misconfigured', ephemeral: true }); - } - - if (subCommand == 'join') { - return testJoin(cmd); - } else if (subCommand == 'screen') { - return testScreen(cmd); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true - }); - } -}; - -const commandData = new SlashCommandBuilder() - .setName('simulate') - .setDescription('Simulate events in this server') - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .setContexts([InteractionContextType.Guild]) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('join') - .setDescription('Simulate someone joining this server') - .addUserOption((opt: SlashCommandUserOption) => opt - .setName('member') - .setDescription('Member to simulate joining as'))) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('screen') - .setDescription('Simulate someone passing screening in this server') - .addUserOption((opt: SlashCommandUserOption) => opt - .setName('member') - .setDescription('Member to simulate passing screening as'))); - -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute -}; -export = slashCommand; \ No newline at end of file diff --git a/src/slash-commands/Config/Subcommands/announcements.ts b/src/slash-commands/Config/Subcommands/announcements.ts deleted file mode 100644 index 152db580..00000000 --- a/src/slash-commands/Config/Subcommands/announcements.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { SlashCommandSubcommandGroupBuilder, SlashCommandSubcommandBuilder, SlashCommandChannelOption, ChatInputCommandInteraction } from 'discord.js'; -import { ServerConfig } from '../../../client/models/ServerConfig'; -import { SlashSubGroupCommand } from '../../../types/SlashCommand'; -import { ConfigManager } from '../../../utilities/ConfigManager'; - -const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - ConfigManager.updateChannelNew(cmd, config, 'announcementsChannelId'); - return get(cmd, config); -}; - -const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const channel = await ConfigManager.getChannel(cmd, config, 'announcementsChannelId'); - return cmd.reply({ - content: `The announcements channel for this server is: ${channel ? channel.toString() : 'Not Set'}`, - ephemeral: true - }); -}; - -const clear = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - ConfigManager.clearChannel(config, 'announcementsChannelId'); - return get(cmd, config); -}; - -const exec = async(cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommand(); - if (subCommand == 'set') { - return set(cmd, config); - } else if (subCommand == 'get') { - return get(cmd, config); - } else if (subCommand == 'clear') { - return clear(cmd, config); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true - }); - } -}; - -const data = new SlashCommandSubcommandGroupBuilder() - .setName('announcements') - .setDescription('Configure the announcements channel for this server') - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('set') - .setDescription('Set\'s the channel to post announcements in') - .addChannelOption((opt: SlashCommandChannelOption) => opt - .setName('channel') - .setDescription('Channel to set as the announcements channel') - .setRequired(true))) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('get') - .setDescription('Get\'s the channel announcements are posted in')) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('clear') - .setDescription('Disables the announcements channel feature')); - -export const AnnouncementsCommand: SlashSubGroupCommand = { - commandData: data, - execute: exec -}; \ No newline at end of file diff --git a/src/slash-commands/Config/Subcommands/audit.ts b/src/slash-commands/Config/Subcommands/audit.ts deleted file mode 100644 index 369eefae..00000000 --- a/src/slash-commands/Config/Subcommands/audit.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { SlashCommandSubcommandGroupBuilder, SlashCommandSubcommandBuilder, SlashCommandChannelOption, ChatInputCommandInteraction } from 'discord.js'; -import { ServerConfig } from '../../../client/models/ServerConfig'; -import { SlashSubGroupCommand } from '../../../types/SlashCommand'; -import { ConfigManager } from '../../../utilities/ConfigManager'; - -const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - ConfigManager.updateChannelNew(cmd, config, 'auditChannelId'); - return get(cmd, config); -}; - -const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const channel = await ConfigManager.getChannel(cmd, config, 'auditChannelId'); - return cmd.reply({ - content: `The audit channel for this server is: ${channel ? channel.toString() : 'Not Set'}`, - ephemeral: true - }); -}; - -const clear = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - ConfigManager.clearChannel(config, 'auditChannelId'); - return get(cmd, config); -}; - -const exec = async(cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommand(); - if (subCommand == 'set') { - return set(cmd, config); - } else if (subCommand == 'get') { - return get(cmd, config); - } else if (subCommand == 'clear') { - return clear(cmd, config); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true - }); - } -}; - -const data = new SlashCommandSubcommandGroupBuilder() - .setName('audit') - .setDescription('Configure the audit channel for this server') - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('set') - .setDescription('Set\'s the channel to post audits in') - .addChannelOption((opt: SlashCommandChannelOption) => opt - .setName('channel') - .setDescription('Channel to set as the audits channel') - .setRequired(true))) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('get') - .setDescription('Get\'s the channel audits are posted in')) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('clear') - .setDescription('Disables the audits channel feature')); - -export const AuditCommand: SlashSubGroupCommand = { - commandData: data, - execute: exec -}; \ No newline at end of file diff --git a/src/slash-commands/Config/Subcommands/birthdays.ts b/src/slash-commands/Config/Subcommands/birthdays.ts deleted file mode 100644 index 6af34829..00000000 --- a/src/slash-commands/Config/Subcommands/birthdays.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { SlashCommandSubcommandBuilder, SlashCommandChannelOption, ChatInputCommandInteraction, SlashCommandSubcommandGroupBuilder } from 'discord.js'; -import { ServerConfig } from '../../../client/models/ServerConfig'; -import { SlashSubGroupCommand } from '../../../types/SlashCommand'; -import { BirthdayManager } from '../../../utilities/BirthdayManager'; - -const create = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const { id } = cmd.options.getChannel('channel'); - const channel = cmd.guild.channels.cache.get(id); - - if (!channel || !channel.isTextBased()) { - return cmd.reply('Provided channel is not a text channel'); - } - - const birthdaysCalendar = await channel.send({ - content: 'Placeholder calendar message - populating...' - }); - config.birthdayCalendarMessagePath = `${birthdaysCalendar.channel.id}/${birthdaysCalendar.id}`; - await config.save(); - await BirthdayManager.populateCalendars(cmd.client, cmd.guild.id); - - cmd.reply({ content: 'Birthday calendar has been created.', ephemeral: true }); -}; - -const sync = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - try { - await BirthdayManager.populateCalendars(cmd.client, config.serverId); - } catch { - return cmd.reply({ - content: 'An error ocurred running the calendar sync for this server.', - ephemeral: true - }); - } - - return cmd.reply({ - content: `Calendar successfully synchronised for ${cmd.guild.name}.`, - ephemeral: true - }); -}; - -const exec = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommand(); - if (subCommand == 'create') { - return create(cmd, config); - } else if (subCommand == 'sync') { - return sync(cmd, config); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true - }); - } -}; - -const data = new SlashCommandSubcommandGroupBuilder() - .setName('birthdays') - .setDescription('Configure the birthday calendar for this server') - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('create') - .setDescription('Creates or recreates a birthday calendar for this server') - .addChannelOption((opt: SlashCommandChannelOption) => opt - .setName('channel') - .setDescription('Channel to create the birthday calendar in') - .setRequired(true))) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('sync') - .setDescription('Syncs the birthday calendar for this server')); - - -export const BirthdaysConfigCommand: SlashSubGroupCommand = { - commandData: data, - execute: exec -}; \ No newline at end of file diff --git a/src/slash-commands/Config/Subcommands/debug.ts b/src/slash-commands/Config/Subcommands/debug.ts deleted file mode 100644 index 4fc7239e..00000000 --- a/src/slash-commands/Config/Subcommands/debug.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { SlashCommandSubcommandGroupBuilder, SlashCommandSubcommandBuilder, ChatInputCommandInteraction, SlashCommandBooleanOption } from 'discord.js'; -import { ServerConfig } from '../../../client/models/ServerConfig'; -import { SlashSubGroupCommand } from '../../../types/SlashCommand'; - -const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const value = cmd.options.getBoolean('enabled'); - config.debug = value; - config.save(); - - return get(cmd, config); -}; - -const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const value = config.debug; - - return cmd.reply({ - content: `Debug mode is: ${value ? 'enabled' : 'disabled'}`, - ephemeral: true - }); -}; - -const exec = async(cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommand(); - if (subCommand == 'set') { - return set(cmd, config); - } else if (subCommand == 'get') { - return get(cmd, config); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true - }); - } -}; - -const data = new SlashCommandSubcommandGroupBuilder() - .setName('debug') - .setDescription('Toggle the debugging mode for this server') - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('set') - .setDescription('Set\'s whether debug mode is enabled for this server') - .addBooleanOption((opt: SlashCommandBooleanOption) => opt - .setName('enabled') - .setDescription('Whether to enable debug mode or not') - .setRequired(true))) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('get') - .setDescription('Get\'s whether debug mode is enabled or not')); - -export const DebugCommand: SlashSubGroupCommand = { - commandData: data, - execute: exec -}; \ No newline at end of file diff --git a/src/slash-commands/Config/Subcommands/honeypot.ts b/src/slash-commands/Config/Subcommands/honeypot.ts deleted file mode 100644 index 7d8becd5..00000000 --- a/src/slash-commands/Config/Subcommands/honeypot.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { - ChannelType, - ChatInputCommandInteraction, - SlashCommandChannelOption, - SlashCommandSubcommandBuilder, - SlashCommandSubcommandGroupBuilder, -} from 'discord.js'; -import { ServerConfig } from '../../../client/models/ServerConfig'; -import { SlashSubGroupCommand } from '../../../types/SlashCommand'; - -const get = async ( - cmd: ChatInputCommandInteraction, - config: ServerConfig -) => { - const channel = config.honeyPotChannelId - ? cmd.guild.channels.cache.get(config.honeyPotChannelId) - : null; - - return cmd.reply({ - content: `The honey-pot channel for this server is: ${ - channel ? channel.toString() : 'Not Set' - }`, - ephemeral: true, - }); -}; - -const set = async ( - cmd: ChatInputCommandInteraction, - config: ServerConfig -) => { - const channel = cmd.options.getChannel('channel', true); - if ( - channel.type !== ChannelType.GuildText && - channel.type !== ChannelType.GuildAnnouncement - ) { - return cmd.reply({ - content: 'Honey-pot channels must be Text or Announcement Channels.', - ephemeral: true, - }); - } - - config.honeyPotChannelId = channel.id; - await config.save(); - - return cmd.reply({ - content: `Honey-pot channel updated to ${channel.toString()}.`, - ephemeral: true, - }); -}; - -const clear = async ( - cmd: ChatInputCommandInteraction, - config: ServerConfig -) => { - config.honeyPotChannelId = null; - await config.save(); - - return cmd.reply({ - content: 'Honey-pot channel disabled.', - ephemeral: true, - }); -}; - -const exec = async ( - cmd: ChatInputCommandInteraction, - config: ServerConfig -) => { - switch (cmd.options.getSubcommand()) { - case 'set': - return set(cmd, config); - case 'get': - return get(cmd, config); - case 'clear': - return clear(cmd, config); - default: - return cmd.reply({ - content: 'A valid option was not supplied for this command.', - ephemeral: true, - }); - } -}; - -const data = new SlashCommandSubcommandGroupBuilder() - .setName('honeypot') - .setDescription('Configure the honey-pot channel for this server') - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub - .setName('set') - .setDescription('Set the channel that automatically bans people who post in it') - .addChannelOption((opt: SlashCommandChannelOption) => - opt - .setName('channel') - .setDescription('Channel to use as the honey-pot') - .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement) - .setRequired(true) - ) - ) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub - .setName('get') - .setDescription('Get the configured honey-pot channel') - ) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => - sub - .setName('clear') - .setDescription('Disable the honey-pot channel') - ); - -export const HoneyPotCommand: SlashSubGroupCommand = { - commandData: data, - execute: exec, -}; diff --git a/src/slash-commands/Config/Subcommands/newroles.ts b/src/slash-commands/Config/Subcommands/newroles.ts deleted file mode 100644 index a210494e..00000000 --- a/src/slash-commands/Config/Subcommands/newroles.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { SlashCommandSubcommandGroupBuilder, SlashCommandSubcommandBuilder, ChatInputCommandInteraction, SlashCommandRoleOption } from 'discord.js'; -import { ServerConfig } from '../../../client/models/ServerConfig'; -import { EmbedColours } from '../../../resources/EmbedColours'; -import { EmbedCompatLayer } from '../../../types/EmbedCompatLayer'; -import { SlashSubGroupCommand } from '../../../types/SlashCommand'; -import { ChannelService } from '../../../utilities/ChannelService'; - -const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const role1 = cmd.options.getRole('role-one'); - const role2 = cmd.options.getRole('role-two'); - - const newGuestRoles: string = [role1.id, role2?.id].filter(Boolean).join(','); - config.guestRoleIds = newGuestRoles; - config.save(); - - return get(cmd, config); -}; - -const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - if (!config.guestRoleIds) { - return cmd.reply('No guest roles configured for this server'); - } - - const roleIds: string[] = config.guestRoleIds.split(','); - const roles = cmd.guild.roles.cache.filter(r => roleIds.includes(r.id)); - const member = cmd.guild.members.cache.get(cmd.user.id); - - const audit = new EmbedCompatLayer() - .setColor(EmbedColours.info) - .setAuthor({ - name: member.displayName, - iconURL: cmd.user.displayAvatarURL() - }) - .setDescription(`New user roles ${!config.guestRoleIds ? 'Removed' : 'Updated'}`) - .setTimestamp(); - - if (config.guestRoleIds) { - audit.addField('New user roles', roles.map(r => r.toString()).join('\n')); - } - - if (roles.size !== roleIds.length) { - audit.addField('WARNING', 'Not all roles configured are available in this server, please reconfigure new user roles'); - } - - ChannelService.sendAuditMessage(cmd.client, config, audit); - cmd.reply({ - content: `New user roles ${!config.guestRoleIds ? 'Removed' : 'Updated'}`, - ephemeral: true - }); -}; - -const clear = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - config.guestRoleIds = null; - config.save(); - - return get(cmd, config); -}; - -const exec = async(cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommand(); - if (subCommand == 'set') { - return set(cmd, config); - } else if (subCommand == 'get') { - return get(cmd, config); - } else if (subCommand == 'clear') { - return clear(cmd, config); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true - }); - } -}; - -const data = new SlashCommandSubcommandGroupBuilder() - .setName('newroles') - .setDescription('Control the roles assigned to newly screened members') - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('get') - .setDescription('Gets the roles assigned to newly screened members')) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('set') - .setDescription('Sets the roles of newly screened members') - .addRoleOption((option: SlashCommandRoleOption) => option - .setName('role-one') - .setDescription('First role to give to newly screened members') - .setRequired(true)) - .addRoleOption((option: SlashCommandRoleOption) => option - .setName('role-two') - .setDescription('Second optional role to give to newly screened members'))) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('clear') - .setDescription('Clears the roles assigned to newly screened members')); - -export const NewRolesCommand: SlashSubGroupCommand = { - commandData: data, - execute: exec -}; \ No newline at end of file diff --git a/src/slash-commands/Config/Subcommands/systemMsgs.ts b/src/slash-commands/Config/Subcommands/systemMsgs.ts deleted file mode 100644 index 8d2bfacc..00000000 --- a/src/slash-commands/Config/Subcommands/systemMsgs.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { SlashCommandSubcommandGroupBuilder, SlashCommandSubcommandBuilder, ChatInputCommandInteraction, SlashCommandBooleanOption } from 'discord.js'; -import { ServerConfig } from '../../../client/models/ServerConfig'; -import { SlashSubGroupCommand } from '../../../types/SlashCommand'; - -const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const value = cmd.options.getBoolean('enabled'); - config.systemMessagesEnabled = value; - config.save(); - - return get(cmd, config); -}; - -const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const value = config.systemMessagesEnabled; - - return cmd.reply({ - content: `Bot System messages are: ${value ? 'enabled' : 'disabled'}`, - ephemeral: true - }); -}; - -const exec = async(cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommand(); - if (subCommand == 'set') { - return set(cmd, config); - } else if (subCommand == 'get') { - return get(cmd, config); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true - }); - } -}; - -const data = new SlashCommandSubcommandGroupBuilder() - .setName('sysmsgs') - .setDescription('Toggle Bot created System Messages for this server') - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('set') - .setDescription('Set\'s whether system messages are posted in the default channel for this server') - .addBooleanOption((opt: SlashCommandBooleanOption) => opt - .setName('enabled') - .setDescription('Whether to post bot created system messages or not') - .setRequired(true))) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('get') - .setDescription('Get\'s whether bot created system messages are posted or not')); - -export const SystemMessagesCommand: SlashSubGroupCommand = { - commandData: data, - execute: exec -}; \ No newline at end of file diff --git a/src/slash-commands/Config/Subcommands/welcome.ts b/src/slash-commands/Config/Subcommands/welcome.ts deleted file mode 100644 index 22cd5105..00000000 --- a/src/slash-commands/Config/Subcommands/welcome.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { SlashCommandSubcommandGroupBuilder, SlashCommandSubcommandBuilder, ChatInputCommandInteraction, SlashCommandStringOption } from 'discord.js'; -import { ServerConfig } from '../../../client/models/ServerConfig'; -import { SlashSubGroupCommand } from '../../../types/SlashCommand'; - -const imageChoice = { - name: 'Welcome Image', value: 'welcomeMessageBackgroundUrl' -}; -const msgChoice = { - name: 'Welcome Message', value: 'welcomeMessage' -}; - -const set = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - console.log(cmd.options); - config[cmd.options.getString('content')] = cmd.options.getString('value'); - config.save(); - - return get(cmd, config); -}; - -const get = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const value = config[cmd.options.getString('content')]; - - return cmd.reply({ - content: `Current value is: ${value ? value : 'Not Set'}`, - ephemeral: true - }); -}; - -const clear = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - config[cmd.options.getString('content')] = null; - config.save(); - - return get(cmd, config); -}; - -const exec = async(cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommand(); - if (subCommand == 'set') { - return set(cmd, config); - } else if (subCommand == 'get') { - return get(cmd, config); - } else if (subCommand == 'clear') { - return clear(cmd, config); - } else { - return cmd.reply({ - content: 'A valid option was not supplied for this command', - ephemeral: true - }); - } -}; - -const data = new SlashCommandSubcommandGroupBuilder() - .setName('welcome') - .setDescription('Control welcome information for this server') - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('set') - .setDescription('Set\'s the welcome information for this server') - .addStringOption((opt: SlashCommandStringOption) => opt - .setName('content') - .setDescription('Set the welcome image, or text?') - .setRequired(true) - .addChoices(imageChoice, msgChoice)) - .addStringOption((opt: SlashCommandStringOption) => opt - .setName('value') - .setDescription('URL to the welcome image, or the welcome text') - .setRequired(true))) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('get') - .setDescription('Get\'s the welcome information for this server') - .addStringOption((opt: SlashCommandStringOption) => opt - .setName('content') - .setDescription('Get the welcome image, or text?') - .setRequired(true) - .addChoices(imageChoice, msgChoice))) - .addSubcommand((sub: SlashCommandSubcommandBuilder) => sub - .setName('clear') - .setDescription('Clear\'s the welcome information for this server') - .addStringOption((opt: SlashCommandStringOption) => opt - .setName('content') - .setDescription('Clear the welcome image, or text?') - .setRequired(true) - .addChoices(imageChoice, msgChoice))); - -export const WelcomeCommand: SlashSubGroupCommand = { - commandData: data, - execute: exec -}; \ No newline at end of file diff --git a/src/slash-commands/Config/config.ts b/src/slash-commands/Config/config.ts deleted file mode 100644 index a7007ab1..00000000 --- a/src/slash-commands/Config/config.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { ChatInputCommandInteraction, SlashCommandBuilder, PermissionFlagsBits, InteractionContextType } from 'discord.js'; -import { ServerConfig } from '../../client/models/ServerConfig'; -import { SlashCommand, SlashSubCommand, SlashSubGroupCommand } from '../../types/SlashCommand'; -import { AnnouncementsCommand } from './Subcommands/announcements'; -import { AuditCommand } from './Subcommands/audit'; -import { BirthdaysConfigCommand } from './Subcommands/birthdays'; -import { DebugCommand } from './Subcommands/debug'; -import { HoneyPotCommand } from './Subcommands/honeypot'; -import { NewRolesCommand } from './Subcommands/newroles'; -import { SystemMessagesCommand } from './Subcommands/systemMsgs'; -import { WelcomeCommand } from './Subcommands/welcome'; - -const cmdMap: {[key: string]: SlashSubGroupCommand | SlashSubCommand} = { - announcements: AnnouncementsCommand, - debug: DebugCommand, - sysmsgs: SystemMessagesCommand, - audit: AuditCommand, - welcome: WelcomeCommand, - newroles: NewRolesCommand, - birthdays: BirthdaysConfigCommand, - honeypot: HoneyPotCommand -}; - -const execute = async (cmd: ChatInputCommandInteraction, config: ServerConfig) => { - const subCommand = cmd.options.getSubcommandGroup() ?? cmd.options.getSubcommand(); - const subCmd = cmdMap[subCommand]; - - if (!subCommand) { - cmd.reply({ content: 'This command is misconfigured', ephemeral: true }); - } - - subCmd.execute(cmd, config); -}; - -const commandData = new SlashCommandBuilder() - .setName('config') - .setDescription('Manage configuration data for this server') - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .setContexts([InteractionContextType.Guild]) - .addSubcommandGroup(AnnouncementsCommand.commandData) - .addSubcommandGroup(DebugCommand.commandData) - .addSubcommandGroup(SystemMessagesCommand.commandData) - .addSubcommandGroup(AuditCommand.commandData) - .addSubcommandGroup(WelcomeCommand.commandData) - .addSubcommandGroup(NewRolesCommand.commandData) - .addSubcommandGroup(BirthdaysConfigCommand.commandData) - .addSubcommandGroup(HoneyPotCommand.commandData); - -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute -}; -export = slashCommand; diff --git a/src/slash-commands/Info/about.ts b/src/slash-commands/Info/about.ts deleted file mode 100644 index 99b9c510..00000000 --- a/src/slash-commands/Info/about.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ChatInputCommandInteraction, InteractionContextType, SlashCommandBuilder } from 'discord.js'; -import { EmbedColours } from '../../resources/EmbedColours'; -import { EmbedCompatLayer } from '../../types/EmbedCompatLayer'; -import { SlashCommand } from '../../types/SlashCommand'; -import * as packageJson from '../../../package.json'; - -const execute = async (cmd: ChatInputCommandInteraction) => { - const version = packageJson.version; - const projectUrl = packageJson.repository; - - const embed = new EmbedCompatLayer() - .setColor(EmbedColours.positive) - .setAuthor({ - name: cmd.client.user.tag, - iconURL: cmd.client.user.displayAvatarURL(), - }) - .setDescription('Dingir Discord Bot') - .addField('Version', version.length > 0 ? version : 'Unknown') - .addField('Project URL', projectUrl.url) - .addField('Servers', cmd.client.guilds.cache.size.toString()) - .setTimestamp(); - - return cmd.reply({ - embeds: [embed], - ephemeral: true - }); -}; - -const commandData = new SlashCommandBuilder() - .setName('about') - .setDescription('Returns info about the bot and server') - .setContexts([InteractionContextType.Guild]); - -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute -}; -export = slashCommand; \ No newline at end of file diff --git a/src/slash-commands/Info/mybirthday.ts b/src/slash-commands/Info/mybirthday.ts deleted file mode 100644 index a30262c6..00000000 --- a/src/slash-commands/Info/mybirthday.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { ChatInputCommandInteraction, InteractionContextType, SlashCommandBuilder, SlashCommandNumberOption } from 'discord.js'; -import { DateTime } from 'luxon'; -import { SlashCommand } from '../../types/SlashCommand'; -import { BirthdayManager } from '../../utilities/BirthdayManager'; -import { UserProfileService } from '../../utilities/UserProfileService'; - -const execute = async (cmd: ChatInputCommandInteraction) => { - let day = cmd.options.getNumber('day'); - const month = cmd.options.getNumber('month'); - - let alteredForLeap = false; - - const now = DateTime.local(); - - if (!now.isInLeapYear && day === 29 && month === 2) { - day--; - alteredForLeap = true; - } - - let nextDate = DateTime.local(now.year, month, day); - - if (nextDate <= now) { - nextDate = nextDate.plus({ - year: 1, - }); - if (nextDate.isInLeapYear && alteredForLeap) { - nextDate = nextDate.plus({ - day: 1, - }); - } - } - - if (!nextDate.isValid) { - return cmd.reply({ - content: 'It looks like that date was invalid, make sure a valid day and month were given', - ephemeral: true - }); - } - - const userProfile = await UserProfileService.getUserProfile( - cmd.guild.id, - cmd.user.id - ); - - userProfile.birthdayDay = day; - userProfile.birthdayMonth = month; - - await userProfile.save(); - - cmd.reply({ - content: `I've set your next birthday to ${nextDate.toLocaleString(DateTime.DATE_FULL)}!`, - ephemeral: true - }); - return BirthdayManager.populateCalendars(cmd.client, cmd.guild.id); -}; - -const commandData = new SlashCommandBuilder() - .setName('mybirthday') - .setDescription('Set your birthday in this server') - .addNumberOption((opt: SlashCommandNumberOption) => opt - .setName('day') - .setDescription('Day of the month of your birthday') - .setRequired(true) - .setMinValue(1) - .setMaxValue(31)) - .addNumberOption((opt: SlashCommandNumberOption) => opt - .setName('month') - .setDescription('Month of your birthday') - .setRequired(true) - .setMinValue(1) - .setMaxValue(12)) - .setContexts([InteractionContextType.Guild]); - -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute -}; -export = slashCommand; \ No newline at end of file diff --git a/src/slash-commands/Info/ping.ts b/src/slash-commands/Info/ping.ts deleted file mode 100644 index e8f985ba..00000000 --- a/src/slash-commands/Info/ping.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ChatInputCommandInteraction, InteractionContextType, SlashCommandBuilder } from 'discord.js'; -import { SlashCommand } from '../../types/SlashCommand'; - -const execute = async (cmd: ChatInputCommandInteraction) => { - return cmd.reply({ - content: 'Pong!', - ephemeral: true - }); -}; - -const commandData = new SlashCommandBuilder() - .setName('ping') - .setDescription('Pings Dingir') - .setContexts([InteractionContextType.Guild]); - -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute -}; -export = slashCommand; \ No newline at end of file diff --git a/src/slash-commands/Info/profile.ts b/src/slash-commands/Info/profile.ts deleted file mode 100644 index 76aefa71..00000000 --- a/src/slash-commands/Info/profile.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { ChatInputCommandInteraction, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder, SlashCommandUserOption } from 'discord.js'; -import { EmbedColours } from '../../resources/EmbedColours'; -import { EmbedCompatLayer } from '../../types/EmbedCompatLayer'; -import { SlashCommand } from '../../types/SlashCommand'; -import { UserProfileService } from '../../utilities/UserProfileService'; - -const execute = async (cmd: ChatInputCommandInteraction) => { - const user = cmd.options.getUser('member'); - const member = cmd.guild.members.cache.get(user.id); - - const userProfile = await UserProfileService.getUserProfile( - cmd.guild.id, - member.id - ); - - const embed = new EmbedCompatLayer() - .setThumbnail(member.displayAvatarURL()) - .setColor(EmbedColours.info) - .setTitle('User Profile') - .setTimestamp() - .addField('Member', member.toString()) - .addField('Nickname', member.nickname ? member.nickname : 'Not set') - .addField('Username', member.user.tag.endsWith('#0') ? member.user.username : member.user.tag) - .addField('Joined', ``) - .addField('Screening', member.pending ? 'Not completed' : 'Passed') - .addField( - 'Activity Score', - userProfile ? userProfile.activityScore.toString() : 'Not found' - ) - .addField('ID', member.user.id); - - return cmd.reply({ - embeds: [embed], - ephemeral: true - }); -}; - -const commandData = new SlashCommandBuilder() - .setName('profile') - .setDescription('Fetches profiles for server members') - .addUserOption((opt: SlashCommandUserOption) => opt - .setName('member') - .setDescription('Member to fetch profile for') - .setRequired(true)) - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) - .setContexts([InteractionContextType.Guild]); - -const slashCommand: SlashCommand = { - commandData: commandData, - execute: execute -}; -export = slashCommand; \ No newline at end of file diff --git a/src/types/Command.ts b/src/types/Command.ts deleted file mode 100644 index 80922d50..00000000 --- a/src/types/Command.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ChannelType, Message } from 'discord.js'; -import { ServerConfig } from '../client/models/ServerConfig'; -import { NovaClient } from '../client/NovaClient'; - -export interface Command { - name: string; - run( - client: NovaClient, - message: Message, - config: ServerConfig, - args: any[] - ): Promise; - title: string; - description: string; - usage: string; - example: string; - admin: boolean; - deleteCmd: boolean; - limited: boolean; - limitation?: string; - channels: ChannelType[]; -} diff --git a/src/types/EmbedCompatLayer.ts b/src/types/EmbedCompatLayer.ts deleted file mode 100644 index d9892da0..00000000 --- a/src/types/EmbedCompatLayer.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { EmbedBuilder } from 'discord.js'; - -export class EmbedCompatLayer extends EmbedBuilder { - public addField(name: string, value: string): EmbedCompatLayer { - this.addFields([ - { - name: name, - value: value, - }, - ]); - - return this; - } -} diff --git a/src/types/Event.ts b/src/types/Event.ts deleted file mode 100644 index 0a4f7f78..00000000 --- a/src/types/Event.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { NovaClient } from '../client/NovaClient'; -export interface RunFunction { - (client: NovaClient, ...params: any[]): Promise; -} -export interface Event { - name: string; - run: RunFunction; -} diff --git a/src/types/SlashCommand.ts b/src/types/SlashCommand.ts deleted file mode 100644 index 78cdcef0..00000000 --- a/src/types/SlashCommand.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - ChatInputCommandInteraction, - SlashCommandBuilder, - SlashCommandOptionsOnlyBuilder, - SlashCommandSubcommandBuilder, - SlashCommandSubcommandGroupBuilder, - SlashCommandSubcommandsOnlyBuilder} from 'discord.js'; -import { ServerConfig } from '../client/models/ServerConfig'; - -export interface SlashCommand { - commandData: SlashCommandBuilder | SlashCommandSubcommandsOnlyBuilder | SlashCommandOptionsOnlyBuilder; - execute(interaction: ChatInputCommandInteraction, config: ServerConfig): Promise -} - -export interface SlashSubGroupCommand { - commandData: SlashCommandSubcommandGroupBuilder; - execute(interaction: ChatInputCommandInteraction, config: ServerConfig): Promise -} - -export interface SlashSubCommand { - commandData: SlashCommandSubcommandBuilder; - execute(interaction: ChatInputCommandInteraction, config: ServerConfig): Promise -} \ No newline at end of file diff --git a/src/utilities/BirthdayManager.ts b/src/utilities/BirthdayManager.ts deleted file mode 100644 index 77cd6ad6..00000000 --- a/src/utilities/BirthdayManager.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { NovaClient } from '../client/NovaClient'; -import { Client, Guild, GuildMember, TextChannel } from 'discord.js'; -import { DateTime } from 'luxon'; -import _ from 'underscore'; -import { ConfigService } from './ConfigService'; -import { UserProfileService } from './UserProfileService'; -import { Logger } from './Logger'; -import { ServerConfig } from '../client/models/ServerConfig'; -import { UserProfile } from '../client/models/UserProfile'; - -export class BirthdayManager { - public static async populateCalendars (client: Client, serverId?: string): Promise { - Logger.writeLog('Running birthday calendar update job.'); - - let parsedConfigs: ServerConfig[]; - - if (serverId) { - const serverConfig = await ConfigService.getConfig(serverId).catch( - (err) => { - return Logger.writeError( - 'Error fetching server config for single run.', - err - ); - } - ); - if (serverConfig) { - parsedConfigs = [serverConfig]; - } - } else { - const serverConfigs = await ConfigService.getConfigs().catch((err) => { - return Logger.writeError( - 'Error fetching server configs for bulk run.', - err - ); - }); - if (serverConfigs) { - parsedConfigs = serverConfigs; - } - } - - if (parsedConfigs.length < 1) { - return Logger.writeLog( - 'No server configs to run birthday calendar population for.' - ); - } - - parsedConfigs = parsedConfigs.filter( - (config) => config.birthdayCalendarMessagePath - ); - - for (const config of parsedConfigs) { - const profiles = await UserProfileService.getServerBirthdays( - config.serverId - ); - const [channelId, messageId] = - config.birthdayCalendarMessagePath.split('/'); - let messageContent = ':tada: ~ Upcoming Birthdays ~ :tada:\n'; - - const chanToEdit = await client.channels.fetch(channelId); - if (!chanToEdit || !chanToEdit.isTextBased()) { - Logger.writeError( - `Could not find birthday channel for server ${config.serverId}` - ); - } - - const birthdayMessage = await (chanToEdit as TextChannel).messages.fetch( - messageId - ); - if (!birthdayMessage) { - Logger.writeError( - `Could not find birthday message for server ${config.serverId}` - ); - } - - if (profiles.length < 1) { - messageContent += '-------------'; - messageContent += 'There are no birthdays in this server, set yours with `/mybirthday`'; - } else { - const mapped = profiles.map((u) => { - let alteredForLeap = false; - - const now = DateTime.local(); - - if ( - !now.isInLeapYear && - u.birthdayDay === 29 && - u.birthdayMonth === 2 - ) { - u.birthdayDay--; - alteredForLeap = true; - } - - let nextDate = DateTime.local( - now.year, - u.birthdayMonth, - u.birthdayDay - ); - - if (nextDate <= now) { - nextDate = nextDate.plus({ - year: 1, - }); - if (nextDate.isInLeapYear && alteredForLeap) { - nextDate = nextDate.plus({ - day: 1, - }); - } - } - - return { - userId: u.userId, - birthday: nextDate, - }; - }); - - const sorted = _.sortBy(mapped, (o) => o.birthday).slice(0, 10); - const groupedSort = _.groupBy(sorted, 'birthday'); - - messageContent += "Here's the next 10 birthdays in this guild!\n"; - messageContent += 'Add your birthday using `/mybirthday`\n'; - messageContent += '-------------'; - - for (const group in groupedSort) { - const date = groupedSort[group][0].birthday.toLocaleString( - DateTime.DATE_FULL - ); - let members = ''; - - groupedSort[group].forEach((m) => { - members += `<@${m.userId}>\n`; - }); - messageContent += `\n**${date}**\n${members}`; - messageContent += '-------------'; - } - } - - await birthdayMessage.edit({ - content: messageContent, - allowedMentions: { - users: [], - }, - }); - } - - Logger.writeLog('Finished birthday calendar update job.'); - } - - public static async notifyServerBirthdays(client: NovaClient, profiles: UserProfile[], config: ServerConfig) { - if (config && config.announcementsChannelId) { - const server: Guild = client.guilds.cache.get(config.serverId); - if (!server) { - return Logger.writeError(`Server missing with id ${config.serverId}`); - } - - const announcementsChannel = server.channels.cache.get(config.announcementsChannelId); - if (!announcementsChannel) { - return Logger.writeError(`Announcements Channel missing for: ${server.toString()}`); - } - - if (!announcementsChannel.isTextBased()) { - return Logger.writeError(`Channel not text based for ${server.toString()}`); - } - - let tags = ''; - - for (const user of profiles) { - const member: GuildMember = await server.members.fetch(user.userId); - if (member) { - tags += `${member.toString()}, `; - } - } - - if (tags) { - tags = tags.replace(/,\s*$/, ''); - const msg = `Happy Birthday to ${tags}!`; - announcementsChannel.send(msg).catch((err) => { - return Logger.writeError('Error sending Birthday message', err); - }); - } - } - } - - public static async notifyBirthdays(client: NovaClient): Promise { - Logger.writeLog('Running birthday notifications job.'); - - const profiles = await UserProfileService.getBirthdaysToday(); - const usersWithBirthdaysByServer = _.groupBy(profiles, 'serverId'); - - for (const server in usersWithBirthdaysByServer) { - const config = await ConfigService.getConfig(server); - this.notifyServerBirthdays(client, usersWithBirthdaysByServer[server], config); - } - - Logger.writeLog('Finished birthday notifications job.'); - } -} diff --git a/src/utilities/ChannelService.ts b/src/utilities/ChannelService.ts deleted file mode 100644 index 6aa9b2c0..00000000 --- a/src/utilities/ChannelService.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Attachment, Client, EmbedBuilder } from 'discord.js'; -import { ServerConfig } from '../client/models/ServerConfig'; -import { NovaClient } from '../client/NovaClient'; -export class ChannelService { - public static async sendAuditMessage( - client: NovaClient | Client, - serverConfig: ServerConfig, - embed: EmbedBuilder, - attachment?: Attachment - ): Promise { - if (!serverConfig.auditChannelId) { - return; - } - - const auditChannel = client.channels.cache.get(serverConfig.auditChannelId); - if (!auditChannel || !auditChannel.isSendable()) { - return; - } - - await auditChannel.send({ - embeds: [embed], - files: attachment ? [attachment] : undefined - }); - } -} diff --git a/src/utilities/CommandRegistrar.ts b/src/utilities/CommandRegistrar.ts deleted file mode 100644 index 4e8f168c..00000000 --- a/src/utilities/CommandRegistrar.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { REST, Routes } from 'discord.js'; -import { NovaClient } from '../client/NovaClient'; -import { Logger } from './Logger'; - -export class CommandRegistrar { - public static async registerGlobalCommands(client: NovaClient) { - Logger.writeLog('Registering Global Commands...'); - const cmdData = client.slashCommands.map(v => v.commandData.toJSON()); - - const rest = new REST({ version: '10' }).setToken(process.env.TOKEN); - - rest.put(Routes.applicationCommands(process.env.CLIENT_ID), { body: cmdData }) - .then((data: any) => Logger.writeLog(`Successfully registered ${data.length} application commands.`)) - .catch(console.error); - } -} diff --git a/src/utilities/ConfigManager.ts b/src/utilities/ConfigManager.ts deleted file mode 100644 index 42afece0..00000000 --- a/src/utilities/ConfigManager.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { ChannelType, ChatInputCommandInteraction, InteractionResponse } from 'discord.js'; -import { ServerConfig } from '../client/models/ServerConfig'; - -export class ConfigManager { - public static async updateChannel(serverConfig: ServerConfig, interaction: ChatInputCommandInteraction, field: string): Promise { - const chan = interaction.options.getChannel('channel'); - - let messageContent: string; - if (!chan && serverConfig[field]) { - serverConfig[field] = null; - } else if (!chan && !serverConfig[field]) { - messageContent = 'Please tag the channel.'; - } else if (!chan) { - messageContent = - 'Channel not found, or I do not have permission to access it.'; - } else if ( - chan.type !== ChannelType.GuildText && - chan.type !== ChannelType.GuildNews - ) { - messageContent = 'Channel must be a Text Channel.'; - } - - if (messageContent) { - return interaction.reply({ - content: messageContent, - ephemeral: true - }); - } - - if (chan) { - serverConfig[field] = chan.id; - } - - await serverConfig.save(); - - if (serverConfig[field]) { - return interaction.reply({ - content: `Channel updated to ${chan.toString()}.`, - ephemeral: true - }); - } else { - return interaction.reply({ - content: 'Channel disabled.', - ephemeral: true - }); - } - } - - public static async getChannel(interaction: ChatInputCommandInteraction, serverConfig: ServerConfig, field: string) { - if (serverConfig[field]) { - return interaction.guild.channels.cache.get(serverConfig[field]); - } - - return null; - } - - public static async updateChannelNew(interaction: ChatInputCommandInteraction, serverConfig: ServerConfig, field: string) { - const chan = interaction.options.getChannel('channel'); - if (!chan) { - return interaction.reply('Provided channel is not valid'); - } - serverConfig[field] = chan.id; - serverConfig.save(); - - return chan; - } - - public static async clearChannel(serverConfig: ServerConfig, field: string) { - serverConfig[field] = null; - serverConfig.save(); - } -} diff --git a/src/utilities/ConfigService.ts b/src/utilities/ConfigService.ts deleted file mode 100644 index e646c482..00000000 --- a/src/utilities/ConfigService.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ChatInputCommandInteraction, Message } from 'discord.js'; -import { ServerConfig } from '../client/models/ServerConfig'; - -export class ConfigService { - public static async getConfigByMessage (message: Message | ChatInputCommandInteraction): Promise { - const [ config ] = await ServerConfig.findOrCreate({ - where: { - serverId: message.guild.id - } - }); - - return config; - } - - public static async getConfig(serverId: string): Promise { - const [config] = await ServerConfig.findOrCreate({ - where: { - serverId: serverId, - }, - }); - - return config; - } - - public static async getConfigs(): Promise { - return ServerConfig.findAll(); - } - - public static async deleteConfig(serverId: string): Promise { - const recordsDeleted = await ServerConfig.destroy({ - where: { - serverId: serverId, - }, - }); - - return recordsDeleted > 0; - } -} diff --git a/src/utilities/DataCheck.ts b/src/utilities/DataCheck.ts deleted file mode 100644 index 2a9e7e59..00000000 --- a/src/utilities/DataCheck.ts +++ /dev/null @@ -1,37 +0,0 @@ -import _ from 'underscore'; -import { NovaClient } from '../client/NovaClient'; -import { ConfigService } from './ConfigService'; -import { Logger } from './Logger'; -import { UserProfileService } from './UserProfileService'; - -export class DataCheck { - public static async dataCleanup(client: NovaClient): Promise { - Logger.writeLog('Running data checkup job.'); - - const servers = await ConfigService.getConfigs(); - const groupedSrvs = _.groupBy(servers, 'serverId'); - - for (const srv in groupedSrvs) { - const guild = await client.guilds.fetch(srv).catch(async (err) => { - if (err.code === 50001) { - Logger.writeLog(`No longer have access to guild: ${srv}.`); - await ConfigService.deleteConfig(srv); - await UserProfileService.deleteUsersByServer(srv); - } - }); - - if (guild) { - const users = await UserProfileService.getServerBirthdays(guild.id); - const guildUsers = await guild.members.fetch(); - - for (const u of users) { - if (!guildUsers.find((gu) => gu.user.id === u.userId)) { - await UserProfileService.deleteUser(u.serverId, u.userId); - } - } - } - } - - Logger.writeLog('Data checkup job complete.'); - } -} diff --git a/src/utilities/HoneyPotEnforcementService.ts b/src/utilities/HoneyPotEnforcementService.ts deleted file mode 100644 index f3efda35..00000000 --- a/src/utilities/HoneyPotEnforcementService.ts +++ /dev/null @@ -1,37 +0,0 @@ -const ENFORCEMENT_WINDOW_MS = 5 * 60 * 1000; - -export class HoneyPotEnforcementService { - private static readonly activeEnforcements = new Map(); - - private static getKey(serverId: string, userId: string): string { - return `${serverId}:${userId}`; - } - - public static begin(serverId: string, userId: string): boolean { - const key = this.getKey(serverId, userId); - if (this.activeEnforcements.has(key)) { - return false; - } - - const timeout = setTimeout(() => { - this.activeEnforcements.delete(key); - }, ENFORCEMENT_WINDOW_MS); - timeout.unref(); - - this.activeEnforcements.set(key, timeout); - return true; - } - - public static isActive(serverId: string, userId: string): boolean { - return this.activeEnforcements.has(this.getKey(serverId, userId)); - } - - public static cancel(serverId: string, userId: string): void { - const key = this.getKey(serverId, userId); - const timeout = this.activeEnforcements.get(key); - if (timeout) { - clearTimeout(timeout); - this.activeEnforcements.delete(key); - } - } -} diff --git a/src/utilities/Logger.ts b/src/utilities/Logger.ts index 034951cf..f10a597c 100644 --- a/src/utilities/Logger.ts +++ b/src/utilities/Logger.ts @@ -1,22 +1,53 @@ -import { DateTime } from 'luxon'; +import { pino, type DestinationStream } from 'pino'; -export class Logger { - public static writeLog(log: string): void { - console.log( - `[${DateTime.local().toLocaleString(DateTime.DATETIME_SHORT)}]: ${log}` - ); - } +type Context = Record; - public static writeError(log: string, error?: string): void { - console.error( - `[${DateTime.local().toLocaleString(DateTime.DATETIME_SHORT)}]: ${log}` - ); - if (error) { - console.error( - `[${DateTime.local().toLocaleString( - DateTime.DATETIME_SHORT - )}]: ${error}` - ); - } - } +export const LOG_LEVELS = ['fatal', 'error', 'warn', 'info', 'debug', 'trace'] as const; +export type LogLevel = (typeof LOG_LEVELS)[number]; + +export const DEFAULT_LOG_LEVEL: LogLevel = 'info'; + +export const parseLogLevel = (value: string | undefined): LogLevel | undefined => + LOG_LEVELS.find((level) => level === value); + +export interface Logger { + debug(message: string, context?: Context, error?: unknown): void; + info(message: string, context?: Context, error?: unknown): void; + warn(message: string, context?: Context, error?: unknown): void; + error(message: string, context?: Context, error?: unknown): void; + fatal(message: string, context?: Context, error?: unknown): void; } + +export interface LoggerOptions { + level?: LogLevel; + /** Defaults to stdout. */ + destination?: DestinationStream; +} + +/** Structured JSON logger (pino) writing one line per entry; pipe through pino-pretty for humans. */ +export const createConsoleLogger = ({ + level = DEFAULT_LOG_LEVEL, + destination, +}: LoggerOptions = {}): Logger => { + const base = pino( + { level, timestamp: pino.stdTimeFunctions.isoTime, base: undefined }, + destination ?? pino.destination(1), + ); + const at = + (method: keyof Logger): Logger[keyof Logger] => + (message, context, error) => { + const fields = error === undefined || error === null ? context : { ...context, err: error }; + if (fields) { + base[method](fields, message); + } else { + base[method](message); + } + }; + return { + debug: at('debug'), + info: at('info'), + warn: at('warn'), + error: at('error'), + fatal: at('fatal'), + }; +}; diff --git a/src/utilities/UserProfileService.ts b/src/utilities/UserProfileService.ts deleted file mode 100644 index a35779f3..00000000 --- a/src/utilities/UserProfileService.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { DateTime } from 'luxon'; -import { Op } from 'sequelize'; -import { UserProfile } from '../client/models/UserProfile'; - -export class UserProfileService { - public static async getBirthdaysToday(): Promise { - const today = DateTime.local(); - const includeLeapDayInNoneLeapYear = - !today.isInLeapYear && today.day === 28 && today.month === 2; - const day = includeLeapDayInNoneLeapYear ? [28, 29] : today.day; - - return UserProfile.findAll({ - where: { - birthdayDay: day, - birthdayMonth: today.month, - }, - }); - } - - public static async getAllBirthdays(): Promise { - return UserProfile.findAll({ - where: { - birthdayDay: { - [Op.not]: null, - }, - birthdayMonth: { - [Op.not]: null, - }, - }, - }); - } - - public static async getServerBirthdays( - serverId: string - ): Promise { - return UserProfile.findAll({ - where: { - birthdayDay: { - [Op.not]: null, - }, - birthdayMonth: { - [Op.not]: null, - }, - serverId: serverId, - }, - }); - } - - public static async getUserProfile( - serverId: string, - userId: string - ): Promise { - const [profiles] = await UserProfile.findOrCreate({ - where: { - serverId: serverId, - userId: userId, - }, - }); - - return profiles; - } - - public static async incrementActivityScore( - serverId: string, - userId: string - ): Promise { - await this.getUserProfile(serverId, userId); - await UserProfile.increment( - { - activityScore: 1, - }, - { - where: { - serverId: serverId, - userId: userId, - }, - } - ); - return this.getUserProfile(serverId, userId); - } - - public static async decrementActivityScore( - serverId: string, - userId: string - ): Promise { - await this.getUserProfile(serverId, userId); - await UserProfile.increment( - { - activityScore: -1, - }, - { - where: { - serverId: serverId, - userId: userId, - }, - } - ); - return this.getUserProfile(serverId, userId); - } - - public static async deleteUser( - serverId: string, - userId: string - ): Promise { - const recordsDeleted = await UserProfile.destroy({ - where: { - serverId: serverId, - userId: userId, - }, - }); - - return recordsDeleted > 0; - } - - public static async deleteUsersByServer(serverId: string): Promise { - const recordsDeleted = await UserProfile.destroy({ - where: { - serverId: serverId, - }, - }); - - return recordsDeleted > 0; - } -} diff --git a/src/utilities/format.ts b/src/utilities/format.ts new file mode 100644 index 00000000..5b8508b8 --- /dev/null +++ b/src/utilities/format.ts @@ -0,0 +1,56 @@ +export function toRepoUrl(url: string): string { + let result = url; + if (result.startsWith('git+')) { + result = result.slice(4); + } + if (result.endsWith('.git')) { + result = result.slice(0, -4); + } + return result; +} + +export function formatUptime(ms: number | null): string { + if (ms === null) { + return 'unknown'; + } + + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + const remainingMinutes = minutes % 60; + const remainingHours = hours % 24; + + if (days > 0) { + return `${days}d ${remainingHours}h ${remainingMinutes}m`; + } + + if (hours > 0) { + return `${hours}h ${remainingMinutes}m`; + } + + if (minutes > 0) { + return `${minutes}m`; + } + + return '0m'; +} + +const TRUNCATION_NOTE = '\n*(preview truncated)*'; + +/** + * Joins a prefix and user-supplied body so the result never exceeds Discord's message limit. + * An over-long body is cut with an ellipsis and followed by a truncation note. + */ +export function fitMessage(prefix: string, body: string, max = 2000): string { + if (prefix.length + body.length <= max) { + return prefix + body; + } + let room = Math.max(0, max - prefix.length - TRUNCATION_NOTE.length - 1); + const last = body.charCodeAt(room - 1); + if (room > 0 && last >= 0xd800 && last <= 0xdbff) { + room -= 1; // don't split a surrogate pair + } + return `${prefix}${body.slice(0, room)}…${TRUNCATION_NOTE}`; +} diff --git a/tests/about.test.ts b/tests/about.test.ts new file mode 100644 index 00000000..6ec5d794 --- /dev/null +++ b/tests/about.test.ts @@ -0,0 +1,66 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fitMessage, toRepoUrl, formatUptime } from '../src/utilities/format.ts'; + +test('toRepoUrl', async (t) => { + await t.test('removes git+ prefix', () => { + assert.equal( + toRepoUrl('git+https://github.com/BrentOates/dingir.git'), + 'https://github.com/BrentOates/dingir', + ); + }); + + await t.test('removes .git suffix', () => { + assert.equal( + toRepoUrl('https://github.com/BrentOates/dingir.git'), + 'https://github.com/BrentOates/dingir', + ); + }); + + await t.test('leaves URL unchanged if no prefix or suffix', () => { + assert.equal( + toRepoUrl('https://github.com/BrentOates/dingir'), + 'https://github.com/BrentOates/dingir', + ); + }); + + await t.test('removes both git+ prefix and .git suffix', () => { + assert.equal( + toRepoUrl('git+https://github.com/BrentOates/dingir.git'), + 'https://github.com/BrentOates/dingir', + ); + }); +}); + +test('formatUptime', async (t) => { + await t.test('returns "unknown" for null', () => { + assert.equal(formatUptime(null), 'unknown'); + }); + + await t.test('returns "0m" for 0', () => { + assert.equal(formatUptime(0), '0m'); + }); + + await t.test('returns "0m" for 59 seconds', () => { + assert.equal(formatUptime(59000), '0m'); + }); + + await t.test('returns "1m" for 1 minute', () => { + assert.equal(formatUptime(60000), '1m'); + }); + + await t.test('returns "1h 0m" for 1 hour', () => { + assert.equal(formatUptime(3600000), '1h 0m'); + }); + + await t.test('returns "3d 4h 12m" for 3 days 4 hours 12 minutes', () => { + const ms = 3 * 24 * 60 * 60 * 1000 + 4 * 60 * 60 * 1000 + 12 * 60 * 1000; + assert.equal(formatUptime(ms), '3d 4h 12m'); + }); +}); + +test('fitMessage never splits a surrogate pair', () => { + const out = fitMessage('Prefix: ', 'a' + '😀'.repeat(1500)); + assert.ok(out.isWellFormed()); + assert.ok(out.length <= 2000); +}); diff --git a/tests/app.test.ts b/tests/app.test.ts new file mode 100644 index 00000000..99a49aed --- /dev/null +++ b/tests/app.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createApp } from '../src/app.ts'; +import { loadEnv } from '../src/config/env.ts'; +import { createShutdownRegistry } from '../src/framework/shutdown.ts'; +import { createHoneypotTracker } from '../src/services/HoneypotTracker.ts'; +import { createTestApp, fakeLogger, FIXED_NOW } from './helpers/app.ts'; + +test('createApp opens and migrates the database, and closes it on shutdown', async () => { + const { logger, logs } = fakeLogger(); + const env = loadEnv({ TOKEN: 't', CLIENT_ID: 'c', DB_PATH: ':memory:' }); + const app = createApp({ env, logger }); + + const tables = app.db.$client + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() as { name: string }[]; + assert.ok(tables.some((t) => t.name === 'ServerConfigs')); + assert.ok(logs.some((l) => l.message === 'Database migrations complete')); + + await app.shutdown.run(); + assert.equal(app.db.$client.open, false); +}); + +test('createApp keeps supplied dependencies', () => { + const app = createTestApp(); + const again = createApp({ ...app }); + assert.equal(again.db, app.db); + assert.equal(again.clock(), FIXED_NOW); + app.close(); +}); + +test('shutdown hooks run in reverse order and a failing hook does not block the rest', async () => { + const { logger, logs } = fakeLogger(); + const registry = createShutdownRegistry(logger); + const order: string[] = []; + registry.register(() => void order.push('first')); + registry.register(() => { + throw new Error('nope'); + }); + registry.register(async () => void order.push('last')); + await registry.run(); + assert.deepEqual(order, ['last', 'first']); + assert.equal(logs.filter((l) => l.level === 'error').length, 1); +}); + +test('honeypot tracker is scoped to its own instance', () => { + const a = createHoneypotTracker(); + const b = createHoneypotTracker(); + assert.equal(a.begin('g', 'u'), true); + assert.equal(a.begin('g', 'u'), false); + assert.equal(a.isActive('g', 'u'), true); + assert.equal(b.isActive('g', 'u'), false); + a.cancel('g', 'u'); + assert.equal(a.isActive('g', 'u'), false); +}); diff --git a/tests/birthdays/BirthdayService.test.ts b/tests/birthdays/BirthdayService.test.ts new file mode 100644 index 00000000..6b528756 --- /dev/null +++ b/tests/birthdays/BirthdayService.test.ts @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict'; +import { after, beforeEach, test } from 'node:test'; +import { DateTime } from 'luxon'; +import type { ServerConfig } from '../../src/db/schema.ts'; +import { notifyBirthdays, refreshCalendar } from '../../src/services/BirthdayService.ts'; +import { + fakeClient, + fakeGuildWithMembers, + fakeEditableMessage, + fakeTextChannel, +} from '../fakes/guild.ts'; +import { createTestApp, type TestApp } from '../helpers/app.ts'; +import { dbFixtures } from '../helpers/db.ts'; +import { contentOf, nth } from '../helpers/assertions.ts'; + +const zone = 'Europe/London'; +const at = (iso: string): Date => DateTime.fromISO(iso, { zone }).toJSDate(); + +const base = createTestApp(); +const { clearConfigs, clearProfiles, createConfig, createProfiles } = dbFixtures(base); +const appAt = (iso?: string): TestApp => + createTestApp({ db: base.db, ...(iso ? { clock: () => at(iso) } : {}) }); + +after(() => { + base.close(); +}); + +beforeEach(() => { + clearProfiles(); + clearConfigs(); +}); + +const makeConfig = (fields: Record = {}): ServerConfig => + createConfig({ serverId: 'g1', ...fields }); + +test('refreshCalendar: not configured', async () => { + const config = makeConfig(); + assert.equal(await refreshCalendar(appAt(), fakeClient(), config), 'not-configured'); +}); + +test('refreshCalendar: edits with Feb 29 shown on Feb 28 and today counted', async () => { + const config = makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); + createProfiles([ + { serverId: 'g1', userId: 'leap', birthdayMonth: 2, birthdayDay: 29 }, + { serverId: 'g1', userId: 'today', birthdayMonth: 2, birthdayDay: 27 }, + { serverId: 'g1', userId: 'nobday' }, + ]); + const message = fakeEditableMessage('m1'); + const client = fakeClient({ channels: [fakeTextChannel('c1', [message])] }); + + const status = await refreshCalendar(appAt('2027-02-27T10:00'), client, config); + assert.equal(status, 'updated'); + const edit = nth(message.edits); + const text = contentOf(edit); + assert.deepEqual(edit.allowedMentions, { parse: [] }); + assert.match(text, /Upcoming Birthdays/); + assert.match(text, /Set yours with `\/mybirthday set`/); + assert.match(text, /\*\*27 February 2027\*\*\n<@today>/); + assert.match(text, /\*\*28 February 2027\*\*\n<@leap>/); + assert.doesNotMatch(text, /nobday/); + assert.ok(text.indexOf('<@today>') < text.indexOf('<@leap>')); +}); + +test('refreshCalendar: empty state', async () => { + const config = makeConfig({ birthdayCalendarMessagePath: 'c1/m1' }); + const message = fakeEditableMessage('m1'); + const client = fakeClient({ channels: [fakeTextChannel('c1', [message])] }); + assert.equal(await refreshCalendar(appAt(), client, config), 'updated'); + assert.match(contentOf(nth(message.edits)), /There are no birthdays in this server/); +}); + +test('notifyBirthdays: skips departed members and mentions only present ones', async () => { + makeConfig({ announcementsChannelId: 'ann' }); + createProfiles([ + { serverId: 'g1', userId: 'here', birthdayMonth: 6, birthdayDay: 1 }, + { serverId: 'g1', userId: 'gone', birthdayMonth: 6, birthdayDay: 1 }, + { serverId: 'g1', userId: 'other', birthdayMonth: 6, birthdayDay: 2 }, + ]); + const channel = fakeTextChannel('ann'); + const guild = fakeGuildWithMembers({ + id: 'g1', + memberIds: ['here', 'other'], + channels: [channel], + }); + const client = fakeClient({ guilds: { g1: guild } }); + + await notifyBirthdays(appAt('2027-06-01T09:00'), client); + assert.equal(channel.sent.length, 1); + assert.equal(nth(channel.sent).content, 'Happy Birthday to <@here>!'); + assert.deepEqual(nth(channel.sent).allowedMentions, { users: ['here'] }); +}); + +test('notifyBirthdays: Feb 29 is celebrated on Feb 28 in a non-leap year', async () => { + makeConfig({ announcementsChannelId: 'ann' }); + createProfiles([ + { serverId: 'g1', userId: 'a', birthdayMonth: 2, birthdayDay: 29 }, + { serverId: 'g1', userId: 'b', birthdayMonth: 2, birthdayDay: 28 }, + ]); + const channel = fakeTextChannel('ann'); + const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['a', 'b'], channels: [channel] }); + const client = fakeClient({ guilds: { g1: guild } }); + + await notifyBirthdays(appAt('2027-02-28T09:00'), client); + assert.equal(channel.sent.length, 1); + assert.match(contentOf(nth(channel.sent)), /<@a>/); + assert.match(contentOf(nth(channel.sent)), /<@b>/); + + channel.sent.length = 0; + await notifyBirthdays(appAt('2027-03-01T09:00'), client); + assert.equal(channel.sent.length, 0); +}); + +test('notifyBirthdays: no announcements channel sends nothing', async () => { + makeConfig(); + createProfiles([{ serverId: 'g1', userId: 'a', birthdayMonth: 6, birthdayDay: 1 }]); + const channel = fakeTextChannel('ann'); + const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['a'], channels: [channel] }); + await notifyBirthdays(appAt('2027-06-01T09:00'), fakeClient({ guilds: { g1: guild } })); + assert.equal(channel.sent.length, 0); +}); + +test('notifyBirthdays: a large day is split into messages within the length limit', async () => { + makeConfig({ announcementsChannelId: 'ann' }); + const ids = Array.from({ length: 120 }, (_, i) => String(100000000000000000n + BigInt(i))); + createProfiles( + ids.map((userId) => ({ serverId: 'g1', userId, birthdayMonth: 6, birthdayDay: 1 })), + ); + const channel = fakeTextChannel('ann'); + const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ids, channels: [channel] }); + const client = fakeClient({ guilds: { g1: guild } }); + + await notifyBirthdays(appAt('2027-06-01T09:00'), client); + assert.ok(channel.sent.length > 1); + const seen: string[] = []; + for (const sent of channel.sent) { + const content = contentOf(sent); + assert.ok(content.length <= 2000); + assert.ok(content.startsWith('Happy Birthday to ')); + const mentioned = [...content.matchAll(/<@(\d+)>/g)].map((m) => m[1]!); + assert.deepEqual((sent.allowedMentions as { users: string[] }).users, mentioned); + seen.push(...mentioned); + } + assert.deepEqual([...seen].sort(), [...ids].sort()); +}); diff --git a/tests/birthdays/commands.test.ts b/tests/birthdays/commands.test.ts new file mode 100644 index 00000000..71131324 --- /dev/null +++ b/tests/birthdays/commands.test.ts @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import { after, beforeEach, test } from 'node:test'; +import mybirthday from '../../src/commands/info/mybirthday.ts'; +import profile from '../../src/commands/info/profile.ts'; +import type { EmbedBuilder, Guild } from 'discord.js'; +import type { Command, ReplyOptions } from '../../src/framework/command.ts'; +import { fakeCommandContext } from '../fakes/command.ts'; +import { stub } from '../fakes/discord.ts'; +import { fakeClient, fakeGuildWithMembers } from '../fakes/guild.ts'; +import { createTestApp } from '../helpers/app.ts'; +import { last, nth, rejectsUserError } from '../helpers/assertions.ts'; +import { dbFixtures } from '../helpers/db.ts'; + +const app = createTestApp(); +const { clearConfigs, clearProfiles, countProfiles, createConfig, createProfiles, findProfile } = + dbFixtures(app); + +after(() => { + app.close(); +}); + +beforeEach(() => { + clearProfiles(); + clearConfigs(); +}); + +const exec = async ( + command: Command, + opts: { subcommand?: string; options?: Record; guild?: object }, +) => { + const config = createConfig({ serverId: 'guild-1' }); + const { ctx, replies, interaction } = fakeCommandContext(app, opts.options, { + guild: opts.guild ? stub(opts.guild) : undefined, + config, + client: fakeClient(), + subcommand: opts.subcommand, + }); + await command.resolve(interaction)!.run(ctx); + return replies; +}; + +const lastContent = (replies: ReplyOptions[]): string => last(replies).content!; + +test('mybirthday set stores Feb 29 as 29', async () => { + const replies = await exec(mybirthday, { subcommand: 'set', options: { day: 29, month: 2 } }); + const stored = findProfile('user-1'); + assert.equal(stored?.birthdayDay, 29); + assert.equal(stored?.birthdayMonth, 2); + assert.match(lastContent(replies), /^Saved. Your next birthday is /); +}); + +test('mybirthday set rejects invalid dates', async () => { + await rejectsUserError( + exec(mybirthday, { subcommand: 'set', options: { day: 31, month: 4 } }), + /invalid/, + ); + assert.equal(countProfiles(), 0); +}); + +test('mybirthday clear removes the birthday but keeps the profile', async () => { + createProfiles([ + { serverId: 'guild-1', userId: 'user-1', birthdayMonth: 5, birthdayDay: 5, activityScore: 7 }, + ]); + await exec(mybirthday, { subcommand: 'clear' }); + const stored = findProfile('user-1'); + assert.equal(stored?.birthdayDay, null); + assert.equal(stored?.birthdayMonth, null); + assert.equal(stored?.activityScore, 7); +}); + +test('profile replies for a non-member without creating a profile', async () => { + const guild = fakeGuildWithMembers({ id: 'guild-1', memberIds: [] }); + await rejectsUserError( + exec(profile, { options: { member: { id: 'ghost' } }, guild }), + "That user isn't a member of this server.", + ); + assert.equal(countProfiles(), 0); +}); + +test('profile for a member without a profile does not create one', async () => { + const guild = fakeGuildWithMembers({ id: 'guild-1', memberIds: ['m1'] }); + const member = guild.members.cache.get('m1'); + assert.ok(member); + Object.assign(member, { + nickname: null, + pending: true, + joinedTimestamp: 0, + user: { username: 'someone' }, + displayAvatarURL: () => 'https://example.com/a.png', + }); + const replies = await exec(profile, { options: { member: { id: 'm1' } }, guild }); + const embed = nth(last(replies).embeds as EmbedBuilder[] | undefined).toJSON(); + const field = (name: string): string => embed.fields?.find((f) => f.name === name)?.value ?? ''; + assert.equal(field('Onboarding'), 'Not completed'); + assert.equal(field('Activity Score'), '0'); + assert.equal(countProfiles(), 0); +}); + +test('mybirthday clear reports no birthday for a profile with only activity', async () => { + createProfiles([{ serverId: 'guild-1', userId: 'user-1', activityScore: 3 }]); + const replies = await exec(mybirthday, { subcommand: 'clear' }); + assert.equal(lastContent(replies), "You don't have a birthday set."); + assert.equal(findProfile('user-1')?.activityScore, 3); +}); + +test('mybirthday clear confirms removal when a birthday was set', async () => { + createProfiles([{ serverId: 'guild-1', userId: 'user-1', birthdayMonth: 5, birthdayDay: 5 }]); + const replies = await exec(mybirthday, { subcommand: 'clear' }); + assert.equal(lastContent(replies), 'Your birthday has been removed.'); +}); + +test('profile shows Unknown when the join time is unavailable', async () => { + const guild = fakeGuildWithMembers({ id: 'guild-1', memberIds: ['m2'] }); + const member = guild.members.cache.get('m2'); + assert.ok(member); + Object.assign(member, { + nickname: null, + pending: false, + joinedTimestamp: null, + user: { username: 'someone' }, + displayAvatarURL: () => 'https://example.com/a.png', + }); + const replies = await exec(profile, { options: { member: { id: 'm2' } }, guild }); + const embed = nth(last(replies).embeds as EmbedBuilder[] | undefined).toJSON(); + assert.equal(embed.fields?.find((f) => f.name === 'Joined')?.value, 'Unknown'); +}); diff --git a/tests/birthdays/stateMachine.test.ts b/tests/birthdays/stateMachine.test.ts new file mode 100644 index 00000000..1c2405b8 --- /dev/null +++ b/tests/birthdays/stateMachine.test.ts @@ -0,0 +1,585 @@ +import assert from 'node:assert/strict'; +import { after, beforeEach, describe, test } from 'node:test'; +import { PermissionFlagsBits, type Client, type Guild, type GuildMember } from 'discord.js'; +import BirthdaysGroup from '../../src/commands/config/groups/birthdays.ts'; +import guildMemberRemove from '../../src/events/guildMemberRemove.ts'; +import type { DingirClient } from '../../src/client/DingirClient.ts'; +import type { Handler } from '../../src/framework/command.ts'; +import { + deleteCalendarMessage, + refreshAllCalendars, + refreshCalendar, + type CalendarStatus, +} from '../../src/services/BirthdayService.ts'; +import { fakeCommandContext } from '../fakes/command.ts'; +import { fakeAuditChannel, fakeMember, stub, type SentPayload } from '../fakes/discord.ts'; +import { + apiError, + fakeClient, + fakeEditableMessage, + fakeTextChannel, + type FakeChannel, +} from '../fakes/guild.ts'; +import { createTestApp } from '../helpers/app.ts'; +import { contentOf, last, nth } from '../helpers/assertions.ts'; +import { dbFixtures } from '../helpers/db.ts'; + +const app = createTestApp(); +const { clearConfigs, clearProfiles, createConfig, createProfiles, findConfig } = dbFixtures(app); + +after(() => { + app.close(); +}); + +beforeEach(() => { + clearConfigs(); + clearProfiles(); +}); + +const sub = (name: string): Handler => BirthdaysGroup.subcommands.find((s) => s.name === name)!.run; +const create = sub('create'); +const remove = sub('remove'); +const pathOf = (): string | null | undefined => findConfig('guild-1')?.birthdayCalendarMessagePath; +const network = Object.assign(new Error('network'), { code: 500 }); + +// --------------------------------------------------------------------------------------------- +// refresh: stored path x failure -> status; the stored path is never changed by a refresh. +// --------------------------------------------------------------------------------------------- + +interface RefreshRow { + name: string; + path: string | null; + channel?: 'ok' | 'unknown-channel' | Error | 'not-text'; + message?: 'ok' | Error; + edit?: Error; + status: CalendarStatus; +} + +const refreshRows: RefreshRow[] = [ + { name: 'no path', path: null, status: 'not-configured' }, + { name: 'malformed path', path: 'garbage', status: 'not-configured' }, + { name: 'all fine', path: 'c1/m1', status: 'updated' }, + { + name: 'channel not found', + path: 'c1/m1', + channel: 'unknown-channel', + status: 'channel-missing', + }, + { + name: 'channel is not text based', + path: 'c1/m1', + channel: 'not-text', + status: 'channel-missing', + }, + { + name: 'channel fetch: Unknown Channel', + path: 'c1/m1', + channel: apiError(10003), + status: 'channel-missing', + }, + { + name: 'channel fetch: Missing Access', + path: 'c1/m1', + channel: apiError(50001), + status: 'no-access', + }, + { + name: 'channel fetch: network error is transient', + path: 'c1/m1', + channel: network, + status: 'failed', + }, + { + name: 'message fetch: Unknown Message', + path: 'c1/m1', + message: apiError(10008), + status: 'message-missing', + }, + { + name: 'message fetch: Missing Permissions', + path: 'c1/m1', + message: apiError(50013), + status: 'no-access', + }, + { + name: 'message fetch: network error is transient', + path: 'c1/m1', + message: network, + status: 'failed', + }, + { + name: 'edit: message deleted meanwhile', + path: 'c1/m1', + edit: apiError(10008), + status: 'message-missing', + }, + { name: 'edit: cannot edit', path: 'c1/m1', edit: apiError(50005), status: 'no-access' }, + { name: 'edit: network error is transient', path: 'c1/m1', edit: network, status: 'failed' }, +]; + +describe('birthday calendar refresh', () => { + for (const row of refreshRows) { + test(row.name, async () => { + const config = createConfig({ serverId: 'guild-1', birthdayCalendarMessagePath: row.path }); + const message = fakeEditableMessage('m1'); + if (row.edit) { + message.edit = async () => { + throw row.edit; + }; + } + const channel = fakeTextChannel('c1', [message]); + if (row.message instanceof Error) { + channel.messages.fetch = async () => { + throw row.message as Error; + }; + } + if (row.channel === 'not-text') { + channel.isTextBased = () => false; + } + const client = fakeClient({ + channels: row.channel === 'unknown-channel' ? [] : [channel], + ...(row.channel instanceof Error ? { channelFetchError: row.channel } : {}), + }); + assert.equal(await refreshCalendar(app, client, config), row.status); + assert.equal(pathOf(), row.path, 'a refresh never changes the stored path'); + assert.equal(message.edits.length, row.status === 'updated' ? 1 : 0); + }); + } + + test('concurrent refreshes run in order, so the last edit has the latest profiles', async () => { + const config = createConfig({ serverId: 'guild-1', birthdayCalendarMessagePath: 'c1/m1' }); + createProfiles([{ serverId: 'guild-1', userId: 'alice', birthdayMonth: 3, birthdayDay: 4 }]); + const message = fakeEditableMessage('m1'); + const firstEdit = Promise.withResolvers(); + const record = (payload: unknown) => { + message.edits.push(payload as SentPayload); + return Promise.resolve(message); + }; + let calls = 0; + message.edit = async (payload) => { + if (calls++ === 0) { + await firstEdit.promise; + } + return record(payload); + }; + const client = fakeClient({ channels: [fakeTextChannel('c1', [message])] }); + const first = refreshCalendar(app, client, config); + await new Promise((resolve) => setImmediate(resolve)); + createProfiles([{ serverId: 'guild-1', userId: 'bob', birthdayMonth: 3, birthdayDay: 5 }]); + const second = refreshCalendar(app, client, config); + await new Promise((resolve) => setImmediate(resolve)); + firstEdit.resolve(); + await Promise.all([first, second]); + assert.match(contentOf(last(message.edits)), /<@alice>/); + assert.match(contentOf(last(message.edits)), /<@bob>/); + }); + + test('a failed refresh does not block the next one', async () => { + const config = createConfig({ serverId: 'guild-1', birthdayCalendarMessagePath: 'c1/m1' }); + const bad = fakeClient({ channelFetchError: network }); + const good = fakeClient({ channels: [fakeTextChannel('c1', [fakeEditableMessage('m1')])] }); + assert.equal(await refreshCalendar(app, bad, config), 'failed'); + assert.equal(await refreshCalendar(app, good, config), 'updated'); + }); + + test('the scheduled refresh skips a config purged since it was listed', async () => { + createConfig({ serverId: 'guild-1', birthdayCalendarMessagePath: 'c1/m1' }); + const message = fakeEditableMessage('m1'); + const channel = fakeTextChannel('c1', [message]); + const client = fakeClient({ channels: [channel] }); + const fetch = client.channels.fetch.bind(client.channels); + client.channels.fetch = (async (id: string) => { + clearConfigs(); // guildDelete lands while the job is running + return fetch(id); + }) as typeof client.channels.fetch; + await refreshAllCalendars(app, client); + assert.equal(findConfig('guild-1'), null, 'no config row is recreated'); + }); +}); + +// --------------------------------------------------------------------------------------------- +// create / remove: previous path x injected failure -> stored path + message side effects. +// --------------------------------------------------------------------------------------------- + +interface CommandRow { + name: string; + command: 'create' | 'remove'; + previous: boolean; + failSend?: boolean; + failPersist?: boolean; + failPopulate?: boolean; + noReadHistory?: boolean; + oldChannel?: 'normal' | 'gone' | 'throws'; + oldMessage?: 'normal' | Error; + /** Expected error message, or undefined when the command succeeds. */ + error?: RegExp; + reply?: string | RegExp; + path: 'old' | 'new' | null; + oldDeleted: boolean; + newDeleted: boolean; +} + +const OLD = 'old-chan/old-msg'; +const commandRows: CommandRow[] = [ + { + name: 'create over an old calendar: new path saved, then old deleted', + command: 'create', + previous: true, + reply: /created/, + path: 'new', + oldDeleted: true, + newDeleted: false, + }, + { + name: 'create with no calendar: path saved', + command: 'create', + previous: false, + reply: /created/, + path: 'new', + oldDeleted: false, + newDeleted: false, + }, + { + name: 'create, send fails: old calendar and path intact', + command: 'create', + previous: true, + failSend: true, + error: /missing access/, + path: 'old', + oldDeleted: false, + newDeleted: false, + }, + { + name: 'create, persisting fails: new message removed, old intact', + command: 'create', + previous: true, + failPersist: true, + error: /./, + path: 'old', + oldDeleted: false, + newDeleted: true, + }, + { + name: 'create, populate fails over old: old path restored, new removed', + command: 'create', + previous: true, + failPopulate: true, + error: /existing calendar was kept/, + path: 'old', + oldDeleted: false, + newDeleted: true, + }, + { + name: 'create, populate fails with no old: path cleared, new removed', + command: 'create', + previous: false, + failPopulate: true, + error: /nothing was changed/, + path: null, + oldDeleted: false, + newDeleted: true, + }, + { + name: 'create without Read Message History: nothing sent', + command: 'create', + previous: true, + noReadHistory: true, + error: /read message history/, + path: 'old', + oldDeleted: false, + newDeleted: false, + }, + { + name: 'create, old message cannot be deleted: new calendar still wins', + command: 'create', + previous: true, + oldMessage: network, + reply: /created/, + path: 'new', + oldDeleted: false, + newDeleted: false, + }, + { + name: 'create, old channel gone: new calendar wins', + command: 'create', + previous: true, + oldChannel: 'gone', + reply: /created/, + path: 'new', + oldDeleted: false, + newDeleted: false, + }, + { + name: 'remove: message deleted, path cleared', + command: 'remove', + previous: true, + reply: 'Birthday calendar removed.', + path: null, + oldDeleted: true, + newDeleted: false, + }, + { + name: 'remove, channel already gone: path cleared', + command: 'remove', + previous: true, + oldChannel: 'gone', + reply: /already gone/, + path: null, + oldDeleted: false, + newDeleted: false, + }, + { + name: 'remove, message already gone (10008): path cleared', + command: 'remove', + previous: true, + oldMessage: apiError(10008), + reply: /already gone/, + path: null, + oldDeleted: false, + newDeleted: false, + }, + { + name: 'remove, Missing Permissions: path kept, error', + command: 'remove', + previous: true, + oldMessage: apiError(50013), + error: /nothing was changed/, + path: 'old', + oldDeleted: false, + newDeleted: false, + }, + { + name: 'remove, transient channel error: path kept, error', + command: 'remove', + previous: true, + oldChannel: 'throws', + error: /nothing was changed/, + path: 'old', + oldDeleted: false, + newDeleted: false, + }, + { + name: 'remove with no calendar: error', + command: 'remove', + previous: false, + error: /no birthday calendar/, + path: null, + oldDeleted: false, + newDeleted: false, + }, +]; + +function commandEnv( + row: Pick & Partial, +) { + const order: string[] = []; + const oldMessage = fakeEditableMessage('old-msg'); + oldMessage.delete = async () => { + order.push(`delete-old(path=${pathOf()})`); + oldMessage.deleted = true; + }; + const oldChannel = fakeTextChannel('old-chan', [oldMessage]); + if (row.oldMessage instanceof Error) { + const error = row.oldMessage; + oldChannel.messages.fetch = async () => { + throw error; + }; + } + const newMessage = fakeEditableMessage('sent-1'); + newMessage.delete = async () => { + order.push('delete-new'); + newMessage.deleted = true; + }; + if (row.failPopulate) { + newMessage.edit = async () => { + throw new Error('edit failed'); + }; + } + const target = fakeTextChannel('new-chan', [newMessage]); + target.send = async () => { + order.push('send-new'); + if (row.failSend) { + throw new Error('missing access'); + } + return { id: 'sent-1', channelId: 'new-chan', delete: () => newMessage.delete() }; + }; + Object.assign(target, { + permissionsFor: () => ({ + has: (flag: bigint) => + !(row.noReadHistory && flag === PermissionFlagsBits.ReadMessageHistory), + }), + }); + const guild = stub({ + id: 'guild-1', + name: 'Test Guild', + members: { me: { id: 'bot' } }, + channels: { cache: new Map([['new-chan', target]]), fetch: async () => target }, + }); + const config = createConfig({ + serverId: 'guild-1', + birthdayCalendarMessagePath: row.previous ? OLD : null, + }); + const client = fakeClient({ + channels: row.oldChannel === 'gone' ? [target] : [oldChannel, target], + ...(row.oldChannel === 'throws' ? { channelFetchError: network } : {}), + }); + const { ctx, replies } = fakeCommandContext( + app, + { channel: { id: 'new-chan' } }, + { guild, config, client }, + ); + return { ctx, replies, order, oldMessage, newMessage, oldChannel, target, guild, client }; +} + +describe('birthday calendar create and remove', () => { + for (const row of commandRows) { + test(row.name, async () => { + const env = commandEnv(row); + if (row.failPersist) { + app.db.$client.exec('ALTER TABLE `ServerConfigs` RENAME TO `ServerConfigsX`'); + } + try { + const run = (row.command === 'create' ? create : remove)(env.ctx); + if (row.error) { + await assert.rejects(run, row.error); + } else { + await run; + } + } finally { + if (row.failPersist) { + app.db.$client.exec('ALTER TABLE `ServerConfigsX` RENAME TO `ServerConfigs`'); + } + } + const expectedPath = row.path === 'old' ? OLD : row.path === 'new' ? 'new-chan/sent-1' : null; + assert.equal(pathOf() ?? null, expectedPath, 'stored path'); + assert.equal(env.oldMessage.deleted, row.oldDeleted, 'old message deleted'); + assert.equal(env.newMessage.deleted, row.newDeleted, 'new message deleted'); + if (row.reply !== undefined) { + assert.match(contentOf(last(env.replies)), new RegExp(row.reply)); + } else { + assert.equal(env.replies.length, 0); + } + if (row.command === 'create' && row.path === 'new' && row.oldDeleted) { + assert.ok( + env.order.indexOf('delete-old(path=new-chan/sent-1)') > env.order.indexOf('send-new'), + 'the old message is deleted only after the new path is saved', + ); + } + if (row.noReadHistory) { + assert.deepEqual(env.order, [], 'nothing is sent without permission'); + } + }); + } + + test('concurrent creates leave exactly one live calendar', async () => { + const env = commandEnv({ previous: true }); + const messages = ['a', 'b'].map((id) => fakeEditableMessage(`sent-${id}`)); + const channel = fakeTextChannel('new-chan', messages); + let sends = 0; + channel.send = async () => { + const message = nth(messages, sends++); + return { id: message.id, channelId: 'new-chan', delete: () => message.delete() }; + }; + Object.assign(channel, { permissionsFor: () => ({ has: () => true }) }); + Object.assign(env.guild, { channels: { cache: new Map([['new-chan', channel]]) } }); + Object.assign(env.client, { + channels: { + fetch: async (id: string) => + id === 'new-chan' ? channel : id === 'old-chan' ? env.oldChannel : null, + }, + }); + const second = fakeCommandContext( + app, + { channel: { id: 'new-chan' } }, + { guild: env.guild, config: env.ctx.config, client: env.client }, + ); + await Promise.all([create(env.ctx), create(second.ctx)]); + assert.equal(pathOf(), 'new-chan/sent-b'); + assert.equal(env.oldMessage.deleted, true); + assert.equal(nth(messages, 0).deleted, true, 'the replaced calendar is not orphaned'); + assert.equal(nth(messages, 1).deleted, false); + }); + + test('a create whose population fails does not clobber a calendar saved meanwhile', async () => { + const env = commandEnv({ previous: true, failPopulate: true }); + env.newMessage.edit = async () => { + // A concurrent create saves its own calendar while this one is populating. + app.db.$client.exec( + "UPDATE `ServerConfigs` SET birthdayCalendarMessagePath = 'other-chan/other-msg'", + ); + app.configCache.clear(); + throw new Error('edit failed'); + }; + await assert.rejects(create(env.ctx), /existing calendar was kept/); + assert.equal(pathOf(), 'other-chan/other-msg'); + }); + + test('deleteCalendarMessage classifies results', async () => { + const msg = fakeEditableMessage('m'); + const chan = fakeTextChannel('c', [msg]); + const cases: [string, Parameters[1], string | null][] = [ + ['deleted', fakeClient({ channels: [chan] }), 'c/m'], + ['already-missing', fakeClient(), 'c/m'], + ['already-missing', fakeClient({ channels: [chan] }), 'c/other'], + ['already-missing', fakeClient(), null], + ['already-missing', fakeClient({ channelFetchError: apiError(10003) }), 'c/m'], + ['failed', fakeClient({ channelFetchError: apiError(50013) }), 'c/m'], + ['failed', fakeClient({ channelFetchError: new Error('network') }), 'c/m'], + ]; + for (const [status, client, path] of cases) { + assert.equal(await deleteCalendarMessage(app, client, path), status); + } + }); +}); + +// --------------------------------------------------------------------------------------------- +// member leaves: the calendar must stop listing them. +// --------------------------------------------------------------------------------------------- + +describe('member leaving and the calendar', () => { + const leave = async (profile: Record | null, failEdit = false) => { + createConfig({ + serverId: 'guild-1', + birthdayCalendarMessagePath: 'c1/m1', + auditChannelId: 'audit-1', + }); + if (profile) { + createProfiles([{ serverId: 'guild-1', userId: 'user-1', ...profile }]); + } + const message = fakeEditableMessage('m1'); + if (failEdit) { + message.edit = async () => { + throw network; + }; + } + const calendar: FakeChannel = fakeTextChannel('c1', [message]); + const audit = fakeAuditChannel(); + const client = stub({ + channels: { fetch: async (id: string) => (id === 'c1' ? calendar : audit.channel) }, + }); + const member = fakeMember('user-1', { guild: stub({ id: 'guild-1' }), partial: false }); + await guildMemberRemove.run(app, client, stub(member)); + return { message, audit }; + }; + + test('a departing member with a birthday triggers a calendar refresh', async () => { + const { message, audit } = await leave({ birthdayMonth: 3, birthdayDay: 4 }); + assert.equal(message.edits.length, 1); + assert.equal(audit.sent.length, 1); + }); + + test('a departing member without a birthday does not', async () => { + const { message } = await leave({ activityScore: 3 }); + assert.equal(message.edits.length, 0); + }); + + test('a failing refresh does not stop the leave being audited', async () => { + const { audit } = await leave({ birthdayMonth: 3, birthdayDay: 4 }, true); + assert.equal(audit.sent.length, 1); + }); + + test('profile data is deleted whatever happens to the calendar', async () => { + await leave({ birthdayMonth: 3, birthdayDay: 4 }, true); + assert.equal(dbFixtures(app).findProfile('user-1'), null); + }); +}); diff --git a/tests/command.test.ts b/tests/command.test.ts new file mode 100644 index 00000000..7a5bff99 --- /dev/null +++ b/tests/command.test.ts @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { MessageFlags } from 'discord.js'; +import { + type CommandContext, + defineCommand, + defineSubcommandGroup, + createReply, +} from '../src/framework/command.ts'; +import { fakeInteraction } from './fakes/interaction.ts'; +import { nth } from './helpers/assertions.ts'; + +const noop = async () => {}; + +test('defineCommand builds guild-only JSON and admin permissions', () => { + const json = defineCommand({ name: 'ping', description: 'Pings', run: noop }).toJSON(); + assert.equal(json.name, 'ping'); + assert.deepEqual(json.contexts, [0]); + assert.equal(json.default_member_permissions, undefined); + + const admin = defineCommand({ name: 'secret', description: 'Admin', adminOnly: true, run: noop }); + assert.equal(admin.toJSON().default_member_permissions, '8'); +}); + +test('defineCommand applies leaf options', () => { + const json = defineCommand({ + name: 'say', + description: 'Says', + options: (b) => + b.addStringOption((o) => o.setName('text').setDescription('Text').setRequired(true)), + run: noop, + }).toJSON(); + assert.equal(nth(json.options).name, 'text'); +}); + +test('defineCommand rejects invalid shapes with descriptive errors', () => { + assert.throws( + () => defineCommand({ name: 'a', description: 'd' }), + /command "a" must define either run or subcommands/, + ); + assert.throws( + () => + defineCommand({ + name: 'a', + description: 'd', + run: noop, + subcommands: [{ name: 's', description: 'd', run: noop }], + }), + /cannot define both run and subcommands/, + ); + assert.throws( + () => + defineCommand({ + name: 'a', + description: 'd', + defer: 'ephemeral', + subcommands: [{ name: 's', description: 'd', run: noop }], + }), + /not a leaf command/, + ); + assert.throws( + () => + defineCommand({ + name: 'a', + description: 'd', + subcommands: [ + { name: 's', description: 'd', run: noop }, + { name: 's', description: 'd', run: noop }, + ], + }), + /Duplicate subcommand\/group name "s" in command "a"/, + ); + assert.throws( + () => + defineCommand({ + name: 'a', + description: 'd', + subcommands: [{ name: 'x', description: 'd', run: noop }], + groups: [ + { + name: 'x', + description: 'd', + subcommands: [{ name: 's', description: 'd', run: noop }], + }, + ], + }), + /Duplicate subcommand\/group name "x"/, + ); + assert.throws( + () => + defineCommand({ + name: 'a', + description: 'd', + groups: [ + { + name: 'g', + description: 'd', + subcommands: [ + { name: 's', description: 'd', run: noop }, + { name: 's', description: 'd', run: noop }, + ], + }, + ], + }), + /Duplicate subcommand name "s" in group "g"/, + ); + assert.throws( + () => + defineCommand({ + name: 'a', + description: 'd', + groups: [{ name: 'g', description: 'd', subcommands: [] }], + }), + /group "g" in command "a" must define at least one subcommand/, + ); + assert.throws( + () => defineCommand({ name: 'Bad Name', description: 'd', run: noop }), + /Invalid command "Bad Name"/, + ); +}); + +test('resolve routes leaf, subcommand and grouped subcommand handlers', () => { + const ran: string[] = []; + const handler = (id: string) => async () => { + ran.push(id); + }; + + const leaf = defineCommand({ + name: 'leaf', + description: 'd', + defer: 'public', + run: handler('leaf'), + }); + const resolvedLeaf = leaf.resolve(fakeInteraction({ commandName: 'leaf' }).interaction)!; + assert.equal(resolvedLeaf.defer, 'public'); + assert.equal(resolvedLeaf.path, 'leaf'); + + const group = defineSubcommandGroup({ + name: 'grp', + description: 'd', + subcommands: [{ name: 'go', description: 'd', defer: 'ephemeral', run: handler('grp go') }], + }); + const tree = defineCommand({ + name: 'tree', + description: 'd', + subcommands: [{ name: 'plain', description: 'd', run: handler('tree plain') }], + groups: [group], + }); + + const plain = tree.resolve(fakeInteraction({ subcommand: 'plain' }).interaction)!; + assert.equal(plain.defer, false); + assert.equal(plain.path, 'tree plain'); + const grouped = tree.resolve(fakeInteraction({ group: 'grp', subcommand: 'go' }).interaction)!; + assert.equal(grouped.defer, 'ephemeral'); + assert.equal(grouped.path, 'tree grp go'); + + assert.equal(tree.resolve(fakeInteraction({ subcommand: 'missing' }).interaction), undefined); + assert.equal(tree.resolve(fakeInteraction({}).interaction), undefined); + assert.equal( + tree.resolve(fakeInteraction({ group: 'grp', subcommand: 'plain' }).interaction), + undefined, + ); + assert.equal(leaf.resolve(fakeInteraction({ subcommand: 'x' }).interaction), undefined); + + return Promise.all( + [resolvedLeaf.run, plain.run, grouped.run].map((run) => run({} as CommandContext)), + ).then(() => { + assert.deepEqual(ran, ['leaf', 'tree plain', 'grp go']); + }); +}); + +test('reply uses reply() when nothing has been sent, ephemeral by default', async () => { + const { interaction, calls } = fakeInteraction(); + await createReply(interaction)('hello'); + assert.deepEqual(calls, [ + { method: 'reply', payload: { content: 'hello', flags: MessageFlags.Ephemeral } }, + ]); +}); + +test('reply omits the ephemeral flag when ephemeral is false', async () => { + const { interaction, calls } = fakeInteraction(); + await createReply(interaction)({ content: 'public', ephemeral: false }); + assert.deepEqual(nth(calls).payload, { content: 'public' }); +}); + +test('reply uses editReply() after deferring, then followUp() afterwards', async () => { + const { interaction, calls } = fakeInteraction({ deferred: true }); + const reply = createReply(interaction); + await reply('first'); + await reply('second'); + assert.deepEqual( + calls.map((c) => c.method), + ['editReply', 'followUp'], + ); + assert.equal(nth(calls).payload.flags, undefined); + assert.equal(nth(calls, 1).payload.flags, MessageFlags.Ephemeral); +}); + +test('reply uses followUp() when already replied', async () => { + const { interaction, calls } = fakeInteraction({ replied: true }); + await createReply(interaction)({ content: 'more', allowedMentions: { parse: [] } }); + assert.equal(nth(calls).method, 'followUp'); + assert.deepEqual(nth(calls).payload.allowedMentions, { parse: [] }); +}); diff --git a/tests/database/migrations.test.ts b/tests/database/migrations.test.ts new file mode 100644 index 00000000..9e3b5752 --- /dev/null +++ b/tests/database/migrations.test.ts @@ -0,0 +1,341 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import Database from 'better-sqlite3'; +import { migrate, migrations, type Migration } from '../../src/db/migrator.ts'; +import { fakeLogger } from '../helpers/app.ts'; +import { nth } from '../helpers/assertions.ts'; + +const { logger, logs } = fakeLogger(); + +type Db = Database.Database; + +const INDEX = 'user_profiles_server_user_unique'; +const ALL = [ + '001-baseline', + '002-userprofile-unique', + '003-serverconfig-access-tracking', + '004-bot-state', + '005-userprofile-backfill', + '006-userprofile-onboarded-at', + '007-userprofile-screening-pending', +]; + +const LEGACY_SERVER_CONFIGS = + "CREATE TABLE `ServerConfigs` (`serverId` VARCHAR(255) PRIMARY KEY, `prefix` VARCHAR(255) DEFAULT '^', `rulesMessagePath` VARCHAR(255), `rulesMessage` VARCHAR(255), `guestRoleIds` VARCHAR(255), `adminRoleId` VARCHAR(255), `welcomeMessage` VARCHAR(255), `debug` TINYINT(1) DEFAULT 0, `auditChannelId` VARCHAR(255), `welcomeMessageBackgroundUrl` VARCHAR(255), `systemMessagesEnabled` TINYINT(1) DEFAULT 0, `announcementsChannelId` VARCHAR(255), `birthdayCalendarMessagePath` VARCHAR(255), `honeyPotChannelId` VARCHAR(255), `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)"; +const LEGACY_USER_PROFILES = + 'CREATE TABLE `UserProfiles` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `serverId` VARCHAR(255), `userId` VARCHAR(255), `birthdayYear` INTEGER, `birthdayMonth` INTEGER, `birthdayDay` INTEGER, `activityScore` INTEGER DEFAULT 0, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)'; + +const withDb = (fn: (db: Db) => void): void => { + const db = new Database(':memory:'); + try { + fn(db); + } finally { + db.close(); + } +}; + +const columnsOf = (db: Db, table: string): string[] => + (db.pragma(`table_info(\`${table}\`)`) as { name: string }[]).map((c) => c.name); + +const indexesOf = (db: Db, table: string): { name: string; unique: number }[] => + db.pragma(`index_list(\`${table}\`)`) as { name: string; unique: number }[]; + +const profileRow = ( + serverId: string, + userId: string, + month: number | null, + day: number | null, + score: number, + updatedAt: string, +) => ({ serverId, userId, month, day, score, updatedAt }); + +const insertProfiles = (db: Db, rows: ReturnType[]): void => { + const insert = db.prepare( + 'INSERT INTO `UserProfiles` (serverId, userId, birthdayMonth, birthdayDay, activityScore, createdAt, updatedAt) VALUES (@serverId, @userId, @month, @day, @score, @updatedAt, @updatedAt)', + ); + for (const r of rows) { + insert.run(r); + } +}; + +const runUpTo = (db: Db, to: string): string[] => + migrate(db, logger, migrations.slice(0, migrations.findIndex((m) => m.name === to) + 1)); + +const allRows = (db: Db, sql: string): unknown[] => db.prepare(sql).all(); + +test('migrations on an empty database create the expected schema', () => { + withDb((db) => { + assert.deepEqual(migrate(db, logger), ALL); + + const serverColumns = columnsOf(db, 'ServerConfigs'); + for (const column of [ + 'serverId', + 'prefix', + 'adminRoleId', + 'guestRoleIds', + 'honeyPotChannelId', + 'accessFailureCount', + 'firstAccessFailureAt', + 'createdAt', + 'updatedAt', + ]) { + assert.ok(serverColumns.includes(column), `ServerConfigs.${column}`); + } + assert.deepEqual(columnsOf(db, 'UserProfiles'), [ + 'id', + 'serverId', + 'userId', + 'birthdayYear', + 'birthdayMonth', + 'birthdayDay', + 'activityScore', + 'createdAt', + 'updatedAt', + 'onboardedAt', + 'screeningPendingAt', + ]); + + assert.ok(indexesOf(db, 'UserProfiles').find((i) => i.name === INDEX)?.unique); + + const meta = allRows(db, 'SELECT name FROM `SequelizeMeta` ORDER BY name') as { + name: string; + }[]; + assert.deepEqual( + meta.map((m) => m.name), + ALL, + ); + }); +}); + +test('running migrations twice is a no-op', () => { + withDb((db) => { + migrate(db, logger); + insertProfiles(db, [profileRow('s1', 'u1', null, null, 4, '2024-01-01 00:00:00.000 +00:00')]); + assert.deepEqual(migrate(db, logger), []); + assert.equal(allRows(db, 'SELECT * FROM `UserProfiles`').length, 1); + }); +}); + +test('baseline is a no-op on a legacy sync() database and later migrations preserve data', () => { + withDb((db) => { + db.exec(LEGACY_SERVER_CONFIGS); + db.exec(LEGACY_USER_PROFILES); + db.exec( + "INSERT INTO `ServerConfigs` (serverId, prefix, adminRoleId, auditChannelId, createdAt, updatedAt) VALUES ('s1', '!', 'admin', 'chan', '2024-01-01 00:00:00.000 +00:00', '2024-01-01 00:00:00.000 +00:00')", + ); + insertProfiles(db, [ + profileRow('s1', 'u1', 5, 6, 3, '2024-01-01 00:00:00.000 +00:00'), + profileRow('s1', 'u2', null, null, 9, '2024-01-01 00:00:00.000 +00:00'), + ]); + const before = allRows(db, 'SELECT * FROM `UserProfiles` ORDER BY id'); + + assert.deepEqual(runUpTo(db, '001-baseline'), ['001-baseline']); + assert.deepEqual(allRows(db, 'SELECT * FROM `UserProfiles` ORDER BY id'), before); + assert.ok(!columnsOf(db, 'ServerConfigs').includes('accessFailureCount')); + + assert.deepEqual(migrate(db, logger), ALL.slice(1)); + + const config = db + .prepare('SELECT * FROM `ServerConfigs` WHERE serverId = ?') + .get('s1') as Record; + assert.equal(config.auditChannelId, 'chan'); + assert.equal(config.accessFailureCount, 0); + assert.equal(config.firstAccessFailureAt, null); + assert.deepEqual(allRows(db, 'SELECT prefix, adminRoleId FROM `ServerConfigs`'), [ + { prefix: '!', adminRoleId: 'admin' }, + ]); + + const profiles = allRows( + db, + 'SELECT userId, birthdayMonth, activityScore FROM `UserProfiles` ORDER BY id', + ); + assert.deepEqual(profiles, [ + { userId: 'u1', birthdayMonth: 5, activityScore: 3 }, + { userId: 'u2', birthdayMonth: null, activityScore: 9 }, + ]); + }); +}); + +test('a database already migrated by the previous umzug setup runs nothing and keeps its data', () => { + withDb((db) => { + migrate(db, logger); + insertProfiles(db, [profileRow('s1', 'u1', 5, 6, 3, '2024-01-01 00:00:00.000 +00:00')]); + db.exec( + "INSERT INTO `ServerConfigs` (serverId, accessFailureCount, createdAt, updatedAt) VALUES ('s1', 2, '2024-01-01 00:00:00.000 +00:00', '2024-01-01 00:00:00.000 +00:00')", + ); + + const ran: string[] = []; + const spies: Migration[] = migrations.map((m) => ({ + name: m.name, + up: () => { + ran.push(m.name); + }, + })); + + assert.deepEqual(migrate(db, logger, spies), []); + assert.deepEqual(ran, []); + assert.deepEqual(allRows(db, 'SELECT userId, activityScore FROM `UserProfiles`'), [ + { userId: 'u1', activityScore: 3 }, + ]); + assert.deepEqual(allRows(db, 'SELECT serverId, accessFailureCount FROM `ServerConfigs`'), [ + { serverId: 's1', accessFailureCount: 2 }, + ]); + }); +}); + +test('baseline adds missing nullable columns to an older schema', () => { + withDb((db) => { + db.exec( + 'CREATE TABLE `ServerConfigs` (`serverId` VARCHAR(255) PRIMARY KEY, `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL)', + ); + migrate(db, logger); + assert.ok(columnsOf(db, 'ServerConfigs').includes('honeyPotChannelId')); + assert.ok(columnsOf(db, 'UserProfiles').includes('activityScore')); + }); +}); + +test('002 dedupes UserProfiles and enforces uniqueness afterwards', () => { + withDb((db) => { + runUpTo(db, '001-baseline'); + insertProfiles(db, [ + // a: only one has a birthday; it is older and lower score + profileRow('s1', 'a', null, null, 10, '2024-03-01 00:00:00.000 +00:00'), + profileRow('s1', 'a', 2, 14, 1, '2024-01-01 00:00:00.000 +00:00'), + // b: conflicting birthdays; most recent wins + profileRow('s1', 'b', 1, 1, 5, '2024-01-01 00:00:00.000 +00:00'), + profileRow('s1', 'b', 7, 7, 2, '2024-02-01 00:00:00.000 +00:00'), + profileRow('s1', 'b', null, null, 8, '2024-04-01 00:00:00.000 +00:00'), + // c: no birthdays; most recent kept, max score + profileRow('s1', 'c', null, null, 7, '2024-01-01 00:00:00.000 +00:00'), + profileRow('s1', 'c', null, null, 3, '2024-02-01 00:00:00.000 +00:00'), + // same user on another server and a unique row are untouched + profileRow('s2', 'a', 3, 3, 1, '2024-01-01 00:00:00.000 +00:00'), + profileRow('s1', 'd', 4, 4, 0, '2024-01-01 00:00:00.000 +00:00'), + ]); + + runUpTo(db, '002-userprofile-unique'); + + const warnings = logs.filter((entry) => entry.level === 'warn'); + assert.equal(warnings.length, 1); + assert.match(nth(warnings).message, /Conflicting birthdays/); + + const rows = db + .prepare( + 'SELECT serverId, userId, birthdayMonth, birthdayDay, activityScore FROM `UserProfiles` ORDER BY serverId, userId', + ) + .raw() + .all(); + assert.deepEqual(rows, [ + ['s1', 'a', 2, 14, 10], + ['s1', 'b', 7, 7, 8], + ['s1', 'c', null, null, 7], + ['s1', 'd', 4, 4, 0], + ['s2', 'a', 3, 3, 1], + ]); + + assert.throws(() => + insertProfiles(db, [profileRow('s1', 'a', null, null, 0, '2024-05-01 00:00:00.000 +00:00')]), + ); + }); +}); + +test('002 rolls back entirely when it fails', () => { + withDb((db) => { + runUpTo(db, '001-baseline'); + insertProfiles(db, [ + profileRow('s1', 'a', null, null, 1, '2024-01-01 00:00:00.000 +00:00'), + profileRow('s1', 'a', null, null, 5, '2024-02-01 00:00:00.000 +00:00'), + ]); + db.exec('CREATE INDEX `user_profiles_server_user_unique` ON `UserProfiles` (serverId)'); + + assert.throws(() => runUpTo(db, '002-userprofile-unique')); + assert.equal(allRows(db, 'SELECT * FROM `UserProfiles`').length, 2); + assert.deepEqual(allRows(db, 'SELECT name FROM `SequelizeMeta`'), [{ name: '001-baseline' }]); + }); +}); + +test('005 backfills NULL scores, removes unusable rows with a warning, and is idempotent', () => { + withDb((db) => { + db.exec(LEGACY_SERVER_CONFIGS); + db.exec(LEGACY_USER_PROFILES); + const at = "'2024-01-01 00:00:00.000 +00:00'"; + db.exec( + `INSERT INTO \`UserProfiles\` (serverId, userId, activityScore, createdAt, updatedAt) VALUES + ('s1', 'u1', NULL, ${at}, ${at}), + ('s1', 'u2', 7, ${at}, ${at}), + (NULL, 'u3', 4, ${at}, ${at}), + ('s1', NULL, 4, ${at}, ${at})`, + ); + runUpTo(db, '004-bot-state'); + logs.length = 0; + + assert.deepEqual(migrate(db, logger), [ + '005-userprofile-backfill', + '006-userprofile-onboarded-at', + '007-userprofile-screening-pending', + ]); + assert.deepEqual( + allRows(db, 'SELECT userId, activityScore FROM `UserProfiles` ORDER BY userId'), + [ + { userId: 'u1', activityScore: 0 }, + { userId: 'u2', activityScore: 7 }, + ], + ); + const warnings = logs.filter((entry) => entry.level === 'warn'); + assert.equal(warnings.length, 1); + assert.deepEqual(nth(warnings).context, { count: 2 }); + + logs.length = 0; + assert.deepEqual(migrate(db, logger), []); + assert.equal(logs.filter((entry) => entry.level === 'warn').length, 0); + }); +}); + +test('006 adds a nullable onboardedAt column and is idempotent', () => { + withDb((db) => { + db.exec(LEGACY_SERVER_CONFIGS); + db.exec(LEGACY_USER_PROFILES); + const at = "'2024-01-01 00:00:00.000 +00:00'"; + db.exec( + `INSERT INTO \`UserProfiles\` (serverId, userId, createdAt, updatedAt) VALUES ('s1', 'u1', ${at}, ${at})`, + ); + runUpTo(db, '005-userprofile-backfill'); + + assert.deepEqual(migrate(db, logger), [ + '006-userprofile-onboarded-at', + '007-userprofile-screening-pending', + ]); + assert.ok(columnsOf(db, 'UserProfiles').includes('onboardedAt')); + assert.deepEqual(allRows(db, 'SELECT onboardedAt FROM `UserProfiles`'), [ + { onboardedAt: null }, + ]); + + assert.deepEqual(migrate(db, logger), []); + const migration = nth(migrations.filter((m) => m.name === '006-userprofile-onboarded-at')); + assert.doesNotThrow(() => migration.up(db, logger)); + assert.equal(columnsOf(db, 'UserProfiles').filter((c) => c === 'onboardedAt').length, 1); + }); +}); + +test('007 adds a nullable screeningPendingAt column and is idempotent', () => { + withDb((db) => { + db.exec(LEGACY_SERVER_CONFIGS); + db.exec(LEGACY_USER_PROFILES); + const at = "'2024-01-01 00:00:00.000 +00:00'"; + db.exec( + `INSERT INTO \`UserProfiles\` (serverId, userId, createdAt, updatedAt) VALUES ('s1', 'u1', ${at}, ${at})`, + ); + runUpTo(db, '006-userprofile-onboarded-at'); + + assert.deepEqual(migrate(db, logger), ['007-userprofile-screening-pending']); + assert.ok(columnsOf(db, 'UserProfiles').includes('screeningPendingAt')); + assert.deepEqual(allRows(db, 'SELECT screeningPendingAt FROM `UserProfiles`'), [ + { screeningPendingAt: null }, + ]); + + assert.deepEqual(migrate(db, logger), []); + const migration = nth(migrations.filter((m) => m.name === '007-userprofile-screening-pending')); + assert.doesNotThrow(() => migration.up(db, logger)); + assert.equal(columnsOf(db, 'UserProfiles').filter((c) => c === 'screeningPendingAt').length, 1); + }); +}); diff --git a/tests/database/purgeGuild.test.ts b/tests/database/purgeGuild.test.ts new file mode 100644 index 00000000..6b0d35e0 --- /dev/null +++ b/tests/database/purgeGuild.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { after, beforeEach, test } from 'node:test'; +import { count, eq } from 'drizzle-orm'; +import { serverConfigs, userProfiles } from '../../src/db/schema.ts'; +import { getConfig, purgeGuild } from '../../src/services/ConfigService.ts'; +import { createTestApp } from '../helpers/app.ts'; + +const app = createTestApp(); +const { db } = app; + +after(() => { + app.close(); +}); + +beforeEach(() => { + app.configCache.clear(); + db.delete(userProfiles).run(); + db.delete(serverConfigs).run(); + db.insert(serverConfigs) + .values([{ serverId: 's1' }, { serverId: 's2' }]) + .run(); + db.insert(userProfiles) + .values([ + { serverId: 's1', userId: 'a' }, + { serverId: 's1', userId: 'b' }, + { serverId: 's2', userId: 'a' }, + ]) + .run(); +}); + +const profileCount = (serverId?: string): number => { + const query = db.select({ n: count() }).from(userProfiles); + return (serverId ? query.where(eq(userProfiles.serverId, serverId)) : query).get()!.n; +}; + +const configCount = (): number => db.select({ n: count() }).from(serverConfigs).get()!.n; + +test('purgeGuild deletes only the target server and reports counts', async () => { + assert.deepEqual(await purgeGuild(app, 's1'), { config: true, profiles: 2 }); + + assert.equal( + db.select().from(serverConfigs).where(eq(serverConfigs.serverId, 's1')).get(), + undefined, + ); + assert.ok(db.select().from(serverConfigs).where(eq(serverConfigs.serverId, 's2')).get()); + assert.equal(profileCount('s1'), 0); + assert.equal(profileCount('s2'), 1); +}); + +test('purgeGuild on an unknown server deletes nothing', async () => { + assert.deepEqual(await purgeGuild(app, 'nope'), { config: false, profiles: 0 }); + assert.equal(configCount(), 2); + assert.equal(profileCount(), 3); +}); + +test('purgeGuild rolls back the profile delete when the config delete fails', async () => { + db.$client.exec( + "CREATE TRIGGER block_config_delete BEFORE DELETE ON `ServerConfigs` BEGIN SELECT RAISE(ABORT, 'boom'); END", + ); + try { + await assert.rejects(purgeGuild(app, 's1'), /boom/); + } finally { + db.$client.exec('DROP TRIGGER block_config_delete'); + } + + assert.equal(profileCount('s1'), 2); + assert.ok(db.select().from(serverConfigs).where(eq(serverConfigs.serverId, 's1')).get()); +}); + +test('purgeGuild drops the cached config', async () => { + await getConfig(app, 's1'); + assert.ok(app.configCache.has('s1')); + await purgeGuild(app, 's1'); + assert.equal(app.configCache.has('s1'), false); +}); diff --git a/tests/database/schema.test.ts b/tests/database/schema.test.ts new file mode 100644 index 00000000..3ddf68f4 --- /dev/null +++ b/tests/database/schema.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { getTableColumns, getTableName } from 'drizzle-orm'; +import type { SQLiteTable } from 'drizzle-orm/sqlite-core'; +import { botState, serverConfigs, userProfiles } from '../../src/db/schema.ts'; +import { createTestApp } from '../helpers/app.ts'; + +const columnNames = (table: SQLiteTable): string[] => + Object.values(getTableColumns(table)).map((column) => column.name); + +test('Drizzle schema columns exist in the migrated database', () => { + const app = createTestApp(); + try { + for (const table of [serverConfigs, userProfiles, botState]) { + const actual = ( + app.db.$client.pragma(`table_info(\`${getTableName(table)}\`)`) as { name: string }[] + ).map((column) => column.name); + for (const name of columnNames(table)) { + assert.ok(actual.includes(name), `${getTableName(table)}.${name} missing from database`); + } + } + } finally { + app.close(); + } +}); + +test('migrated database applies column defaults and round-trips dates', () => { + const app = createTestApp(); + try { + const { db } = app; + db.insert(serverConfigs).values({ serverId: 's1' }).run(); + const config = db.select().from(serverConfigs).get()!; + assert.equal(config.systemMessagesEnabled, false); + assert.equal(config.auditChannelId, null); + assert.equal(config.accessFailureCount, 0); + assert.equal(config.firstAccessFailureAt, null); + assert.ok(config.createdAt instanceof Date); + + const when = new Date('2024-01-01T12:00:00.000Z'); + db.update(serverConfigs).set({ firstAccessFailureAt: when }).run(); + const raw = app.db.$client + .prepare('SELECT firstAccessFailureAt AS v FROM `ServerConfigs`') + .get() as { + v: string; + }; + assert.equal(raw.v, '2024-01-01 12:00:00.000 +00:00'); + assert.equal( + db.select().from(serverConfigs).get()!.firstAccessFailureAt?.getTime(), + when.getTime(), + ); + + db.insert(userProfiles).values({ serverId: 's1', userId: 'u1' }).run(); + assert.equal(db.select().from(userProfiles).get()!.activityScore, 0); + } finally { + app.close(); + } +}); diff --git a/tests/database/services.test.ts b/tests/database/services.test.ts new file mode 100644 index 00000000..2705bf01 --- /dev/null +++ b/tests/database/services.test.ts @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import { after, beforeEach, test } from 'node:test'; +import { + deleteConfig, + getConfig, + getConfigs, + resetAccessFailures, + updateConfig, + updateExistingConfig, +} from '../../src/services/ConfigService.ts'; +import { + clearBirthday, + getServerBirthdays, + incrementActivityScore, + setBirthday, +} from '../../src/services/UserProfileService.ts'; +import { eq } from 'drizzle-orm'; +import { serverConfigs, type ServerConfig } from '../../src/db/schema.ts'; +import { createTestApp } from '../helpers/app.ts'; +import { nth } from '../helpers/assertions.ts'; +import { dbFixtures } from '../helpers/db.ts'; + +const app = createTestApp(); +const { db } = app; +const { allProfiles, clearConfigs, clearProfiles } = dbFixtures(app); + +after(() => { + app.close(); +}); + +beforeEach(() => { + app.configCache.clear(); + clearProfiles(); + clearConfigs(); +}); + +test('getConfig creates the row once and returns the same one afterwards', async () => { + const first = await getConfig(app, 's1'); + const second = await getConfig(app, 's1'); + assert.equal((await getConfigs(db)).length, 1); + assert.equal(first.createdAt.getTime(), second.createdAt.getTime()); + assert.equal(first.debug, false); +}); + +test('updateConfig patches fields, bumps updatedAt and creates a missing row', async () => { + const created = await updateConfig(app, 's1', { auditChannelId: 'c1' }); + assert.equal(created.auditChannelId, 'c1'); + + const updated = await updateConfig(app, 's1', { debug: true, auditChannelId: null }); + assert.equal(updated.debug, true); + assert.equal(updated.auditChannelId, null); + assert.ok(updated.updatedAt.getTime() >= created.updatedAt.getTime()); + assert.equal((await getConfig(app, 's1')).debug, true); +}); + +const countSelects = (): { count: () => number; restore: () => void } => { + const select = db.select.bind(db); + let calls = 0; + db.select = ((...args: Parameters) => { + calls += 1; + return select(...args); + }) as typeof db.select; + return { + count: () => calls, + restore: () => { + db.select = select as typeof db.select; + }, + }; +}; + +test('getConfig serves repeat lookups from the cache without touching the database', async () => { + const spy = countSelects(); + try { + const first = await getConfig(app, 's1'); + assert.equal(spy.count(), 1); + assert.equal(await getConfig(app, 's1'), first); + assert.equal(spy.count(), 1); + } finally { + spy.restore(); + } +}); + +test('updateConfig refreshes the cached config', async () => { + await getConfig(app, 's1'); + const updated = await updateConfig(app, 's1', { debug: true }); + const spy = countSelects(); + try { + assert.equal(await getConfig(app, 's1'), updated); + assert.equal(spy.count(), 0); + } finally { + spy.restore(); + } + assert.equal((await getConfig(app, 's1')).debug, true); +}); + +test('resetAccessFailures updates the cache', async () => { + const config = await updateConfig(app, 's1', { + accessFailureCount: 2, + firstAccessFailureAt: new Date(), + }); + const reset = await resetAccessFailures(app, config); + assert.equal(reset.accessFailureCount, 0); + assert.equal((await getConfig(app, 's1')).accessFailureCount, 0); +}); + +test('deleteConfig invalidates the cache so the next lookup recreates defaults', async () => { + await updateConfig(app, 's1', { debug: true }); + assert.equal(await deleteConfig(app, 's1'), true); + assert.equal(app.configCache.has('s1'), false); + assert.equal((await getConfig(app, 's1')).debug, false); +}); + +test('incrementActivityScore upserts and increments atomically', async () => { + await incrementActivityScore(db, 's1', 'u1'); + await incrementActivityScore(db, 's1', 'u1'); + await incrementActivityScore(db, 's1', 'u2'); + const scores = allProfiles().map((p) => [p.userId, p.activityScore]); + assert.deepEqual(scores, [ + ['u1', 2], + ['u2', 1], + ]); +}); + +test('setBirthday upserts without losing the activity score; clearBirthday reports presence', async () => { + await incrementActivityScore(db, 's1', 'u1'); + await setBirthday(db, 's1', 'u1', 3, 4); + await setBirthday(db, 's1', 'u1', 5, 6); + const profile = nth(allProfiles()); + assert.deepEqual([profile.birthdayMonth, profile.birthdayDay, profile.activityScore], [5, 6, 1]); + assert.equal(allProfiles().length, 1); + + assert.equal(await clearBirthday(db, 's1', 'u1'), true); + assert.equal(await clearBirthday(db, 's1', 'u1'), false); + assert.equal(await clearBirthday(db, 's1', 'nobody'), false); + assert.equal((await getServerBirthdays(db, 's1')).length, 0); +}); + +test('updateExistingConfig patches an existing row but never creates one', async () => { + await updateConfig(app, 's-existing', { debug: false }); + const patched = await updateExistingConfig(app, 's-existing', { debug: true }); + assert.equal(patched?.debug, true); + assert.equal(app.configCache.get('s-existing')?.debug, true); + + app.configCache.set('s-gone', { serverId: 's-gone' } as ServerConfig); + assert.equal(await updateExistingConfig(app, 's-gone', { debug: true }), null); + assert.equal(app.configCache.has('s-gone'), false); + assert.equal( + app.db.select().from(serverConfigs).where(eq(serverConfigs.serverId, 's-gone')).get(), + undefined, + ); +}); diff --git a/tests/datacheck/stateMachine.test.ts b/tests/datacheck/stateMachine.test.ts new file mode 100644 index 00000000..e3b86afb --- /dev/null +++ b/tests/datacheck/stateMachine.test.ts @@ -0,0 +1,401 @@ +import assert from 'node:assert/strict'; +import { after, beforeEach, describe, test } from 'node:test'; +import { Collection, type Client, type Guild } from 'discord.js'; +import guildCreate from '../../src/events/guildCreate.ts'; +import guildDelete from '../../src/events/guildDelete.ts'; +import type { DingirClient } from '../../src/client/DingirClient.ts'; +import { runDataCheck } from '../../src/services/DataCheckService.ts'; +import { stub } from '../fakes/discord.ts'; +import { apiError, fakeClient, fakeGuildWithMembers } from '../fakes/guild.ts'; +import { createTestApp } from '../helpers/app.ts'; +import { dbFixtures } from '../helpers/db.ts'; + +const policy = { minFailures: 3, graceDays: 7 }; +const DAY = 24 * 60 * 60 * 1000; +const t0 = new Date('2027-01-01T00:00:00Z'); +const at = (days: number): Date => new Date(t0.getTime() + days * DAY); + +const base = createTestApp(); +const { + allProfiles, + clearConfigs, + clearProfiles, + countProfiles, + createConfig, + createConfigs, + createProfiles, + findConfig, +} = dbFixtures(base); +const run = (client: ReturnType, now: Date): Promise => + runDataCheck(createTestApp({ db: base.db, clock: () => now }), client, policy); + +after(() => { + base.close(); +}); + +beforeEach(() => { + clearProfiles(); + clearConfigs(); +}); + +type Outcome = 'ok' | 'missing' | number; + +interface Row { + name: string; + /** Stored failure counters before the run. */ + counters?: { count: number; first: Date | null }; + /** What fetching the guild returns: ok, an API error code, or "not found at all". */ + fetch: Outcome; + /** Fetching the guild fails but the gateway cache still lists it (stale fetch). */ + cached?: boolean; + members?: string[]; + membersFail?: boolean; + profiles?: string[]; + days: number; + /** Expected config afterwards: null when purged. */ + config: { count: number; first: Date | null } | null; + /** Expected remaining profile user ids. */ + remaining: string[]; +} + +const reset = { count: 0, first: null }; +const rows: Row[] = [ + { + name: 'transient error: nothing changes, however long it lasts', + counters: { count: 5, first: t0 }, + fetch: 500, + profiles: ['a'], + days: 30, + config: { count: 5, first: t0 }, + remaining: ['a'], + }, + { + name: 'gone, first time: counts but keeps everything', + fetch: 50001, + profiles: ['a'], + days: 0, + config: { count: 1, first: t0 }, + remaining: ['a'], + }, + { + name: 'gone, enough failures but inside the grace period: kept', + counters: { count: 2, first: t0 }, + fetch: 50001, + profiles: ['a'], + days: 6, + config: { count: 3, first: t0 }, + remaining: ['a'], + }, + { + name: 'gone, grace period over but too few failures: kept', + counters: { count: 1, first: t0 }, + fetch: 50001, + profiles: ['a'], + days: 30, + config: { count: 2, first: t0 }, + remaining: ['a'], + }, + { + name: 'gone past both thresholds: purged with its profiles', + counters: { count: 2, first: t0 }, + fetch: 50001, + profiles: ['a'], + days: 7, + config: null, + remaining: [], + }, + { + name: 'Unknown Guild counts as gone', + counters: { count: 2, first: t0 }, + fetch: 10004, + profiles: ['a'], + days: 7, + config: null, + remaining: [], + }, + { + name: 'guild missing from the client entirely counts as gone', + fetch: 'missing', + days: 0, + config: { count: 1, first: t0 }, + remaining: [], + }, + { + name: 'gone but the gateway still lists the guild: never purged', + counters: { count: 2, first: t0 }, + fetch: 50001, + cached: true, + profiles: ['a'], + days: 30, + config: { count: 2, first: t0 }, + remaining: ['a'], + }, + { + name: 'ok resets the counters', + counters: { count: 2, first: t0 }, + fetch: 'ok', + members: ['a'], + days: 1, + config: reset, + remaining: [], + }, + { + name: 'ok deletes departed members, including non-birthday profiles', + fetch: 'ok', + members: ['here'], + profiles: ['here', 'gone-plain', 'gone-bday'], + days: 0, + config: reset, + remaining: ['here'], + }, + { + name: 'ok with an empty member list deletes nothing', + fetch: 'ok', + members: [], + profiles: ['a'], + days: 0, + config: reset, + remaining: ['a'], + }, + { + name: 'ok but the member fetch fails: nothing deleted, counters still reset', + counters: { count: 1, first: t0 }, + fetch: 'ok', + members: ['a'], + membersFail: true, + profiles: ['x'], + days: 0, + config: reset, + remaining: ['x'], + }, +]; + +describe('data check', () => { + for (const row of rows) { + test(row.name, async () => { + createConfig({ + serverId: 'g1', + accessFailureCount: row.counters?.count ?? 0, + firstAccessFailureAt: row.counters?.first ?? null, + }); + createProfiles([ + ...(row.profiles ?? []).map((userId) => ({ + serverId: 'g1', + userId, + ...(userId === 'gone-bday' ? { birthdayMonth: 1, birthdayDay: 1 } : {}), + })), + { serverId: 'other', userId: 'gone-plain' }, + ]); + const guilds: Record | Error> = + row.fetch === 'ok' + ? { + g1: fakeGuildWithMembers({ + id: 'g1', + memberIds: row.members ?? [], + ...(row.membersFail ? { membersFetchError: new Error('members unavailable') } : {}), + }), + } + : row.fetch === 'missing' + ? {} + : { g1: apiError(row.fetch) }; + const client = fakeClient({ guilds }); + if (row.cached) { + client.guilds.cache.set('g1', stub({ id: 'g1' })); + } + await run(client, at(row.days)); + + const config = findConfig('g1'); + if (row.config === null) { + assert.equal(config, null, 'config purged'); + } else { + assert.equal(config?.accessFailureCount, row.config.count); + assert.deepEqual(config?.firstAccessFailureAt ?? null, row.config.first); + } + assert.deepEqual( + allProfiles() + .filter((p) => p.serverId === 'g1') + .map((p) => p.userId) + .sort(), + [...row.remaining].sort(), + ); + assert.equal( + allProfiles().filter((p) => p.serverId === 'other').length, + 1, + 'other guilds are untouched', + ); + }); + } + + test('one guild throwing does not stop the others', async () => { + createConfigs([{ serverId: 'g1' }, { serverId: 'g2' }]); + createProfiles([ + { serverId: 'g1', userId: 'x' }, + { serverId: 'g2', userId: 'x' }, + ]); + const broken = fakeGuildWithMembers({ + id: 'g1', + memberIds: ['a'], + membersFetchError: new Error('members unavailable'), + }); + const fine = fakeGuildWithMembers({ id: 'g2', memberIds: ['a'] }); + await run(fakeClient({ guilds: { g1: broken, g2: fine } }), t0); + assert.deepEqual( + allProfiles().map((p) => p.serverId), + ['g1'], + ); + }); +}); + +describe('data check racing with other events', () => { + test('a profile created while the member list is being fetched is not deleted', async () => { + createConfig({ serverId: 'g1' }); + createProfiles([ + { serverId: 'g1', userId: 'a' }, + { serverId: 'g1', userId: 'gone' }, + ]); + const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['a'] }); + const fetchMembers = guild.members.fetch.bind(guild.members); + guild.members.fetch = async (id?: string) => { + const result = await fetchMembers(id); + createProfiles([{ serverId: 'g1', userId: 'late-joiner' }]); + return result; + }; + await run(fakeClient({ guilds: { g1: guild } }), t0); + assert.deepEqual( + allProfiles() + .map((p) => p.userId) + .sort(), + ['a', 'late-joiner'], + ); + }); + + test('a member who left and rejoined during the fetch keeps their new profile', async () => { + createConfig({ serverId: 'g1' }); + createProfiles([ + { serverId: 'g1', userId: 'a' }, + { serverId: 'g1', userId: 'rejoiner', birthdayMonth: 2, birthdayDay: 3 }, + ]); + const guild = fakeGuildWithMembers({ id: 'g1', memberIds: ['a'] }); + const fetchMembers = guild.members.fetch.bind(guild.members); + guild.members.fetch = async (id?: string) => { + const result = await fetchMembers(id); // assembled while they were away + base.db.$client.exec("DELETE FROM `UserProfiles` WHERE userId = 'rejoiner'"); // left + createProfiles([{ serverId: 'g1', userId: 'rejoiner', birthdayMonth: 4, birthdayDay: 5 }]); + return result; + }; + await run(fakeClient({ guilds: { g1: guild } }), t0); + const kept = allProfiles().find((p) => p.userId === 'rejoiner'); + assert.equal(kept?.birthdayMonth, 4); + }); + + test('a guild purged mid-run does not get its config recreated', async () => { + createConfig({ serverId: 'g1' }); + const client = fakeClient({ guilds: { g1: apiError(50001) } }); + const fetchGuild = client.guilds.fetch.bind(client.guilds); + client.guilds.fetch = (async (id: string) => { + clearConfigs(); + return fetchGuild(id); + }) as typeof client.guilds.fetch; + await run(client, t0); + assert.equal(findConfig('g1'), null); + }); + + test('a guild purged by guildDelete before its turn is skipped', async () => { + createConfigs([{ serverId: 'g1' }, { serverId: 'g2' }]); + const g2 = fakeGuildWithMembers({ id: 'g2', memberIds: [] }); + const client = fakeClient({ guilds: { g1: apiError(500), g2 } }); + const fetchGuild = client.guilds.fetch.bind(client.guilds); + client.guilds.fetch = (async (id: string) => { + const result = await fetchGuild(id).catch(() => g2); // first guild done... + clearConfigs(); // ...then guildDelete purges the rest + return result; + }) as typeof client.guilds.fetch; + await run(client, t0); + assert.equal(findConfig('g2'), null, 'g2 was not recreated by the job'); + }); + + test('a concurrent guildDelete purge and a data check purge do not conflict', async () => { + createConfig({ serverId: 'g1', accessFailureCount: 2, firstAccessFailureAt: t0 }); + createProfiles([{ serverId: 'g1', userId: 'a' }]); + const client = fakeClient({ guilds: { g1: apiError(50001) } }); + const fetchGuild = client.guilds.fetch.bind(client.guilds); + client.guilds.fetch = (async (id: string) => { + await guildDelete.run( + base, + stub(client), + stub({ id: 'g1', name: 'G1' }), + ); + return fetchGuild(id); + }) as typeof client.guilds.fetch; + await run(client, at(30)); + assert.equal(findConfig('g1'), null); + assert.equal(countProfiles(), 0); + }); +}); + +describe('guild lifecycle events', () => { + test('guildDelete purges the guild immediately, and is idempotent', async () => { + createConfigs([{ serverId: 'g1' }, { serverId: 'g2' }]); + createProfiles([ + { serverId: 'g1', userId: 'a' }, + { serverId: 'g2', userId: 'a' }, + ]); + const guild = stub({ id: 'g1', name: 'G1' }); + await guildDelete.run(base, stub({}), guild); + await guildDelete.run(base, stub({}), guild); + assert.equal(findConfig('g1'), null); + assert.ok(findConfig('g2')); + assert.deepEqual( + allProfiles().map((p) => p.serverId), + ['g2'], + ); + }); + + test('guildCreate creates the config and resets failure counters', async () => { + createConfig({ serverId: 'g1', accessFailureCount: 4, firstAccessFailureAt: t0 }); + const empty = { fetch: async () => new Collection() }; + const guild = stub({ id: 'g1', name: 'G1', members: empty }); + const client = stub({ + guilds: { cache: new Collection([['g1', guild]]) }, + }); + await guildCreate.run(base, client, guild); + assert.equal(findConfig('g1')?.accessFailureCount, 0); + assert.equal(findConfig('g1')?.firstAccessFailureAt, null); + + await guildCreate.run(base, client, stub({ id: 'g3', name: 'G3', members: empty })); + assert.ok(findConfig('g3')); + }); + + test('guildCreate records members already pending, and survives a member fetch failure', async () => { + const pending = { + id: 'p1', + guild: { id: 'g1' }, + user: { id: 'p1', bot: false }, + pending: true, + }; + const guild = stub({ + id: 'g1', + name: 'G1', + members: { fetch: async () => new Collection([['p1', pending]]) }, + }); + const client = stub({ + guilds: { cache: new Collection([['g1', guild]]) }, + }); + await guildCreate.run(base, client, guild); + const recorded = allProfiles().find((p) => p.userId === 'p1'); + assert.equal(recorded?.screeningPendingAt?.getTime(), base.clock().getTime()); + + const broken = stub({ + id: 'g2', + name: 'G2', + members: { + fetch: async () => { + throw new Error('timeout'); + }, + }, + }); + const other = stub({ guilds: { cache: new Collection() } }); + await guildCreate.run(base, other, broken); + assert.ok(findConfig('g2')); + }); +}); diff --git a/tests/deferRoutes.test.ts b/tests/deferRoutes.test.ts new file mode 100644 index 00000000..85d8282f --- /dev/null +++ b/tests/deferRoutes.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { commands } from '../src/commands/index.ts'; +import { fakeInteraction } from './fakes/interaction.ts'; + +const defers = ( + command: string, + group: string | null, + subcommand: string | null, +): string | false | undefined => { + const cmd = commands.find((c) => c.name === command)!; + const { interaction } = fakeInteraction({ commandName: command, group, subcommand }); + return cmd.resolve(interaction)?.defer; +}; + +test('routes that await Discord network calls before replying defer ephemerally', () => { + const routes: [string, string | null, string | null][] = [ + ['profile', null, null], + ['config', 'newroles', 'set'], + ['config', 'newroles', 'clear'], + ['config', 'audit', 'get'], + ['config', 'announcements', 'get'], + ['config', 'honeypot', 'get'], + ['config', 'welcome', 'set-image'], + ['config', 'welcome', 'preview'], + ['config', 'birthdays', 'create'], + ['config', 'birthdays', 'sync'], + ['config', 'birthdays', 'remove'], + ['noroles', null, null], + ['rolesince', null, null], + ['post', null, null], + ['simulate', null, 'join'], + ['simulate', null, 'onboard'], + ]; + for (const [command, group, sub] of routes) { + assert.equal(defers(command, group, sub), 'ephemeral', [command, group, sub].join(' ')); + } +}); diff --git a/tests/dispatcher.test.ts b/tests/dispatcher.test.ts new file mode 100644 index 00000000..7c060de5 --- /dev/null +++ b/tests/dispatcher.test.ts @@ -0,0 +1,241 @@ +import assert from 'node:assert/strict'; +import { after, test } from 'node:test'; +import { Collection, MessageFlags } from 'discord.js'; +import type { DingirClient } from '../src/client/DingirClient.ts'; +import { type Command, type CommandContext, defineCommand } from '../src/framework/command.ts'; +import { UserError } from '../src/framework/errors.ts'; +import interactionCreate from '../src/events/interactionCreate.ts'; +import { stub } from './fakes/discord.ts'; +import { fakeInteraction } from './fakes/interaction.ts'; +import { createTestApp } from './helpers/app.ts'; +import { nth } from './helpers/assertions.ts'; + +const app = createTestApp(); + +after(() => { + app.close(); +}); + +const clientWith = (...commands: Command[]) => + stub({ slashCommands: new Collection(commands.map((c) => [c.name, c])) }); + +const dispatch = (client: DingirClient, interaction: unknown) => + interactionCreate.run(app, client, interaction as never); + +test('ignores interactions that are not chat input commands', async () => { + const { interaction, calls } = fakeInteraction({ chatInput: false }); + await dispatch(clientWith(), interaction); + assert.equal(calls.length, 0); +}); + +test('replies ephemerally when used outside a cached guild', async () => { + const ran: string[] = []; + const cmd = defineCommand({ + name: 'test', + description: 'd', + run: async () => { + ran.push('run'); + }, + }); + const { interaction, calls } = fakeInteraction({ inGuild: false }); + await dispatch(clientWith(cmd), interaction); + + assert.deepEqual(calls, [ + { + method: 'reply', + payload: { content: 'Dingir only works in servers.', flags: MessageFlags.Ephemeral }, + }, + ]); + assert.deepEqual(ran, []); +}); + +test('replies "Unknown command." for unregistered commands and unresolvable subcommands', async () => { + const cmd = defineCommand({ + name: 'tree', + description: 'd', + subcommands: [{ name: 'a', description: 'd', run: async () => {} }], + }); + + const unknown = fakeInteraction({ commandName: 'nope' }); + await dispatch(clientWith(cmd), unknown.interaction); + assert.deepEqual(unknown.calls, [ + { method: 'reply', payload: { content: 'Unknown command.', flags: MessageFlags.Ephemeral } }, + ]); + + const badSub = fakeInteraction({ commandName: 'tree', subcommand: 'zzz' }); + await dispatch(clientWith(cmd), badSub.interaction); + assert.equal(badSub.calls.length, 1); + assert.equal(nth(badSub.calls).payload.content, 'Unknown command.'); +}); + +test('runs the handler with context and applies the defer mode', async () => { + let seen: CommandContext | undefined; + const cmd = defineCommand({ + name: 'test', + description: 'd', + defer: 'ephemeral', + run: async (ctx) => { + seen = ctx; + await ctx.reply('done'); + }, + }); + const { interaction, calls } = fakeInteraction({ guildId: 'guild-9' }); + await dispatch(clientWith(cmd), interaction); + + assert.deepEqual( + calls.map((c) => c.method), + ['deferReply', 'editReply'], + ); + assert.deepEqual(nth(calls).payload, { flags: MessageFlags.Ephemeral }); + assert.equal(seen?.config.serverId, 'guild-9'); + assert.equal(seen?.interaction, interaction); +}); + +test('public defer does not set the ephemeral flag', async () => { + const cmd = defineCommand({ + name: 'test', + description: 'd', + defer: 'public', + run: async () => {}, + }); + const { interaction, calls } = fakeInteraction(); + await dispatch(clientWith(cmd), interaction); + assert.deepEqual(calls, [{ method: 'deferReply', payload: {} }]); +}); + +test('a throwing handler produces exactly one ephemeral error reply', async () => { + const cmd = defineCommand({ + name: 'test', + description: 'd', + run: async () => { + throw new Error('boom'); + }, + }); + const { interaction, calls } = fakeInteraction(); + await dispatch(clientWith(cmd), interaction); + + assert.deepEqual(calls, [ + { + method: 'reply', + payload: { + content: 'Something went wrong running this command.', + flags: MessageFlags.Ephemeral, + }, + }, + ]); + const failure = app.logsAt('error').find((entry) => entry.message === 'Command failed'); + assert.equal(failure?.context?.command, 'test'); +}); + +test('a handler that throws after replying results in a single follow-up error message', async () => { + const cmd = defineCommand({ + name: 'test', + description: 'd', + defer: 'ephemeral', + run: async (ctx) => { + await ctx.reply('partial'); + throw new Error('late failure'); + }, + }); + const { interaction, calls } = fakeInteraction(); + await dispatch(clientWith(cmd), interaction); + + assert.deepEqual( + calls.map((c) => c.method), + ['deferReply', 'editReply', 'followUp'], + ); +}); + +test('a failing error reply is logged and does not throw', async () => { + const cmd = defineCommand({ + name: 'test', + description: 'd', + run: async () => { + throw new Error('boom'); + }, + }); + const { interaction } = fakeInteraction(); + Object.assign(interaction, { + reply: async () => { + throw new Error('reply failed'); + }, + }); + await dispatch(clientWith(cmd), interaction); + assert.ok( + app.logsAt('error').some((entry) => entry.message === 'Could not send command error reply'), + ); +}); + +test('a UserError replies ephemerally with its message and is not logged as an error', async () => { + const cmd = defineCommand({ + name: 'test', + description: 'd', + run: async () => { + throw new UserError('That is not allowed.'); + }, + }); + const { interaction, calls } = fakeInteraction(); + const before = app.logs.length; + await dispatch(clientWith(cmd), interaction); + + assert.deepEqual(calls, [ + { + method: 'reply', + payload: { content: 'That is not allowed.', flags: MessageFlags.Ephemeral }, + }, + ]); + const entries = app.logs.slice(before); + assert.equal(entries.filter((entry) => entry.level === 'error').length, 0); + assert.ok( + entries.some( + (entry) => entry.level === 'info' && entry.context?.reason === 'That is not allowed.', + ), + ); +}); + +test('a UserError thrown after deferring edits the deferred reply', async () => { + const cmd = defineCommand({ + name: 'test', + description: 'd', + defer: 'ephemeral', + run: async () => { + throw new UserError('Nope.'); + }, + }); + const { interaction, calls } = fakeInteraction(); + await dispatch(clientWith(cmd), interaction); + + assert.deepEqual( + calls.map((c) => c.method), + ['deferReply', 'editReply'], + ); + assert.equal(nth(calls, 1).payload.content, 'Nope.'); +}); + +test('defers the reply before looking up the server config', async () => { + const order: string[] = []; + const spied = createTestApp(); + const select = spied.db.select.bind(spied.db); + spied.db.select = ((...args: Parameters) => { + order.push('config'); + return select(...args); + }) as typeof spied.db.select; + try { + const cmd = defineCommand({ + name: 'test', + description: 'd', + defer: 'ephemeral', + run: async () => {}, + }); + const { interaction } = fakeInteraction(); + const original = interaction.deferReply.bind(interaction); + interaction.deferReply = (async (...args: Parameters) => { + order.push('defer'); + return original(...args); + }) as typeof interaction.deferReply; + await interactionCreate.run(spied, clientWith(cmd), interaction as never); + assert.deepEqual(order.slice(0, 2), ['defer', 'config']); + } finally { + spied.close(); + } +}); diff --git a/tests/env.test.ts b/tests/env.test.ts new file mode 100644 index 00000000..fe771c20 --- /dev/null +++ b/tests/env.test.ts @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { loadEnv } from '../src/config/env.ts'; + +const valid = { TOKEN: 'abc', CLIENT_ID: '123' }; + +test('applies defaults when only required variables are set', () => { + const env = loadEnv(valid); + assert.equal(env.token, 'abc'); + assert.equal(env.clientId, '123'); + assert.equal(env.jobSchedule, '0 9 * * *'); + assert.equal(env.timezone, 'Europe/London'); + assert.equal(env.dbPath, 'data/dingir.sqlite'); + assert.equal(env.purgeMinFailures, 3); + assert.equal(env.purgeGraceDays, 7); + assert.equal(env.logLevel, 'info'); + assert.equal(env.devGuildId, undefined); +}); + +test('returns a frozen object', () => { + assert.ok(Object.isFrozen(loadEnv(valid))); +}); + +test('parses overrides', () => { + const env = loadEnv({ + ...valid, + JOB_SCHEDULE: '*/5 * * * * *', + BOT_TIMEZONE: 'America/New_York', + DB_PATH: ':memory:', + PURGE_MIN_FAILURES: '5', + PURGE_GRACE_DAYS: '0', + DEV_GUILD_ID: '999', + }); + assert.equal(env.jobSchedule, '*/5 * * * * *'); + assert.equal(env.timezone, 'America/New_York'); + assert.equal(env.dbPath, ':memory:'); + assert.equal(env.purgeMinFailures, 5); + assert.equal(env.purgeGraceDays, 0); + assert.equal(env.devGuildId, '999'); +}); + +test('treats blank values as unset', () => { + const env = loadEnv({ ...valid, JOB_SCHEDULE: ' ', DEV_GUILD_ID: '' }); + assert.equal(env.jobSchedule, '0 9 * * *'); + assert.equal(env.devGuildId, undefined); +}); + +test('reports every problem in a single error', () => { + assert.throws( + () => + loadEnv({ + JOB_SCHEDULE: 'every day', + BOT_TIMEZONE: 'Mars/Olympus', + PURGE_MIN_FAILURES: '0', + PURGE_GRACE_DAYS: '-1', + }), + (err: Error) => { + for (const expected of [ + 'TOKEN', + 'CLIENT_ID', + 'JOB_SCHEDULE', + 'BOT_TIMEZONE', + 'PURGE_MIN_FAILURES', + 'PURGE_GRACE_DAYS', + ]) { + assert.match(err.message, new RegExp(expected)); + } + return true; + }, + ); +}); + +test('rejects malformed cron strings', () => { + for (const schedule of ['* * * *', '* * * * * * *', '0 9 * * ;']) { + assert.throws(() => loadEnv({ ...valid, JOB_SCHEDULE: schedule }), /JOB_SCHEDULE/); + } +}); + +test('rejects non-integer purge settings', () => { + assert.throws(() => loadEnv({ ...valid, PURGE_MIN_FAILURES: '2.5' }), /PURGE_MIN_FAILURES/); + assert.throws(() => loadEnv({ ...valid, PURGE_GRACE_DAYS: 'abc' }), /PURGE_GRACE_DAYS/); +}); + +test('parses and validates LOG_LEVEL', () => { + assert.equal(loadEnv({ ...valid, LOG_LEVEL: 'debug' }).logLevel, 'debug'); + assert.throws(() => loadEnv({ ...valid, LOG_LEVEL: 'bogus' }), /LOG_LEVEL must be one of/); +}); + +test('rejects a cron expression croner cannot parse', () => { + assert.throws( + () => loadEnv({ ...valid, JOB_SCHEDULE: '99 99 99 99 99' }), + /JOB_SCHEDULE is not a valid cron expression/, + ); +}); + +test('accepts 5- and 6-field cron expressions', () => { + assert.equal(loadEnv({ ...valid, JOB_SCHEDULE: '0 9 * * *' }).jobSchedule, '0 9 * * *'); + assert.equal(loadEnv({ ...valid, JOB_SCHEDULE: '30 0 9 * * *' }).jobSchedule, '30 0 9 * * *'); +}); diff --git a/tests/fakes/command.ts b/tests/fakes/command.ts new file mode 100644 index 00000000..05bc157a --- /dev/null +++ b/tests/fakes/command.ts @@ -0,0 +1,38 @@ +import type { Client, Guild, GuildMember } from 'discord.js'; +import type { App } from '../../src/app.ts'; +import type { ServerConfig } from '../../src/db/schema.ts'; +import type { CommandContext, ReplyOptions } from '../../src/framework/command.ts'; +import { fakeConfig, fakeGuild, fakeMember } from './discord.ts'; +import { fakeInteraction } from './interaction.ts'; + +export interface FakeCommandParts { + guild?: Guild; + member?: GuildMember; + config?: ServerConfig; + client?: Client; + subcommand?: string; +} + +/** A CommandContext whose reply() records normalised ReplyOptions instead of calling Discord. */ +export function fakeCommandContext( + app: App, + options: Record = {}, + parts: FakeCommandParts = {}, +) { + const { interaction } = fakeInteraction({ options, subcommand: parts.subcommand }); + if (parts.client) { + Object.assign(interaction, { client: parts.client }); + } + const replies: ReplyOptions[] = []; + const ctx: CommandContext = { + app, + interaction, + guild: parts.guild ?? fakeGuild(), + member: parts.member ?? fakeMember('user-1'), + config: parts.config ?? fakeConfig(), + reply: async (response) => { + replies.push(typeof response === 'string' ? { content: response } : response); + }, + }; + return { ctx, replies, interaction }; +} diff --git a/tests/fakes/discord.ts b/tests/fakes/discord.ts new file mode 100644 index 00000000..f3cf38df --- /dev/null +++ b/tests/fakes/discord.ts @@ -0,0 +1,130 @@ +import { Collection, User } from 'discord.js'; +import type { Client, Guild, GuildMember } from 'discord.js'; +import type { DingirClient } from '../../src/client/DingirClient.ts'; +import type { ServerConfig } from '../../src/db/schema.ts'; + +/** The one place tests cast structural stubs to discord.js types. */ +export const stub = (partial: object): T => partial as unknown as T; + +export const apiError = (code: number): Error => + Object.assign(new Error(`Discord error ${code}`), { code }); + +export function fakeUser(id: string, overrides: Record = {}): User { + return Object.assign(Object.create(User.prototype), { + id, + bot: false, + username: `user${id}`, + globalName: null, + discriminator: '0', + displayAvatarURL: () => 'https://example.com/a.png', + toString: () => `<@${id}>`, + ...overrides, + }) as User; +} + +export function fakeMember(id: string, overrides: Record = {}): GuildMember { + const user = (overrides.user as User | undefined) ?? fakeUser(id); + return stub({ + id, + user, + displayName: `Member ${id}`, + joinedTimestamp: Date.now(), + permissions: { has: () => false }, + roles: { cache: new Collection([['everyone', {}]]) }, + bannable: true, + ban: async () => undefined, + displayAvatarURL: () => 'https://example.com/a.png', + toString: () => `<@${id}>`, + ...overrides, + }); +} + +export interface FakeGuildOverrides extends Record { + /** Convenience: channels that exist in the guild cache and can be fetched. */ + channelIds?: string[]; +} + +export function fakeGuild(overrides: FakeGuildOverrides = {}): Guild { + const { channelIds = [], ...rest } = overrides; + const cache = new Collection(channelIds.map((id) => [id, { id }])); + return stub({ + id: 'guild-1', + name: 'Test Guild', + channels: { + cache, + fetch: async (id: string) => { + const channel = cache.get(id); + if (!channel) { + throw new Error('Unknown Channel'); + } + return channel; + }, + }, + ...rest, + }); +} + +export function fakeConfig( + overrides: Partial> = {}, +): ServerConfig { + return stub({ + serverId: 'guild-1', + guestRoleIds: null, + welcomeMessage: null, + welcomeMessageBackgroundUrl: null, + systemMessagesEnabled: false, + debug: false, + auditChannelId: null, + ...overrides, + }); +} + +export interface EmbedJson { + title?: string; + description?: string; + author?: { name: string }; + fields: { name: string; value: string; inline?: boolean }[]; + [key: string]: unknown; +} + +/** A message payload as captured from send/edit/reply calls. */ +export interface SentPayload { + content?: string; + embeds?: { toJSON(): EmbedJson }[]; + files?: { attachment: Buffer | string }[]; + [key: string]: unknown; +} + +export interface AuditChannel { + channel: { isSendable(): boolean; send(payload: unknown): Promise }; + sent: SentPayload[]; +} + +/** A sendable channel that records payloads, or rejects every send when `failWith` is given. */ +export function fakeAuditChannel(failWith?: string): AuditChannel { + const sent: SentPayload[] = []; + return { + sent, + channel: { + isSendable: () => true, + send: async (payload) => { + if (failWith) { + throw new Error(failWith); + } + sent.push(payload as SentPayload); + }, + }, + }; +} + +/** A client whose channel cache resolves only `channelId` (to `channel`). */ +export function fakeChannelClient( + channelId: string, + channel: object, + extra: object = {}, +): DingirClient { + return stub({ + channels: { fetch: async (id: string) => (id === channelId ? channel : null) }, + ...extra, + }); +} diff --git a/tests/fakes/guild.ts b/tests/fakes/guild.ts new file mode 100644 index 00000000..98d75c8f --- /dev/null +++ b/tests/fakes/guild.ts @@ -0,0 +1,138 @@ +import { Collection } from 'discord.js'; +import type { Client } from 'discord.js'; +import { apiError, fakeMember, stub, type SentPayload } from './discord.ts'; + +export { apiError }; + +export interface FakeEditableMessage { + id: string; + edits: SentPayload[]; + deleted: boolean; + edit(payload: unknown): Promise; + delete(): Promise; +} + +export const fakeEditableMessage = (id: string): FakeEditableMessage => { + const message: FakeEditableMessage = { + id, + edits: [], + deleted: false, + edit: async (payload) => { + message.edits.push(payload as SentPayload); + return message; + }, + delete: async () => { + message.deleted = true; + }, + }; + return message; +}; + +export interface FakeChannel { + id: string; + sent: SentPayload[]; + messages: { fetch(id: string): Promise }; + isTextBased(): boolean; + isDMBased(): boolean; + isSendable(): boolean; + send(payload: unknown): Promise<{ id: string; channelId: string }>; +} + +export const fakeTextChannel = (id: string, messages: FakeEditableMessage[] = []): FakeChannel => { + const channel: FakeChannel = { + id, + sent: [], + messages: { + fetch: async (messageId) => { + const found = messages.find((m) => m.id === messageId); + if (!found) { + throw apiError(10008); + } + return found; + }, + }, + isTextBased: () => true, + isDMBased: () => false, + isSendable: () => true, + send: async (payload) => { + channel.sent.push(payload as SentPayload); + return { id: `sent-${channel.sent.length}`, channelId: id }; + }, + }; + return channel; +}; + +export interface FakeGuildOptions { + id: string; + memberIds?: string[]; + channels?: FakeChannel[]; + membersFetchError?: Error; +} + +export const fakeGuildWithMembers = (opts: FakeGuildOptions) => { + const members = new Collection((opts.memberIds ?? []).map((id) => [id, fakeMember(id)])); + const channels = new Collection((opts.channels ?? []).map((c) => [c.id, c])); + return { + id: opts.id, + name: `Guild ${opts.id}`, + members: { + cache: members, + fetch: async (id?: string) => { + if (opts.membersFetchError) { + throw opts.membersFetchError; + } + if (id === undefined) { + return members; + } + const found = members.get(id); + if (!found) { + throw apiError(10007); + } + return found; + }, + }, + channels: { cache: channels, fetch: async (id: string) => channels.get(id) ?? null }, + }; +}; + +export type FakeGuildWithMembers = ReturnType; + +export interface FakeClientOptions { + guilds?: Record; + channels?: FakeChannel[]; + channelFetchError?: Error; +} + +export const fakeClient = (opts: FakeClientOptions = {}): Client => { + const guilds = opts.guilds ?? {}; + const channels = opts.channels ?? []; + const cache = new Collection(); + for (const [id, value] of Object.entries(guilds)) { + if (!(value instanceof Error)) { + cache.set(id, value); + } + } + return stub({ + guilds: { + cache, + fetch: async (id: string) => { + const value = guilds[id]; + if (value === undefined) { + throw apiError(10004); + } + if (value instanceof Error) { + throw value; + } + return value; + }, + }, + channels: { + fetch: async (id: string) => { + if (opts.channelFetchError) { + throw opts.channelFetchError; + } + return channels.find((c) => c.id === id) ?? null; + }, + }, + }); +}; diff --git a/tests/fakes/interaction.ts b/tests/fakes/interaction.ts new file mode 100644 index 00000000..db7c05b0 --- /dev/null +++ b/tests/fakes/interaction.ts @@ -0,0 +1,92 @@ +import type { ChatInputCommandInteraction } from 'discord.js'; +import { fakeGuild, type SentPayload, stub } from './discord.ts'; + +export interface Call { + method: 'reply' | 'deferReply' | 'editReply' | 'followUp'; + payload: SentPayload; +} + +export interface FakeInteractionOptions { + commandName?: string; + group?: string | null; + subcommand?: string | null; + options?: Record; + chatInput?: boolean; + inGuild?: boolean; + deferred?: boolean; + replied?: boolean; + guildId?: string; + userId?: string; + guild?: unknown; +} + +export interface FakeInteraction { + interaction: ChatInputCommandInteraction<'cached'>; + calls: Call[]; +} + +export function fakeInteraction(opts: FakeInteractionOptions = {}): FakeInteraction { + const calls: Call[] = []; + const values = opts.options ?? {}; + const guildId = opts.guildId ?? 'guild-1'; + + const required = (name: string, req?: boolean): T | null => { + const value = (values[name] ?? null) as T | null; + if (req && value === null) { + throw new Error(`Required option "${name}" missing`); + } + return value; + }; + + const state = { + commandName: opts.commandName ?? 'test', + deferred: opts.deferred ?? false, + replied: opts.replied ?? false, + guildId, + guild: opts.guild ?? fakeGuild(), + member: { id: opts.userId ?? 'user-1' }, + user: { id: opts.userId ?? 'user-1' }, + createdTimestamp: Date.now(), + isChatInputCommand: () => opts.chatInput ?? true, + inCachedGuild: () => opts.inGuild ?? true, + options: { + getSubcommand: (req = true) => { + if (opts.subcommand == null && req) { + throw new Error('No subcommand'); + } + return opts.subcommand ?? null; + }, + getSubcommandGroup: (req = true) => { + if (opts.group == null && req) { + throw new Error('No subcommand group'); + } + return opts.group ?? null; + }, + getChannel: required, + getBoolean: required, + getString: required, + getUser: required, + getRole: required, + getNumber: required, + getInteger: required, + getAttachment: required, + }, + reply: async (payload: SentPayload) => { + calls.push({ method: 'reply', payload }); + state.replied = true; + }, + deferReply: async (payload: SentPayload) => { + calls.push({ method: 'deferReply', payload }); + state.deferred = true; + }, + editReply: async (payload: SentPayload) => { + calls.push({ method: 'editReply', payload }); + state.replied = true; + }, + followUp: async (payload: SentPayload) => { + calls.push({ method: 'followUp', payload }); + }, + }; + + return { interaction: stub>(state), calls }; +} diff --git a/tests/fakes/messages.ts b/tests/fakes/messages.ts new file mode 100644 index 00000000..b3a48064 --- /dev/null +++ b/tests/fakes/messages.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { Collection, type Message, type OmitPartialGroupDMChannel } from 'discord.js'; +import type { App } from '../../src/app.ts'; +import type { DingirClient } from '../../src/client/DingirClient.ts'; +import type { Command } from '../../src/framework/command.ts'; +import interactionCreate from '../../src/events/interactionCreate.ts'; +import { + type EmbedJson, + fakeAuditChannel, + fakeChannelClient, + fakeGuild, + fakeMember, + fakeUser, + type SentPayload, +} from './discord.ts'; +import type { FakeInteraction } from './interaction.ts'; + +export { fakeMember, fakeUser }; + +export interface AuditSink { + client: DingirClient; + sent: SentPayload[]; +} + +export function fakeAuditClient(channelId = 'audit-1', commands: Command[] = []): AuditSink { + const { channel, sent } = fakeAuditChannel(); + const slashCommands = new Collection(commands.map((c) => [c.name, c])); + return { client: fakeChannelClient(channelId, channel, { slashCommands }), sent }; +} + +export const auditJson = (sent: SentPayload[], index = 0): EmbedJson => { + const embed = sent[index]?.embeds?.[0]; + assert.ok(embed, `no embed in sent payload ${index}`); + return embed.toJSON(); +}; + +export const embedField = (embed: EmbedJson, name: string): { name: string; value: string } => { + const field = embed.fields.find((f) => f.name === name); + assert.ok(field, `embed has no "${name}" field`); + return field; +}; + +export function fakeMessage(overrides: Record = {}) { + const author = + (overrides.author as ReturnType | null | undefined) ?? fakeUser('u1'); + const base = { + id: 'm1', + partial: false, + guildId: 'guild-1', + channelId: 'chan-1', + webhookId: null, + system: false, + content: 'hello', + author, + member: fakeMember(author.id, { user: author }), + guild: fakeGuild({ members: { fetch: async () => null } }), + attachments: new Collection(), + embeds: [] as unknown[], + url: 'https://discord.com/channels/guild-1/chan-1/m1', + inGuild() { + return Boolean(this.guildId); + }, + }; + return { ...base, ...overrides } as unknown as OmitPartialGroupDMChannel; +} + +export async function runSlash( + app: App, + client: DingirClient, + fake: FakeInteraction, + extra: Record = {}, +): Promise { + Object.assign(fake.interaction, { client, ...extra }); + await interactionCreate.run(app, client, fake.interaction); +} diff --git a/tests/fakes/onboarding.ts b/tests/fakes/onboarding.ts new file mode 100644 index 00000000..92931c1f --- /dev/null +++ b/tests/fakes/onboarding.ts @@ -0,0 +1,85 @@ +import type { Client, Guild, GuildMember } from 'discord.js'; +import type { ServerConfig } from '../../src/db/schema.ts'; +import { + fakeAuditChannel, + fakeConfig, + fakeGuild, + fakeMember, + type SentPayload, + stub, +} from './discord.ts'; + +export { fakeCommandContext } from './command.ts'; + +export interface FakeRole { + id: string; + name: string; + position: number; + managed: boolean; +} + +export interface FakeOnboardingOptions { + roles?: FakeRole[]; + botPosition?: number; + config?: Partial>; + systemChannel?: boolean; + auditChannel?: boolean; + failRolesAdd?: boolean; + failSystemSend?: boolean; + failAuditSend?: boolean; +} + +export const role = (id: string, position = 1, managed = false): FakeRole => ({ + id, + name: `role-${id}`, + position, + managed, +}); + +export function fakeOnboarding(opts: FakeOnboardingOptions = {}) { + const roleAdds: string[][] = []; + const systemSends: SentPayload[] = []; + + const roles = new Map((opts.roles ?? []).map((r) => [r.id, r])); + const guild: Guild = fakeGuild({ + roles: { cache: roles, fetch: async () => null }, + members: { me: { roles: { highest: { position: opts.botPosition ?? 10 } } } }, + systemChannel: + opts.systemChannel === false + ? null + : { + send: async (payload: unknown) => { + if (opts.failSystemSend) { + throw new Error('cannot send'); + } + systemSends.push(payload as SentPayload); + }, + }, + }); + + const member: GuildMember = fakeMember('member-1', { + displayName: 'Member', + user: stub({ id: 'member-1', tag: 'member#0001' }), + guild, + roles: { + add: async (ids: string[]) => { + if (opts.failRolesAdd) { + throw new Error('missing permissions'); + } + roleAdds.push(ids); + }, + }, + }); + + const { channel, sent: auditSends } = fakeAuditChannel( + opts.failAuditSend ? 'audit down' : undefined, + ); + const client = stub({ channels: { fetch: async () => channel } }); + + const config = fakeConfig({ + auditChannelId: opts.auditChannel === false ? null : 'audit-1', + ...opts.config, + }); + + return { client, member, guild, config, roleAdds, systemSends, auditSends }; +} diff --git a/tests/helpers/app.ts b/tests/helpers/app.ts new file mode 100644 index 00000000..1c0a7099 --- /dev/null +++ b/tests/helpers/app.ts @@ -0,0 +1,72 @@ +import { createApp, type App } from '../../src/app.ts'; +import { loadEnv } from '../../src/config/env.ts'; +import { openDb } from '../../src/db/db.ts'; +import { migrate } from '../../src/db/migrator.ts'; +import type { Logger } from '../../src/utilities/Logger.ts'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; + +export interface LogEntry { + level: LogLevel; + message: string; + context?: Record; + error?: unknown; +} + +export interface TestApp extends App { + /** Every entry written through app.logger, in order. */ + logs: LogEntry[]; + /** Entries at one level, for concise assertions. */ + logsAt(level: LogLevel): LogEntry[]; + close(): void; +} + +export const FIXED_NOW = new Date('2027-01-15T12:00:00Z'); + +export function fakeLogger(): { logger: Logger; logs: LogEntry[] } { + const logs: LogEntry[] = []; + const at = + (level: LogLevel): Logger[LogLevel] => + (message, context, error) => { + logs.push({ level, message, context, error }); + }; + return { + logger: { + debug: at('debug'), + info: at('info'), + warn: at('warn'), + error: at('error'), + fatal: at('fatal'), + }, + logs, + }; +} + +/** An App backed by a fresh in-memory migrated database, a capturing logger and a fixed clock. */ +export function createTestApp(overrides: Partial = {}): TestApp { + const { logger, logs } = fakeLogger(); + const env = + overrides.env ?? + loadEnv({ + TOKEN: 'test-token', + CLIENT_ID: '1', + DB_PATH: ':memory:', + BOT_TIMEZONE: 'Europe/London', + }); + const db = overrides.db ?? openDb(':memory:'); + if (!overrides.db) { + migrate(db.$client, overrides.logger ?? logger); + } + const app = createApp({ + env, + db, + logger, + clock: () => FIXED_NOW, + ...overrides, + }); + return Object.assign(app, { + logs, + logsAt: (level: LogLevel) => logs.filter((entry) => entry.level === level), + close: () => db.$client.close(), + }); +} diff --git a/tests/helpers/assertions.ts b/tests/helpers/assertions.ts new file mode 100644 index 00000000..889c31b7 --- /dev/null +++ b/tests/helpers/assertions.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { UserError } from '../../src/framework/errors.ts'; + +/** Asserts the promise rejects with a UserError whose message matches. */ +export const rejectsUserError = ( + promise: Promise, + message: string | RegExp, +): Promise => + assert.rejects(promise, (error: unknown) => { + assert.ok(error instanceof UserError, `expected UserError, got ${String(error)}`); + if (typeof message === 'string') { + assert.equal(error.message, message); + } else { + assert.match(error.message, message); + } + return true; + }); + +/** Returns the item at `index`, failing the test if the list is shorter. */ +export function nth(items: readonly T[] | undefined, index = 0): T { + const item = items?.[index]; + assert.ok(item !== undefined, `expected an item at index ${index}`); + return item; +} + +/** Returns the last item, failing the test if the list is empty. */ +export const last = (items: readonly T[]): T => nth(items, items.length - 1); + +/** Returns a payload's text content, failing the test if it has none. */ +export function contentOf(payload: { content?: string | null }): string { + assert.equal(typeof payload.content, 'string', 'expected payload content'); + return payload.content as string; +} + +/** The subset of an application command option's JSON that tests inspect. */ +export interface OptionJson { + name: string; + required?: boolean; + max_length?: number; + channel_types?: number[]; + options?: OptionJson[]; +} + +/** Finds a named option in a command's (or option's) JSON, failing the test if absent. */ +export function optionNamed(parent: { options?: unknown }, name: string): OptionJson { + const found = ((parent.options ?? []) as OptionJson[]).find((o) => o.name === name); + assert.ok(found, `no option named "${name}"`); + return found; +} + +/** The first top-level option of a command's JSON. */ +export const firstOption = (json: { options?: unknown }): OptionJson => + nth(json.options as OptionJson[] | undefined); diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts new file mode 100644 index 00000000..ed86b234 --- /dev/null +++ b/tests/helpers/db.ts @@ -0,0 +1,49 @@ +import { eq } from 'drizzle-orm'; +import type { ConfigDeps } from '../../src/services/ConfigService.ts'; +import { + serverConfigs, + userProfiles, + type ServerConfig, + type UserProfile, +} from '../../src/db/schema.ts'; + +type ConfigInsert = typeof serverConfigs.$inferInsert; +type ProfileInsert = typeof userProfiles.$inferInsert; + +/** + * Direct table access for arranging and asserting database state in tests. + * Config mutations bypass ConfigService, so they also reset the config cache. + */ +export function dbFixtures({ db, configCache }: ConfigDeps) { + const allProfiles = (): UserProfile[] => db.select().from(userProfiles).all(); + return { + clearConfigs: (): void => { + db.delete(serverConfigs).run(); + configCache.clear(); + }, + clearProfiles: (): void => { + db.delete(userProfiles).run(); + }, + createConfig: (values: ConfigInsert): ServerConfig => { + configCache.delete(values.serverId); + return db.insert(serverConfigs).values(values).returning().get(); + }, + createConfigs: (values: ConfigInsert[]): void => { + db.insert(serverConfigs).values(values).run(); + configCache.clear(); + }, + findConfig: (serverId: string): ServerConfig | null => + db.select().from(serverConfigs).where(eq(serverConfigs.serverId, serverId)).get() ?? null, + createProfiles: (values: ProfileInsert[]): void => { + db.insert(userProfiles).values(values).run(); + }, + allProfiles, + countProfiles: (): number => allProfiles().length, + findProfile: (userId: string, serverId?: string): UserProfile | null => + allProfiles().find( + (p) => p.userId === userId && (serverId === undefined || p.serverId === serverId), + ) ?? null, + }; +} + +export type DbFixtures = ReturnType; diff --git a/tests/logger.test.ts b/tests/logger.test.ts new file mode 100644 index 00000000..c36fdcad --- /dev/null +++ b/tests/logger.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createConsoleLogger, parseLogLevel, type LogLevel } from '../src/utilities/Logger.ts'; + +const capture = (level?: LogLevel) => { + const lines: string[] = []; + const logger = createConsoleLogger({ + level, + destination: { + write: (line: string) => { + lines.push(line); + }, + }, + }); + return { + logger, + entries: () => lines.map((line) => JSON.parse(line) as Record), + }; +}; + +test('writes JSON lines with level, message and merged context', () => { + const { logger, entries } = capture(); + logger.info('hello', { guild: '1' }); + logger.warn('careful'); + logger.fatal('dead'); + + const [info, warn, fatal] = entries(); + assert.equal(info?.msg, 'hello'); + assert.equal(info?.guild, '1'); + assert.equal(info?.level, 30); + assert.equal(typeof info?.time, 'string'); + assert.equal(warn?.msg, 'careful'); + assert.equal(warn?.level, 40); + assert.equal(fatal?.level, 60); +}); + +test('serialises errors under err with a stack', () => { + const { logger, entries } = capture(); + logger.error('bad', { event: 'x' }, new Error('nope')); + logger.error('plain failure', undefined, 'just a string'); + + const [first, second] = entries(); + const err = first?.err as { message: string; stack: string }; + assert.equal(first?.event, 'x'); + assert.equal(err.message, 'nope'); + assert.match(err.stack, /Error: nope/); + assert.equal(second?.err, 'just a string'); +}); + +test('respects the configured level', () => { + const { logger, entries } = capture('warn'); + logger.debug('hidden'); + logger.info('hidden'); + logger.warn('shown'); + assert.deepEqual( + entries().map((e) => e.msg), + ['shown'], + ); +}); + +test('defaults to info', () => { + const { logger, entries } = capture(); + logger.debug('hidden'); + logger.info('shown'); + assert.equal(entries().length, 1); +}); + +test('parseLogLevel accepts only known levels', () => { + assert.equal(parseLogLevel('trace'), 'trace'); + assert.equal(parseLogLevel('bogus'), undefined); + assert.equal(parseLogLevel(undefined), undefined); +}); diff --git a/tests/moderation/events.test.ts b/tests/moderation/events.test.ts new file mode 100644 index 00000000..c2cb7566 --- /dev/null +++ b/tests/moderation/events.test.ts @@ -0,0 +1,200 @@ +import assert from 'node:assert/strict'; +import { after, beforeEach, afterEach, test } from 'node:test'; +import { Collection, type PartialGuildMember } from 'discord.js'; +import guildMemberRemove from '../../src/events/guildMemberRemove.ts'; +import messageCreate from '../../src/events/messageCreate.ts'; +import messageDelete from '../../src/events/messageDelete.ts'; +import messageUpdate from '../../src/events/messageUpdate.ts'; +import { updateConfig } from '../../src/services/ConfigService.ts'; +import { incrementActivityScore } from '../../src/services/UserProfileService.ts'; +import { createTestApp } from '../helpers/app.ts'; +import { dbFixtures } from '../helpers/db.ts'; +import { + auditJson, + embedField, + fakeAuditClient, + fakeMember, + fakeMessage, + fakeUser, +} from '../fakes/messages.ts'; +import { nth } from '../helpers/assertions.ts'; + +const app = createTestApp(); +const { clearProfiles, findProfile } = dbFixtures(app); + +after(() => { + app.close(); +}); + +beforeEach(async () => { + await updateConfig(app, 'guild-1', { auditChannelId: 'audit-1', honeyPotChannelId: 'honey-1' }); +}); + +afterEach(() => { + app.honeypot.cancel('guild-1', 'u1'); + clearProfiles(); +}); + +const score = async (): Promise => + findProfile('u1', 'guild-1')?.activityScore ?? null; + +test('honeypot message bans the member and does not count activity', async () => { + const sink = fakeAuditClient(); + let banned: unknown = null; + const message = fakeMessage({ + channelId: 'honey-1', + member: fakeMember('u1', { + ban: async (opts: unknown) => { + banned = opts; + }, + }), + }); + await messageCreate.run(app, sink.client, message); + assert.ok(banned); + assert.equal(await score(), null); + assert.equal(sink.sent.length, 1); + assert.equal(auditJson(sink.sent).description, 'Honey-pot triggered'); +}); + +test('normal messages increment activity once', async () => { + const sink = fakeAuditClient(); + await messageCreate.run(app, sink.client, fakeMessage()); + await messageCreate.run(app, sink.client, fakeMessage()); + assert.equal(await score(), 2); + assert.equal(sink.sent.length, 0); +}); + +test('bot, webhook, system and DM messages do not count', async () => { + const sink = fakeAuditClient(); + await messageCreate.run(app, sink.client, fakeMessage({ author: fakeUser('u1', { bot: true }) })); + await messageCreate.run(app, sink.client, fakeMessage({ webhookId: 'w1' })); + await messageCreate.run(app, sink.client, fakeMessage({ system: true })); + await messageCreate.run(app, sink.client, fakeMessage({ guildId: null })); + assert.equal(await score(), null); +}); + +test('messageDelete never changes the activity score', async () => { + const sink = fakeAuditClient(); + await incrementActivityScore(app.db, 'guild-1', 'u1'); + await messageDelete.run(app, sink.client, fakeMessage()); + assert.equal(await score(), 1); + assert.equal(sink.sent.length, 1); +}); + +test('messageDelete audits uncached messages without content', async () => { + const sink = fakeAuditClient(); + const message = fakeMessage({ partial: true, author: null, id: 'm42', channelId: 'chan-7' }); + await messageDelete.run(app, sink.client, message); + const embed = auditJson(sink.sent); + assert.match(embed.description ?? '', /not cached/); + assert.deepEqual( + embed.fields.map((f) => f.value), + ['<#chan-7>', 'm42'], + ); +}); + +test('messageDelete handles very long content and attachments', async () => { + const sink = fakeAuditClient(); + const message = fakeMessage({ + content: 'x'.repeat(5000), + attachments: new Collection([ + ['a', { name: 'a.png' }], + ['b', { name: 'b.png' }], + ]), + embeds: [{}], + }); + await messageDelete.run(app, sink.client, message); + const embed = auditJson(sink.sent); + assert.ok(embedField(embed, 'Message').value.length <= 1024); + assert.equal(embedField(embed, 'Attachments').value, '2: a.png, b.png'); + assert.equal(embedField(embed, 'Embeds').value, '1'); +}); + +test('messageDelete skips bots and active honeypot enforcement', async () => { + const sink = fakeAuditClient(); + await messageDelete.run(app, sink.client, fakeMessage({ author: fakeUser('u1', { bot: true }) })); + app.honeypot.begin('guild-1', 'u1'); + await messageDelete.run(app, sink.client, fakeMessage()); + assert.equal(sink.sent.length, 0); +}); + +test('messageUpdate notes an uncached previous message', async () => { + const sink = fakeAuditClient(); + await messageUpdate.run( + app, + sink.client, + fakeMessage({ partial: true, content: null }), + fakeMessage({ content: 'edited' }), + ); + const embed = auditJson(sink.sent); + assert.equal(embedField(embed, 'Previous').value, '*(not cached)*'); + embedField(embed, 'Jump to message'); + embedField(embed, 'Channel'); +}); + +test('messageUpdate fetches partial new messages and returns when fetching fails', async () => { + const sink = fakeAuditClient(); + const failing = fakeMessage({ + partial: true, + fetch: async () => { + throw new Error('Unknown Message'); + }, + }); + await messageUpdate.run(app, sink.client, fakeMessage({ content: 'old' }), failing); + assert.equal(sink.sent.length, 0); + + const fetched = fakeMessage({ content: 'new' }); + const partial = fakeMessage({ partial: true, fetch: async () => fetched }); + await messageUpdate.run(app, sink.client, fakeMessage({ content: 'old' }), partial); + assert.equal(sink.sent.length, 1); +}); + +test('messageUpdate skips unchanged content and bots', async () => { + const sink = fakeAuditClient(); + await messageUpdate.run(app, sink.client, fakeMessage(), fakeMessage()); + await messageUpdate.run( + app, + sink.client, + fakeMessage({ content: 'a' }), + fakeMessage({ content: 'b', author: fakeUser('u1', { bot: true }) }), + ); + assert.equal(sink.sent.length, 0); +}); + +test('messageUpdate truncates long content', async () => { + const sink = fakeAuditClient(); + await messageUpdate.run( + app, + sink.client, + fakeMessage({ content: 'a'.repeat(4000) }), + fakeMessage({ content: 'b'.repeat(4000) }), + ); + assert.equal(sink.sent.length, 1); + assert.ok(auditJson(sink.sent).fields.every((f) => f.value.length <= 1024)); +}); + +test('guildMemberRemove handles partial members and deletes profile data', async () => { + const sink = fakeAuditClient(); + await incrementActivityScore(app.db, 'guild-1', 'u1'); + const partial = { + partial: true, + guild: { id: 'guild-1' }, + user: fakeUser('u1'), + } as unknown as PartialGuildMember; + await guildMemberRemove.run(app, sink.client, partial); + assert.equal(await score(), null); + const embed = auditJson(sink.sent); + assert.equal(embed.description, 'Member left'); + assert.equal(embed.author?.name, 'useru1'); + assert.equal(nth(embed.fields, 1).value, 'Deleted'); +}); + +test('guildMemberRemove during honeypot enforcement only deletes data', async () => { + const sink = fakeAuditClient(); + await incrementActivityScore(app.db, 'guild-1', 'u1'); + app.honeypot.begin('guild-1', 'u1'); + const member = fakeMember('u1', { guild: { id: 'guild-1' } }); + await guildMemberRemove.run(app, sink.client, member); + assert.equal(await score(), null); + assert.equal(sink.sent.length, 0); +}); diff --git a/tests/moderation/memberListing.test.ts b/tests/moderation/memberListing.test.ts new file mode 100644 index 00000000..c287d7e0 --- /dev/null +++ b/tests/moderation/memberListing.test.ts @@ -0,0 +1,153 @@ +import assert from 'node:assert/strict'; +import { after, test } from 'node:test'; +import { Collection } from 'discord.js'; +import noroles from '../../src/commands/admin/noroles.ts'; +import rolesince from '../../src/commands/admin/rolesince.ts'; +import { buildMemberListing, INLINE_LIMIT } from '../../src/services/MemberListing.ts'; +import { fetchAllMembers } from '../../src/services/MemberResolver.ts'; +import { UserError } from '../../src/framework/errors.ts'; +import { createTestApp, FIXED_NOW } from '../helpers/app.ts'; +import { fakeInteraction } from '../fakes/interaction.ts'; +import { fakeAuditClient, fakeMember, fakeUser, runSlash } from '../fakes/messages.ts'; +import { last, nth } from '../helpers/assertions.ts'; + +const app = createTestApp(); +after(() => { + app.close(); +}); + +const DAY = 24 * 60 * 60 * 1000; +const members = (count: number, joined: number | null = Date.now()) => + Array.from({ length: count }, (_, i) => + fakeMember(String(100000000000000000 + i), { joinedTimestamp: joined }), + ); + +test('small listings reply inline without mentions', () => { + const result = buildMemberListing('**Header**', 'none', members(3)); + assert.ok(result.content!.length <= INLINE_LIMIT); + assert.equal(result.files, undefined); + assert.deepEqual(result.allowedMentions, { parse: [] }); + assert.match(result.content!, /joined /); +}); + +test('large listings attach a text file with a summary line', () => { + const result = buildMemberListing('**Header**', 'none', members(200, Date.UTC(2024, 0, 15))); + assert.ok(result.content!.length < INLINE_LIMIT); + assert.match(result.content!, /200 members/); + assert.equal(result.files!.length, 1); + const file = nth(result.files as unknown[] | undefined) as { name: string; attachment: Buffer }; + assert.equal(file.name, 'members.txt'); + const text = file.attachment.toString('utf8'); + assert.equal(text.split('\n').length, 200); + assert.match(text, /^user\d+ \(\d+\) — joined 2024-01-15/); +}); + +test('null join dates are described as unknown', () => { + const result = buildMemberListing('**Header**', 'none', members(1, null)); + assert.match(result.content!, /join date unknown/); + const big = buildMemberListing('**Header**', 'none', members(200, null)); + assert.match( + (nth(big.files as unknown[] | undefined) as { attachment: Buffer }).attachment.toString('utf8'), + /join date unknown/, + ); +}); + +test('empty listing returns the empty message', () => { + assert.equal(buildMemberListing('h', 'nobody', []).content, 'nobody'); +}); + +test('rolesince filters by days in server and excludes bots', async () => { + const now = FIXED_NOW.getTime(); + const old = fakeMember('1', { joinedTimestamp: now - 40 * DAY }); + const recent = fakeMember('2', { joinedTimestamp: now - 2 * DAY }); + const bot = fakeMember('3', { + joinedTimestamp: now - 90 * DAY, + user: fakeUser('3', { bot: true }), + }); + const role = { + toString: () => '<@&r1>', + members: new Collection([old, recent, bot].map((m) => [m.id, m])), + }; + let fetched = false; + const guild = { id: 'guild-1', members: { fetch: async () => void (fetched = true) } }; + const sink = fakeAuditClient('a', [rolesince]); + + const fake = fakeInteraction({ commandName: 'rolesince', options: { role }, guild }); + Object.assign(fake.interaction.options, { getInteger: () => 30 }); + await runSlash(app, sink.client, fake); + assert.equal(fetched, true); + assert.equal(nth(fake.calls).method, 'deferReply'); + const content = last(fake.calls).payload.content as string; + assert.match(content, /<@1>/); + assert.doesNotMatch(content, /<@2>/); + assert.doesNotMatch(content, /<@3>/); + assert.match(content, /at least 30 days ago/); + + const all = fakeInteraction({ commandName: 'rolesince', options: { role }, guild }); + Object.assign(all.interaction.options, { getInteger: () => null }); + await runSlash(app, sink.client, all); + const everyone = all.calls.at(-1)!.payload.content as string; + assert.match(everyone, /<@1>/); + assert.match(everyone, /<@2>/); +}); + +test('noroles lists only non-bot members with just the everyone role', async () => { + const plain = fakeMember('1'); + const withRole = fakeMember('2', { + roles: { + cache: new Collection([ + ['e', {}], + ['r', {}], + ]), + }, + }); + const bot = fakeMember('3', { user: fakeUser('3', { bot: true }) }); + const guild = { + id: 'guild-1', + members: { fetch: async () => new Collection([plain, withRole, bot].map((m) => [m.id, m])) }, + }; + const sink = fakeAuditClient('a', [noroles]); + const fake = fakeInteraction({ commandName: 'noroles', guild }); + await runSlash(app, sink.client, fake); + assert.equal(nth(fake.calls).method, 'deferReply'); + const content = last(fake.calls).payload.content as string; + assert.match(content, /<@1>/); + assert.doesNotMatch(content, /<@2>/); + assert.doesNotMatch(content, /<@3>/); +}); + +const rateLimitError = (retryAfter?: number) => + Object.assign(new Error('rate limited'), { + name: 'GatewayRateLimitError', + data: { opcode: 8, retry_after: retryAfter }, + }); +const guildFetching = (error: Error) => ({ members: { fetch: async () => Promise.reject(error) } }); + +test('fetchAllMembers turns a gateway rate limit into a UserError with whole seconds', async () => { + await assert.rejects( + fetchAllMembers(guildFetching(rateLimitError(2.3)) as never), + (error: unknown) => + error instanceof UserError && + /rate-limiting member lookups.*in 3 seconds\./.test(error.message), + ); + await assert.rejects( + fetchAllMembers(guildFetching(rateLimitError()) as never), + (error: unknown) => error instanceof UserError && /a few seconds/.test(error.message), + ); +}); + +test('fetchAllMembers propagates other errors unchanged', async () => { + const boom = new Error('boom'); + await assert.rejects(fetchAllMembers(guildFetching(boom) as never), (error) => error === boom); +}); + +test('noroles replies with the rate-limit message', async () => { + const guild = { + id: 'guild-1', + members: { fetch: async () => Promise.reject(rateLimitError(5)) }, + }; + const sink = fakeAuditClient('a', [noroles]); + const fake = fakeInteraction({ commandName: 'noroles', guild }); + await runSlash(app, sink.client, fake); + assert.match(last(fake.calls).payload.content as string, /try again in 5 seconds/); +}); diff --git a/tests/moderation/post.test.ts b/tests/moderation/post.test.ts new file mode 100644 index 00000000..82cf2e0e --- /dev/null +++ b/tests/moderation/post.test.ts @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import { after, before, test } from 'node:test'; +import { Collection, PermissionFlagsBits } from 'discord.js'; +import post from '../../src/commands/admin/post.ts'; +import { updateConfig } from '../../src/services/ConfigService.ts'; +import { createTestApp } from '../helpers/app.ts'; +import { fakeInteraction } from '../fakes/interaction.ts'; +import { auditJson, fakeAuditClient, fakeMember, runSlash } from '../fakes/messages.ts'; +import { contentOf, last, nth, optionNamed } from '../helpers/assertions.ts'; + +const app = createTestApp(); + +before(async () => { + await updateConfig(app, 'guild-1', { auditChannelId: 'audit-1' }); +}); + +after(() => { + app.close(); +}); + +const target = (send: () => Promise, granted: bigint[]) => ({ + id: 'chan-9', + isTextBased: () => true, + isDMBased: () => false, + isSendable: () => true, + toString: () => '<#chan-9>', + permissionsFor: () => ({ has: (flag: bigint) => granted.includes(flag) }), + send, +}); + +const allPerms = [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.AttachFiles, +]; + +const setup = (channel: ReturnType, options: Record) => { + const guild = { + id: 'guild-1', + members: { me: {} }, + client: { user: {} }, + channels: { + cache: new Collection([[channel.id, channel]]), + fetch: async () => channel, + }, + }; + const fake = fakeInteraction({ + commandName: 'post', + options: { channel: { id: channel.id }, ...options }, + guild, + }); + return fake; +}; + +test('send failure replies with an error and neither succeeds nor audits', async () => { + const sink = fakeAuditClient('audit-1', [post]); + const fake = setup( + target(async () => { + throw new Error('boom'); + }, allPerms), + { content: 'hi' }, + ); + await runSlash(app, sink.client, fake, { member: fakeMember('user-1') }); + const reply = last(fake.calls); + assert.equal(reply.method, 'editReply'); + assert.match(contentOf(reply.payload), /error was encountered/); + assert.equal(sink.sent.length, 0); +}); + +test('success audits then replies with the jump link', async () => { + const sink = fakeAuditClient('audit-1', [post]); + const sentPayloads: number[] = []; + const fake = setup( + target(async () => { + sentPayloads.push(1); + return { url: 'https://discord.com/channels/g/c/m' }; + }, allPerms), + { content: 'hello world', attachment: { name: 'pic.png' } }, + ); + await runSlash(app, sink.client, fake, { member: fakeMember('user-1') }); + assert.equal(sentPayloads.length, 1); + assert.equal(sink.sent.length, 1); + const embed = auditJson(sink.sent); + const names = embed.fields.map((f) => f.name); + assert.deepEqual(names, ['Channel', 'Content', 'Attachment', 'Message']); + assert.equal(nth(embed.fields, 2).value, 'pic.png'); + assert.equal( + contentOf(last(fake.calls).payload), + 'Posted in <#chan-9>: https://discord.com/channels/g/c/m', + ); +}); + +test('missing permission replies clearly and does not send', async () => { + const sink = fakeAuditClient('audit-1', [post]); + let sends = 0; + const fake = setup( + target(async () => { + sends += 1; + return { url: 'x' }; + }, [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages]), + { attachment: { name: 'pic.png' } }, + ); + await runSlash(app, sink.client, fake, { member: fakeMember('user-1') }); + assert.equal(sends, 0); + assert.equal(sink.sent.length, 0); + assert.match(contentOf(last(fake.calls).payload), /AttachFiles/); +}); + +test('requires content or attachment and limits content length', async () => { + const sink = fakeAuditClient('audit-1', [post]); + const fake = setup( + target(async () => ({ url: 'x' }), allPerms), + {}, + ); + await runSlash(app, sink.client, fake, { member: fakeMember('user-1') }); + assert.match(contentOf(last(fake.calls).payload), /at least text or an attachment/); + + assert.equal(optionNamed(post.toJSON(), 'content').max_length, 2000); + assert.deepEqual(optionNamed(post.toJSON(), 'channel').channel_types, [0, 5]); +}); diff --git a/tests/onboarding/OnboardingService.test.ts b/tests/onboarding/OnboardingService.test.ts new file mode 100644 index 00000000..be6ae625 --- /dev/null +++ b/tests/onboarding/OnboardingService.test.ts @@ -0,0 +1,283 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { AttachmentBuilder, type GuildMember } from 'discord.js'; +import { + complete, + completedScreening, + formatOnboardingSummary, + parseRoleIds, +} from '../../src/services/OnboardingService.ts'; +import { fakeOnboarding, role } from '../fakes/onboarding.ts'; +import { createTestApp } from '../helpers/app.ts'; +import { nth } from '../helpers/assertions.ts'; + +const app = createTestApp(); + +const okImage = async () => new AttachmentBuilder(Buffer.from('x')).setName('welcome-image.png'); +const failImage = async () => { + throw new Error('bad image'); +}; + +test('parseRoleIds trims and drops empties', () => { + assert.deepEqual(parseRoleIds(' a, b ,,c'), ['a', 'b', 'c']); + assert.deepEqual(parseRoleIds(null), []); +}); + +test('completedScreening only for pending true to false', () => { + const m = (pending: boolean | null | undefined) => ({ pending }) as unknown as GuildMember; + assert.equal(completedScreening(m(true), m(false)), true); + assert.equal(completedScreening(m(false), m(false)), false); + assert.equal(completedScreening(m(true), m(true)), false); + assert.equal(completedScreening(m(null), m(false)), false); + assert.equal(completedScreening(m(undefined), m(false)), false); +}); + +test('unconfigured server: audit done, roles and welcome skipped, no debug audit', async () => { + const env = fakeOnboarding(); + const result = await complete( + app, + env.client, + env.member, + env.config, + {}, + { renderImage: okImage }, + ); + assert.equal(result.audit, 'done'); + assert.match(result.roles, /^skipped:/); + assert.match(result.welcome, /^skipped:/); + assert.equal(env.auditSends.length, 1); + assert.match(JSON.stringify(nth(nth(env.auditSends).embeds)), /completed onboarding/); +}); + +test('guest roles are added in one call and unassignable ones filtered', async () => { + const env = fakeOnboarding({ + roles: [ + role('ok1', 1), + role('ok2', 2), + role('managed', 1, true), + role('high', 10), + role('guild-1', 0), + ], + config: { guestRoleIds: 'ok1,ok2,managed,high,guild-1,gone' }, + }); + const result = await complete( + app, + env.client, + env.member, + env.config, + {}, + { renderImage: okImage }, + ); + assert.equal(result.roles, 'done'); + assert.deepEqual(env.roleAdds, [['ok1', 'ok2']]); + assert.deepEqual(result.rolesSkipped.map((s) => s.id).sort(), [ + 'gone', + 'guild-1', + 'high', + 'managed', + ]); +}); + +test('no assignable roles skips the roles step without calling add', async () => { + const env = fakeOnboarding({ + roles: [role('high', 10)], + config: { guestRoleIds: 'high' }, + }); + const result = await complete(app, env.client, env.member, env.config); + assert.match(result.roles, /^skipped:/); + assert.equal(env.roleAdds.length, 0); +}); + +test('a failing roles step is audited and does not stop the welcome step', async () => { + const env = fakeOnboarding({ + roles: [role('r1')], + failRolesAdd: true, + config: { + guestRoleIds: 'r1', + systemMessagesEnabled: true, + welcomeMessage: 'Hi {member}', + }, + }); + const result = await complete(app, env.client, env.member, env.config); + assert.match(result.roles, /^failed:missing permissions/); + assert.equal(result.welcome, 'done'); + assert.equal(env.systemSends.length, 1); + assert.ok(env.auditSends.some((a) => /guest role/.test(JSON.stringify(a.embeds?.[0])))); +}); + +test('audit failure does not stop later steps', async () => { + const env = fakeOnboarding({ + roles: [role('r1')], + failAuditSend: true, + config: { guestRoleIds: 'r1' }, + }); + const result = await complete(app, env.client, env.member, env.config); + assert.match(result.audit, /^skipped:/); + assert.equal(result.roles, 'done'); +}); + +test('welcome replaces every {member} and does not ping others', async () => { + const env = fakeOnboarding({ + config: { + systemMessagesEnabled: true, + welcomeMessage: '{member} hello {member}!', + }, + }); + const result = await complete(app, env.client, env.member, env.config); + assert.equal(result.welcome, 'done'); + assert.equal(nth(env.systemSends).content, '<@member-1> hello <@member-1>!'); + assert.deepEqual(nth(env.systemSends).allowedMentions, { + users: ['member-1'], + }); +}); + +test('welcome skip reasons', async () => { + const disabled = fakeOnboarding({ config: { welcomeMessage: 'hi' } }); + assert.match( + (await complete(app, disabled.client, disabled.member, disabled.config)).welcome, + /disabled/, + ); + + const empty = fakeOnboarding({ config: { systemMessagesEnabled: true } }); + assert.match( + (await complete(app, empty.client, empty.member, empty.config)).welcome, + /^skipped:/, + ); + + const noChannel = fakeOnboarding({ + systemChannel: false, + config: { systemMessagesEnabled: true, welcomeMessage: 'hi' }, + }); + assert.match( + (await complete(app, noChannel.client, noChannel.member, noChannel.config)).welcome, + /no system channel/, + ); +}); + +test('image failure still sends text and audits the image failure', async () => { + const env = fakeOnboarding({ + config: { + systemMessagesEnabled: true, + welcomeMessage: 'hi {member}', + welcomeMessageBackgroundUrl: 'https://example.com/bg.png', + }, + }); + const result = await complete( + app, + env.client, + env.member, + env.config, + {}, + { renderImage: failImage }, + ); + assert.equal(result.welcome, 'done'); + assert.equal(env.systemSends.length, 1); + assert.equal(nth(env.systemSends).files, undefined); + assert.ok(env.auditSends.some((a) => /welcome image/.test(JSON.stringify(a.embeds?.[0])))); +}); + +test('image failure with no text fails the welcome step', async () => { + const env = fakeOnboarding({ + config: { + systemMessagesEnabled: true, + welcomeMessageBackgroundUrl: 'https://example.com/bg.png', + }, + }); + const result = await complete( + app, + env.client, + env.member, + env.config, + {}, + { renderImage: failImage }, + ); + assert.match(result.welcome, /^failed:/); + assert.equal(env.systemSends.length, 0); +}); + +test('welcome send failure is audited and reported', async () => { + const env = fakeOnboarding({ + failSystemSend: true, + config: { systemMessagesEnabled: true, welcomeMessage: 'hi' }, + }); + const result = await complete(app, env.client, env.member, env.config); + assert.match(result.welcome, /^failed:cannot send/); + assert.ok(env.auditSends.some((a) => /welcome message/.test(JSON.stringify(a.embeds?.[0])))); +}); + +test('dryRun makes no role, send or audit calls and returns the payload', async () => { + const env = fakeOnboarding({ + roles: [role('r1')], + config: { + guestRoleIds: 'r1', + debug: true, + systemMessagesEnabled: true, + welcomeMessage: 'hi {member}', + welcomeMessageBackgroundUrl: 'https://example.com/bg.png', + }, + }); + const result = await complete( + app, + env.client, + env.member, + env.config, + { dryRun: true }, + { renderImage: okImage }, + ); + assert.equal(env.roleAdds.length, 0); + assert.equal(env.systemSends.length, 0); + assert.equal(env.auditSends.length, 0); + assert.deepEqual(result.rolesAdded, ['r1']); + assert.equal(result.welcomePayload?.content, 'hi <@member-1>'); + assert.ok(result.welcomePayload?.image); + assert.match(formatOnboardingSummary(result), /Would add/); +}); + +test('debug summary audit is sent only when debug is enabled', async () => { + const off = fakeOnboarding({ + roles: [role('r1')], + config: { guestRoleIds: 'r1' }, + }); + await complete(app, off.client, off.member, off.config); + assert.equal(off.auditSends.length, 1); + + const on = fakeOnboarding({ config: { debug: true } }); + const result = await complete(app, on.client, on.member, on.config); + assert.equal(result.debug, 'done'); + assert.equal(on.auditSends.length, 2); + const summary = JSON.stringify(nth(nth(on.auditSends, 1).embeds)); + assert.match(summary, /diagnostics/); + assert.match(summary, /skipped:no guest roles configured/); +}); + +test('skipAudit omits the completion audit but still lists the step', async () => { + const env = fakeOnboarding({ config: { debug: true } }); + const result = await complete( + app, + env.client, + env.member, + env.config, + { skipAudit: true }, + { renderImage: okImage }, + ); + assert.equal(result.audit, 'skipped:already audited on join'); + assert.equal(env.auditSends.length, 1); + const summary = JSON.stringify(nth(env.auditSends).embeds); + assert.match(summary, /Onboarding diagnostics/); + assert.match(summary, /Audit: skipped:already audited on join/); +}); + +test('a legacy welcome template that expands past 2000 characters is truncated, not failed', async () => { + const env = fakeOnboarding({ + config: { + systemMessagesEnabled: true, + welcomeMessage: '{member}'.repeat(250), + }, + }); + const result = await complete(app, env.client, env.member, env.config); + assert.equal(result.welcome, 'done'); + const content = nth(env.systemSends).content ?? ''; + assert.equal(content.length, 2000); + assert.ok(content.endsWith('…')); + assert.ok(app.logsAt('warn').some((entry) => /truncating/.test(entry.message))); +}); diff --git a/tests/onboarding/WelcomeImage.test.ts b/tests/onboarding/WelcomeImage.test.ts new file mode 100644 index 00000000..7fc1dd74 --- /dev/null +++ b/tests/onboarding/WelcomeImage.test.ts @@ -0,0 +1,173 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { test } from 'node:test'; +import { createCanvas, loadImage } from '@napi-rs/canvas'; +import { render } from '../../src/services/WelcomeImage.ts'; +import { fakeMember } from '../fakes/discord.ts'; +import { createTestApp } from '../helpers/app.ts'; + +test('WelcomeImage.render draws avatar with translucent backdrop and thin ring', async (_t) => { + const app = createTestApp(); + + // Create a background canvas (blue) + const bgCanvas = createCanvas(700, 250); + const bgCtx = bgCanvas.getContext('2d'); + bgCtx.fillStyle = '#0000ff'; + bgCtx.fillRect(0, 0, 700, 250); + const bgBuffer = await bgCanvas.encode('png'); + + // Create a transparent avatar canvas (256x256, fully transparent) + const avatarCanvas = createCanvas(256, 256); + const avatarCtx = avatarCanvas.getContext('2d'); + avatarCtx.clearRect(0, 0, 256, 256); + const avatarBuffer = await avatarCanvas.encode('png'); + + // Start an HTTP server to serve the images + const server = createServer((req, res) => { + if (req.url === '/background') { + res.writeHead(200, { 'Content-Type': 'image/png' }); + res.end(bgBuffer); + } else if (req.url === '/avatar') { + res.writeHead(200, { 'Content-Type': 'image/png' }); + res.end(avatarBuffer); + } else { + res.writeHead(404); + res.end(); + } + }); + + try { + const { bgUrl, avatarUrl } = await new Promise<{ bgUrl: string; avatarUrl: string }>( + (resolve, reject) => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + if (addr && typeof addr === 'object' && addr.port) { + const port = addr.port; + resolve({ + bgUrl: `http://127.0.0.1:${port}/background`, + avatarUrl: `http://127.0.0.1:${port}/avatar`, + }); + } else { + reject(new Error('Server did not bind to a port')); + } + }); + server.on('error', reject); + }, + ); + + // Create a fake member with displayAvatarURL returning our test avatar + const member = fakeMember('test-user', { + displayName: 'Test User', + joinedTimestamp: Date.now(), + guild: { + name: 'Test Guild', + }, + displayAvatarURL: () => avatarUrl, + }); + + // Render the welcome image + const attachment = await render(app, member, bgUrl); + const buffer = attachment.attachment as Buffer; + + // Decode the rendered image + const canvas = createCanvas(700, 250); + const ctx = canvas.getContext('2d'); + const image = await loadImage(buffer); + ctx.drawImage(image, 0, 0); + + // Sample and verify pixels + const cx = 125; + const cy = 125; + + // Pixel at avatar center (125, 125): should be backdrop blended over blue + // backdrop = rgba(0, 0, 0, 0.4), blue = rgb(0, 0, 255) + // Result: approximately r=0, g=0, b≈153 (255 * 0.6) + const centerData = ctx.getImageData(cx, cy, 1, 1).data as Uint8ClampedArray; + assert.ok( + centerData[2]! < 200 && centerData[2]! > 100, + `Center pixel blue channel should be ~153 (darkened blue), got ${centerData[2]}`, + ); + + // Pixel at (125 + 77, 125): middle of ring, should be light + const ringData = ctx.getImageData(cx + 77, cy, 1, 1).data as Uint8ClampedArray; + assert.ok( + ringData[0]! > 150 && ringData[1]! > 150, + `Ring pixel should be light (r > 150, g > 150), got r=${ringData[0]}, g=${ringData[1]}`, + ); + + // Pixel at (125 + 73, 125): just inside ring, should not be light + const insideData = ctx.getImageData(cx + 73, cy, 1, 1).data as Uint8ClampedArray; + assert.ok(insideData[0]! < 100, `Inside ring pixel r should be < 100, got ${insideData[0]}`); + + // Pixel at (125 + 83, 125): just outside ring, should be blue background + const outsideData = ctx.getImageData(cx + 83, cy, 1, 1).data as Uint8ClampedArray; + assert.ok( + outsideData[2]! > 230 && outsideData[0]! < 30, + `Outside ring pixel should be blue (b > 230, r < 30), got b=${outsideData[2]}, r=${outsideData[0]}`, + ); + } finally { + await new Promise((resolve) => { + server.close(() => { + resolve(); + }); + }); + app.close(); + } +}); + +const serve = async (handler: Parameters[1]) => { + const server = createServer(handler); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as { port: number }; + return { + url: `http://127.0.0.1:${port}/img`, + close: () => { + server.closeAllConnections(); + server.close(); + }, + }; +}; + +const renderFrom = (url: string) => + render( + createTestApp(), + fakeMember('u', { displayName: 'U', joinedTimestamp: Date.now(), guild: { name: 'G' } }), + url, + ); + +test('render rejects a streamed image over 10 MB without content-length', async () => { + let written = 0; + const srv = await serve((_req, res) => { + res.writeHead(200, { 'Content-Type': 'image/png' }); + const chunk = Buffer.alloc(1024 * 1024); + const timer = setInterval(() => { + if (res.destroyed || written > 200 * 1024 * 1024) { + clearInterval(timer); + return; + } + written += chunk.length; + res.write(chunk); + }, 1); + res.on('close', () => clearInterval(timer)); + }); + try { + const started = Date.now(); + await assert.rejects(renderFrom(srv.url), /larger than 10 MB/); + assert.ok(Date.now() - started < 5000); + assert.ok(written < 100 * 1024 * 1024); + } finally { + srv.close(); + } +}); + +test('render rejects a declared content-length over 10 MB', async () => { + const srv = await serve((_req, res) => { + res.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': 11 * 1024 * 1024 }); + res.write(Buffer.alloc(1024)); + }); + try { + await assert.rejects(renderFrom(srv.url), /larger than 10 MB/); + } finally { + srv.close(); + } +}); diff --git a/tests/onboarding/commands.test.ts b/tests/onboarding/commands.test.ts new file mode 100644 index 00000000..243ad70e --- /dev/null +++ b/tests/onboarding/commands.test.ts @@ -0,0 +1,293 @@ +import assert from 'node:assert/strict'; +import { after, beforeEach, test } from 'node:test'; +import { Collection, SlashCommandSubcommandBuilder } from 'discord.js'; +import type { DingirClient } from '../../src/client/DingirClient.ts'; +import ConfigCommand from '../../src/commands/config/config.ts'; +import interactionCreate from '../../src/events/interactionCreate.ts'; +import { stub } from '../fakes/discord.ts'; +import { fakeInteraction } from '../fakes/interaction.ts'; +import type { Handler } from '../../src/framework/command.ts'; +import NewRolesGroup from '../../src/commands/config/groups/newroles.ts'; +import WelcomeGroup, { + MAX_WELCOME_MESSAGE_LENGTH, + validateImageUrl, + validateWelcomeMessage, +} from '../../src/commands/config/groups/welcome.ts'; +import SimulateCommand from '../../src/commands/admin/simulate.ts'; +import { fakeCommandContext, fakeOnboarding, role } from '../fakes/onboarding.ts'; +import { createTestApp } from '../helpers/app.ts'; +import { nth, rejectsUserError } from '../helpers/assertions.ts'; +import { dbFixtures } from '../helpers/db.ts'; + +const app = createTestApp(); +const { clearConfigs, createConfig } = dbFixtures(app); + +after(() => { + app.close(); +}); + +beforeEach(() => { + clearConfigs(); +}); + +const handler = (group: typeof NewRolesGroup, name: string): Handler => + group.subcommands.find((s) => s.name === name)!.run; + +test('newroles set rejects @everyone, managed and too-high roles and saves nothing', async () => { + const env = fakeOnboarding({ + roles: [role('ok', 1), role('m', 1, true), role('hi', 10)], + }); + const { ctx, replies } = fakeCommandContext( + app, + { + 'role-one': env.guild.roles.cache.get('ok'), + 'role-two': env.guild.roles.cache.get('m'), + 'role-three': env.guild.roles.cache.get('hi'), + }, + env, + ); + await rejectsUserError(handler(NewRolesGroup, 'set')(ctx), /managed[\s\S]*highest role/); + assert.equal(ctx.config, env.config); + assert.equal(replies.length, 0); + + const everyone = fakeCommandContext( + app, + { 'role-one': { id: 'guild-1', name: '@everyone', position: 0 } }, + env, + ); + await rejectsUserError(handler(NewRolesGroup, 'set')(everyone.ctx), /everyone/); + assert.equal(everyone.ctx.config, env.config); +}); + +test('newroles set saves valid roles, dedupes, and audits', async () => { + const env = fakeOnboarding({ roles: [role('a', 1), role('b', 2)] }); + createConfig({ serverId: 'guild-1', auditChannelId: 'audit-1' }); + const a = env.guild.roles.cache.get('a'); + const { ctx, replies } = fakeCommandContext( + app, + { + 'role-one': a, + 'role-two': env.guild.roles.cache.get('b'), + 'role-three': a, + }, + env, + ); + await handler(NewRolesGroup, 'set')(ctx); + assert.equal(ctx.config.guestRoleIds, 'a,b'); + assert.equal(env.auditSends.length, 1); + assert.match(nth(replies).content!, /set to/); +}); + +test('newroles get is read-only: no audit, marks missing roles', async () => { + const env = fakeOnboarding({ + roles: [role('a')], + config: { guestRoleIds: 'a,gone' }, + }); + const { ctx, replies } = fakeCommandContext(app, {}, env); + await handler(NewRolesGroup, 'get')(ctx); + assert.equal(env.auditSends.length, 0); + assert.match(nth(replies).content!, /<@&a>/); + assert.match(nth(replies).content!, /gone \(this role no longer exists\)/); + + const empty = fakeOnboarding(); + const e = fakeCommandContext(app, {}, empty); + await handler(NewRolesGroup, 'get')(e.ctx); + assert.equal(nth(e.replies).content!, 'No new-member roles are configured.'); +}); + +test('newroles clear nulls roles and audits', async () => { + const env = fakeOnboarding({ config: { guestRoleIds: 'a' } }); + createConfig({ + serverId: 'guild-1', + auditChannelId: 'audit-1', + guestRoleIds: 'a', + }); + const { ctx } = fakeCommandContext(app, {}, env); + await handler(NewRolesGroup, 'clear')(ctx); + assert.equal(ctx.config.guestRoleIds, null); + assert.equal(env.auditSends.length, 1); +}); + +test('welcome validators', () => { + assert.equal(validateImageUrl('https://example.com/a.png'), null); + assert.equal(validateImageUrl('http://example.com/a.png'), null); + assert.ok(validateImageUrl('ftp://example.com/a.png')); + assert.ok(validateImageUrl('file:///etc/passwd')); + assert.ok(validateImageUrl('not a url')); + assert.equal(validateWelcomeMessage('hello'), null); + assert.ok(validateWelcomeMessage('x'.repeat(MAX_WELCOME_MESSAGE_LENGTH + 1))); + assert.ok(validateWelcomeMessage(' ')); + assert.ok(validateWelcomeMessage('{member}'.repeat(150))); + assert.equal(validateWelcomeMessage('{member}'.repeat(80)), null); +}); + +test('welcome set-image rejects non-http URLs without saving', async () => { + const env = fakeOnboarding(); + const { ctx, replies } = fakeCommandContext(app, { url: 'ftp://example.com/a.png' }, env); + await rejectsUserError(handler(WelcomeGroup as never, 'set-image')(ctx), /http/); + assert.equal(replies.length, 0); + assert.equal(env.config.welcomeMessageBackgroundUrl, null); +}); + +test('welcome set-image rejects an unreachable image and does not save', async () => { + const env = fakeOnboarding(); + const { ctx, replies } = fakeCommandContext(app, { url: 'http://127.0.0.1:1/bg.png' }, env); + await rejectsUserError(handler(WelcomeGroup as never, 'set-image')(ctx), /could not be used/); + assert.equal(replies.length, 0); + assert.equal(env.config.welcomeMessageBackgroundUrl, null); +}); + +test('welcome set-message, get and clear', async () => { + const env = fakeOnboarding(); + const set = fakeCommandContext(app, { text: 'Hi {member}' }, env); + await handler(WelcomeGroup as never, 'set-message')(set.ctx); + assert.equal(set.ctx.config.welcomeMessage, 'Hi {member}'); + + const get = fakeCommandContext(app, {}, { ...env, config: set.ctx.config }); + await handler(WelcomeGroup as never, 'get')(get.ctx); + assert.match(nth(get.replies).content!, /Hi \{member\}/); + assert.match(nth(get.replies).content!, /Welcome image: Not set/); + + const clear = fakeCommandContext(app, { which: 'all' }, { ...env, config: set.ctx.config }); + await handler(WelcomeGroup as never, 'clear')(clear.ctx); + assert.equal(clear.ctx.config.welcomeMessage, null); +}); + +test('welcome preview explains why nothing would be sent', async () => { + const env = fakeOnboarding({ config: { welcomeMessage: 'hi' } }); + const { ctx, replies } = fakeCommandContext(app, {}, env); + await handler(WelcomeGroup as never, 'preview')(ctx); + assert.match(nth(replies).content!, /disabled/); + assert.equal(env.systemSends.length, 0); +}); + +test('welcome preview shows the text without sending', async () => { + const env = fakeOnboarding({ + config: { + welcomeMessage: 'hi {member} {member}', + systemMessagesEnabled: true, + }, + }); + const { ctx, replies } = fakeCommandContext(app, {}, env); + await handler(WelcomeGroup as never, 'preview')(ctx); + assert.match(nth(replies).content!, /hi <@member-1> <@member-1>/); + assert.equal(env.systemSends.length, 0); +}); + +test('newroles set and clear defer before the audit send', async () => { + for (const sub of ['set', 'clear']) { + const env = fakeOnboarding({ roles: [role('a', 1)] }); + clearConfigs(); + createConfig({ serverId: 'guild-1', auditChannelId: 'audit-1' }); + const order: string[] = []; + const { interaction } = fakeInteraction({ + commandName: 'config', + group: 'newroles', + subcommand: sub, + options: { 'role-one': env.guild.roles.cache.get('a') }, + guild: env.guild, + }); + Object.assign(interaction, { member: env.member, client: env.client }); + const deferReply = interaction.deferReply.bind(interaction) as (o: unknown) => unknown; + Object.assign(interaction, { + deferReply: async (options: unknown) => { + order.push('defer'); + return deferReply(options); + }, + }); + const fetchChannel = env.client.channels.fetch.bind(env.client.channels); + env.client.channels.fetch = (async (id: string) => { + order.push('audit'); + return fetchChannel(id); + }) as typeof env.client.channels.fetch; + + const client = stub({ + slashCommands: new Collection([[ConfigCommand.name, ConfigCommand]]), + }); + await interactionCreate.run(app, client, interaction as never); + assert.deepEqual(order, ['defer', 'audit'], sub); + assert.equal(env.auditSends.length, 1, sub); + } +}); + +const TRUNCATED = /\*\(preview truncated\)\*$/; + +test('welcome preview is truncated to fit Discord limits', async () => { + const env = fakeOnboarding({ + config: { welcomeMessage: 'x'.repeat(2000), systemMessagesEnabled: true }, + }); + const { ctx, replies } = fakeCommandContext(app, {}, env); + await handler(WelcomeGroup as never, 'preview')(ctx); + const content = nth(replies).content!; + assert.ok(content.length <= 2000); + assert.match(content, TRUNCATED); +}); + +test('welcome get is truncated for long legacy messages', async () => { + const env = fakeOnboarding({ config: { welcomeMessage: 'y'.repeat(2000) } }); + const { ctx, replies } = fakeCommandContext(app, {}, env); + await handler(WelcomeGroup as never, 'get')(ctx); + const content = nth(replies).content!; + assert.ok(content.length <= 2000); + assert.match(content, /Welcome image: Not set$/); + assert.match(content, /preview truncated/); +}); + +test('simulate onboard truncates a long welcome preview and keeps short ones intact', async () => { + const run = SimulateCommand.resolve(fakeInteraction({ subcommand: 'onboard' }).interaction)!.run; + const long = fakeOnboarding({ + config: { welcomeMessage: 'z'.repeat(2000), systemMessagesEnabled: true }, + }); + const a = fakeCommandContext(app, {}, long); + await run(a.ctx); + assert.ok(nth(a.replies).content!.length <= 2000); + assert.match(nth(a.replies).content!, TRUNCATED); + + const short = fakeOnboarding({ + config: { welcomeMessage: 'hello there', systemMessagesEnabled: true }, + }); + const b = fakeCommandContext(app, {}, short); + await run(b.ctx); + assert.match(nth(b.replies).content!, /hello there/); + assert.doesNotMatch(nth(b.replies).content!, /truncated/); +}); + +test('welcome get keeps the reply within the limit for a legacy long image URL', async () => { + const env = fakeOnboarding({ + config: { + welcomeMessage: 'hi', + welcomeMessageBackgroundUrl: `https://e.com/${'a'.repeat(2100)}`, + }, + }); + const { ctx, replies } = fakeCommandContext(app, {}, env); + await handler(WelcomeGroup as never, 'get')(ctx); + assert.ok(nth(replies).content!.length <= 2000); +}); + +test('welcome set-image option caps the URL length', () => { + const sub = WelcomeGroup.subcommands.find((s) => s.name === 'set-image')!; + const json = ( + sub.options!( + new SlashCommandSubcommandBuilder().setName('x').setDescription('x'), + ) as SlashCommandSubcommandBuilder + ).toJSON(); + assert.equal((json.options![0] as { max_length?: number }).max_length, 1000); +}); + +test('image failure notes survive truncation in preview and simulate', async () => { + const config = { + welcomeMessage: 'q'.repeat(2000), + welcomeMessageBackgroundUrl: 'http://127.0.0.1:1/bg.png', + systemMessagesEnabled: true, + }; + const p = fakeCommandContext(app, {}, fakeOnboarding({ config })); + await handler(WelcomeGroup as never, 'preview')(p.ctx); + assert.ok(nth(p.replies).content!.length <= 2000); + assert.match(nth(p.replies).content!, /The image failed to render/); + + const run = SimulateCommand.resolve(fakeInteraction({ subcommand: 'onboard' }).interaction)!.run; + const s = fakeCommandContext(app, {}, fakeOnboarding({ config })); + await run(s.ctx); + assert.ok(nth(s.replies).content!.length <= 2000); + assert.match(nth(s.replies).content!, /Image failed/); +}); diff --git a/tests/onboarding/stateMachine.test.ts b/tests/onboarding/stateMachine.test.ts new file mode 100644 index 00000000..9c24f5c6 --- /dev/null +++ b/tests/onboarding/stateMachine.test.ts @@ -0,0 +1,500 @@ +import assert from 'node:assert/strict'; +import { after, beforeEach, describe, test } from 'node:test'; +import { Collection, type Client, type Guild, type GuildMember } from 'discord.js'; +import guildMemberAdd from '../../src/events/guildMemberAdd.ts'; +import guildMemberRemove from '../../src/events/guildMemberRemove.ts'; +import guildMemberUpdate from '../../src/events/guildMemberUpdate.ts'; +import type { DingirClient } from '../../src/client/DingirClient.ts'; +import { complete } from '../../src/services/OnboardingService.ts'; +import { syncScreeningState } from '../../src/services/ScreeningService.ts'; +import { claimOnboarding } from '../../src/services/UserProfileService.ts'; +import { stub } from '../fakes/discord.ts'; +import { fakeOnboarding, role } from '../fakes/onboarding.ts'; +import { createTestApp, FIXED_NOW } from '../helpers/app.ts'; +import { nth } from '../helpers/assertions.ts'; +import { dbFixtures } from '../helpers/db.ts'; + +const app = createTestApp(); +const { clearConfigs, clearProfiles, createConfig, createProfiles, findProfile } = dbFixtures(app); + +after(() => { + app.close(); +}); + +beforeEach(() => { + clearConfigs(); + clearProfiles(); + createConfig({ serverId: 'guild-1', guestRoleIds: 'r1', auditChannelId: 'audit-1' }); +}); + +const NOW = FIXED_NOW; +const JOINED = new Date(NOW.getTime() - 24 * 60 * 60 * 1000); +/** Recorded during the current membership. */ +const RECENT = new Date(NOW.getTime() - 12 * 60 * 60 * 1000); +/** Recorded before the member's current join: belongs to a previous membership. */ +const STALE = new Date('2026-12-01T00:00:00Z'); + +type Stamp = Date | null; +interface State { + pending: Stamp; + onboarded: Stamp; +} + +type OldMember = boolean | 'partial'; +type Event = + | { kind: 'join'; pending: boolean; bot?: boolean } + | { kind: 'update'; old: OldMember; new: boolean | null; bot?: boolean } + | { kind: 'sweep'; pending: boolean; bot?: boolean } + | { kind: 'leave' }; + +interface Row { + name: string; + /** Undefined: the member has no profile row. */ + initial?: Partial; + event: Event; + failRolesAdd?: boolean; + /** Undefined: no profile row afterwards. */ + state?: State; + roles: number; +} + +const none = null; +const onboardedNow: State = { pending: none, onboarded: NOW }; +const pendingNow: State = { pending: NOW, onboarded: none }; + +const rows: Row[] = [ + // join + { + name: 'join pending, no row: records pending', + event: { kind: 'join', pending: true }, + state: pendingNow, + roles: 0, + }, + { + name: 'join pending, stale onboarding (rejoin): resets to a new cycle', + initial: { onboarded: STALE }, + event: { kind: 'join', pending: true }, + state: pendingNow, + roles: 0, + }, + { + name: 'join pending, stale pending: restarts the cycle', + initial: { pending: STALE }, + event: { kind: 'join', pending: true }, + state: pendingNow, + roles: 0, + }, + { + name: 'join not pending: onboards immediately', + event: { kind: 'join', pending: false }, + state: onboardedNow, + roles: 1, + }, + { + name: 'join not pending, stale onboarding (rejoin): onboards again', + initial: { onboarded: STALE }, + event: { kind: 'join', pending: false }, + state: onboardedNow, + roles: 1, + }, + { + name: 'join not pending, roles fail: claim stands, not retried', + event: { kind: 'join', pending: false }, + failRolesAdd: true, + state: onboardedNow, + roles: 0, + }, + { + name: 'join of a bot: no state, no onboarding', + event: { kind: 'join', pending: false, bot: true }, + roles: 0, + }, + // update, previous state known + { + name: 'update true->true, no row: records pending', + event: { kind: 'update', old: true, new: true }, + state: pendingNow, + roles: 0, + }, + { + name: 'update false->true, no row: records pending', + event: { kind: 'update', old: false, new: true }, + state: pendingNow, + roles: 0, + }, + { + name: 'update still pending: keeps the recorded time', + initial: { pending: RECENT }, + event: { kind: 'update', old: true, new: true }, + state: { pending: RECENT, onboarded: none }, + roles: 0, + }, + { + name: 'update pending, stale onboarding (missed rejoin): new cycle', + initial: { onboarded: STALE }, + event: { kind: 'update', old: true, new: true }, + state: pendingNow, + roles: 0, + }, + { + name: 'update true->false, no row: onboards', + event: { kind: 'update', old: true, new: false }, + state: onboardedNow, + roles: 1, + }, + { + name: 'update true->false, pending recorded: onboards and clears pending', + initial: { pending: RECENT }, + event: { kind: 'update', old: true, new: false }, + state: onboardedNow, + roles: 1, + }, + { + name: 'update true->false, already onboarded: replay is ignored', + initial: { onboarded: RECENT }, + event: { kind: 'update', old: true, new: false }, + state: { pending: none, onboarded: RECENT }, + roles: 0, + }, + { + name: 'update true->false, stale onboarding (missed rejoin): onboards', + initial: { onboarded: STALE }, + event: { kind: 'update', old: true, new: false }, + state: onboardedNow, + roles: 1, + }, + { + name: 'update true->false, roles fail: claim stands', + event: { kind: 'update', old: true, new: false }, + failRolesAdd: true, + state: onboardedNow, + roles: 0, + }, + { + name: 'update false->false: ignored', + event: { kind: 'update', old: false, new: false }, + roles: 0, + }, + { + name: 'update of a bot: ignored', + event: { kind: 'update', old: true, new: false, bot: true }, + roles: 0, + }, + // update, previous state unknown (partial old member) + { + name: 'partial old, pending recorded: onboards', + initial: { pending: RECENT }, + event: { kind: 'update', old: 'partial', new: false }, + state: onboardedNow, + roles: 1, + }, + { + name: 'partial old, nothing recorded: cannot prove screening, ignored', + event: { kind: 'update', old: 'partial', new: false }, + roles: 0, + }, + { + name: 'partial old, already onboarded: ignored', + initial: { pending: RECENT, onboarded: RECENT }, + event: { kind: 'update', old: 'partial', new: false }, + state: { pending: RECENT, onboarded: RECENT }, + roles: 0, + }, + { + name: 'partial old, stale pending (rejoin while offline): ignored and cleaned', + initial: { pending: STALE }, + event: { kind: 'update', old: 'partial', new: false }, + state: { pending: none, onboarded: none }, + roles: 0, + }, + { + name: 'partial old, stale onboarding (rejoin while offline): not re-onboarded, cleaned', + initial: { onboarded: STALE }, + event: { kind: 'update', old: 'partial', new: false }, + state: { pending: none, onboarded: none }, + roles: 0, + }, + { + name: 'partial old, still pending: records pending', + event: { kind: 'update', old: 'partial', new: true }, + state: pendingNow, + roles: 0, + }, + { + name: 'partial old, pending with stale onboarding (rejoin while offline): new cycle', + initial: { onboarded: STALE }, + event: { kind: 'update', old: 'partial', new: true }, + state: pendingNow, + roles: 0, + }, + { + name: 'partial old, unknown new pending: ignored', + event: { kind: 'update', old: 'partial', new: null }, + roles: 0, + }, + // startup sweep + { + name: 'sweep pending member, no row: records pending', + event: { kind: 'sweep', pending: true }, + state: pendingNow, + roles: 0, + }, + { + name: 'sweep pending member already recorded: unchanged', + initial: { pending: RECENT }, + event: { kind: 'sweep', pending: true }, + state: { pending: RECENT, onboarded: none }, + roles: 0, + }, + { + name: 'sweep pending member with stale onboarding (rejoined offline): new cycle', + initial: { onboarded: STALE }, + event: { kind: 'sweep', pending: true }, + state: pendingNow, + roles: 0, + }, + { + name: 'sweep pending member with stale pending: re-recorded', + initial: { pending: STALE }, + event: { kind: 'sweep', pending: true }, + state: pendingNow, + roles: 0, + }, + { + name: 'sweep settled member, no row: nothing recorded', + event: { kind: 'sweep', pending: false }, + roles: 0, + }, + { + name: 'sweep settled member, pending recorded (finished offline): onboarded late', + initial: { pending: RECENT }, + event: { kind: 'sweep', pending: false }, + state: onboardedNow, + roles: 1, + }, + { + name: 'sweep settled member, stale pending (rejoined offline): not onboarded', + initial: { pending: STALE }, + event: { kind: 'sweep', pending: false }, + state: { pending: none, onboarded: none }, + roles: 0, + }, + { + name: 'sweep settled member already onboarded: unchanged', + initial: { onboarded: RECENT }, + event: { kind: 'sweep', pending: false }, + state: { pending: none, onboarded: RECENT }, + roles: 0, + }, + { name: 'sweep ignores bots', event: { kind: 'sweep', pending: true, bot: true }, roles: 0 }, + // leave + { + name: 'leave deletes the row', + initial: { onboarded: RECENT }, + event: { kind: 'leave' }, + roles: 0, + }, + { name: 'leave with no row is harmless', event: { kind: 'leave' }, roles: 0 }, +]; + +function scenario(row: Row) { + const env = fakeOnboarding({ roles: [role('r1')], failRolesAdd: row.failRolesAdd }); + const pending = + row.event.kind === 'update' + ? row.event.new + : 'pending' in row.event + ? row.event.pending + : false; + Object.assign(env.member, { + pending, + joinedTimestamp: JOINED.getTime(), + ...('bot' in row.event && row.event.bot + ? { user: stub({ id: 'member-1', tag: 'bot#0001', bot: true }) } + : {}), + }); + const guilds = new Collection([['guild-1', env.guild]]); + Object.assign(env.guild.members, { + fetch: async () => new Collection([['member-1', env.member]]), + }); + const client = stub( + Object.assign(env.client, { guilds: { cache: guilds } }), + ); + return { env, client }; +} + +async function play(row: Row): Promise { + const { env, client } = scenario(row); + const { event } = row; + switch (event.kind) { + case 'join': + await guildMemberAdd.run(app, client, env.member); + break; + case 'leave': + await guildMemberRemove.run(app, client, env.member); + break; + case 'sweep': + await syncScreeningState(app, client); + break; + case 'update': { + const old = + event.old === 'partial' + ? { guild: env.guild, id: 'member-1', partial: true } + : { guild: env.guild, id: 'member-1', partial: false, pending: event.old }; + await guildMemberUpdate.run(app, client, stub(old), env.member); + break; + } + } + return env.roleAdds.length; +} + +describe('onboarding state machine', () => { + for (const row of rows) { + test(row.name, async () => { + if (row.initial) { + createProfiles([ + { + serverId: 'guild-1', + userId: 'member-1', + screeningPendingAt: row.initial.pending ?? null, + onboardedAt: row.initial.onboarded ?? null, + }, + ]); + } + const roles = await play(row); + assert.equal(roles, row.roles, 'guest role assignments'); + const profile = findProfile('member-1', 'guild-1'); + if (row.state === undefined) { + assert.equal(profile, null, 'no profile row'); + } else { + assert.ok(profile, 'profile row exists'); + assert.deepEqual( + { pending: profile.screeningPendingAt, onboarded: profile.onboardedAt }, + row.state, + ); + } + }); + } +}); + +describe('onboarding invariants under failure and concurrency', () => { + test('concurrent identical updates onboard exactly once', async () => { + const { env, client } = scenario({ + name: '', + event: { kind: 'update', old: true, new: false }, + roles: 1, + }); + const old = stub({ + guild: env.guild, + id: 'member-1', + partial: false, + pending: true, + }); + await Promise.all([ + guildMemberUpdate.run(app, client, old, env.member), + guildMemberUpdate.run(app, client, old, env.member), + ]); + assert.equal(env.roleAdds.length, 1); + }); + + test('an update arriving while the join audit is in flight does not onboard twice', async () => { + const { env, client } = scenario({ + name: '', + event: { kind: 'join', pending: false }, + roles: 1, + }); + const old = stub({ + guild: env.guild, + id: 'member-1', + partial: false, + pending: true, + }); + await Promise.all([ + guildMemberAdd.run(app, client, env.member), + guildMemberUpdate.run(app, client, old, env.member), + ]); + assert.equal(env.roleAdds.length, 1); + }); + + test('a leave during onboarding leaves no profile behind', async () => { + const { env, client } = scenario({ + name: '', + event: { kind: 'join', pending: false }, + roles: 1, + }); + const joining = guildMemberAdd.run(app, client, env.member); + await new Promise((resolve) => setImmediate(resolve)); // gateway events are separate tasks + await guildMemberRemove.run(app, client, env.member); + await joining; + assert.equal(findProfile('member-1', 'guild-1'), null); + }); + + test('onboarding is claimed once per membership', async () => { + assert.equal(await claimOnboarding(app.db, 'guild-1', 'member-1', NOW), true); + assert.equal(await claimOnboarding(app.db, 'guild-1', 'member-1', RECENT), false); + assert.deepEqual(findProfile('member-1')?.onboardedAt, NOW); + }); + + test('a dry run never touches persisted state', async () => { + const env = fakeOnboarding({ roles: [role('r1')] }); + await complete(app, stub(env.client), env.member, env.config, { dryRun: true }); + assert.equal(findProfile('member-1'), null); + assert.equal(env.roleAdds.length, 0); + }); + + test('a join audits "New member joined"; a bot join is audited but not onboarded', async () => { + const human = scenario({ name: '', event: { kind: 'join', pending: false }, roles: 1 }); + await guildMemberAdd.run(app, human.client, human.env.member); + assert.equal(human.env.auditSends.length, 1); + assert.match(JSON.stringify(nth(nth(human.env.auditSends).embeds)), /New member joined/); + + const bot = scenario({ + name: '', + event: { kind: 'join', pending: false, bot: true }, + roles: 0, + }); + await guildMemberAdd.run(app, bot.client, bot.env.member); + assert.equal(bot.env.auditSends.length, 1); + assert.equal(bot.env.roleAdds.length, 0); + }); + + test('screening completion sends the completion audit', async () => { + const { env, client } = scenario({ + name: '', + event: { kind: 'update', old: true, new: false }, + roles: 1, + }); + const old = stub({ + guild: env.guild, + id: 'member-1', + partial: false, + pending: true, + }); + await guildMemberUpdate.run(app, client, old, env.member); + assert.match(JSON.stringify(nth(nth(env.auditSends).embeds)), /Member completed onboarding/); + }); + + test('the sweep isolates a failing guild and stops once its guild is removed', async () => { + const good = scenario({ name: '', event: { kind: 'sweep', pending: true }, roles: 0 }); + const bad = stub({ + id: 'guild-2', + members: { + fetch: async () => { + throw new Error('boom'); + }, + }, + }); + const cache = new Collection([ + ['guild-2', bad], + ['guild-1', good.env.guild], + ]); + const client = stub(Object.assign(good.env.client, { guilds: { cache } })); + app.logs.length = 0; + assert.deepEqual(await syncScreeningState(app, client), { recorded: 1, caughtUp: 0 }); + assert.equal(app.logsAt('error').length, 1); + + clearProfiles(); + cache.delete('guild-1'); // guildDelete purged it mid-sweep + assert.deepEqual(await syncScreeningState(app, client), { recorded: 0, caughtUp: 0 }); + assert.equal(findProfile('member-1'), null); + }); +}); diff --git a/tests/process.test.ts b/tests/process.test.ts new file mode 100644 index 00000000..7fd83b7e --- /dev/null +++ b/tests/process.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { test } from 'node:test'; +import { installProcessHandlers, type ProcessHandlerDeps } from '../src/framework/process.ts'; +import { fakeLogger } from './helpers/app.ts'; + +const setup = (runShutdown: () => Promise, timeouts = {}) => { + const proc = new EventEmitter(); + const { logger, logs } = fakeLogger(); + const exits: number[] = []; + const handlers = installProcessHandlers({ + proc: proc as unknown as ProcessHandlerDeps['proc'], + logger, + runShutdown, + exit: (code) => { + exits.push(code); + }, + ...timeouts, + }); + return { proc, logs, exits, handlers }; +}; + +const never = (): Promise => new Promise(() => {}); + +test('unhandled rejections are logged and do not exit', () => { + const { proc, logs, exits } = setup(async () => {}); + proc.emit('unhandledRejection', new Error('nope')); + assert.equal(logs.length, 1); + assert.equal(logs[0]?.level, 'error'); + assert.deepEqual(exits, []); +}); + +test('uncaught exceptions log fatal, run shutdown, then exit 1', async () => { + const order: string[] = []; + const { logs, exits, handlers } = setup(async () => { + order.push('shutdown'); + }); + await handlers.onUncaughtException(new Error('boom')); + assert.equal(logs[0]?.level, 'fatal'); + assert.deepEqual(order, ['shutdown']); + assert.deepEqual(exits, [1]); +}); + +test('uncaught exceptions force exit when shutdown hangs', async () => { + const { logs, exits, handlers } = setup(never, { crashTimeoutMs: 10 }); + await handlers.onUncaughtException(new Error('boom')); + assert.deepEqual(exits, [1]); + assert.ok(logs.some((l) => /timed out/.test(l.message))); +}); + +test('signals run shutdown and exit 0, only once', async () => { + let runs = 0; + const { exits, handlers } = setup(async () => { + runs += 1; + }); + await handlers.onSignal('SIGTERM'); + await handlers.onSignal('SIGINT'); + assert.equal(runs, 1); + assert.deepEqual(exits, [0]); +}); + +test('signals force exit 1 when shutdown hangs', async () => { + const { exits, handlers } = setup(never, { signalTimeoutMs: 10 }); + await handlers.onSignal('SIGINT'); + assert.deepEqual(exits, [1]); +}); + +test('a throwing shutdown still exits', async () => { + const { logs, exits, handlers } = setup(async () => { + throw new Error('hook'); + }); + await handlers.onSignal('SIGTERM'); + assert.deepEqual(exits, [0]); + assert.ok(logs.some((l) => l.message === 'Shutdown failed')); +}); + +test('real process events are wired to the handlers', () => { + const { proc } = setup(async () => {}); + for (const event of ['unhandledRejection', 'uncaughtException', 'SIGTERM', 'SIGINT']) { + assert.equal(proc.listenerCount(event), 1, event); + } +}); diff --git a/tests/registrar/stateMachine.test.ts b/tests/registrar/stateMachine.test.ts new file mode 100644 index 00000000..c5dbad47 --- /dev/null +++ b/tests/registrar/stateMachine.test.ts @@ -0,0 +1,259 @@ +import assert from 'node:assert/strict'; +import { after, beforeEach, describe, test } from 'node:test'; +import { defineCommand, type Command } from '../../src/framework/command.ts'; +import { commandScope, hashCommands, syncCommands } from '../../src/framework/registrar.ts'; +import { getState, setState } from '../../src/services/BotState.ts'; +import { apiError } from '../fakes/discord.ts'; +import { createTestApp } from '../helpers/app.ts'; + +const app = createTestApp(); + +after(() => { + app.close(); +}); + +beforeEach(() => { + app.db.$client.exec('DELETE FROM `BotState`'); + app.logs.length = 0; +}); + +const command = (name: string, description = 'd'): Command => + defineCommand({ name, description, run: async () => {} }); + +const V1 = [command('one')]; +const V2 = [command('one'), command('two')]; + +test('hashCommands is stable and changes with command content', () => { + const a = [command('one'), command('two')]; + assert.equal(hashCommands(a), hashCommands([command('one'), command('two')])); + assert.notEqual(hashCommands(a), hashCommands([command('one'), command('two', 'changed')])); + assert.notEqual(hashCommands(a), hashCommands([command('one')])); +}); + +test('commandScope distinguishes global and dev guild targets and applications', () => { + const key = (devGuildId: string | undefined, clientId = '1') => + commandScope({ devGuildId, clientId }).stateKey; + assert.equal(key(undefined), 'commandsHash:1:global'); + assert.equal(key('42'), 'commandsHash:1:guild:42'); + assert.equal(key(undefined, '2'), 'commandsHash:2:global'); +}); + +type Result = 'registered' | 'unchanged' | 'failed'; + +interface Step { + /** DEV_GUILD_ID for this start; undefined is the global scope. */ + dev?: string; + commands?: readonly Command[]; + clientId?: string; + put?: 'ok' | 'fail'; + clear?: 'ok' | 'fail' | 'unknown-guild' | 'missing-access'; + result: Result; + puts: number; + /** Guilds whose commands were cleared during this step. */ + cleared?: string[]; + /** BotState scope afterwards (for the step's client id). */ + scope?: string; +} + +interface Row { + name: string; + /** Pre-existing BotState rows. */ + state?: Record; + steps: Step[]; + /** A warning matching this must have been logged by the last step. */ + warns?: RegExp; +} + +const rows: Row[] = [ + { + name: 'first start registers, then skips while the hash is unchanged', + steps: [ + { result: 'registered', puts: 1, scope: 'global' }, + { result: 'unchanged', puts: 0, scope: 'global' }, + { commands: V2, result: 'registered', puts: 1, scope: 'global' }, + ], + }, + { + name: 'hash is tracked per scope and per application', + steps: [ + { result: 'registered', puts: 1, scope: 'global' }, + { dev: '42', result: 'registered', puts: 1, scope: 'guild:42' }, + { dev: '42', result: 'unchanged', puts: 0, scope: 'guild:42' }, + { clientId: '2', result: 'registered', puts: 1, scope: 'global' }, + { clientId: '2', result: 'unchanged', puts: 0, scope: 'global' }, + ], + }, + { + name: 'a failed registration records nothing and is retried', + steps: [ + { put: 'fail', result: 'failed', puts: 1 }, + { result: 'registered', puts: 1, scope: 'global' }, + ], + }, + { + name: 'hashes under the old key format are ignored', + state: { 'commandsHash:global': 'stale' }, + steps: [{ result: 'registered', puts: 1, scope: 'global' }], + }, + { + name: 'a hash without a recorded scope (interrupted run) re-registers to record it', + state: { 'commandsHash:1:global': hashCommands(V1) }, + steps: [ + { result: 'registered', puts: 1, scope: 'global' }, + { result: 'unchanged', puts: 0, scope: 'global' }, + ], + }, + { + name: 'dev guild to global clears the old guild', + steps: [ + { dev: '42', result: 'registered', puts: 1, cleared: [], scope: 'guild:42' }, + { result: 'registered', puts: 1, cleared: ['42'], scope: 'global' }, + ], + }, + { + name: 'dev guild A to B clears A once, and staying put clears nothing more', + steps: [ + { dev: 'A', result: 'registered', puts: 1, scope: 'guild:A' }, + { dev: 'B', result: 'registered', puts: 1, cleared: ['A'], scope: 'guild:B' }, + { dev: 'B', commands: V2, result: 'registered', puts: 1, cleared: [], scope: 'guild:B' }, + ], + }, + { + name: 'global to a dev guild never clears global commands and warns', + steps: [ + { result: 'registered', puts: 1, scope: 'global' }, + { dev: '42', result: 'registered', puts: 1, cleared: [], scope: 'guild:42' }, + ], + warns: /deploy:commands/, + }, + { + name: 'returning to a scope whose stored hash still matches re-registers', + steps: [ + { dev: 'A', result: 'registered', puts: 1, scope: 'guild:A' }, + { result: 'registered', puts: 1, cleared: ['A'], scope: 'global' }, + { dev: 'A', result: 'registered', puts: 1, cleared: [], scope: 'guild:A' }, + ], + }, + { + name: 'a failed registration while switching clears nothing and keeps the scope', + steps: [ + { dev: '42', result: 'registered', puts: 1, scope: 'guild:42' }, + { put: 'fail', result: 'failed', puts: 1, cleared: [], scope: 'guild:42' }, + { result: 'registered', puts: 1, cleared: ['42'], scope: 'global' }, + ], + }, + { + name: 'a failed cleanup keeps the old scope so every start retries it', + steps: [ + { dev: '42', result: 'registered', puts: 1, scope: 'guild:42' }, + { clear: 'fail', result: 'registered', puts: 1, cleared: ['42'], scope: 'guild:42' }, + { clear: 'fail', result: 'registered', puts: 1, cleared: ['42'], scope: 'guild:42' }, + { result: 'registered', puts: 1, cleared: ['42'], scope: 'global' }, + { result: 'unchanged', puts: 0, scope: 'global' }, + ], + }, + { + name: 'a dev guild the bot has left cannot be cleared and is not retried forever', + steps: [ + { dev: '42', result: 'registered', puts: 1, scope: 'guild:42' }, + { clear: 'unknown-guild', result: 'registered', puts: 1, cleared: ['42'], scope: 'global' }, + { result: 'unchanged', puts: 0, scope: 'global' }, + ], + }, + { + name: 'Missing Access on cleanup is treated the same as an unknown guild', + steps: [ + { dev: '42', result: 'registered', puts: 1, scope: 'guild:42' }, + { clear: 'missing-access', result: 'registered', puts: 1, cleared: ['42'], scope: 'global' }, + ], + }, +]; + +describe('command registration state machine', () => { + for (const row of rows) { + test(row.name, async () => { + for (const [key, value] of Object.entries(row.state ?? {})) { + setState(app.db, key, value); + } + const hashes = new Map(); + for (const [index, step] of row.steps.entries()) { + let puts = 0; + const cleared: string[] = []; + const instance = { + ...app, + env: Object.freeze({ ...app.env, devGuildId: step.dev, clientId: step.clientId ?? '1' }), + }; + app.logs.length = 0; + const result = await syncCommands(instance, step.commands ?? V1, { + put: async (_env, commands) => { + puts++; + if (step.put === 'fail') { + throw new Error('discord down'); + } + return commands.length; + }, + clear: async (_env, guildId) => { + cleared.push(guildId); + if (step.clear === 'fail') { + throw new Error('forbidden'); + } + if (step.clear === 'unknown-guild') { + throw apiError(10004); + } + if (step.clear === 'missing-access') { + throw apiError(50001); + } + }, + }); + const at = `step ${index + 1}`; + assert.equal(result, step.result, `${at}: result`); + assert.equal(puts, step.puts, `${at}: register calls`); + assert.deepEqual(cleared, step.cleared ?? [], `${at}: cleared guilds`); + const clientId = step.clientId ?? '1'; + if ('scope' in step) { + assert.equal(getState(app.db, `commandsScope:${clientId}`), step.scope, `${at}: scope`); + } + const stateKey = commandScope(instance.env).stateKey; + if (step.result === 'failed') { + assert.equal( + getState(app.db, stateKey), + hashes.get(stateKey), + `${at}: a failed registration records no hash`, + ); + } else { + assert.equal( + getState(app.db, stateKey), + hashCommands(step.commands ?? V1), + `${at}: hash`, + ); + hashes.set(stateKey, hashCommands(step.commands ?? V1)); + } + } + if (row.warns) { + assert.ok(app.logsAt('warn').some((e) => row.warns!.test(e.message))); + } + }); + } + + test('a failed registration is logged once as an error', async () => { + const failing = async (): Promise => { + throw new Error('discord down'); + }; + assert.equal(await syncCommands(app, V1, failing), 'failed'); + assert.equal(app.logsAt('error').length, 1); + }); + + test('registering in a dev guild warns that global commands may remain', async () => { + const dev = { ...app, env: Object.freeze({ ...app.env, devGuildId: '42' }) }; + await syncCommands(dev, V1, async (_env, commands) => commands.length); + assert.ok(app.logsAt('warn').some((e) => /global commands may also exist/.test(e.message))); + assert.ok(app.logsAt('warn').some((e) => /deploy:commands/.test(e.message))); + }); + + test('skipping logs that commands are unchanged', async () => { + const put = async (_env: unknown, commands: readonly Command[]) => commands.length; + await syncCommands(app, V1, put); + await syncCommands(app, V1, put); + assert.ok(app.logsAt('info').some((e) => /unchanged, skipping/.test(e.message))); + }); +}); diff --git a/tests/registry.test.ts b/tests/registry.test.ts new file mode 100644 index 00000000..24ab13a7 --- /dev/null +++ b/tests/registry.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { events } from '../src/events/index.ts'; +import { validateRegistry } from '../src/framework/registry.ts'; +import { commands } from '../src/commands/index.ts'; + +test('validateRegistry accepts unique names', () => { + assert.doesNotThrow(() => validateRegistry('command', [{ name: 'a' }, { name: 'b' }])); +}); + +test('validateRegistry rejects duplicate names', () => { + assert.throws( + () => validateRegistry('command', [{ name: 'same' }, { name: 'same' }]), + /Duplicate command "same"/, + ); + assert.throws( + () => validateRegistry('event', [{ name: 'x' }, { name: 'x' }]), + /Duplicate event "x"/, + ); +}); + +test('validateRegistry rejects missing names', () => { + assert.throws(() => validateRegistry('command', [{ name: '' }]), /non-empty name/); +}); + +test('the real command registry is valid and shaped correctly', () => { + validateRegistry('command', commands); + assert.ok(commands.length > 0); + for (const command of commands) { + assert.equal(typeof command.toJSON, 'function'); + assert.equal(typeof command.resolve, 'function'); + } +}); + +test('the real event registry is valid and shaped correctly', () => { + validateRegistry('event', events); + assert.ok(events.length > 0); + for (const event of events) { + assert.equal(typeof event.run, 'function'); + } +}); diff --git a/tests/services/AuditEmbed.test.ts b/tests/services/AuditEmbed.test.ts new file mode 100644 index 00000000..9dcf34f2 --- /dev/null +++ b/tests/services/AuditEmbed.test.ts @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { fakeMember, stub } from '../fakes/discord.ts'; +import { AuditEmbed, memberAuditEmbed } from '../../src/services/AuditEmbed.ts'; +import { EmbedColours } from '../../src/resources/EmbedColours.ts'; +import { nth } from '../helpers/assertions.ts'; + +const total = (json: ReturnType): number => + (json.title?.length ?? 0) + + (json.description?.length ?? 0) + + (json.author?.name.length ?? 0) + + (json.footer?.text.length ?? 0) + + (json.fields ?? []).reduce((s, f) => s + f.name.length + f.value.length, 0); + +test('short fields pass through untouched', () => { + const json = new AuditEmbed().addField('Name', 'Value', true).toJSON(); + assert.deepEqual(json.fields, [{ name: 'Name', value: 'Value', inline: true }]); +}); + +test('1025-char value is truncated to 1024 with ellipsis and a note', () => { + const json = new AuditEmbed().addField('Big', 'x'.repeat(1025)).toJSON(); + const field = nth(json.fields); + const note = nth(json.fields, 1); + assert.equal(field.value.length, 1024); + assert.ok(field.value.endsWith('…')); + assert.equal(note.name, 'Truncated'); +}); + +test('empty and whitespace values render as *(empty)*', () => { + const json = new AuditEmbed().addField('A', '').addField('B', ' ').toJSON(); + assert.deepEqual( + json.fields?.map((f) => f.value), + ['*(empty)*', '*(empty)*'], + ); + assert.equal(json.fields?.length, 2); +}); + +test('long names truncate to 256', () => { + const json = new AuditEmbed().addField('n'.repeat(300), 'v').toJSON(); + assert.equal(nth(json.fields).name.length, 256); +}); + +test('30 fields are capped at 25 including the truncation note', () => { + const embed = new AuditEmbed(); + for (let i = 0; i < 30; i += 1) { + embed.addField(`f${i}`, 'v'); + } + const json = embed.toJSON(); + assert.equal(json.fields?.length, 25); + assert.equal(nth(json.fields, 24).name, 'Truncated'); + assert.equal(nth(json.fields, 23).name, 'f23'); +}); + +test('exactly 25 fields need no note', () => { + const embed = new AuditEmbed(); + for (let i = 0; i < 25; i += 1) { + embed.addField(`f${i}`, 'v'); + } + const json = embed.toJSON(); + assert.equal(json.fields?.length, 25); + assert.notEqual(nth(json.fields, 24).name, 'Truncated'); +}); + +test('description over 4096 is truncated', () => { + const json = new AuditEmbed().setDescription('d'.repeat(5000)).toJSON(); + assert.equal(json.description?.length, 4096); + assert.equal(json.fields?.at(-1)?.name, 'Truncated'); +}); + +test('7000 total chars are trimmed to 6000 or less, dropping trailing fields', () => { + const embed = new AuditEmbed().setDescription('d'.repeat(1000)); + for (let i = 0; i < 7; i += 1) { + embed.addField(`f${i}`, 'v'.repeat(1000)); + } + const json = embed.toJSON(); + assert.ok(total(json) <= 6000, `total was ${total(json)}`); + assert.equal(json.fields?.at(-1)?.name, 'Truncated'); + assert.equal(nth(json.fields).name, 'f0'); + assert.ok((json.fields?.length ?? 0) < 8); +}); + +test('oversized description plus fields still fits', () => { + const embed = new AuditEmbed().setDescription('d'.repeat(4096)); + embed + .addField('a', 'v'.repeat(1024)) + .addField('b', 'v'.repeat(1024)) + .addField('c', 'v'.repeat(1024)); + assert.ok(total(embed.toJSON()) <= 6000); +}); + +test('toJSON does not mutate the builder', () => { + const embed = new AuditEmbed(); + for (let i = 0; i < 8; i += 1) { + embed.addField(`f${i}`, 'v'.repeat(1000)); + } + embed.toJSON(); + assert.equal(embed.data.fields?.length, 8); +}); + +test('memberAuditEmbed sets author, colour, description and timestamp', () => { + const member = fakeMember('1', { + displayName: 'Alice', + user: stub({ tag: 'alice#0' }), + displayAvatarURL: () => 'https://cdn.example/a.png', + }); + const json = memberAuditEmbed(member, EmbedColours.info, 'hello').toJSON(); + assert.equal(json.author?.name, 'Alice'); + assert.equal(json.author?.icon_url, 'https://cdn.example/a.png'); + assert.equal(json.description, 'hello'); + assert.ok(json.timestamp); + assert.equal(typeof json.color, 'number'); +}); diff --git a/tests/services/AuditService.test.ts b/tests/services/AuditService.test.ts new file mode 100644 index 00000000..8458b29b --- /dev/null +++ b/tests/services/AuditService.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { Client } from 'discord.js'; +import { EmbedBuilder } from 'discord.js'; +import type { ServerConfig } from '../../src/db/schema.ts'; +import { sendAudit } from '../../src/services/AuditService.ts'; +import { fakeConfig, stub } from '../fakes/discord.ts'; +import { createTestApp } from '../helpers/app.ts'; + +const app = createTestApp(); + +const config = (auditChannelId: string | null): ServerConfig => + fakeConfig({ serverId: 'g1', auditChannelId }); +const clientWith = (fetch: () => Promise): Client => stub({ channels: { fetch } }); +const embed = new EmbedBuilder().setDescription('x'); + +test('returns false without fetching when no audit channel configured', async () => { + let fetched = false; + const client = clientWith(async () => { + fetched = true; + return null; + }); + assert.equal(await sendAudit(app, client, config(null), embed), false); + assert.equal(fetched, false); +}); + +test('returns false when fetch rejects', async () => { + const client = clientWith(async () => { + throw new Error('Missing Access'); + }); + assert.equal(await sendAudit(app, client, config('c1'), embed), false); +}); + +test('returns false when channel is missing or not sendable', async () => { + assert.equal( + await sendAudit( + app, + clientWith(async () => null), + config('c1'), + embed, + ), + false, + ); + const unsendable = clientWith(async () => ({ isSendable: () => false })); + assert.equal(await sendAudit(app, unsendable, config('c1'), embed), false); +}); + +test('returns false when send rejects', async () => { + const client = clientWith(async () => ({ + isSendable: () => true, + send: async () => { + throw new Error('Missing Permissions'); + }, + })); + assert.equal(await sendAudit(app, client, config('c1'), embed), false); +}); + +test('sends embed and files, returns true', async () => { + const sent: unknown[] = []; + const client = clientWith(async () => ({ + isSendable: () => true, + send: async (payload: unknown) => void sent.push(payload), + })); + assert.equal(await sendAudit(app, client, config('c1'), embed), true); + assert.deepEqual(sent, [{ embeds: [embed], files: undefined }]); +}); diff --git a/tests/services/BirthdayDates.test.ts b/tests/services/BirthdayDates.test.ts new file mode 100644 index 00000000..4c8608e2 --- /dev/null +++ b/tests/services/BirthdayDates.test.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { DateTime } from 'luxon'; +import { + celebrationDate, + isBirthdayToday, + isValidBirthday, + nextOccurrence, + upcoming, +} from '../../src/services/BirthdayDates.ts'; + +const LONDON = 'Europe/London'; +const at = (iso: string, zone = 'utc'): DateTime => DateTime.fromISO(iso, { zone }); +const ymd = (dt: DateTime): string => dt.toISODate() ?? ''; + +test('isValidBirthday', () => { + assert.equal(isValidBirthday(2, 29), true); + assert.equal(isValidBirthday(4, 31), false); + assert.equal(isValidBirthday(13, 1), false); + assert.equal(isValidBirthday(1, 0), false); + assert.equal(isValidBirthday(12, 31), true); +}); + +test('celebrationDate moves Feb 29 to Feb 28 in non-leap years', () => { + assert.deepEqual(celebrationDate(2, 29, 2026), { year: 2026, month: 2, day: 28 }); + assert.deepEqual(celebrationDate(2, 29, 2027), { year: 2027, month: 2, day: 28 }); + assert.deepEqual(celebrationDate(2, 29, 2028), { year: 2028, month: 2, day: 29 }); + assert.deepEqual(celebrationDate(3, 1, 2026), { year: 2026, month: 3, day: 1 }); +}); + +test('nextOccurrence: today counts as upcoming', () => { + const next = nextOccurrence(6, 15, at('2026-06-15T13:00:00'), 'utc'); + assert.equal(ymd(next), '2026-06-15'); + assert.equal(next.hour, 0); +}); + +test('nextOccurrence: passed date rolls to next year, Dec 31 to Jan 1', () => { + assert.equal(ymd(nextOccurrence(6, 14, at('2026-06-15T00:00:00'), 'utc')), '2027-06-14'); + assert.equal(ymd(nextOccurrence(1, 1, at('2026-12-31T23:00:00'), 'utc')), '2027-01-01'); + assert.equal(ymd(nextOccurrence(12, 31, at('2026-12-31T23:00:00'), 'utc')), '2026-12-31'); +}); + +test('nextOccurrence: Feb 29 across leap and non-leap years', () => { + assert.equal(ymd(nextOccurrence(2, 29, at('2026-01-10T12:00:00'), 'utc')), '2026-02-28'); + assert.equal(ymd(nextOccurrence(2, 29, at('2026-03-01T12:00:00'), 'utc')), '2027-02-28'); + assert.equal(ymd(nextOccurrence(2, 29, at('2027-03-01T12:00:00'), 'utc')), '2028-02-29'); + assert.equal(ymd(nextOccurrence(2, 29, at('2028-02-29T09:00:00'), 'utc')), '2028-02-29'); + assert.equal(ymd(nextOccurrence(2, 29, at('2028-03-01T09:00:00'), 'utc')), '2029-02-28'); +}); + +test('isBirthdayToday handles Feb 29 on Feb 28/29', () => { + assert.equal(isBirthdayToday(2, 29, at('2026-02-28T10:00:00'), 'utc'), true); + assert.equal(isBirthdayToday(2, 29, at('2027-02-28T10:00:00'), 'utc'), true); + assert.equal(isBirthdayToday(2, 29, at('2028-02-28T10:00:00'), 'utc'), false); + assert.equal(isBirthdayToday(2, 29, at('2028-02-29T10:00:00'), 'utc'), true); + assert.equal(isBirthdayToday(2, 29, at('2026-03-01T00:00:00'), 'utc'), false); +}); + +test('timezone: UTC date differs from Europe/London date (BST)', () => { + const now = at('2026-06-14T23:30:00Z'); + assert.equal(isBirthdayToday(6, 14, now, 'utc'), true); + assert.equal(isBirthdayToday(6, 14, now, LONDON), false); + assert.equal(isBirthdayToday(6, 15, now, LONDON), true); + assert.equal(ymd(nextOccurrence(6, 15, now, LONDON)), '2026-06-15'); + assert.equal(nextOccurrence(6, 15, now, LONDON).zoneName, LONDON); +}); + +test('timezone: Pacific/Auckland is ahead of UTC', () => { + const now = at('2026-06-14T20:00:00Z'); + assert.equal(isBirthdayToday(6, 15, now, 'Pacific/Auckland'), true); + assert.equal(isBirthdayToday(6, 15, now, 'utc'), false); + assert.equal(ymd(nextOccurrence(6, 14, now, 'Pacific/Auckland')), '2027-06-14'); +}); + +test('upcoming groups by date, sorts, and does not mutate input', () => { + const profiles = [ + { userId: 'c', month: 7, day: 1 }, + { userId: 'b', month: 6, day: 20 }, + { userId: 'a', month: 6, day: 20 }, + { userId: 'd', month: 1, day: 5 }, + { userId: 'bad', month: 4, day: 31 }, + ]; + const copy = structuredClone(profiles); + const result = upcoming(profiles, at('2026-06-15T12:00:00'), 'utc'); + assert.deepEqual( + result.map((g) => [ymd(g.date), g.userIds]), + [ + ['2026-06-20', ['a', 'b']], + ['2026-07-01', ['c']], + ['2027-01-05', ['d']], + ], + ); + assert.deepEqual(profiles, copy); +}); + +test('upcoming limit: stops after the group that reaches the limit, never splitting a date', () => { + const profiles = [ + { userId: 'a', month: 6, day: 16 }, + { userId: 'b', month: 6, day: 17 }, + { userId: 'c', month: 6, day: 17 }, + { userId: 'd', month: 6, day: 18 }, + ]; + const now = at('2026-06-15T12:00:00'); + assert.deepEqual( + upcoming(profiles, now, 'utc', 2).map((g) => g.userIds), + [['a'], ['b', 'c']], + ); + assert.deepEqual( + upcoming(profiles, now, 'utc', 3).map((g) => g.userIds), + [['a'], ['b', 'c']], + ); + assert.equal(upcoming(profiles, now, 'utc', 4).length, 3); + assert.deepEqual(upcoming([], now, 'utc'), []); +}); diff --git a/tests/services/MemberResolver.test.ts b/tests/services/MemberResolver.test.ts new file mode 100644 index 00000000..85901826 --- /dev/null +++ b/tests/services/MemberResolver.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { Guild } from 'discord.js'; +import { apiError, fakeMember, stub } from '../fakes/discord.ts'; +import { resolveMember, resolveTextChannel } from '../../src/services/MemberResolver.ts'; + +const guildWith = ( + cached: Record, + fetch: (id: string) => Promise, +): Guild => + stub({ + members: { cache: new Map(Object.entries(cached)), fetch }, + channels: { cache: new Map(Object.entries(cached)), fetch }, + }); + +test('cache hit does not fetch', async () => { + const member = fakeMember('u1'); + const guild = guildWith({ u1: member }, async () => { + throw new Error('should not fetch'); + }); + assert.equal(await resolveMember(guild, 'u1'), member); + assert.equal(await resolveMember(guild, { id: 'u1' } as never), member); +}); + +test('cache miss fetches', async () => { + const member = fakeMember('u2'); + const guild = guildWith({}, async () => member); + assert.equal(await resolveMember(guild, 'u2'), member); +}); + +test('Unknown Member and Unknown User resolve to null', async () => { + for (const code of [10007, 10013]) { + const guild = guildWith({}, async () => { + throw apiError(code); + }); + assert.equal(await resolveMember(guild, 'u3'), null); + } +}); + +test('other errors are rethrown', async () => { + const guild = guildWith({}, async () => { + throw apiError(50013); + }); + await assert.rejects(resolveMember(guild, 'u3'), { code: 50013 }); +}); + +test('resolveTextChannel returns sendable guild text channels only', async () => { + const good = { isTextBased: () => true, isDMBased: () => false, isSendable: () => true }; + const voice = { isTextBased: () => false, isDMBased: () => false, isSendable: () => false }; + const guild = guildWith({ good, voice }, async () => { + throw apiError(10003); + }); + assert.equal(await resolveTextChannel(guild, 'good'), good as never); + assert.equal(await resolveTextChannel(guild, 'voice'), null); + assert.equal(await resolveTextChannel(guild, 'missing'), null); +}); + +test('partial cached member is force-fetched', async () => { + const partial = fakeMember('u4', { partial: true }); + const full = fakeMember('u4'); + const calls: unknown[] = []; + const guild = guildWith({ u4: partial }, async (arg) => { + calls.push(arg); + return full; + }); + assert.equal(await resolveMember(guild, 'u4'), full); + assert.deepEqual(calls, [{ user: 'u4', force: true }]); +}); + +test('partial cached member resolves to null when it is gone', async () => { + const guild = guildWith({ u5: fakeMember('u5', { partial: true }) }, async () => { + throw apiError(10007); + }); + assert.equal(await resolveMember(guild, 'u5'), null); +}); + +test('full cached member is not fetched', async () => { + const member = fakeMember('u6', { partial: false }); + const guild = guildWith({ u6: member }, async () => { + throw new Error('should not fetch'); + }); + assert.equal(await resolveMember(guild, 'u6'), member); +}); diff --git a/tests/services/RetentionPolicy.test.ts b/tests/services/RetentionPolicy.test.ts new file mode 100644 index 00000000..8a07b8ca --- /dev/null +++ b/tests/services/RetentionPolicy.test.ts @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + type AccessState, + classifyGuildFetchError, + nextAccessState, +} from '../../src/services/RetentionPolicy.ts'; + +const policy = { minFailures: 3, graceDays: 7 }; +const day = (n: number): Date => new Date(Date.UTC(2026, 0, 1 + n)); +const fresh: AccessState = { accessFailureCount: 0, firstAccessFailureAt: null }; + +test('classifyGuildFetchError checks code structurally', () => { + assert.equal(classifyGuildFetchError({ code: 50001 }), 'gone'); + assert.equal(classifyGuildFetchError({ code: 10004 }), 'gone'); + assert.equal(classifyGuildFetchError({ code: 500 }), 'transient'); + assert.equal(classifyGuildFetchError(new Error('ECONNRESET')), 'transient'); + assert.equal(classifyGuildFetchError(null), 'transient'); + assert.equal(classifyGuildFetchError('50001'), 'transient'); +}); + +test('single failure sets first-failure and does not purge', () => { + const next = nextAccessState(fresh, 'gone', day(0), policy); + assert.deepEqual(next, { accessFailureCount: 1, firstAccessFailureAt: day(0), purge: false }); +}); + +test('enough failures but inside grace period does not purge', () => { + let state = fresh; + for (let i = 0; i < 5; i += 1) { + const next = nextAccessState(state, 'gone', day(i), policy); + assert.equal(next.purge, false); + state = next; + } + assert.equal(state.accessFailureCount, 5); +}); + +test('min failures and grace days both met purges', () => { + const state: AccessState = { accessFailureCount: 2, firstAccessFailureAt: day(0) }; + const next = nextAccessState(state, 'gone', day(7), policy); + assert.equal(next.accessFailureCount, 3); + assert.equal(next.purge, true); + assert.deepEqual(next.firstAccessFailureAt, day(0)); +}); + +test('grace days elapsed but too few failures does not purge', () => { + const state: AccessState = { accessFailureCount: 1, firstAccessFailureAt: day(0) }; + assert.equal(nextAccessState(state, 'gone', day(30), policy).purge, false); +}); + +test('ok resets state', () => { + const state: AccessState = { accessFailureCount: 9, firstAccessFailureAt: day(0) }; + assert.deepEqual(nextAccessState(state, 'ok', day(40), policy), { + accessFailureCount: 0, + firstAccessFailureAt: null, + purge: false, + }); +}); + +test('transient leaves state unchanged and never purges', () => { + const state: AccessState = { accessFailureCount: 9, firstAccessFailureAt: day(0) }; + assert.deepEqual(nextAccessState(state, 'transient', day(40), policy), { + ...state, + purge: false, + }); +}); diff --git a/tests/services/Scheduler.test.ts b/tests/services/Scheduler.test.ts new file mode 100644 index 00000000..bd1ffe29 --- /dev/null +++ b/tests/services/Scheduler.test.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { type ScheduleFn, Scheduler } from '../../src/services/Scheduler.ts'; +import { fakeLogger } from '../helpers/app.ts'; + +const { logger } = fakeLogger(); + +const fakeSchedule = (): { + fn: ScheduleFn; + calls: unknown[]; + cancelled: () => number; + fire: () => void; +} => { + const calls: unknown[] = []; + let cancels = 0; + let cb: () => void = () => undefined; + const fn: ScheduleFn = (spec, callback) => { + calls.push(spec); + cb = callback; + return { cancel: () => (cancels += 1), nextInvocation: () => new Date(0) }; + }; + return { fn, calls, cancelled: () => cancels, fire: () => cb() }; +}; + +test('tasks run sequentially in order with error isolation', async () => { + const order: string[] = []; + const scheduler = new Scheduler(logger, '0 0 * * *', 'utc', [ + { name: 'a', run: async () => void order.push('a') }, + { + name: 'boom', + run: async () => { + order.push('boom'); + throw new Error('fail'); + }, + }, + { name: 'c', run: async () => void order.push('c') }, + ]); + await scheduler.runNow(); + assert.deepEqual(order, ['a', 'boom', 'c']); +}); + +test('overlapping runNow is skipped, then allowed again', async () => { + let runs = 0; + let release: () => void = () => undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const scheduler = new Scheduler(logger, '0 0 * * *', 'utc', [ + { + name: 'slow', + run: async () => { + runs += 1; + await gate; + }, + }, + ]); + const first = scheduler.runNow(); + await scheduler.runNow(); + assert.equal(runs, 1); + release(); + await first; + await scheduler.runNow(); + assert.equal(runs, 2); +}); + +test('start schedules with cron and tz; second start is a no-op', () => { + const fake = fakeSchedule(); + const scheduler = new Scheduler(logger, '0 9 * * *', 'Europe/London', [], fake.fn); + scheduler.start(); + scheduler.start(); + assert.deepEqual(fake.calls, [{ rule: '0 9 * * *', tz: 'Europe/London' }]); + assert.deepEqual(scheduler.nextInvocation(), new Date(0)); +}); + +test('fired job triggers tasks', async () => { + const fake = fakeSchedule(); + let ran = 0; + const scheduler = new Scheduler( + logger, + '* * * * *', + 'utc', + [{ name: 't', run: async () => void (ran += 1) }], + fake.fn, + ); + scheduler.start(); + fake.fire(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(ran, 1); +}); + +test('start throws when the schedule is invalid', () => { + const scheduler = new Scheduler(logger, 'nope', 'utc', [], () => null); + assert.throws(() => scheduler.start(), /Invalid cron/); +}); + +test('stop cancels the job and clears nextInvocation', async () => { + const fake = fakeSchedule(); + const scheduler = new Scheduler(logger, '* * * * *', 'utc', [], fake.fn); + scheduler.start(); + await scheduler.stop(); + assert.equal(fake.cancelled(), 1); + assert.equal(scheduler.nextInvocation(), null); + scheduler.start(); + assert.equal(fake.calls.length, 2); +}); + +test('real croner schedule rejects an invalid cron string', () => { + const scheduler = new Scheduler(logger, 'not a cron', 'utc', []); + assert.throws(() => scheduler.start()); +}); + +test('stop during a run waits for it to finish', async () => { + let release: () => void = () => undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + let finished = false; + const scheduler = new Scheduler(logger, '* * * * *', 'utc', [ + { + name: 'slow', + run: async () => { + await gate; + finished = true; + }, + }, + ]); + scheduler.start(); + const run = scheduler.runNow(); + let stopped = false; + const stopping = scheduler.stop().then(() => { + stopped = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(stopped, false); + release(); + await stopping; + await run; + assert.equal(finished, true); + assert.equal(stopped, true); +}); + +test('real croner schedule accepts a valid cron and reports the next run', async () => { + const scheduler = new Scheduler(logger, '0 9 * * *', 'Europe/London', []); + scheduler.start(); + assert.ok(scheduler.nextInvocation() instanceof Date); + await scheduler.stop(); +}); diff --git a/tests/settings.test.ts b/tests/settings.test.ts new file mode 100644 index 00000000..8b90dcfb --- /dev/null +++ b/tests/settings.test.ts @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict'; +import { after, beforeEach, test } from 'node:test'; +import { ChannelType } from 'discord.js'; +import type { ServerConfig } from '../src/db/schema.ts'; +import { getConfig, updateConfig } from '../src/services/ConfigService.ts'; +import { defineCommand, type Handler, type ReplyOptions } from '../src/framework/command.ts'; +import { + booleanSetting, + booleanText, + channelClearText, + channelGetText, + channelSetText, + channelSetting, +} from '../src/framework/settings.ts'; +import { fakeCommandContext } from './fakes/command.ts'; +import { fakeGuild } from './fakes/discord.ts'; +import { createTestApp } from './helpers/app.ts'; +import { dbFixtures } from './helpers/db.ts'; +import { nth, optionNamed, firstOption } from './helpers/assertions.ts'; + +const app = createTestApp(); +const { clearConfigs, findConfig } = dbFixtures(app); +let config: ServerConfig; +let persistedAtReply: (ServerConfig | null)[]; + +after(() => { + app.close(); +}); + +beforeEach(async () => { + clearConfigs(); + config = await getConfig(app, 'guild-1'); + persistedAtReply = []; +}); + +const channelGroup = channelSetting({ + name: 'audit', + description: 'Audit channel', + field: 'auditChannelId', + label: 'Audit channel', + channelTypes: [ChannelType.GuildText, ChannelType.GuildAnnouncement], +}); +const boolGroup = booleanSetting({ + name: 'sysmsgs', + description: 'System messages', + field: 'systemMessagesEnabled', + label: 'Bot system messages', +}); + +const makeCtx = (options: Record, channelIds: string[] = []) => { + const { ctx, replies } = fakeCommandContext(app, options, { + guild: fakeGuild({ channelIds }), + config, + }); + const reply = ctx.reply; + ctx.reply = async (response) => { + persistedAtReply.push(findConfig('guild-1')); + await reply(response); + }; + return { ctx, replies }; +}; + +const texts = (replies: ReplyOptions[]): (string | undefined)[] => replies.map((r) => r.content); + +const run = (group: typeof channelGroup, name: string): Handler => + group.subcommands.find((s) => s.name === name)!.run; + +test('pure helpers format responses', () => { + assert.equal(channelSetText('Audit channel', '1'), 'Audit channel set to <#1>.'); + assert.equal(channelGetText('Audit channel', '1', true), 'Audit channel: <#1>'); + assert.equal(channelGetText('Audit channel', null, false), 'Audit channel: not set'); + assert.match(channelGetText('Audit channel', '1', false), /no longer exists/); + assert.equal(channelClearText('Audit channel'), 'Audit channel cleared.'); + assert.equal(booleanText('Bot system messages', true), 'Bot system messages: enabled'); + assert.equal(booleanText('Bot system messages', false), 'Bot system messages: disabled'); +}); + +test('channelSetting set saves before replying and persists', async () => { + const { ctx, replies } = makeCtx({ channel: { id: '123' } }); + await run(channelGroup, 'set')(ctx); + + assert.equal(persistedAtReply[0]?.auditChannelId, '123'); + assert.deepEqual(texts(replies), ['Audit channel set to <#123>.']); + config = await getConfig(app, 'guild-1'); + assert.equal(config.auditChannelId, '123'); +}); + +test('channelSetting get reports set, missing and not-set channels', async () => { + const notSet = makeCtx({}, []); + await run(channelGroup, 'get')(notSet.ctx); + assert.deepEqual(texts(notSet.replies), ['Audit channel: not set']); + + config.auditChannelId = '123'; + const present = makeCtx({}, ['123']); + await run(channelGroup, 'get')(present.ctx); + assert.deepEqual(texts(present.replies), ['Audit channel: <#123>']); + + const missing = makeCtx({}, []); + await run(channelGroup, 'get')(missing.ctx); + assert.match(nth(missing.replies).content!, /no longer exists/); +}); + +test('channelSetting clear nulls the field, saves, then replies', async () => { + config = await updateConfig(app, 'guild-1', { auditChannelId: '123' }); + + const { ctx, replies } = makeCtx({}); + await run(channelGroup, 'clear')(ctx); + + assert.equal(persistedAtReply[0]?.auditChannelId, null); + assert.deepEqual(texts(replies), ['Audit channel cleared.']); + config = await getConfig(app, 'guild-1'); + assert.equal(config.auditChannelId, null); +}); + +test('channelSetting restricts the channel option types', () => { + const json = defineCommand({ name: 'config', description: 'd', groups: [channelGroup] }).toJSON(); + const group = firstOption(json); + const set = optionNamed(group, 'set'); + const channel = nth(set.options); + assert.deepEqual(channel.channel_types, [ChannelType.GuildText, ChannelType.GuildAnnouncement]); + assert.equal(channel.required, true); + assert.deepEqual( + group.options?.map((o) => o.name), + ['set', 'get', 'clear'], + ); +}); + +test('booleanSetting set saves before replying; get reads the stored value', async () => { + const set = makeCtx({ enabled: true }); + await run(boolGroup, 'set')(set.ctx); + assert.equal(persistedAtReply[0]?.systemMessagesEnabled, true); + assert.deepEqual(texts(set.replies), ['Bot system messages: enabled']); + config = await getConfig(app, 'guild-1'); + assert.equal(config.systemMessagesEnabled, true); + + const get = makeCtx({}); + await run(boolGroup, 'get')(get.ctx); + assert.deepEqual(texts(get.replies), ['Bot system messages: enabled']); +}); + +test('booleanSetting requires the enabled option', () => { + const json = defineCommand({ name: 'config', description: 'd', groups: [boolGroup] }).toJSON(); + const set = optionNamed(firstOption(json), 'set'); + assert.equal(nth(set.options).name, 'enabled'); + assert.equal(nth(set.options).required, true); +}); diff --git a/tsconfig.json b/tsconfig.json index 289b022a..39930a2e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,21 +1,20 @@ { - "compilerOptions": { - "lib": ["ESNext"], - "module": "NodeNext", - "moduleResolution": "NodeNext", - "target": "ES2024", - "strict": false, - "outDir": "dist", - "sourceMap": true, - "esModuleInterop": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "allowSyntheticDefaultImports": true, - "skipLibCheck": true, - "skipDefaultLibCheck": true, - "resolveJsonModule": true, - "importHelpers": true, - }, - "include": ["src"], - "exclude": ["node_modules", "**/*.spec.ts"] + "compilerOptions": { + "lib": ["ESNext"], + "types": ["node"], + "module": "nodenext", + "moduleResolution": "nodenext", + "target": "ES2024", + "noEmit": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "skipLibCheck": true + }, + "include": ["src", "tests"] }